diff --git a/src/modules/platform-tools/docker/docker.service.ts b/src/modules/platform-tools/docker/docker.service.ts index bbde8e475..6ba45b487 100644 --- a/src/modules/platform-tools/docker/docker.service.ts +++ b/src/modules/platform-tools/docker/docker.service.ts @@ -11,14 +11,19 @@ export class DockerService { constructor( private readonly configService: ConfigService, private readonly logger: Logger, - ) {} + ) { } /** * Returns the docker startup.sh script */ async getStartupScript() { - const script = await readFile(this.configService.startupScript, 'utf-8') - return { script } + try { + const script = await readFile(this.configService.startupScript, 'utf-8') + return { script } + } catch (error) { + this.logger.error('Error reading startup script:', error) + throw new Error('Could not read the startup script.') + } } /** diff --git a/src/modules/status/status.gateway.ts b/src/modules/status/status.gateway.ts index 89b511d58..6adeaea24 100644 --- a/src/modules/status/status.gateway.ts +++ b/src/modules/status/status.gateway.ts @@ -18,7 +18,7 @@ export class StatusGateway { constructor( private statusService: StatusService, private pluginsService: PluginsService, - ) {} + ) { } @SubscribeMessage('get-dashboard-layout') async getDashboardLayout() { @@ -65,6 +65,15 @@ export class StatusGateway { } } + @SubscribeMessage('docker-version-check') + async dockerVersionCheck() { + try { + return await this.statusService.getDockerDetails() + } catch (e) { + return new WsException(e.message) + } + } + @SubscribeMessage('nodejs-version-check') async nodeJsVersionCheck() { try { diff --git a/src/modules/status/status.service.ts b/src/modules/status/status.service.ts index bd41572f2..6d1815572 100644 --- a/src/modules/status/status.service.ts +++ b/src/modules/status/status.service.ts @@ -46,6 +46,22 @@ export interface HomebridgeStatusUpdate { pin?: string } +interface DockerRelease { + tag_name: string + published_at: string + prerelease: boolean + body: string +} + +interface DockerReleaseInfo { + version: string + publishedAt: string + isPrerelease: boolean + isTest: boolean + testTag: 'beta' | 'test' | null + isLatestStable: boolean +} + const execAsync = promisify(exec) @Injectable() @@ -592,4 +608,140 @@ export class StatusService { return output } + + /** + * Fetches Docker package details, including version information, release body, and system details. + * Accounts for version tag formats: YYYY-MM-DD (stable), beta-YYYY-MM-DD or test-YYYY-MM-DD (test). + * If currentVersion is beta/test, latestVersion is the latest beta/test version; otherwise, it's the latest stable. + * @returns A promise resolving to the Docker details object. + */ + public async getDockerDetails() { + const currentVersion = process.env.DOCKER_HOMEBRIDGE_VERSION + let latestVersion: string | null = null + let latestReleaseBody = '' + let updateAvailable = false + + try { + const { releases, rawReleases } = await this.getRecentReleases() + + // Determine the type of currentVersion and select the appropriate latest version + if (currentVersion) { + const lowerCurrentVersion = currentVersion.toLowerCase() + let targetReleases: DockerReleaseInfo[] = [] + + if (lowerCurrentVersion.startsWith('beta-')) { + // Current version is beta; select latest beta version + targetReleases = releases + .filter(release => release.testTag === 'beta' && /^beta-\d{4}-\d{2}-\d{2}$/i.test(release.version)) + .sort((a, b) => b.version.localeCompare(a.version)) // Sort by date descending + latestVersion = targetReleases[0]?.version || null + } else if (lowerCurrentVersion.startsWith('test-')) { + // Current version is test; select latest test version + targetReleases = releases + .filter(release => release.testTag === 'test' && /^test-\d{4}-\d{2}-\d{2}$/i.test(release.version)) + .sort((a, b) => b.version.localeCompare(a.version)) // Sort by date descending + latestVersion = targetReleases[0]?.version || null + } else { + // Current version is stable or invalid; select latest stable version + const stableRelease = releases.find(release => release.isLatestStable) + latestVersion = stableRelease?.version || null + } + + if (currentVersion && latestVersion) { + // Compare versions as dates if they match the expected format + const dateRegex = /\d{4}-\d{2}-\d{2}$/ + if (dateRegex.test(currentVersion) && dateRegex.test(latestVersion)) { + const currentDate = new Date(currentVersion.match(dateRegex)![0]) + const latestDate = new Date(latestVersion.match(dateRegex)![0]) + updateAvailable = latestDate > currentDate + } else { + // Fallback to string comparison + updateAvailable = currentVersion !== latestVersion + } + } + } else { + // No currentVersion; default to latest stable + const stableRelease = releases.find(release => release.isLatestStable) + latestVersion = stableRelease?.version || null + } + + // Fetch the release body for the latestVersion + if (latestVersion) { + const rawRelease = rawReleases.find(r => r.tag_name === latestVersion) + latestReleaseBody = rawRelease?.body || '' + } + } catch (error) { + console.error('Failed to fetch Docker details:', error instanceof Error ? error.message : error) + } + + return { + currentVersion, + latestVersion, + latestReleaseBody, + updateAvailable, + } + } + + private readonly DOCKER_GITHUB_API_URL = 'https://api.github.com/repos/homebridge/docker-homebridge/releases' + + /** + * Fetches the most recent releases (up to 100) of the homebridge/docker-homebridge package from GitHub, + * tagging test versions (tags starting with 'beta-' or 'test-') and the latest stable version (YYYY-MM-DD format). + * Includes a testTag field for test versions. + * @returns A promise resolving to an object with processed releases and raw release data, or empty arrays if an error occurs. + */ + public async getRecentReleases(): Promise<{ releases: DockerReleaseInfo[], rawReleases: DockerRelease[] }> { + try { + // Fetch the first page of up to 100 releases + const response = await fetch(`${this.DOCKER_GITHUB_API_URL}?per_page=100`, { + headers: { + Accept: 'application/vnd.github.v3+json', + // Optional: Add GitHub token for higher rate limits + // 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`, + }, + }) + + if (!response.ok) { + console.error(`GitHub API error: ${response.status} ${response.statusText}`) + return { releases: [], rawReleases: [] } + } + + const data: DockerRelease[] = await response.json() + + if (!Array.isArray(data)) { + console.error('Invalid response from GitHub API: Expected an array') + return { releases: [], rawReleases: [] } + } + + // Find the latest stable release by sorting YYYY-MM-DD tags + const stableReleases = data + .filter(release => /^\d{4}-\d{2}-\d{2}$/.test(release.tag_name)) // Stable: YYYY-MM-DD + .sort((a, b) => b.tag_name.localeCompare(a.tag_name)) // Sort descending (most recent first) + const latestStableTag = stableReleases[0]?.tag_name || null + + const releases = data.map((release) => { + const tagName = release.tag_name.toLowerCase() + let testTag: 'beta' | 'test' | null = null + if (tagName.startsWith('beta-')) { + testTag = 'beta' + } else if (tagName.startsWith('test-')) { + testTag = 'test' + } + + return { + version: release.tag_name, + publishedAt: release.published_at, + isPrerelease: release.prerelease, + isTest: testTag !== null, + testTag, + isLatestStable: release.tag_name === latestStableTag, + } + }) + + return { releases, rawReleases: data } + } catch (error) { + console.error('Failed to fetch docker-homebridge releases:', error instanceof Error ? error.message : error) + return { releases: [], rawReleases: [] } + } + } } diff --git a/ui/src/app/core/components/information/information.component.html b/ui/src/app/core/components/information/information.component.html index 5cdb3ff46..686161ed0 100644 --- a/ui/src/app/core/components/information/information.component.html +++ b/ui/src/app/core/components/information/information.component.html @@ -18,6 +18,8 @@
@if (message2) { + } @if (markdownMessage2) { +