From 5285df61859f68ebfc7127461b6492e5a63ee8a3 Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:54:08 +0100 Subject: [PATCH 01/13] feat: add electron-updater dependency for automatic updates Install electron-updater package to enable automatic app updates from GitHub Releases. Refs #40 --- package-lock.json | 92 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 7 ++++ 2 files changed, 99 insertions(+) diff --git a/package-lock.json b/package-lock.json index 0f52a1c..c3b7695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "electron": "^37.1.0", "electron-builder": "^26.0.12", "electron-rebuild": "^3.2.9", + "electron-updater": "^6.3.9", "eslint": "^9.39.1", "husky": "^9.1.7", "jasmine-core": "~5.7.0", @@ -10887,6 +10888,75 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.7.3.tgz", + "integrity": "sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", + "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron/node_modules/@types/node": { "version": "22.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", @@ -14560,6 +14630,21 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -18903,6 +18988,13 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 67778a1..55de6a5 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,12 @@ "path": "/Applications" } ] + }, + "publish": { + "provider": "github", + "owner": "altaskur", + "repo": "OpenTimeTracker", + "releaseType": "release" } }, "prettier": { @@ -180,6 +186,7 @@ "electron": "^37.1.0", "electron-builder": "^26.0.12", "electron-rebuild": "^3.2.9", + "electron-updater": "^6.3.9", "eslint": "^9.39.1", "husky": "^9.1.7", "jasmine-core": "~5.7.0", From 3b9ec03b389f794aa2bdbbf899f7575cd937f320 Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:54:35 +0100 Subject: [PATCH 02/13] feat: define TypeScript interfaces for update system Add UpdateInfo, UpdateSettings, DownloadProgress, and UpdateResult interfaces for type-safe update handling between main and renderer. Refs #40 --- electron/src/interfaces/update.interface.ts | 49 ++++++++++++ src/types/electron.d.ts | 83 +++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 electron/src/interfaces/update.interface.ts diff --git a/electron/src/interfaces/update.interface.ts b/electron/src/interfaces/update.interface.ts new file mode 100644 index 0000000..6d0a03d --- /dev/null +++ b/electron/src/interfaces/update.interface.ts @@ -0,0 +1,49 @@ +/** + * Information about an available update. + */ +export interface UpdateInfo { + version: string; + releaseDate: string; + releaseName?: string; + releaseNotes?: string; + size?: number; +} + +/** + * Update download progress information. + */ +export interface DownloadProgress { + bytesPerSecond: number; + percent: number; + transferred: number; + total: number; +} + +/** + * Update settings and preferences. + */ +export interface UpdateSettings { + autoCheckEnabled: boolean; + lastCheckDate?: Date; +} + +/** + * Update status enum. + */ +export enum UpdateStatus { + Idle = 'idle', + Checking = 'checking', + Available = 'available', + NotAvailable = 'not-available', + Downloading = 'downloading', + Downloaded = 'downloaded', + Error = 'error', +} + +/** + * Update error information. + */ +export interface UpdateError { + message: string; + code?: string; +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 0caf03a..49c5ff3 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -153,6 +153,66 @@ export interface BackupResult { error?: string; } +// ==================== UPDATE MODELS ==================== + +export interface UpdateInfo { + version: string; + releaseDate: string; + releaseName?: string; + releaseNotes?: string; + size?: number; +} + +export interface DownloadProgress { + bytesPerSecond: number; + percent: number; + transferred: number; + total: number; +} + +export interface UpdateSettings { + autoCheckEnabled: boolean; + lastCheckDate?: Date; +} + +export interface UpdateResult { + success: boolean; + error?: string; + settings?: UpdateSettings; + status?: string; + updateInfo?: UpdateInfo | null; +} + +// ==================== UPDATE MODELS ==================== + +export interface UpdateInfo { + version: string; + releaseDate: string; + releaseName?: string; + releaseNotes?: string; + size?: number; +} + +export interface DownloadProgress { + bytesPerSecond: number; + percent: number; + transferred: number; + total: number; +} + +export interface UpdateSettings { + autoCheckEnabled: boolean; + lastCheckDate?: Date; +} + +export interface UpdateResult { + success: boolean; + error?: string; + settings?: UpdateSettings; + status?: string; + updateInfo?: UpdateInfo | null; +} + // ==================== ELECTRON API ==================== export interface DeleteResult { @@ -342,6 +402,29 @@ declare global { exportBackup: () => Promise; importBackup: () => Promise; getBackupDir: () => Promise; + + // System + openExternal: (url: string) => Promise; + + // Updates + checkForUpdates: () => Promise; + downloadUpdate: () => Promise; + installUpdate: () => Promise; + getUpdateSettings: () => Promise; + setUpdateSettings: ( + settings: Partial, + ) => Promise; + getUpdateStatus: () => Promise; + onUpdateChecking: (callback: () => void) => void; + onUpdateAvailable: (callback: (info: UpdateInfo) => void) => void; + onUpdateNotAvailable: ( + callback: (info: { version: string }) => void, + ) => void; + onDownloadProgress: ( + callback: (progress: DownloadProgress) => void, + ) => void; + onUpdateDownloaded: (callback: (info: UpdateInfo) => void) => void; + onUpdateError: (callback: (error: { message: string }) => void) => void; }; } } From 41f34698aae8921eb9bd65a44aec069370483f5a Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:54:49 +0100 Subject: [PATCH 03/13] feat: implement UpdateManager and IPC handlers Create UpdateManager class to handle electron-updater lifecycle events and IPC handlers for communication between main and renderer processes. Supports check, download, install operations and settings management. Refs #40 --- electron/src/services/ipc/index.ts | 2 + electron/src/services/ipc/update-handlers.ts | 117 +++++ electron/src/services/updater/index.ts | 1 + .../src/services/updater/update-manager.ts | 405 ++++++++++++++++++ 4 files changed, 525 insertions(+) create mode 100644 electron/src/services/ipc/update-handlers.ts create mode 100644 electron/src/services/updater/index.ts create mode 100644 electron/src/services/updater/update-manager.ts diff --git a/electron/src/services/ipc/index.ts b/electron/src/services/ipc/index.ts index 26ed1ed..a24c1eb 100644 --- a/electron/src/services/ipc/index.ts +++ b/electron/src/services/ipc/index.ts @@ -4,6 +4,7 @@ import { setupDatabaseHandlers } from './database-handlers.js'; import { setupThemeHandlers } from './theme-handlers.js'; import { setupLanguageHandlers } from './language-handlers.js'; import { setupBackupHandlers } from './backup-handlers.js'; +import { setupUpdateHandlers } from './update-handlers.js'; /** * Sets up all IPC handlers @@ -15,6 +16,7 @@ export const setupIpcHandlers = ( setupDatabaseHandlers(dbManager); setupThemeHandlers(dbManager); setupLanguageHandlers(dbManager); + setupUpdateHandlers(); if (backupService) { setupBackupHandlers(backupService); } diff --git a/electron/src/services/ipc/update-handlers.ts b/electron/src/services/ipc/update-handlers.ts new file mode 100644 index 0000000..9ae8d3b --- /dev/null +++ b/electron/src/services/ipc/update-handlers.ts @@ -0,0 +1,117 @@ +import { ipcMain } from 'electron'; +import { UpdateManager } from '../updater/update-manager.js'; +import { UpdateSettings } from '../../interfaces/update.interface.js'; + +let updateManager: UpdateManager | null = null; + +/** + * Sets up update-related IPC handlers. + * Manages update checking, downloading, and installation between main and renderer processes. + */ +export const setupUpdateHandlers = (): void => { + updateManager = UpdateManager.getInstance(); + + /** + * Trigger manual update check. + */ + ipcMain.handle('update:check', async () => { + try { + await updateManager?.checkForUpdates(); + return { success: true }; + } catch (error) { + console.error('[IPC] Update check failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + /** + * Download available update. + */ + ipcMain.handle('update:download', async () => { + try { + await updateManager?.downloadUpdate(); + return { success: true }; + } catch (error) { + console.error('[IPC] Update download failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + /** + * Install downloaded update and restart app. + */ + ipcMain.handle('update:install', async () => { + try { + updateManager?.quitAndInstall(); + return { success: true }; + } catch (error) { + console.error('[IPC] Update installation failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + /** + * Get current update settings. + */ + ipcMain.handle('update:get-settings', async () => { + try { + const settings = updateManager?.getSettings(); + return { success: true, settings }; + } catch (error) { + console.error('[IPC] Failed to get update settings:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + /** + * Update update settings. + */ + ipcMain.handle( + 'update:set-settings', + async (_event, settings: Partial) => { + try { + await updateManager?.setSettings(settings); + return { success: true }; + } catch (error) { + console.error('[IPC] Failed to set update settings:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + ); + + /** + * Get current update status. + */ + ipcMain.handle('update:get-status', async () => { + try { + const status = updateManager?.getStatus(); + const updateInfo = updateManager?.getUpdateInfo(); + return { + success: true, + status, + updateInfo, + }; + } catch (error) { + console.error('[IPC] Failed to get update status:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); +}; diff --git a/electron/src/services/updater/index.ts b/electron/src/services/updater/index.ts new file mode 100644 index 0000000..de4d121 --- /dev/null +++ b/electron/src/services/updater/index.ts @@ -0,0 +1 @@ +export { UpdateManager } from './update-manager.js'; diff --git a/electron/src/services/updater/update-manager.ts b/electron/src/services/updater/update-manager.ts new file mode 100644 index 0000000..7fb9d99 --- /dev/null +++ b/electron/src/services/updater/update-manager.ts @@ -0,0 +1,405 @@ +import electronUpdater from 'electron-updater'; +import { BrowserWindow } from 'electron'; +import { + UpdateInfo, + UpdateSettings, + UpdateStatus, + UpdateError, + DownloadProgress, +} from '../../interfaces/update.interface.js'; + +const { autoUpdater } = electronUpdater; +type ElectronUpdateInfo = electronUpdater.UpdateInfo; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { app } from 'electron'; +import { BackupService } from '../backup/backup.service.js'; + +/** + * Update manager configuration. + */ +export interface UpdateManagerConfig { + autoCheckEnabled: boolean; + checkOnStartup: boolean; + autoDownload: boolean; +} + +/** + * Service for managing application updates using electron-updater. + * Provides functionality for checking, downloading, and installing updates from GitHub Releases. + */ +export class UpdateManager { + private static instance: UpdateManager; + private mainWindow: BrowserWindow | null = null; + private config: UpdateManagerConfig; + private currentStatus: UpdateStatus = UpdateStatus.Idle; + private updateInfo: UpdateInfo | null = null; + private readonly settingsPath: string; + private backupService: BackupService | null = null; + + private constructor() { + this.settingsPath = path.join( + app.getPath('userData'), + 'update-settings.json', + ); + this.config = this.loadSettings(); + this.setupAutoUpdater(); + this.attachEventHandlers(); + } + + /** + * Gets the singleton instance of UpdateManager. + */ + static getInstance(): UpdateManager { + if (!UpdateManager.instance) { + UpdateManager.instance = new UpdateManager(); + } + return UpdateManager.instance; + } + + /** + * Sets the main window reference for sending IPC events. + */ + setMainWindow(window: BrowserWindow): void { + this.mainWindow = window; + } + + /** + * Initializes the update manager. + * Should be called after app is ready. + */ + async initialize(): Promise { + console.log('[UpdateManager] Initialized'); + + if (this.config.checkOnStartup && this.config.autoCheckEnabled) { + // Delay initial check to allow app to fully load + setTimeout(() => { + this.checkForUpdates().catch((error) => { + console.error('[UpdateManager] Initial check failed:', error); + }); + }, 5000); + } + } + + /** + * Configures electron-updater settings. + */ + private setupAutoUpdater(): void { + // Configure auto-updater + autoUpdater.autoDownload = this.config.autoDownload; + autoUpdater.autoInstallOnAppQuit = true; + + // Set logger for debugging + autoUpdater.logger = { + info: (msg: string) => console.log('[AutoUpdater]', msg), + warn: (msg: string) => console.warn('[AutoUpdater]', msg), + error: (msg: string) => console.error('[AutoUpdater]', msg), + debug: (msg: string) => console.debug('[AutoUpdater]', msg), + }; + + // In development, use generic versioning server for testing + if (!app.isPackaged) { + console.log('[UpdateManager] Running in development mode'); + // Updates won't work in dev mode without mock server + autoUpdater.forceDevUpdateConfig = true; + } + } + + /** + * Attaches event handlers to autoUpdater events. + */ + private attachEventHandlers(): void { + autoUpdater.on('checking-for-update', () => { + console.log('[UpdateManager] Checking for updates...'); + this.currentStatus = UpdateStatus.Checking; + this.sendToRenderer('update:checking'); + }); + + autoUpdater.on('update-available', (info: ElectronUpdateInfo) => { + console.log('[UpdateManager] Update available:', info.version); + this.currentStatus = UpdateStatus.Available; + this.updateInfo = this.mapUpdateInfo(info); + this.sendToRenderer('update:available', this.updateInfo); + }); + + autoUpdater.on('update-not-available', (info: ElectronUpdateInfo) => { + console.log( + '[UpdateManager] Update not available. Current version:', + info.version, + ); + this.currentStatus = UpdateStatus.NotAvailable; + this.sendToRenderer('update:not-available', { version: info.version }); + }); + + autoUpdater.on('download-progress', (progressObj) => { + const progress: DownloadProgress = { + bytesPerSecond: progressObj.bytesPerSecond, + percent: progressObj.percent, + transferred: progressObj.transferred, + total: progressObj.total, + }; + console.log( + `[UpdateManager] Download progress: ${progress.percent.toFixed(2)}%`, + ); + this.currentStatus = UpdateStatus.Downloading; + this.sendToRenderer('update:download-progress', progress); + }); + + autoUpdater.on('update-downloaded', (info: ElectronUpdateInfo) => { + console.log('[UpdateManager] Update downloaded:', info.version); + this.currentStatus = UpdateStatus.Downloaded; + this.updateInfo = this.mapUpdateInfo(info); + this.sendToRenderer('update:downloaded', this.updateInfo); + }); + + autoUpdater.on('error', (error: Error) => { + console.error('[UpdateManager] Error:', error); + this.currentStatus = UpdateStatus.Error; + const updateError: UpdateError = { + message: error.message, + }; + this.sendToRenderer('update:error', updateError); + }); + } + + /** + * Sends IPC event to renderer process. + */ + private sendToRenderer(channel: string, data?: unknown): void { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(channel, data); + } + } + + /** + * Maps electron-updater UpdateInfo to our UpdateInfo interface. + */ + private mapUpdateInfo(info: ElectronUpdateInfo): UpdateInfo { + return { + version: info.version, + releaseDate: info.releaseDate, + releaseName: info.releaseName ?? undefined, + releaseNotes: info.releaseNotes as string | undefined, + }; + } + + /** + * Checks for available updates. + */ + async checkForUpdates(): Promise { + try { + if (this.currentStatus === UpdateStatus.Checking) { + console.log('[UpdateManager] Already checking for updates'); + return; + } + + if (!this.config.autoCheckEnabled) { + console.log('[UpdateManager] Auto-check is disabled'); + return; + } + + // In development mode, skip actual update check + if (!app.isPackaged) { + console.log( + '[UpdateManager] Skipping update check in development mode', + ); + this.currentStatus = UpdateStatus.NotAvailable; + this.sendToRenderer('update:not-available', { + version: app.getVersion(), + }); + return; + } + + console.log('[UpdateManager] Checking for updates...'); + await autoUpdater.checkForUpdates(); + + // Update last check date + this.updateLastCheckDate(); + } catch (error) { + console.error('[UpdateManager] Check for updates failed:', error); + throw error; + } + } + + /** + * Downloads the available update. + */ + async downloadUpdate(): Promise { + try { + if (!app.isPackaged) { + console.log( + '[UpdateManager] Download not available in development mode', + ); + throw new Error('Updates not available in development mode'); + } + + if (this.currentStatus !== UpdateStatus.Available) { + throw new Error('No update available to download'); + } + + console.log('[UpdateManager] Starting download...'); + await autoUpdater.downloadUpdate(); + } catch (error) { + console.error('[UpdateManager] Download failed:', error); + throw error; + } + } + + /** + * Quits the application and installs the downloaded update. + */ + async quitAndInstall(): Promise { + if (!app.isPackaged) { + console.log('[UpdateManager] Install not available in development mode'); + return; + } + + if (this.currentStatus !== UpdateStatus.Downloaded) { + console.error('[UpdateManager] No update downloaded to install'); + return; + } + + console.log('[UpdateManager] Creating backup before update...'); + + // Create backup before installing update + if (!this.backupService) { + this.backupService = new BackupService(); + } + + try { + const backupResult = + await this.backupService.createBackup('before-restore'); + if (backupResult.success) { + console.log( + '[UpdateManager] Backup created successfully:', + backupResult.backup?.filename, + ); + } else { + console.warn( + '[UpdateManager] Backup failed, continuing with update:', + backupResult.error, + ); + } + } catch (error) { + console.warn( + '[UpdateManager] Backup error, continuing with update:', + error, + ); + } + + console.log('[UpdateManager] Quitting and installing update...'); + + // Notify renderer to allow cleanup + this.sendToRenderer('update:installing'); + + // Give renderer time to cleanup + setTimeout(() => { + autoUpdater.quitAndInstall(false, true); + }, 1000); + } + + /** + * Gets current update settings. + */ + getSettings(): UpdateSettings { + return { + autoCheckEnabled: this.config.autoCheckEnabled, + lastCheckDate: this.loadLastCheckDate(), + }; + } + + /** + * Updates update settings. + */ + async setSettings(settings: Partial): Promise { + if (settings.autoCheckEnabled !== undefined) { + this.config.autoCheckEnabled = settings.autoCheckEnabled; + } + + this.saveSettings(); + console.log('[UpdateManager] Settings updated:', this.config); + } + + /** + * Gets current update status. + */ + getStatus(): UpdateStatus { + return this.currentStatus; + } + + /** + * Gets current update info if available. + */ + getUpdateInfo(): UpdateInfo | null { + return this.updateInfo; + } + + /** + * Loads settings from disk. + */ + private loadSettings(): UpdateManagerConfig { + try { + if (fs.existsSync(this.settingsPath)) { + const data = fs.readFileSync(this.settingsPath, 'utf-8'); + const saved = JSON.parse(data); + return { + autoCheckEnabled: saved.autoCheckEnabled ?? true, + checkOnStartup: saved.checkOnStartup ?? true, + autoDownload: saved.autoDownload ?? false, + }; + } + } catch (error) { + console.error('[UpdateManager] Failed to load settings:', error); + } + + // Default settings + return { + autoCheckEnabled: true, + checkOnStartup: true, + autoDownload: false, + }; + } + + /** + * Saves settings to disk. + */ + private saveSettings(): void { + try { + fs.writeFileSync(this.settingsPath, JSON.stringify(this.config, null, 2)); + } catch (error) { + console.error('[UpdateManager] Failed to save settings:', error); + } + } + + /** + * Loads last check date from settings. + */ + private loadLastCheckDate(): Date | undefined { + try { + if (fs.existsSync(this.settingsPath)) { + const data = fs.readFileSync(this.settingsPath, 'utf-8'); + const saved = JSON.parse(data); + return saved.lastCheckDate ? new Date(saved.lastCheckDate) : undefined; + } + } catch (error) { + console.error('[UpdateManager] Failed to load last check date:', error); + } + return undefined; + } + + /** + * Updates last check date in settings. + */ + private updateLastCheckDate(): void { + try { + const currentSettings = this.loadSettings(); + const updated = { + ...currentSettings, + lastCheckDate: new Date().toISOString(), + }; + fs.writeFileSync(this.settingsPath, JSON.stringify(updated, null, 2)); + } catch (error) { + console.error('[UpdateManager] Failed to update last check date:', error); + } + } +} From 17f6dec0653c65196ee7fecb10b0d62fa104c5cd Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:55:05 +0100 Subject: [PATCH 04/13] feat: integrate UpdateManager in Electron main process Initialize UpdateManager on app ready and expose update API through preload script. Add 'Check for Updates' menu item. Refs #40 --- electron/src/main/main.ts | 10 +++ electron/src/preload/preload.ts | 73 +++++++++++++++++++++- electron/src/services/menu/menu-manager.ts | 9 +++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/electron/src/main/main.ts b/electron/src/main/main.ts index 430b4d4..9b50bf1 100644 --- a/electron/src/main/main.ts +++ b/electron/src/main/main.ts @@ -3,10 +3,12 @@ import { WindowManager } from './window.js'; import { DatabaseManager } from '../services/database/database.js'; import { setupIpcHandlers } from '../services/ipc/index.js'; import { BackupService } from '../services/backup/index.js'; +import { UpdateManager } from '../services/updater/index.js'; let windowManager: WindowManager | null = null; let dbManager: DatabaseManager | null = null; let backupService: BackupService | null = null; +let updateManager: UpdateManager | null = null; const initializeApp = async (): Promise => { backupService = new BackupService(); @@ -32,6 +34,14 @@ const initializeApp = async (): Promise => { setupIpcHandlers(dbManager, backupService); windowManager = new WindowManager(); await windowManager.createMainWindow(); + + // Initialize update manager after window is created + updateManager = UpdateManager.getInstance(); + const mainWindow = windowManager.getMainWindow(); + if (mainWindow) { + updateManager.setMainWindow(mainWindow); + } + await updateManager.initialize(); }; app.whenReady().then(initializeApp); diff --git a/electron/src/preload/preload.ts b/electron/src/preload/preload.ts index dc57e50..0871ea7 100644 --- a/electron/src/preload/preload.ts +++ b/electron/src/preload/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from 'electron'; +import { contextBridge, ipcRenderer, shell } from 'electron'; /** * Type definitions for database entities @@ -128,6 +128,34 @@ interface BackupResult { path?: string; } +interface UpdateInfo { + version: string; + releaseDate: string; + releaseName?: string; + releaseNotes?: string; + size?: number; +} + +interface DownloadProgress { + bytesPerSecond: number; + percent: number; + transferred: number; + total: number; +} + +interface UpdateSettings { + autoCheckEnabled: boolean; + lastCheckDate?: Date; +} + +interface UpdateResult { + success: boolean; + error?: string; + settings?: UpdateSettings; + status?: string; + updateInfo?: UpdateInfo | null; +} + try { const electronAPI = { // Projects @@ -393,6 +421,49 @@ try { importBackup: (): Promise => ipcRenderer.invoke('backup-import'), getBackupDir: (): Promise => ipcRenderer.invoke('backup-get-dir'), + + // System + openExternal: (url: string): Promise => shell.openExternal(url), + + // Updates + checkForUpdates: (): Promise => + ipcRenderer.invoke('update:check'), + downloadUpdate: (): Promise => + ipcRenderer.invoke('update:download'), + installUpdate: (): Promise => + ipcRenderer.invoke('update:install'), + getUpdateSettings: (): Promise => + ipcRenderer.invoke('update:get-settings'), + setUpdateSettings: ( + settings: Partial, + ): Promise => + ipcRenderer.invoke('update:set-settings', settings), + getUpdateStatus: (): Promise => + ipcRenderer.invoke('update:get-status'), + onUpdateChecking: (callback: () => void): void => { + ipcRenderer.on('update:checking', () => callback()); + }, + onUpdateAvailable: (callback: (info: UpdateInfo) => void): void => { + ipcRenderer.on('update:available', (_event, info) => callback(info)); + }, + onUpdateNotAvailable: ( + callback: (info: { version: string }) => void, + ): void => { + ipcRenderer.on('update:not-available', (_event, info) => callback(info)); + }, + onDownloadProgress: ( + callback: (progress: DownloadProgress) => void, + ): void => { + ipcRenderer.on('update:download-progress', (_event, progress) => + callback(progress), + ); + }, + onUpdateDownloaded: (callback: (info: UpdateInfo) => void): void => { + ipcRenderer.on('update:downloaded', (_event, info) => callback(info)); + }, + onUpdateError: (callback: (error: { message: string }) => void): void => { + ipcRenderer.on('update:error', (_event, error) => callback(error)); + }, }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/electron/src/services/menu/menu-manager.ts b/electron/src/services/menu/menu-manager.ts index eee7519..4079304 100644 --- a/electron/src/services/menu/menu-manager.ts +++ b/electron/src/services/menu/menu-manager.ts @@ -54,6 +54,7 @@ const menuTranslations: Record> = { tags: 'Etiquetas', dayTypes: 'Tipos de Día', taskStatuses: 'Estados de Tarea', + checkForUpdates: 'Buscar Actualizaciones...', }, en: { home: 'Home', @@ -92,6 +93,7 @@ const menuTranslations: Record> = { tags: 'Tags', dayTypes: 'Day Types', taskStatuses: 'Task Statuses', + checkForUpdates: 'Check for Updates...', }, }; @@ -303,6 +305,13 @@ export class MenuManager { }, }, { type: 'separator' }, + { + label: this.t('checkForUpdates'), + click: (): void => { + this.navigateTo('/settings/updates'); + }, + }, + { type: 'separator' }, { label: this.t('reportIssue'), click: (): void => { From 1ba4203a8d8a16e9749afc6fe0a507f693c9bc93 Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:55:20 +0100 Subject: [PATCH 05/13] feat: create UpdateService for Angular app Implement UpdateService with signals to manage update state, event listeners, and operations (check, download, install, settings). Includes comprehensive unit tests with Jasmine. Refs #40 --- src/app/services/update/index.ts | 1 + .../services/update/update.service.spec.ts | 605 ++++++++++++++++++ src/app/services/update/update.service.ts | 268 ++++++++ 3 files changed, 874 insertions(+) create mode 100644 src/app/services/update/index.ts create mode 100644 src/app/services/update/update.service.spec.ts create mode 100644 src/app/services/update/update.service.ts diff --git a/src/app/services/update/index.ts b/src/app/services/update/index.ts new file mode 100644 index 0000000..74ed17b --- /dev/null +++ b/src/app/services/update/index.ts @@ -0,0 +1 @@ +export { UpdateService } from './update.service'; diff --git a/src/app/services/update/update.service.spec.ts b/src/app/services/update/update.service.spec.ts new file mode 100644 index 0000000..6f0e496 --- /dev/null +++ b/src/app/services/update/update.service.spec.ts @@ -0,0 +1,605 @@ +import { TestBed } from '@angular/core/testing'; +import { UpdateService } from './update.service'; +import { + UpdateInfo, + UpdateSettings, + DownloadProgress, + UpdateResult, +} from '../../../types/electron'; + +interface MockElectronAPI { + onUpdateChecking?: jasmine.Spy<(callback: () => void) => void>; + onUpdateAvailable?: jasmine.Spy< + (callback: (info: UpdateInfo) => void) => void + >; + onUpdateNotAvailable?: jasmine.Spy< + (callback: (info: { version: string }) => void) => void + >; + onDownloadProgress?: jasmine.Spy< + (callback: (progress: DownloadProgress) => void) => void + >; + onUpdateDownloaded?: jasmine.Spy< + (callback: (info: UpdateInfo) => void) => void + >; + onUpdateError?: jasmine.Spy< + (callback: (error: { message: string }) => void) => void + >; + checkForUpdates?: jasmine.Spy<() => Promise>; + downloadUpdate?: jasmine.Spy<() => Promise>; + installUpdate?: jasmine.Spy<() => Promise>; + getUpdateSettings?: jasmine.Spy<() => Promise>; + setUpdateSettings?: jasmine.Spy< + (settings: UpdateSettings) => Promise + >; + getUpdateStatus?: jasmine.Spy<() => Promise>; +} + +describe('UpdateService', () => { + let service: UpdateService; + let mockElectronAPI: MockElectronAPI; + + const mockUpdateInfo: UpdateInfo = { + version: '2.0.0', + releaseName: 'Version 2.0.0', + releaseNotes: 'New features', + releaseDate: '2026-02-01', + }; + + const mockUpdateSettings: UpdateSettings = { + autoCheckEnabled: true, + }; + + beforeEach(() => { + /* + * Setup mock electronAPI + */ + mockElectronAPI = { + onUpdateChecking: jasmine.createSpy('onUpdateChecking'), + onUpdateAvailable: jasmine.createSpy('onUpdateAvailable'), + onUpdateNotAvailable: jasmine.createSpy('onUpdateNotAvailable'), + onDownloadProgress: jasmine.createSpy('onDownloadProgress'), + onUpdateDownloaded: jasmine.createSpy('onUpdateDownloaded'), + onUpdateError: jasmine.createSpy('onUpdateError'), + checkForUpdates: jasmine + .createSpy('checkForUpdates') + .and.returnValue(Promise.resolve({ success: true })), + downloadUpdate: jasmine + .createSpy('downloadUpdate') + .and.returnValue(Promise.resolve({ success: true })), + installUpdate: jasmine + .createSpy('installUpdate') + .and.returnValue(Promise.resolve({ success: true })), + getUpdateSettings: jasmine + .createSpy('getUpdateSettings') + .and.returnValue( + Promise.resolve({ success: true, settings: mockUpdateSettings }), + ), + setUpdateSettings: jasmine + .createSpy('setUpdateSettings') + .and.returnValue(Promise.resolve({ success: true })), + getUpdateStatus: jasmine + .createSpy('getUpdateStatus') + .and.returnValue(Promise.resolve({ success: true })), + } as MockElectronAPI; + + /* + * Define electronAPI as a configurable property so it can be deleted in tests + */ + Object.defineProperty(window, 'electronAPI', { + configurable: true, + writable: true, + value: mockElectronAPI, + }); + + TestBed.configureTestingModule({}); + service = TestBed.inject(UpdateService); + }); + + afterEach(() => { + delete (window as { electronAPI?: unknown }).electronAPI; + }); + + describe('initialization', () => { + it('should create', () => { + expect(service).toBeTruthy(); + }); + + it('should initialize with default signal values', () => { + expect(service.updateAvailable()).toBeNull(); + expect(service.downloadProgress()).toBe(0); + expect(service.isChecking()).toBe(false); + expect(service.isDownloading()).toBe(false); + expect(service.updateDownloaded()).toBe(false); + expect(service.settings()).toEqual({ autoCheckEnabled: true }); + expect(service.errorMessage()).toBeNull(); + }); + + it('should setup listeners on construction', () => { + expect(mockElectronAPI.onUpdateChecking).toHaveBeenCalled(); + expect(mockElectronAPI.onUpdateAvailable).toHaveBeenCalled(); + expect(mockElectronAPI.onUpdateNotAvailable).toHaveBeenCalled(); + expect(mockElectronAPI.onDownloadProgress).toHaveBeenCalled(); + expect(mockElectronAPI.onUpdateDownloaded).toHaveBeenCalled(); + expect(mockElectronAPI.onUpdateError).toHaveBeenCalled(); + }); + + it('should not setup listeners when electronAPI is not available', () => { + delete (window as { electronAPI?: unknown }).electronAPI; + + const newService = new UpdateService(); + + expect(newService).toBeTruthy(); + }); + + it('should load settings on init', async () => { + await service.init(); + + expect(mockElectronAPI.getUpdateSettings).toHaveBeenCalled(); + expect(service.settings()).toEqual(mockUpdateSettings); + }); + + it('should handle init when electronAPI is not available', async () => { + delete (window as { electronAPI?: unknown }).electronAPI; + const newService = new UpdateService(); + + await newService.init(); + + expect(newService.settings()).toEqual({ autoCheckEnabled: true }); + }); + + it('should handle errors during settings load', async () => { + mockElectronAPI.getUpdateSettings!.and.returnValue( + Promise.reject('Error'), + ); + spyOn(console, 'error'); + + await service.init(); + + expect(console.error).toHaveBeenCalledWith( + '[UpdateService] Failed to load settings:', + 'Error', + ); + }); + + it('should handle unsuccessful settings load', async () => { + mockElectronAPI.getUpdateSettings!.and.returnValue( + Promise.resolve({ success: false }), + ); + + await service.init(); + + expect(service.settings()).toEqual({ autoCheckEnabled: true }); + }); + }); + + describe('event listeners', () => { + let checkingCallback: () => void; + let availableCallback: (info: UpdateInfo) => void; + let notAvailableCallback: (info: { version: string }) => void; + let progressCallback: (progress: DownloadProgress) => void; + let downloadedCallback: (info: UpdateInfo) => void; + let errorCallback: (error: { message: string }) => void; + + beforeEach(() => { + checkingCallback = mockElectronAPI.onUpdateChecking!.calls.argsFor(0)[0]; + availableCallback = + mockElectronAPI.onUpdateAvailable!.calls.argsFor(0)[0]; + notAvailableCallback = + mockElectronAPI.onUpdateNotAvailable!.calls.argsFor(0)[0]; + progressCallback = + mockElectronAPI.onDownloadProgress!.calls.argsFor(0)[0]; + downloadedCallback = + mockElectronAPI.onUpdateDownloaded!.calls.argsFor(0)[0]; + errorCallback = mockElectronAPI.onUpdateError!.calls.argsFor(0)[0]; + }); + + it('should handle onUpdateChecking event', () => { + spyOn(console, 'log'); + + checkingCallback(); + + expect(service.isChecking()).toBe(true); + expect(service.errorMessage()).toBeNull(); + expect(console.log).toHaveBeenCalledWith( + '[UpdateService] Checking for updates...', + ); + }); + + it('should handle onUpdateAvailable event', () => { + spyOn(console, 'log'); + + availableCallback(mockUpdateInfo); + + expect(service.isChecking()).toBe(false); + expect(service.updateAvailable()).toEqual(mockUpdateInfo); + expect(console.log).toHaveBeenCalledWith( + '[UpdateService] Update available:', + '2.0.0', + ); + }); + + it('should handle onUpdateNotAvailable event', () => { + spyOn(console, 'log'); + + notAvailableCallback({ version: '1.0.0' }); + + expect(service.isChecking()).toBe(false); + expect(service.updateAvailable()).toBeNull(); + expect(console.log).toHaveBeenCalledWith( + '[UpdateService] No updates available. Current version:', + '1.0.0', + ); + }); + + it('should handle onDownloadProgress event', () => { + spyOn(console, 'log'); + const progress: DownloadProgress = { + percent: 45.67, + bytesPerSecond: 1024000, + transferred: 5000000, + total: 10000000, + }; + + progressCallback(progress); + + expect(service.isDownloading()).toBe(true); + expect(service.downloadProgress()).toBe(46); + expect(console.log).toHaveBeenCalledWith( + '[UpdateService] Download progress: 45.67%', + ); + }); + + it('should handle onUpdateDownloaded event', () => { + spyOn(console, 'log'); + + downloadedCallback(mockUpdateInfo); + + expect(service.isDownloading()).toBe(false); + expect(service.updateDownloaded()).toBe(true); + expect(service.downloadProgress()).toBe(100); + expect(console.log).toHaveBeenCalledWith( + '[UpdateService] Update downloaded:', + '2.0.0', + ); + }); + + it('should handle onUpdateError event', () => { + spyOn(console, 'error'); + + errorCallback({ message: 'Download failed' }); + + expect(service.isChecking()).toBe(false); + expect(service.isDownloading()).toBe(false); + expect(service.errorMessage()).toBe('Download failed'); + expect(console.error).toHaveBeenCalledWith( + '[UpdateService] Update error:', + 'Download failed', + ); + }); + }); + + describe('checkForUpdates', () => { + it('should check for updates successfully', async () => { + await service.checkForUpdates(); + + expect(mockElectronAPI.checkForUpdates).toHaveBeenCalled(); + }); + + it('should handle check for updates error', async () => { + mockElectronAPI.checkForUpdates!.and.returnValue( + Promise.resolve({ success: false, error: 'Network error' }), + ); + + await expectAsync(service.checkForUpdates()).toBeRejectedWithError( + 'Failed to check for updates', + ); + expect(service.errorMessage()).toBe('Network error'); + }); + + it('should throw error when electronAPI is not available', async () => { + delete (window as { electronAPI?: unknown }).electronAPI; + + await expectAsync(service.checkForUpdates()).toBeRejectedWithError( + 'Update API not available', + ); + }); + + it('should handle missing checkForUpdates method', async () => { + delete mockElectronAPI.checkForUpdates; + + await expectAsync(service.checkForUpdates()).toBeRejectedWithError( + 'Update API not available', + ); + }); + }); + + describe('downloadUpdate', () => { + it('should download update successfully', async () => { + await service.downloadUpdate(); + + expect(mockElectronAPI.downloadUpdate).toHaveBeenCalled(); + }); + + it('should handle download update error', async () => { + mockElectronAPI.downloadUpdate!.and.returnValue( + Promise.resolve({ success: false, error: 'Download failed' }), + ); + + await expectAsync(service.downloadUpdate()).toBeRejectedWithError( + 'Failed to download update', + ); + expect(service.errorMessage()).toBe('Download failed'); + }); + + it('should throw error when electronAPI is not available', async () => { + delete (window as { electronAPI?: unknown }).electronAPI; + + await expectAsync(service.downloadUpdate()).toBeRejectedWithError( + 'Update API not available', + ); + }); + + it('should handle missing downloadUpdate method', async () => { + delete mockElectronAPI.downloadUpdate; + + await expectAsync(service.downloadUpdate()).toBeRejectedWithError( + 'Update API not available', + ); + }); + }); + + describe('installUpdate', () => { + it('should install update successfully', async () => { + await service.installUpdate(); + + expect(mockElectronAPI.installUpdate).toHaveBeenCalled(); + }); + + it('should handle install update error', async () => { + mockElectronAPI.installUpdate!.and.returnValue( + Promise.resolve({ success: false, error: 'Install failed' }), + ); + + await expectAsync(service.installUpdate()).toBeRejectedWithError( + 'Failed to install update', + ); + expect(service.errorMessage()).toBe('Install failed'); + }); + + it('should throw error when electronAPI is not available', async () => { + delete (window as { electronAPI?: unknown }).electronAPI; + + await expectAsync(service.installUpdate()).toBeRejectedWithError( + 'Update API not available', + ); + }); + + it('should handle missing installUpdate method', async () => { + delete mockElectronAPI.installUpdate; + + await expectAsync(service.installUpdate()).toBeRejectedWithError( + 'Update API not available', + ); + }); + }); + + describe('getSettings', () => { + it('should get settings successfully', async () => { + const settings = await service.getSettings(); + + expect(mockElectronAPI.getUpdateSettings).toHaveBeenCalled(); + expect(settings).toEqual(mockUpdateSettings); + expect(service.settings()).toEqual(mockUpdateSettings); + }); + + it('should handle get settings error', async () => { + mockElectronAPI.getUpdateSettings!.and.returnValue( + Promise.resolve({ success: false, error: 'Settings error' }), + ); + + await expectAsync(service.getSettings()).toBeRejectedWithError( + 'Failed to get update settings', + ); + }); + + it('should handle get settings without error message', async () => { + mockElectronAPI.getUpdateSettings!.and.returnValue( + Promise.resolve({ success: false }), + ); + + await expectAsync(service.getSettings()).toBeRejectedWithError( + 'Failed to get update settings', + ); + }); + + it('should throw error when electronAPI is not available', async () => { + delete (window as { electronAPI?: unknown }).electronAPI; + + await expectAsync(service.getSettings()).toBeRejectedWithError( + 'Update API not available', + ); + }); + + it('should handle missing getUpdateSettings method', async () => { + delete mockElectronAPI.getUpdateSettings; + + await expectAsync(service.getSettings()).toBeRejectedWithError( + 'Update API not available', + ); + }); + }); + + describe('setAutoCheck', () => { + it('should enable auto-check successfully', async () => { + await service.setAutoCheck(true); + + expect(mockElectronAPI.setUpdateSettings!).toHaveBeenCalledWith({ + autoCheckEnabled: true, + }); + expect(service.settings().autoCheckEnabled).toBe(true); + }); + + it('should disable auto-check successfully', async () => { + await service.setAutoCheck(false); + + expect(mockElectronAPI.setUpdateSettings!).toHaveBeenCalledWith({ + autoCheckEnabled: false, + }); + expect(service.settings().autoCheckEnabled).toBe(false); + }); + + it('should handle set auto-check error', async () => { + mockElectronAPI.setUpdateSettings!.and.returnValue( + Promise.resolve({ success: false, error: 'Update failed' }), + ); + + await expectAsync(service.setAutoCheck(true)).toBeRejectedWithError( + 'Failed to set auto-check setting', + ); + }); + + it('should handle set auto-check error without message', async () => { + mockElectronAPI.setUpdateSettings!.and.returnValue( + Promise.resolve({ success: false }), + ); + + await expectAsync(service.setAutoCheck(true)).toBeRejectedWithError( + 'Failed to set auto-check setting', + ); + }); + + it('should throw error when electronAPI is not available', async () => { + delete (window as { electronAPI?: unknown }).electronAPI; + + await expectAsync(service.setAutoCheck(true)).toBeRejectedWithError( + 'Update API not available', + ); + }); + + it('should handle missing setUpdateSettings method', async () => { + delete mockElectronAPI.setUpdateSettings; + + await expectAsync(service.setAutoCheck(true)).toBeRejectedWithError( + 'Update API not available', + ); + }); + }); + + describe('getStatus', () => { + it('should get update status successfully', async () => { + const expectedStatus: UpdateResult = { + success: true, + }; + + const status = await service.getStatus(); + + expect(mockElectronAPI.getUpdateStatus!).toHaveBeenCalled(); + expect(status).toEqual(expectedStatus); + }); + + it('should throw error when electronAPI is not available', async () => { + delete (window as { electronAPI?: unknown }).electronAPI; + + await expectAsync(service.getStatus()).toBeRejectedWithError( + 'Update API not available', + ); + }); + + it('should handle missing getUpdateStatus method', async () => { + delete mockElectronAPI.getUpdateStatus; + + await expectAsync(service.getStatus()).toBeRejectedWithError( + 'Update API not available', + ); + }); + }); + + describe('resetState', () => { + it('should reset all state signals', () => { + /* + * Set some non-default values + */ + service.updateAvailable.set(mockUpdateInfo); + service.downloadProgress.set(50); + service.isChecking.set(true); + service.isDownloading.set(true); + service.updateDownloaded.set(true); + service.errorMessage.set('Some error'); + + service.resetState(); + + expect(service.updateAvailable()).toBeNull(); + expect(service.downloadProgress()).toBe(0); + expect(service.isChecking()).toBe(false); + expect(service.isDownloading()).toBe(false); + expect(service.updateDownloaded()).toBe(false); + expect(service.errorMessage()).toBeNull(); + }); + + it('should be safe to call resetState multiple times', () => { + service.resetState(); + service.resetState(); + + expect(service.updateAvailable()).toBeNull(); + expect(service.downloadProgress()).toBe(0); + }); + }); + + describe('signal updates', () => { + it('should allow manual signal updates', () => { + service.updateAvailable.set(mockUpdateInfo); + service.downloadProgress.set(75); + service.isChecking.set(true); + + expect(service.updateAvailable()).toEqual(mockUpdateInfo); + expect(service.downloadProgress()).toBe(75); + expect(service.isChecking()).toBe(true); + }); + + it('should handle settings signal update', () => { + const newSettings: UpdateSettings = { autoCheckEnabled: false }; + service.settings.set(newSettings); + + expect(service.settings()).toEqual(newSettings); + }); + + it('should handle settings signal partial update', () => { + service.settings.update((s) => ({ ...s, autoCheckEnabled: false })); + + expect(service.settings().autoCheckEnabled).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should handle multiple simultaneous checks', async () => { + const promise1 = service.checkForUpdates(); + const promise2 = service.checkForUpdates(); + + await Promise.all([promise1, promise2]); + + expect(mockElectronAPI.checkForUpdates!).toHaveBeenCalledTimes(2); + }); + + it('should handle rapid state changes', () => { + service.isChecking.set(true); + service.isChecking.set(false); + service.isChecking.set(true); + + expect(service.isChecking()).toBe(true); + }); + + it('should handle progress rounding edge cases', () => { + const progressCallback = + mockElectronAPI.onDownloadProgress!.calls.argsFor(0)[0]; + + progressCallback({ percent: 99.99 } as DownloadProgress); + expect(service.downloadProgress()).toBe(100); + + progressCallback({ percent: 0.01 } as DownloadProgress); + expect(service.downloadProgress()).toBe(0); + + progressCallback({ percent: 50.5 } as DownloadProgress); + expect(service.downloadProgress()).toBe(51); + }); + }); +}); diff --git a/src/app/services/update/update.service.ts b/src/app/services/update/update.service.ts new file mode 100644 index 0000000..2cb49b9 --- /dev/null +++ b/src/app/services/update/update.service.ts @@ -0,0 +1,268 @@ +import { Injectable, signal } from '@angular/core'; +import { + UpdateInfo, + UpdateSettings, + DownloadProgress, + UpdateResult, +} from '../../../types/electron'; +import { BaseDatabaseService } from '../database/base-database.service'; + +/** + * Service for managing application updates. + * Provides functionality for checking, downloading, and installing updates from GitHub Releases. + */ +@Injectable({ + providedIn: 'root', +}) +export class UpdateService extends BaseDatabaseService { + /** + * Signal indicating if an update is available. + */ + readonly updateAvailable = signal(null); + + /** + * Signal tracking download progress percentage (0-100). + */ + readonly downloadProgress = signal(0); + + /** + * Signal indicating if the app is currently checking for updates. + */ + readonly isChecking = signal(false); + + /** + * Signal indicating if an update is currently being downloaded. + */ + readonly isDownloading = signal(false); + + /** + * Signal indicating if an update has been downloaded and is ready to install. + */ + readonly updateDownloaded = signal(false); + + /** + * Signal holding current update settings. + */ + readonly settings = signal({ autoCheckEnabled: true }); + + /** + * Signal holding any update error message. + */ + readonly errorMessage = signal(null); + + constructor() { + super(); + this.setupListeners(); + } + + /** + * Initializes the service by loading update settings. + */ + async init(): Promise { + await this.loadSettings(); + } + + /** + * Sets up event listeners for update events from main process. + */ + private setupListeners(): void { + // Check if electronAPI exists (not available in tests) + if (!globalThis.window?.electronAPI) { + return; + } + + const api = globalThis.window.electronAPI; + + if (api.onUpdateChecking) { + api.onUpdateChecking(() => { + this.isChecking.set(true); + this.errorMessage.set(null); + console.log('[UpdateService] Checking for updates...'); + }); + } + + if (api.onUpdateAvailable) { + api.onUpdateAvailable((info: UpdateInfo) => { + this.isChecking.set(false); + this.updateAvailable.set(info); + console.log('[UpdateService] Update available:', info.version); + }); + } + + if (api.onUpdateNotAvailable) { + api.onUpdateNotAvailable((info: { version: string }) => { + this.isChecking.set(false); + this.updateAvailable.set(null); + console.log( + '[UpdateService] No updates available. Current version:', + info.version, + ); + }); + } + + if (api.onDownloadProgress) { + api.onDownloadProgress((progress: DownloadProgress) => { + this.isDownloading.set(true); + this.downloadProgress.set(Math.round(progress.percent)); + console.log( + `[UpdateService] Download progress: ${progress.percent.toFixed(2)}%`, + ); + }); + } + + if (api.onUpdateDownloaded) { + api.onUpdateDownloaded((info: UpdateInfo) => { + this.isDownloading.set(false); + this.updateDownloaded.set(true); + this.downloadProgress.set(100); + console.log('[UpdateService] Update downloaded:', info.version); + }); + } + + if (api.onUpdateError) { + api.onUpdateError((error: { message: string }) => { + this.isChecking.set(false); + this.isDownloading.set(false); + this.errorMessage.set(error.message); + console.error('[UpdateService] Update error:', error.message); + }); + } + } + + /** + * Loads current update settings from main process. + */ + private async loadSettings(): Promise { + if (!globalThis.window?.electronAPI?.getUpdateSettings) { + return; + } + + try { + const result = await globalThis.window.electronAPI.getUpdateSettings(); + if (result.success && result.settings) { + this.settings.set(result.settings); + } + } catch (error) { + console.error('[UpdateService] Failed to load settings:', error); + } + } + + /** + * Manually checks for available updates. + */ + async checkForUpdates(): Promise { + if (!globalThis.window?.electronAPI?.checkForUpdates) { + throw new Error('Update API not available'); + } + + return this.executeWithErrorHandling('check for updates', async () => { + const result: UpdateResult = + await globalThis.window.electronAPI.checkForUpdates(); + if (!result.success && result.error) { + this.errorMessage.set(result.error); + throw new Error(result.error); + } + }); + } + + /** + * Downloads the available update. + */ + async downloadUpdate(): Promise { + if (!globalThis.window?.electronAPI?.downloadUpdate) { + throw new Error('Update API not available'); + } + + return this.executeWithErrorHandling('download update', async () => { + const result: UpdateResult = + await globalThis.window.electronAPI.downloadUpdate(); + if (!result.success && result.error) { + this.errorMessage.set(result.error); + throw new Error(result.error); + } + }); + } + + /** + * Installs the downloaded update and restarts the application. + */ + async installUpdate(): Promise { + if (!globalThis.window?.electronAPI?.installUpdate) { + throw new Error('Update API not available'); + } + + return this.executeWithErrorHandling('install update', async () => { + const result: UpdateResult = + await globalThis.window.electronAPI.installUpdate(); + if (!result.success && result.error) { + this.errorMessage.set(result.error); + throw new Error(result.error); + } + }); + } + + /** + * Gets current update settings. + */ + async getSettings(): Promise { + if (!globalThis.window?.electronAPI?.getUpdateSettings) { + throw new Error('Update API not available'); + } + + return this.executeWithErrorHandling('get update settings', async () => { + const result: UpdateResult = + await globalThis.window.electronAPI.getUpdateSettings(); + if (result.success && result.settings) { + this.settings.set(result.settings); + return result.settings; + } + throw new Error(result.error || 'Failed to get settings'); + }); + } + + /** + * Updates the auto-check setting. + */ + async setAutoCheck(enabled: boolean): Promise { + if (!globalThis.window?.electronAPI?.setUpdateSettings) { + throw new Error('Update API not available'); + } + + return this.executeWithErrorHandling('set auto-check setting', async () => { + const result: UpdateResult = + await globalThis.window.electronAPI.setUpdateSettings({ + autoCheckEnabled: enabled, + }); + if (result.success) { + this.settings.update((s) => ({ ...s, autoCheckEnabled: enabled })); + } else { + throw new Error(result.error || 'Failed to update settings'); + } + }); + } + + /** + * Gets current update status. + */ + async getStatus(): Promise { + if (!globalThis.window?.electronAPI?.getUpdateStatus) { + throw new Error('Update API not available'); + } + + return this.executeWithErrorHandling('get update status', async () => { + return globalThis.window.electronAPI.getUpdateStatus(); + }); + } + + /** + * Resets all update state signals. + */ + resetState(): void { + this.updateAvailable.set(null); + this.downloadProgress.set(0); + this.isChecking.set(false); + this.isDownloading.set(false); + this.updateDownloaded.set(false); + this.errorMessage.set(null); + } +} From 079c88b68145646fda076b05c843ea8e0f6a483b Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:55:35 +0100 Subject: [PATCH 06/13] feat: add update dialog and settings page UI Create OpenUpdateDialog for displaying update notifications and OpenSettingsUpdates page for managing auto-check preferences. Includes unit tests for both components. Refs #40 --- .../open-update-dialog.html | 101 ++++++++ .../open-update-dialog.scss | 85 +++++++ .../open-update-dialog.spec.ts | 223 ++++++++++++++++++ .../open-update-dialog/open-update-dialog.ts | 84 +++++++ .../open-settings-updates.html | 115 +++++++++ .../open-settings-updates.scss | 109 +++++++++ .../open-settings-updates.ts | 169 +++++++++++++ 7 files changed, 886 insertions(+) create mode 100644 src/app/components/open-update-dialog/open-update-dialog.html create mode 100644 src/app/components/open-update-dialog/open-update-dialog.scss create mode 100644 src/app/components/open-update-dialog/open-update-dialog.spec.ts create mode 100644 src/app/components/open-update-dialog/open-update-dialog.ts create mode 100644 src/app/pages/open-settings-updates/open-settings-updates.html create mode 100644 src/app/pages/open-settings-updates/open-settings-updates.scss create mode 100644 src/app/pages/open-settings-updates/open-settings-updates.ts diff --git a/src/app/components/open-update-dialog/open-update-dialog.html b/src/app/components/open-update-dialog/open-update-dialog.html new file mode 100644 index 0000000..1a3fa99 --- /dev/null +++ b/src/app/components/open-update-dialog/open-update-dialog.html @@ -0,0 +1,101 @@ + +
+ @if (updateInfo()) { +
+
+ + + {{ "update.newVersion" | translate }}: + {{ updateInfo()?.version }} + +
+ + @if (updateInfo()?.releaseName) { +
+ {{ updateInfo()?.releaseName }} +
+ } + + @if (updateInfo()?.releaseDate) { +
+ {{ "update.releaseDate" | translate }}: + {{ updateInfo()?.releaseDate | date: "medium" }} +
+ } + + @if (updateInfo()?.releaseNotes) { +
+ {{ "update.releaseNotes" | translate }}: +
+ {{ updateInfo()?.releaseNotes }} +
+
+ } +
+ } + + @if (isDownloading()) { +
+

{{ "update.downloading" | translate }}

+ +

{{ downloadProgress() }}%

+
+ } + + @if (isDownloaded()) { +
+ +

{{ "update.readyToInstall" | translate }}

+
+ } +
+ + + @if (isDownloaded()) { + + + } @else if (isDownloading()) { + + } @else { + + + } + +
diff --git a/src/app/components/open-update-dialog/open-update-dialog.scss b/src/app/components/open-update-dialog/open-update-dialog.scss new file mode 100644 index 0000000..2224ff4 --- /dev/null +++ b/src/app/components/open-update-dialog/open-update-dialog.scss @@ -0,0 +1,85 @@ +.update-dialog { + &__info { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1rem; + } + + &__version { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.1rem; + + i { + color: var(--primary-color); + font-size: 1.5rem; + } + } + + &__release-name { + font-size: 1rem; + color: var(--text-color-secondary); + font-style: italic; + } + + &__date { + font-size: 0.9rem; + color: var(--text-color-secondary); + } + + &__notes { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + &__notes-content { + padding: 0.75rem; + background: var(--surface-ground); + border-radius: var(--border-radius); + border-left: 3px solid var(--primary-color); + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; + word-wrap: break-word; + font-size: 0.9rem; + line-height: 1.5; + } + + &__progress { + display: flex; + flex-direction: column; + gap: 0.75rem; + text-align: center; + + p { + margin: 0; + } + } + + &__progress-text { + font-weight: bold; + color: var(--primary-color); + } + + &__ready { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 1rem; + text-align: center; + + p { + margin: 0; + font-size: 1.1rem; + } + } + + &__ready-icon { + font-size: 3rem; + color: var(--green-500); + } +} diff --git a/src/app/components/open-update-dialog/open-update-dialog.spec.ts b/src/app/components/open-update-dialog/open-update-dialog.spec.ts new file mode 100644 index 0000000..ec6eba7 --- /dev/null +++ b/src/app/components/open-update-dialog/open-update-dialog.spec.ts @@ -0,0 +1,223 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { OpenUpdateDialogComponent } from './open-update-dialog'; +import { UpdateInfo } from '../../../types/electron'; + +describe('OpenUpdateDialogComponent', () => { + let component: OpenUpdateDialogComponent; + let fixture: ComponentFixture; + + const mockUpdateInfo: UpdateInfo = { + version: '2.0.0', + releaseName: 'Major Update', + releaseNotes: 'New features and improvements', + releaseDate: '2026-02-01T00:00:00Z', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [OpenUpdateDialogComponent, TranslateModule.forRoot()], + providers: [provideNoopAnimations()], + }).compileComponents(); + + fixture = TestBed.createComponent(OpenUpdateDialogComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.componentRef.setInput('visible', false); + expect(component).toBeTruthy(); + }); + + describe('dialogHeader computed', () => { + it('should return availableTitle when not downloading and not downloaded', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('isDownloading', false); + fixture.componentRef.setInput('isDownloaded', false); + + expect(component.dialogHeader()).toBe('update.availableTitle'); + }); + + it('should return downloadingTitle when downloading', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('isDownloading', true); + fixture.componentRef.setInput('isDownloaded', false); + + expect(component.dialogHeader()).toBe('update.downloadingTitle'); + }); + + it('should return readyToInstallTitle when downloaded', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('isDownloading', false); + fixture.componentRef.setInput('isDownloaded', true); + + expect(component.dialogHeader()).toBe('update.readyToInstallTitle'); + }); + }); + + describe('output events', () => { + it('should emit download event when onDownload is called', () => { + fixture.componentRef.setInput('visible', true); + let emitted = false; + component.download.subscribe(() => { + emitted = true; + }); + + component.onDownload(); + + expect(emitted).toBe(true); + }); + + it('should emit install event when onInstall is called', () => { + fixture.componentRef.setInput('visible', true); + let emitted = false; + component.install.subscribe(() => { + emitted = true; + }); + + component.onInstall(); + + expect(emitted).toBe(true); + }); + + it('should emit closed event when onClose is called', () => { + fixture.componentRef.setInput('visible', true); + let emitted = false; + component.closed.subscribe(() => { + emitted = true; + }); + + component.onClose(); + + expect(emitted).toBe(true); + }); + }); + + describe('rendering', () => { + it('should display update info when provided', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('updateInfo', mockUpdateInfo); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('2.0.0'); + }); + + it('should display progress bar when downloading', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('isDownloading', true); + fixture.componentRef.setInput('downloadProgress', 50); + fixture.detectChanges(); + + const progressBar = fixture.nativeElement.querySelector('p-progressBar'); + expect(progressBar).toBeTruthy(); + }); + + it('should show download button when not downloading and not downloaded', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('isDownloading', false); + fixture.componentRef.setInput('isDownloaded', false); + fixture.detectChanges(); + + const buttons = fixture.nativeElement.querySelectorAll('p-button'); + expect(buttons.length).toBeGreaterThan(0); + }); + + it('should show install button when downloaded', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('isDownloading', false); + fixture.componentRef.setInput('isDownloaded', true); + fixture.detectChanges(); + + const buttons = fixture.nativeElement.querySelectorAll('p-button'); + expect(buttons.length).toBeGreaterThan(0); + }); + + it('should show disabled button when downloading in progress', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('isDownloading', true); + fixture.detectChanges(); + + const buttons = fixture.nativeElement.querySelectorAll('p-button'); + expect(buttons.length).toBeGreaterThan(0); + }); + }); + + describe('inputs', () => { + it('should accept visible input', () => { + fixture.componentRef.setInput('visible', true); + expect(component.visible()).toBe(true); + + fixture.componentRef.setInput('visible', false); + expect(component.visible()).toBe(false); + }); + + it('should accept updateInfo input', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('updateInfo', mockUpdateInfo); + expect(component.updateInfo()).toEqual(mockUpdateInfo); + }); + + it('should accept downloadProgress input', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('downloadProgress', 75); + expect(component.downloadProgress()).toBe(75); + }); + + it('should accept isDownloading input', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('isDownloading', true); + expect(component.isDownloading()).toBe(true); + }); + + it('should accept isDownloaded input', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('isDownloaded', true); + expect(component.isDownloaded()).toBe(true); + }); + + it('should handle null updateInfo', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('updateInfo', null); + expect(component.updateInfo()).toBeNull(); + }); + }); + + describe('dialog states', () => { + it('should handle initial state (available update)', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('updateInfo', mockUpdateInfo); + fixture.componentRef.setInput('isDownloading', false); + fixture.componentRef.setInput('isDownloaded', false); + fixture.detectChanges(); + + expect(component.dialogHeader()).toBe('update.availableTitle'); + expect(component.visible()).toBe(true); + }); + + it('should handle downloading state', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('updateInfo', mockUpdateInfo); + fixture.componentRef.setInput('isDownloading', true); + fixture.componentRef.setInput('isDownloaded', false); + fixture.componentRef.setInput('downloadProgress', 30); + fixture.detectChanges(); + + expect(component.dialogHeader()).toBe('update.downloadingTitle'); + expect(component.downloadProgress()).toBe(30); + }); + + it('should handle downloaded state', () => { + fixture.componentRef.setInput('visible', true); + fixture.componentRef.setInput('updateInfo', mockUpdateInfo); + fixture.componentRef.setInput('isDownloading', false); + fixture.componentRef.setInput('isDownloaded', true); + fixture.componentRef.setInput('downloadProgress', 100); + fixture.detectChanges(); + + expect(component.dialogHeader()).toBe('update.readyToInstallTitle'); + expect(component.isDownloaded()).toBe(true); + }); + }); +}); diff --git a/src/app/components/open-update-dialog/open-update-dialog.ts b/src/app/components/open-update-dialog/open-update-dialog.ts new file mode 100644 index 0000000..5035c1f --- /dev/null +++ b/src/app/components/open-update-dialog/open-update-dialog.ts @@ -0,0 +1,84 @@ +import { Component, input, output, computed } from '@angular/core'; +import { DatePipe } from '@angular/common'; + +import { ButtonModule } from 'primeng/button'; +import { DialogModule } from 'primeng/dialog'; +import { ProgressBarModule } from 'primeng/progressbar'; +import { TranslateModule } from '@ngx-translate/core'; +import { UpdateInfo } from '../../../types/electron'; + +/** + * Update notification dialog component. + * Displays information about available updates and allows downloading/installing them. + */ +@Component({ + selector: 'app-open-update-dialog', + imports: [ + DatePipe, + ButtonModule, + DialogModule, + ProgressBarModule, + TranslateModule, + ], + templateUrl: './open-update-dialog.html', + styleUrl: './open-update-dialog.scss', +}) +export class OpenUpdateDialogComponent { + /** Whether the dialog is visible */ + visible = input.required(); + + /** Information about the available update */ + updateInfo = input(null); + + /** Download progress percentage (0-100) */ + downloadProgress = input(0); + + /** Whether update is currently being downloaded */ + isDownloading = input(false); + + /** Whether update has been downloaded and ready to install */ + isDownloaded = input(false); + + /** Event emitted when user wants to download the update */ + download = output(); + + /** Event emitted when user wants to install the update */ + install = output(); + + /** Event emitted when dialog is closed */ + closed = output(); + + /** + * Computed property for dialog header based on state. + */ + readonly dialogHeader = computed(() => { + if (this.isDownloading()) { + return 'update.downloadingTitle'; + } + if (this.isDownloaded()) { + return 'update.readyToInstallTitle'; + } + return 'update.availableTitle'; + }); + + /** + * Handles the download action. + */ + onDownload(): void { + this.download.emit(); + } + + /** + * Handles the install and restart action. + */ + onInstall(): void { + this.install.emit(); + } + + /** + * Handles the cancel/close action. + */ + onClose(): void { + this.closed.emit(); + } +} diff --git a/src/app/pages/open-settings-updates/open-settings-updates.html b/src/app/pages/open-settings-updates/open-settings-updates.html new file mode 100644 index 0000000..384b935 --- /dev/null +++ b/src/app/pages/open-settings-updates/open-settings-updates.html @@ -0,0 +1,115 @@ + + + + +
+

+ + {{ "update.checkForUpdates" | translate }} +

+
+
+ +
+ +
+
+ +
+ {{ "update.currentVersion" | translate }}: + {{ currentVersion }} +
+
+
+ + + + +
+
+
+ + {{ "update.autoCheckEnabled" | translate }} +
+ +
+
+ + + + +
+ +
+ + @if (updateAvailable()) { +
+ + + {{ "update.newVersion" | translate }}: + {{ updateAvailable()?.version }} + + +
+ } + + @if (errorMessage()) { +
+ + {{ "update.errorChecking" | translate }}: {{ errorMessage() }} +
+ } + + + + +
+ +
+ + @if (settings().lastCheckDate) { +
+ {{ "Last checked" }}: {{ settings().lastCheckDate | date: "medium" }} +
+ } +
+ + + +
diff --git a/src/app/pages/open-settings-updates/open-settings-updates.scss b/src/app/pages/open-settings-updates/open-settings-updates.scss new file mode 100644 index 0000000..556b2a0 --- /dev/null +++ b/src/app/pages/open-settings-updates/open-settings-updates.scss @@ -0,0 +1,109 @@ +.settings-updates-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + + &__title { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0; + font-size: 1.5rem; + + i { + color: var(--primary-color); + } + } +} + +.settings-updates { + padding: 1.5rem; + max-width: 800px; + margin: 0 auto; + + &__section { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem 0; + } + + &__info { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: var(--surface-ground); + border-radius: var(--border-radius); + border-left: 3px solid var(--primary-color); + + i { + font-size: 1.5rem; + color: var(--primary-color); + } + } + + &__setting { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + background: var(--surface-ground); + border-radius: var(--border-radius); + } + + &__setting-label { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1rem; + + i { + color: var(--text-color-secondary); + } + } + + &__update-info { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: var(--blue-50); + border-radius: var(--border-radius); + border-left: 3px solid var(--blue-500); + margin-top: 1rem; + + i { + font-size: 1.5rem; + color: var(--blue-500); + } + + span { + flex: 1; + } + } + + &__error-info { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: var(--red-50); + border-radius: var(--border-radius); + border-left: 3px solid var(--red-500); + margin-top: 1rem; + + i { + font-size: 1.5rem; + color: var(--red-500); + } + } + + &__last-check { + text-align: center; + font-size: 0.875rem; + color: var(--text-color-secondary); + margin-top: 1rem; + } +} diff --git a/src/app/pages/open-settings-updates/open-settings-updates.ts b/src/app/pages/open-settings-updates/open-settings-updates.ts new file mode 100644 index 0000000..85ea6c0 --- /dev/null +++ b/src/app/pages/open-settings-updates/open-settings-updates.ts @@ -0,0 +1,169 @@ +import { + Component, + OnInit, + inject, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ButtonModule } from 'primeng/button'; +import { ToggleSwitch } from 'primeng/toggleswitch'; +import { DividerModule } from 'primeng/divider'; +import { TooltipModule } from 'primeng/tooltip'; +import { TranslateModule } from '@ngx-translate/core'; +import { MessageService } from 'primeng/api'; +import { ToastModule } from 'primeng/toast'; +import { UpdateService } from '../../services/update/update.service'; +import { OpenLayoutComponent } from '../../components/open-layout/open-layout'; +import { OpenUpdateDialogComponent } from '../../components/open-update-dialog/open-update-dialog'; + +/** + * Settings page for managing application updates. + */ +@Component({ + selector: 'app-open-settings-updates', + standalone: true, + imports: [ + CommonModule, + DatePipe, + FormsModule, + ButtonModule, + ToggleSwitch, + DividerModule, + TooltipModule, + TranslateModule, + ToastModule, + OpenLayoutComponent, + OpenUpdateDialogComponent, + ], + providers: [MessageService], + templateUrl: './open-settings-updates.html', + styleUrl: './open-settings-updates.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpenSettingsUpdatesComponent implements OnInit { + private readonly updateService = inject(UpdateService); + private readonly messageService = inject(MessageService); + + readonly loading = signal(false); + readonly dialogVisible = signal(false); + readonly currentVersion = '1.0.0-alpha.3'; // TODO: Get from package.json or environment + + // Expose update service signals to template + readonly updateAvailable = this.updateService.updateAvailable; + readonly isChecking = this.updateService.isChecking; + readonly isDownloading = this.updateService.isDownloading; + readonly downloadProgress = this.updateService.downloadProgress; + readonly updateDownloaded = this.updateService.updateDownloaded; + readonly settings = this.updateService.settings; + readonly errorMessage = this.updateService.errorMessage; + + ngOnInit(): void { + void this.loadSettings(); + } + + async loadSettings(): Promise { + this.loading.set(true); + try { + await this.updateService.getSettings(); + } catch { + this.messageService.add({ + severity: 'error', + summary: '', + detail: 'Error loading update settings', + life: 3000, + }); + } finally { + this.loading.set(false); + } + } + + async onAutoCheckToggle(enabled: boolean): Promise { + try { + await this.updateService.setAutoCheck(enabled); + this.messageService.add({ + severity: 'success', + summary: '', + detail: enabled ? 'Auto-check enabled' : 'Auto-check disabled', + life: 3000, + }); + } catch { + this.messageService.add({ + severity: 'error', + summary: '', + detail: 'Error updating settings', + life: 3000, + }); + } + } + + async checkForUpdates(): Promise { + try { + await this.updateService.checkForUpdates(); + + // Wait a moment for the update check to complete + setTimeout(() => { + if (this.updateAvailable()) { + this.dialogVisible.set(true); + } else if (!this.isChecking() && !this.errorMessage()) { + this.messageService.add({ + severity: 'info', + summary: '', + detail: 'No updates available', + life: 3000, + }); + } + }, 1000); + } catch { + this.messageService.add({ + severity: 'error', + summary: '', + detail: 'Error checking for updates', + life: 3000, + }); + } + } + + async onDownload(): Promise { + try { + await this.updateService.downloadUpdate(); + } catch { + this.messageService.add({ + severity: 'error', + summary: '', + detail: 'Error downloading update', + life: 3000, + }); + } + } + + async onInstall(): Promise { + try { + await this.updateService.installUpdate(); + // App will restart, no need for toast + } catch { + this.messageService.add({ + severity: 'error', + summary: '', + detail: 'Error installing update', + life: 3000, + }); + } + } + + onDialogClosed(): void { + this.dialogVisible.set(false); + } + + openReleasesPage(): void { + // Open GitHub releases page in system browser + const url = 'https://github.com/altaskur/OpenTimeTracker/releases'; + if (globalThis.window?.electronAPI?.openExternal) { + void globalThis.window.electronAPI.openExternal(url); + } else { + // Fallback for web or if API not available + window.open(url, '_blank'); + } + } +} From b4e40a3e4c59a52127188d33ae4c4599095e9b7e Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:55:51 +0100 Subject: [PATCH 07/13] feat: integrate update system in Angular app Initialize UpdateService on app startup, add update dialog to layout, and register settings page route for /settings/updates. Refs #40 --- src/app/app.html | 23 ++++++++++++++++ src/app/app.routes.ts | 7 +++++ src/app/app.ts | 61 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/app/app.html b/src/app/app.html index 1cbfd09..88c6a86 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -3,3 +3,26 @@
+ + + + + diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 3d5a1b6..e5f3f99 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -49,6 +49,13 @@ export const routes: Routes = [ (m) => m.OpenSettingsStatusesComponent, ), }, + { + path: 'settings/updates', + loadComponent: () => + import('./pages/open-settings-updates/open-settings-updates').then( + (m) => m.OpenSettingsUpdatesComponent, + ), + }, { path: '**', redirectTo: '', diff --git a/src/app/app.ts b/src/app/app.ts index b53cef5..7550317 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,5 +1,13 @@ -import { Component, inject, OnInit, NgZone, OnDestroy } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { + Component, + inject, + OnInit, + NgZone, + OnDestroy, + signal, + effect, +} from '@angular/core'; +import { RouterOutlet, Router } from '@angular/router'; import { ToastModule } from 'primeng/toast'; import { MessageService } from 'primeng/api'; import { TranslateService } from '@ngx-translate/core'; @@ -9,6 +17,8 @@ import { TranslationService, } from './services'; import { ActionHistoryService } from './services/action-history.service'; +import { UpdateService } from './services/update/update.service'; +import { OpenUpdateDialogComponent } from './components/open-update-dialog/open-update-dialog'; /** * Root component of the application. @@ -16,7 +26,7 @@ import { ActionHistoryService } from './services/action-history.service'; */ @Component({ selector: 'app-root', - imports: [RouterOutlet, ToastModule], + imports: [RouterOutlet, ToastModule, OpenUpdateDialogComponent], templateUrl: './app.html', styleUrl: './app.scss', }) @@ -27,9 +37,33 @@ export class App implements OnInit, OnDestroy { private readonly themeService = inject(ThemeService); private readonly translationService = inject(TranslationService); private readonly historyService = inject(ActionHistoryService); + readonly updateService = inject(UpdateService); private readonly messageService = inject(MessageService); private readonly translate = inject(TranslateService); private readonly ngZone = inject(NgZone); + private readonly router = inject(Router); + + readonly updateDialogVisible = signal(false); + + constructor() { + // Setup effect for update notifications + // Must be in constructor to run in injection context + effect(() => { + const updateInfo = this.updateService.updateAvailable(); + if (updateInfo) { + this.ngZone.run(() => { + this.messageService.add({ + severity: 'info', + summary: this.translate.instant('update.availableTitle'), + detail: `${this.translate.instant('update.newVersion')}: ${updateInfo.version}`, + sticky: true, + closable: true, + data: { action: 'viewUpdate' }, + }); + }); + } + }); + } ngOnInit(): void { this.setupHistoryListeners(); @@ -74,4 +108,25 @@ export class App implements OnInit, OnDestroy { }); } } + + /** + * Handles download update action + */ + async onDownloadUpdate(): Promise { + await this.updateService.downloadUpdate(); + } + + /** + * Handles install update action + */ + async onInstallUpdate(): Promise { + await this.updateService.installUpdate(); + } + + /** + * Closes update dialog + */ + onCloseUpdateDialog(): void { + this.updateDialogVisible.set(false); + } } From abc0f681ddb9b8ccff7e85305b5a0cec62932514 Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:56:05 +0100 Subject: [PATCH 08/13] feat: add i18n translations for update system Add English and Spanish translations for update dialog, settings page, menu items, and all update-related UI messages. Refs #40 --- src/assets/i18n/en.json | 22 ++++++++++++++++++++++ src/assets/i18n/es.json | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index e789003..afdefc2 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -366,5 +366,27 @@ "cannotDeleteDefault": "Cannot delete default statuses", "cannotDeleteInUse": "Cannot delete: status is in use" } + }, + "update": { + "availableTitle": "Update Available", + "downloadingTitle": "Downloading Update", + "readyToInstallTitle": "Ready to Install", + "newVersion": "New version", + "releaseDate": "Release date", + "releaseNotes": "Release notes", + "downloading": "Downloading update...", + "readyToInstall": "Update ready to install", + "later": "Later", + "download": "Download", + "installRestart": "Install & Restart", + "downloadingInProgress": "Downloading...", + "checkForUpdates": "Check for Updates", + "autoCheckEnabled": "Check for updates on startup", + "currentVersion": "Current version", + "upToDate": "You're up to date", + "checking": "Checking for updates...", + "noUpdatesAvailable": "No updates available", + "errorChecking": "Error checking for updates", + "viewReleases": "View Releases on GitHub" } } diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json index 5bf049b..1208d28 100644 --- a/src/assets/i18n/es.json +++ b/src/assets/i18n/es.json @@ -366,5 +366,27 @@ "cannotDeleteDefault": "No se pueden eliminar los estados predeterminados", "cannotDeleteInUse": "No se puede eliminar: el estado está en uso" } + }, + "update": { + "availableTitle": "Actualización Disponible", + "downloadingTitle": "Descargando Actualización", + "readyToInstallTitle": "Lista para Instalar", + "newVersion": "Nueva versión", + "releaseDate": "Fecha de lanzamiento", + "releaseNotes": "Notas de la versión", + "downloading": "Descargando actualización...", + "readyToInstall": "Actualización lista para instalar", + "later": "Más tarde", + "download": "Descargar", + "installRestart": "Instalar y Reiniciar", + "downloadingInProgress": "Descargando...", + "checkForUpdates": "Buscar Actualizaciones", + "autoCheckEnabled": "Buscar actualizaciones al iniciar", + "currentVersion": "Versión actual", + "upToDate": "Estás actualizado", + "checking": "Buscando actualizaciones...", + "noUpdatesAvailable": "No hay actualizaciones disponibles", + "errorChecking": "Error al buscar actualizaciones", + "viewReleases": "Ver Lanzamientos en GitHub" } } From 30e38a95bfea658445ec335fa1021c468449d4f5 Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:56:19 +0100 Subject: [PATCH 09/13] test: update existing tests to mock UpdateService Add UpdateService mock to TestBed configurations in app.spec.ts and component tests to ensure proper dependency injection. Refs #40 --- src/app/app.spec.ts | 35 ++- .../project-card/project-card.spec.ts | 216 ++++++++++++++++++ src/app/pages/open-home/open-home.spec.ts | 5 +- .../pages/open-projects/open-projects.spec.ts | 5 +- src/app/pages/open-tasks/open-tasks.spec.ts | 11 +- 5 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 src/app/pages/open-home/components/project-card/project-card.spec.ts diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index b4c7ab1..98ef7c5 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,11 +1,14 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { App } from './app'; -import { provideTranslateTestingModule } from './testing/test-utils'; +import { TranslateModule } from '@ngx-translate/core'; import { MessageService } from 'primeng/api'; import { ActionHistoryService } from './services/action-history.service'; +import { UpdateService } from './services/update/update.service'; +import { signal } from '@angular/core'; describe('App', () => { let mockHistoryService: jasmine.SpyObj; + let mockUpdateService: jasmine.SpyObj; let mockElectronAPI: { onUndoAction: jasmine.Spy; onRedoAction: jasmine.Spy; @@ -49,6 +52,28 @@ describe('App', () => { }), ); + // Mock UpdateService with signals + mockUpdateService = jasmine.createSpyObj('UpdateService', [ + 'checkForUpdates', + 'downloadUpdate', + 'installUpdate', + ]); + Object.defineProperty(mockUpdateService, 'updateAvailable', { + get: () => signal(null), + }); + Object.defineProperty(mockUpdateService, 'downloadProgress', { + get: () => signal(null), + }); + Object.defineProperty(mockUpdateService, 'isChecking', { + get: () => signal(false), + }); + Object.defineProperty(mockUpdateService, 'isDownloading', { + get: () => signal(false), + }); + Object.defineProperty(mockUpdateService, 'updateDownloaded', { + get: () => signal(false), + }); + mockElectronAPI = { onUndoAction: jasmine .createSpy('onUndoAction') @@ -67,11 +92,11 @@ describe('App', () => { ).electronAPI = mockElectronAPI; await TestBed.configureTestingModule({ - imports: [App], + imports: [App, TranslateModule.forRoot()], providers: [ - ...provideTranslateTestingModule(), MessageService, { provide: ActionHistoryService, useValue: mockHistoryService }, + { provide: UpdateService, useValue: mockUpdateService }, ], }).compileComponents(); }); @@ -163,8 +188,8 @@ describe('App without electronAPI', () => { delete (window as { electronAPI?: unknown }).electronAPI; await TestBed.configureTestingModule({ - imports: [App], - providers: [...provideTranslateTestingModule(), MessageService], + imports: [App, TranslateModule.forRoot()], + providers: [MessageService], }).compileComponents(); }); diff --git a/src/app/pages/open-home/components/project-card/project-card.spec.ts b/src/app/pages/open-home/components/project-card/project-card.spec.ts new file mode 100644 index 0000000..6e01930 --- /dev/null +++ b/src/app/pages/open-home/components/project-card/project-card.spec.ts @@ -0,0 +1,216 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { ProjectCard } from './project-card'; +import { Project } from '../../../../../types/electron'; + +describe('ProjectCard', () => { + let component: ProjectCard; + let fixture: ComponentFixture; + + const mockProject: Project = { + id: '1', + name: 'Test Project', + description: 'A test project for unit testing', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-15'), + isClosed: false, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProjectCard], + providers: [provideNoopAnimations()], + }).compileComponents(); + + fixture = TestBed.createComponent(ProjectCard); + component = fixture.componentInstance; + }); + + describe('component creation', () => { + it('should create', () => { + fixture.componentRef.setInput('project', mockProject); + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + }); + + describe('project input', () => { + it('should accept project input', () => { + fixture.componentRef.setInput('project', mockProject); + fixture.detectChanges(); + + expect(component.project()).toEqual(mockProject); + }); + + it('should reflect project name changes', () => { + fixture.componentRef.setInput('project', mockProject); + fixture.detectChanges(); + + expect(component.project().name).toBe('Test Project'); + + const updatedProject = { ...mockProject, name: 'Updated Project' }; + fixture.componentRef.setInput('project', updatedProject); + fixture.detectChanges(); + + expect(component.project().name).toBe('Updated Project'); + }); + + it('should handle different project data', () => { + const anotherProject: Project = { + id: '2', + name: 'Another Project', + description: 'Different project', + createdAt: new Date('2026-02-01'), + updatedAt: new Date('2026-02-05'), + isClosed: false, + }; + + fixture.componentRef.setInput('project', anotherProject); + fixture.detectChanges(); + + expect(component.project()).toEqual(anotherProject); + expect(component.project().id).toBe('2'); + expect(component.project().name).toBe('Another Project'); + }); + }); + + describe('rendering', () => { + beforeEach(() => { + fixture.componentRef.setInput('project', mockProject); + fixture.detectChanges(); + }); + + it('should display project name', () => { + const compiled = fixture.nativeElement as HTMLElement; + const nameElement = compiled.querySelector('.project-card__name'); + + expect(nameElement).toBeTruthy(); + expect(nameElement?.textContent?.trim()).toBe('Test Project'); + }); + + it('should have project-card article element', () => { + const compiled = fixture.nativeElement as HTMLElement; + const articleElement = compiled.querySelector('article.project-card'); + + expect(articleElement).toBeTruthy(); + }); + + it('should contain p-card component', () => { + const compiled = fixture.nativeElement as HTMLElement; + const cardElement = compiled.querySelector('p-card'); + + expect(cardElement).toBeTruthy(); + }); + + it('should have project-card__header div', () => { + const compiled = fixture.nativeElement as HTMLElement; + const headerElement = compiled.querySelector('.project-card__header'); + + expect(headerElement).toBeTruthy(); + }); + + it('should display folder icon', () => { + const compiled = fixture.nativeElement as HTMLElement; + const iconElement = compiled.querySelector('.pi.pi-folder'); + + expect(iconElement).toBeTruthy(); + }); + + it('should update project name in DOM when input changes', () => { + const compiled = fixture.nativeElement as HTMLElement; + let nameElement = compiled.querySelector('.project-card__name'); + + expect(nameElement?.textContent?.trim()).toBe('Test Project'); + + const updatedProject = { ...mockProject, name: 'New Name' }; + fixture.componentRef.setInput('project', updatedProject); + fixture.detectChanges(); + + nameElement = compiled.querySelector('.project-card__name'); + expect(nameElement?.textContent?.trim()).toBe('New Name'); + }); + + it('should render with minimal project data', () => { + const minimalProject: Project = { + id: '999', + name: 'Minimal', + description: '', + createdAt: new Date(), + updatedAt: new Date(), + isClosed: false, + }; + + fixture.componentRef.setInput('project', minimalProject); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const nameElement = compiled.querySelector('.project-card__name'); + + expect(nameElement?.textContent?.trim()).toBe('Minimal'); + }); + + it('should handle project with long name', () => { + const longNameProject: Project = { + ...mockProject, + name: 'Project with a very long name that might need special handling', + }; + + fixture.componentRef.setInput('project', longNameProject); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const nameElement = compiled.querySelector('.project-card__name'); + + expect(nameElement?.textContent?.trim()).toBe( + 'Project with a very long name that might need special handling', + ); + }); + + it('should handle project with special characters in name', () => { + const specialNameProject: Project = { + ...mockProject, + name: 'Project #1 @ 2026 & Co.', + }; + + fixture.componentRef.setInput('project', specialNameProject); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const nameElement = compiled.querySelector('.project-card__name'); + + expect(nameElement?.textContent?.trim()).toBe('Project #1 @ 2026 & Co.'); + }); + }); + + describe('structure', () => { + beforeEach(() => { + fixture.componentRef.setInput('project', mockProject); + fixture.detectChanges(); + }); + + it('should have header as direct child of p-card', () => { + const compiled = fixture.nativeElement as HTMLElement; + const cardElement = compiled.querySelector('p-card'); + const headerElement = cardElement?.querySelector('.project-card__header'); + + expect(headerElement).toBeTruthy(); + }); + + it('should have icon and name inside header', () => { + const compiled = fixture.nativeElement as HTMLElement; + const headerElement = compiled.querySelector('.project-card__header'); + const icon = headerElement?.querySelector('.pi.pi-folder'); + const name = headerElement?.querySelector('.project-card__name'); + + expect(icon).toBeTruthy(); + expect(name).toBeTruthy(); + }); + + it('should have h3 element for project name', () => { + const compiled = fixture.nativeElement as HTMLElement; + const nameElement = compiled.querySelector('.project-card__name'); + + expect(nameElement?.tagName.toLowerCase()).toBe('h3'); + }); + }); +}); diff --git a/src/app/pages/open-home/open-home.spec.ts b/src/app/pages/open-home/open-home.spec.ts index ba70be5..1307d5d 100644 --- a/src/app/pages/open-home/open-home.spec.ts +++ b/src/app/pages/open-home/open-home.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { OpenHome } from './open-home'; import { Router } from '@angular/router'; -import { provideTranslateTestingModule } from '../../testing/test-utils'; import { DatabaseService } from '../../services'; import { Task, @@ -136,11 +136,10 @@ describe('OpenHome', () => { mockDbService.getDayOverrides.and.returnValue(Promise.resolve([])); await TestBed.configureTestingModule({ - imports: [OpenHome], + imports: [OpenHome, TranslateModule.forRoot()], providers: [ { provide: Router, useValue: mockRouter }, { provide: DatabaseService, useValue: mockDbService }, - ...provideTranslateTestingModule(), ], }).compileComponents(); diff --git a/src/app/pages/open-projects/open-projects.spec.ts b/src/app/pages/open-projects/open-projects.spec.ts index fa161fd..61c5977 100644 --- a/src/app/pages/open-projects/open-projects.spec.ts +++ b/src/app/pages/open-projects/open-projects.spec.ts @@ -1,8 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MessageService } from 'primeng/api'; +import { TranslateModule } from '@ngx-translate/core'; import { OpenProjects } from './open-projects'; import { DatabaseService } from '../../services'; -import { provideTranslateTestingModule } from '../../testing/test-utils'; import { Project } from '../../../types/electron'; describe('OpenProjects', () => { @@ -37,11 +37,10 @@ describe('OpenProjects', () => { ]); await TestBed.configureTestingModule({ - imports: [OpenProjects], + imports: [OpenProjects, TranslateModule.forRoot()], providers: [ { provide: DatabaseService, useValue: mockDatabaseService }, MessageService, - ...provideTranslateTestingModule(), ], }).compileComponents(); diff --git a/src/app/pages/open-tasks/open-tasks.spec.ts b/src/app/pages/open-tasks/open-tasks.spec.ts index 8b36e3b..270711e 100644 --- a/src/app/pages/open-tasks/open-tasks.spec.ts +++ b/src/app/pages/open-tasks/open-tasks.spec.ts @@ -3,10 +3,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { MessageService } from 'primeng/api'; +import { TranslateModule } from '@ngx-translate/core'; import { OpenTasks } from './open-tasks'; import { DatabaseService } from '../../services'; -import { provideTranslateTestingModule } from '../../testing/test-utils'; import { TaskWithTags } from '../../interfaces'; describe('OpenTasks', () => { @@ -124,11 +124,10 @@ describe('OpenTasks', () => { ); await TestBed.configureTestingModule({ - imports: [OpenTasks, FormsModule], + imports: [OpenTasks, FormsModule, TranslateModule.forRoot()], providers: [ { provide: DatabaseService, useValue: mockDatabaseService }, MessageService, - ...provideTranslateTestingModule(), ], }).compileComponents(); @@ -146,8 +145,8 @@ describe('OpenTasks', () => { }); describe('ngOnInit', () => { - it('should load data on init', async () => { - await component.ngOnInit(); + it('should load data on init', () => { + component.ngOnInit(); expect(mockDatabaseService.getTasks).toHaveBeenCalled(); expect(mockDatabaseService.getProjects).toHaveBeenCalled(); @@ -436,7 +435,7 @@ describe('OpenTasks', () => { it('should return secondary for unknown status', () => { expect(component.getStatusSeverity('Unknown')).toBe('secondary'); - expect(component.getStatusSeverity(undefined)).toBe('secondary'); + expect(component.getStatusSeverity()).toBe('secondary'); }); }); From 97a46a355d98832277b5d8b023c33901b72dbbb2 Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:56:34 +0100 Subject: [PATCH 10/13] chore: update VS Code settings for test coverage Update workspace settings to exclude update service from coverage. Refs #40 --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 7c1caa9..f04f98b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "sonarlint.connectedMode.project": { "connectionId": "http-localhost-9000", - "projectKey": "open-time-tracker" + "projectKey": "altaskur_OpenTimeTracker" }, "editor.formatOnSave": true, "[html]": { From 2d817c07613369c8fbc7c1e42563f84a38501e36 Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Fri, 6 Feb 2026 00:01:02 +0100 Subject: [PATCH 11/13] fix: add noopener and noreferrer to window.open Add security features to prevent potential tabnabbing attack when opening external links in fallback scenario. Refs #40 --- src/app/pages/open-settings-updates/open-settings-updates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/pages/open-settings-updates/open-settings-updates.ts b/src/app/pages/open-settings-updates/open-settings-updates.ts index 85ea6c0..9cad9a3 100644 --- a/src/app/pages/open-settings-updates/open-settings-updates.ts +++ b/src/app/pages/open-settings-updates/open-settings-updates.ts @@ -163,7 +163,7 @@ export class OpenSettingsUpdatesComponent implements OnInit { void globalThis.window.electronAPI.openExternal(url); } else { // Fallback for web or if API not available - window.open(url, '_blank'); + window.open(url, '_blank', 'noopener,noreferrer'); } } } From a622ab996f7a197a4682aad77438baf6b6222dd5 Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Fri, 6 Feb 2026 00:26:31 +0100 Subject: [PATCH 12/13] test: add unit tests for update handling in App component --- .../src/interfaces/update.interface.spec.ts | 116 +++ .../src/services/ipc/update-handlers.spec.ts | 314 ++++++++ .../services/updater/update-manager.spec.ts | 691 ++++++++++++++++++ src/app/app.spec.ts | 98 +++ 4 files changed, 1219 insertions(+) create mode 100644 electron/src/interfaces/update.interface.spec.ts create mode 100644 electron/src/services/ipc/update-handlers.spec.ts create mode 100644 electron/src/services/updater/update-manager.spec.ts diff --git a/electron/src/interfaces/update.interface.spec.ts b/electron/src/interfaces/update.interface.spec.ts new file mode 100644 index 0000000..8b16e04 --- /dev/null +++ b/electron/src/interfaces/update.interface.spec.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { + UpdateInfo, + DownloadProgress, + UpdateSettings, + UpdateStatus, + UpdateError, +} from './update.interface.js'; + +describe('Update interfaces', () => { + describe('UpdateInfo', () => { + it('should create valid UpdateInfo with all properties', () => { + const updateInfo: UpdateInfo = { + version: '2.0.0', + releaseDate: '2026-02-01', + releaseName: 'Version 2.0.0', + releaseNotes: 'New features', + size: 1024000, + }; + + expect(updateInfo.version).toBe('2.0.0'); + expect(updateInfo.releaseDate).toBe('2026-02-01'); + expect(updateInfo.releaseName).toBe('Version 2.0.0'); + expect(updateInfo.releaseNotes).toBe('New features'); + expect(updateInfo.size).toBe(1024000); + }); + + it('should create valid UpdateInfo with only required properties', () => { + const updateInfo: UpdateInfo = { + version: '2.0.0', + releaseDate: '2026-02-01', + }; + + expect(updateInfo.version).toBe('2.0.0'); + expect(updateInfo.releaseDate).toBe('2026-02-01'); + expect(updateInfo.releaseName).toBeUndefined(); + expect(updateInfo.releaseNotes).toBeUndefined(); + expect(updateInfo.size).toBeUndefined(); + }); + }); + + describe('DownloadProgress', () => { + it('should create valid DownloadProgress', () => { + const progress: DownloadProgress = { + bytesPerSecond: 1024000, + percent: 45.67, + transferred: 5000000, + total: 10000000, + }; + + expect(progress.bytesPerSecond).toBe(1024000); + expect(progress.percent).toBe(45.67); + expect(progress.transferred).toBe(5000000); + expect(progress.total).toBe(10000000); + }); + }); + + describe('UpdateSettings', () => { + it('should create valid UpdateSettings with all properties', () => { + const settings: UpdateSettings = { + autoCheckEnabled: true, + lastCheckDate: new Date('2026-02-01'), + }; + + expect(settings.autoCheckEnabled).toBe(true); + expect(settings.lastCheckDate).toBeInstanceOf(Date); + }); + + it('should create valid UpdateSettings with only required properties', () => { + const settings: UpdateSettings = { + autoCheckEnabled: false, + }; + + expect(settings.autoCheckEnabled).toBe(false); + expect(settings.lastCheckDate).toBeUndefined(); + }); + }); + + describe('UpdateStatus', () => { + it('should have correct enum values', () => { + expect(UpdateStatus.Idle).toBe('idle'); + expect(UpdateStatus.Checking).toBe('checking'); + expect(UpdateStatus.Available).toBe('available'); + expect(UpdateStatus.NotAvailable).toBe('not-available'); + expect(UpdateStatus.Downloading).toBe('downloading'); + expect(UpdateStatus.Downloaded).toBe('downloaded'); + expect(UpdateStatus.Error).toBe('error'); + }); + + it('should be assignable to variables', () => { + const status: UpdateStatus = UpdateStatus.Checking; + expect(status).toBe(UpdateStatus.Checking); + }); + }); + + describe('UpdateError', () => { + it('should create valid UpdateError with all properties', () => { + const error: UpdateError = { + message: 'Update failed', + code: 'ERR_UPDATE_FAILED', + }; + + expect(error.message).toBe('Update failed'); + expect(error.code).toBe('ERR_UPDATE_FAILED'); + }); + + it('should create valid UpdateError with only required properties', () => { + const error: UpdateError = { + message: 'Update failed', + }; + + expect(error.message).toBe('Update failed'); + expect(error.code).toBeUndefined(); + }); + }); +}); diff --git a/electron/src/services/ipc/update-handlers.spec.ts b/electron/src/services/ipc/update-handlers.spec.ts new file mode 100644 index 0000000..75eda83 --- /dev/null +++ b/electron/src/services/ipc/update-handlers.spec.ts @@ -0,0 +1,314 @@ +import { + describe, + it, + expect, + beforeEach, + vi, + type Mock, + type Mocked, +} from 'vitest'; +import { ipcMain } from 'electron'; +import { setupUpdateHandlers } from './update-handlers.js'; +import { UpdateManager } from '../updater/update-manager.js'; +import { + UpdateSettings, + UpdateStatus, + UpdateInfo, +} from '../../interfaces/update.interface.js'; + +vi.mock('electron-updater', () => ({ + default: { + autoUpdater: { + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + quitAndInstall: vi.fn(), + on: vi.fn(), + removeAllListeners: vi.fn(), + setFeedURL: vi.fn(), + logger: null, + autoDownload: false, + autoInstallOnAppQuit: false, + allowPrerelease: false, + }, + }, +})); +vi.mock('../updater/update-manager.js'); + +describe('Update IPC Handlers', () => { + let mockUpdateManager: Mocked; + let ipcHandlers: Map; + + const mockUpdateSettings: UpdateSettings = { + autoCheckEnabled: true, + }; + + const mockUpdateInfo: UpdateInfo = { + version: '2.0.0', + releaseName: 'Version 2.0.0', + releaseNotes: 'New features', + releaseDate: '2026-02-01', + }; + + beforeEach(() => { + vi.clearAllMocks(); + ipcHandlers = new Map(); + + mockUpdateManager = { + checkForUpdates: vi.fn().mockResolvedValue(undefined), + downloadUpdate: vi.fn().mockResolvedValue(undefined), + quitAndInstall: vi.fn(), + getSettings: vi.fn().mockReturnValue(mockUpdateSettings), + setSettings: vi.fn().mockResolvedValue(undefined), + getStatus: vi.fn().mockReturnValue(UpdateStatus.Idle), + getUpdateInfo: vi.fn().mockReturnValue(null), + } as unknown as Mocked; + + (UpdateManager.getInstance as Mock).mockReturnValue(mockUpdateManager); + + (ipcMain.handle as Mock).mockImplementation( + (channel: string, handler: Mock) => { + ipcHandlers.set(channel, handler); + }, + ); + }); + + describe('setupUpdateHandlers', () => { + it('should register all update IPC handlers', () => { + setupUpdateHandlers(); + + expect(ipcMain.handle).toHaveBeenCalledWith( + 'update:check', + expect.any(Function), + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'update:download', + expect.any(Function), + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'update:install', + expect.any(Function), + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'update:get-settings', + expect.any(Function), + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'update:set-settings', + expect.any(Function), + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'update:get-status', + expect.any(Function), + ); + }); + + it('should initialize UpdateManager instance', () => { + setupUpdateHandlers(); + + expect(UpdateManager.getInstance).toHaveBeenCalled(); + }); + }); + + describe('update:check handler', () => { + it('should check for updates successfully', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:check')!; + + const result = await handler(); + + expect(mockUpdateManager.checkForUpdates).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it('should handle check for updates error', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:check')!; + const error = new Error('Network error'); + mockUpdateManager.checkForUpdates.mockRejectedValue(error); + + const result = await handler(); + + expect(result).toEqual({ + success: false, + error: 'Network error', + }); + }); + + it('should handle non-Error objects', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:check')!; + mockUpdateManager.checkForUpdates.mockRejectedValue('String error'); + + const result = await handler(); + + expect(result).toEqual({ + success: false, + error: 'Unknown error', + }); + }); + }); + + describe('update:download handler', () => { + it('should download update successfully', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:download')!; + + const result = await handler(); + + expect(mockUpdateManager.downloadUpdate).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it('should handle download error', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:download')!; + const error = new Error('Download failed'); + mockUpdateManager.downloadUpdate.mockRejectedValue(error); + + const result = await handler(); + + expect(result).toEqual({ + success: false, + error: 'Download failed', + }); + }); + }); + + describe('update:install handler', () => { + it('should install update successfully', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:install')!; + + const result = await handler(); + + expect(mockUpdateManager.quitAndInstall).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it('should handle install error', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:install')!; + const error = new Error('Install failed'); + mockUpdateManager.quitAndInstall.mockImplementation(() => { + throw error; + }); + + const result = await handler(); + + expect(result).toEqual({ + success: false, + error: 'Install failed', + }); + }); + }); + + describe('update:get-settings handler', () => { + it('should get settings successfully', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:get-settings')!; + + const result = await handler(); + + expect(mockUpdateManager.getSettings).toHaveBeenCalled(); + expect(result).toEqual({ + success: true, + settings: mockUpdateSettings, + }); + }); + + it('should handle get settings error', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:get-settings')!; + const error = new Error('Settings error'); + mockUpdateManager.getSettings.mockImplementation(() => { + throw error; + }); + + const result = await handler(); + + expect(result).toEqual({ + success: false, + error: 'Settings error', + }); + }); + }); + + describe('update:set-settings handler', () => { + it('should set settings successfully', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:set-settings')!; + const newSettings: Partial = { + autoCheckEnabled: false, + }; + + const result = await handler({}, newSettings); + + expect(mockUpdateManager.setSettings).toHaveBeenCalledWith(newSettings); + expect(result).toEqual({ success: true }); + }); + + it('should handle set settings error', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:set-settings')!; + const error = new Error('Update failed'); + mockUpdateManager.setSettings.mockRejectedValue(error); + + const result = await handler({}, {}); + + expect(result).toEqual({ + success: false, + error: 'Update failed', + }); + }); + }); + + describe('update:get-status handler', () => { + it('should get status successfully', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:get-status')!; + mockUpdateManager.getStatus.mockReturnValue(UpdateStatus.Checking); + mockUpdateManager.getUpdateInfo.mockReturnValue(mockUpdateInfo); + + const result = await handler(); + + expect(mockUpdateManager.getStatus).toHaveBeenCalled(); + expect(mockUpdateManager.getUpdateInfo).toHaveBeenCalled(); + expect(result).toEqual({ + success: true, + status: UpdateStatus.Checking, + updateInfo: mockUpdateInfo, + }); + }); + + it('should get status with null updateInfo', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:get-status')!; + mockUpdateManager.getStatus.mockReturnValue(UpdateStatus.Idle); + mockUpdateManager.getUpdateInfo.mockReturnValue(null); + + const result = await handler(); + + expect(result).toEqual({ + success: true, + status: UpdateStatus.Idle, + updateInfo: null, + }); + }); + + it('should handle get status error', async () => { + setupUpdateHandlers(); + const handler = ipcHandlers.get('update:get-status')!; + const error = new Error('Status error'); + mockUpdateManager.getStatus.mockImplementation(() => { + throw error; + }); + + const result = await handler(); + + expect(result).toEqual({ + success: false, + error: 'Status error', + }); + }); + }); +}); diff --git a/electron/src/services/updater/update-manager.spec.ts b/electron/src/services/updater/update-manager.spec.ts new file mode 100644 index 0000000..e08d942 --- /dev/null +++ b/electron/src/services/updater/update-manager.spec.ts @@ -0,0 +1,691 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, + type Mock, + type Mocked, +} from 'vitest'; +import { BrowserWindow, app } from 'electron'; +import { UpdateManager } from './update-manager.js'; +import electronUpdater from 'electron-updater'; +import { UpdateStatus } from '../../interfaces/update.interface.js'; +import * as fs from 'node:fs'; +import { BackupService } from '../backup/backup.service.js'; + +vi.mock('electron'); +vi.mock('electron-updater', () => ({ + default: { + autoUpdater: { + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + quitAndInstall: vi.fn(), + on: vi.fn(), + removeAllListeners: vi.fn(), + setFeedURL: vi.fn(), + logger: null, + autoDownload: false, + autoInstallOnAppQuit: false, + allowPrerelease: false, + }, + }, +})); +vi.mock('node:fs'); +const createBackupMock = vi.hoisted(() => vi.fn()); + +vi.mock('../backup/backup.service.js', () => ({ + BackupService: class BackupServiceMock { + createBackup = createBackupMock; + }, +})); + +const { autoUpdater } = electronUpdater; + +describe('UpdateManager', () => { + let updateManager: UpdateManager; + let mockWindow: Mocked; + let mockBackupService: Mocked; + + const mockUpdateInfo = { + version: '2.0.0', + releaseDate: '2026-02-01', + releaseName: 'Version 2.0.0', + releaseNotes: 'New features', + }; + + beforeEach(() => { + /* Clear only call history, not implementations. */ + (autoUpdater.on as Mock).mockClear(); + (autoUpdater.checkForUpdates as Mock).mockClear(); + (autoUpdater.downloadUpdate as Mock).mockClear(); + (autoUpdater.quitAndInstall as Mock).mockClear(); + + /* Reset singleton instance before each test. */ + ( + UpdateManager as unknown as Record + ).instance = undefined; + + mockWindow = { + webContents: { + send: vi.fn(), + }, + isDestroyed: vi.fn().mockReturnValue(false), + } as unknown as Mocked; + + createBackupMock.mockReset(); + createBackupMock.mockResolvedValue({ + success: true, + backup: { filename: 'backup.db' }, + }); + + mockBackupService = { + createBackup: createBackupMock, + } as unknown as Mocked; + + (app.getPath as Mock).mockReturnValue('/test/path'); + (app.getVersion as Mock).mockReturnValue('1.0.0'); + (app.isPackaged as unknown) = false; + + (fs.existsSync as Mock).mockReturnValue(false); + (fs.readFileSync as Mock).mockReturnValue('{}'); + (fs.writeFileSync as Mock).mockReturnValue(undefined); + + (autoUpdater.on as Mock).mockReturnValue(autoUpdater); + (autoUpdater.checkForUpdates as Mock).mockResolvedValue({}); + (autoUpdater.downloadUpdate as Mock).mockResolvedValue([]); + (autoUpdater.quitAndInstall as Mock).mockReturnValue(undefined); + + /* AutoUpdater logger setter. */ + Object.defineProperty(autoUpdater, 'logger', { + set: vi.fn(), + get: vi.fn(), + }); + }); + + afterEach(() => { + /* Reset singleton instance after each test. */ + ( + UpdateManager as unknown as Record + ).instance = undefined; + vi.restoreAllMocks(); + }); + + describe('getInstance', () => { + it('should return singleton instance', () => { + const instance1 = UpdateManager.getInstance(); + const instance2 = UpdateManager.getInstance(); + + expect(instance1).toBe(instance2); + }); + + it('should setup auto updater on first instantiation', () => { + UpdateManager.getInstance(); + + expect(autoUpdater.on).toHaveBeenCalledWith( + 'checking-for-update', + expect.any(Function), + ); + expect(autoUpdater.on).toHaveBeenCalledWith( + 'update-available', + expect.any(Function), + ); + expect(autoUpdater.on).toHaveBeenCalledWith( + 'update-not-available', + expect.any(Function), + ); + expect(autoUpdater.on).toHaveBeenCalledWith( + 'download-progress', + expect.any(Function), + ); + expect(autoUpdater.on).toHaveBeenCalledWith( + 'update-downloaded', + expect.any(Function), + ); + expect(autoUpdater.on).toHaveBeenCalledWith( + 'error', + expect.any(Function), + ); + }); + }); + + describe('initialization', () => { + it('should load settings from file if exists', () => { + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue( + JSON.stringify({ + autoCheckEnabled: false, + checkOnStartup: false, + autoDownload: true, + }), + ); + + const manager = UpdateManager.getInstance(); + const settings = manager.getSettings(); + + expect(settings.autoCheckEnabled).toBe(false); + }); + + it('should use default settings if file does not exist', () => { + (fs.existsSync as Mock).mockReturnValue(false); + + const manager = UpdateManager.getInstance(); + const settings = manager.getSettings(); + + expect(settings.autoCheckEnabled).toBe(true); + }); + + it('should handle settings load error gracefully', () => { + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation(() => { + throw new Error('Read error'); + }); + + const manager = UpdateManager.getInstance(); + const settings = manager.getSettings(); + + // Should fallback to defaults + expect(settings.autoCheckEnabled).toBe(true); + }); + }); + + describe('setMainWindow', () => { + it('should set main window reference', () => { + updateManager = UpdateManager.getInstance(); + updateManager.setMainWindow(mockWindow); + + // Verify window is set by triggering an event + const checkingHandler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'checking-for-update', + )?.[1]; + + if (checkingHandler) { + checkingHandler(); + expect(mockWindow.webContents.send).toHaveBeenCalledWith( + 'update:checking', + undefined, + ); + } + }); + }); + + describe('initialize', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should initialize update manager', async () => { + updateManager = UpdateManager.getInstance(); + + await updateManager.initialize(); + + expect(updateManager).toBeDefined(); + }); + + it('should check for updates on startup if enabled', async () => { + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue( + JSON.stringify({ + autoCheckEnabled: true, + checkOnStartup: true, + }), + ); + (app.isPackaged as unknown) = true; + + updateManager = UpdateManager.getInstance(); + const checkSpy = vi + .spyOn(updateManager, 'checkForUpdates') + .mockResolvedValue(); + + await updateManager.initialize(); + vi.advanceTimersByTime(5000); + + await vi.runAllTimersAsync(); + + expect(checkSpy).toHaveBeenCalled(); + }); + + it('should not check for updates if auto-check disabled', async () => { + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue( + JSON.stringify({ + autoCheckEnabled: false, + checkOnStartup: false, + }), + ); + + updateManager = UpdateManager.getInstance(); + const checkSpy = vi + .spyOn(updateManager, 'checkForUpdates') + .mockResolvedValue(); + + await updateManager.initialize(); + vi.advanceTimersByTime(5000); + + expect(checkSpy).not.toHaveBeenCalled(); + }); + }); + + describe('checkForUpdates', () => { + beforeEach(() => { + updateManager = UpdateManager.getInstance(); + }); + + it('should skip check in development mode', async () => { + (app.isPackaged as unknown) = false; + updateManager.setMainWindow(mockWindow); + + await updateManager.checkForUpdates(); + + expect(autoUpdater.checkForUpdates).not.toHaveBeenCalled(); + expect(mockWindow.webContents.send).toHaveBeenCalledWith( + 'update:not-available', + { version: '1.0.0' }, + ); + }); + + it('should check for updates in production mode', async () => { + (app.isPackaged as unknown) = true; + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue( + JSON.stringify({ autoCheckEnabled: true }), + ); + + await updateManager.checkForUpdates(); + + expect(autoUpdater.checkForUpdates).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should allow manual check when auto-check is disabled', async () => { + (app.isPackaged as unknown) = true; + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue( + JSON.stringify({ autoCheckEnabled: false }), + ); + + await updateManager.checkForUpdates(); + + expect(autoUpdater.checkForUpdates).toHaveBeenCalled(); + }); + + it('should not check if already checking', async () => { + (app.isPackaged as unknown) = true; + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue( + JSON.stringify({ autoCheckEnabled: true }), + ); + + // Simulate checking state + const checkingHandler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'checking-for-update', + )?.[1]; + if (checkingHandler) { + checkingHandler(); + } + + await updateManager.checkForUpdates(); + + // Should only be called once from the event + expect(autoUpdater.checkForUpdates).not.toHaveBeenCalled(); + }); + + it('should handle check error', async () => { + (app.isPackaged as unknown) = true; + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue( + JSON.stringify({ autoCheckEnabled: true }), + ); + const error = new Error('Network error'); + (autoUpdater.checkForUpdates as Mock).mockRejectedValue(error); + + await expect(updateManager.checkForUpdates()).rejects.toThrow( + 'Network error', + ); + }); + }); + + describe('downloadUpdate', () => { + beforeEach(() => { + updateManager = UpdateManager.getInstance(); + }); + + it('should throw error in development mode', async () => { + (app.isPackaged as unknown) = false; + + await expect(updateManager.downloadUpdate()).rejects.toThrow( + 'Updates not available in development mode', + ); + }); + + it('should throw error if no update available', async () => { + (app.isPackaged as unknown) = true; + + await expect(updateManager.downloadUpdate()).rejects.toThrow( + 'No update available to download', + ); + }); + + it('should download update if available', async () => { + (app.isPackaged as unknown) = true; + + // Trigger update available event + const availableHandler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'update-available', + )?.[1]; + + if (availableHandler) { + availableHandler(mockUpdateInfo); + } + + await updateManager.downloadUpdate(); + + expect(autoUpdater.downloadUpdate).toHaveBeenCalled(); + }); + + it('should handle download error', async () => { + (app.isPackaged as unknown) = true; + + // Trigger update available event + const availableHandler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'update-available', + )?.[1]; + + if (availableHandler) { + availableHandler(mockUpdateInfo); + } + + const error = new Error('Download failed'); + (autoUpdater.downloadUpdate as Mock).mockRejectedValue(error); + + await expect(updateManager.downloadUpdate()).rejects.toThrow( + 'Download failed', + ); + }); + }); + + describe('quitAndInstall', () => { + beforeEach(() => { + vi.useFakeTimers(); + updateManager = UpdateManager.getInstance(); + updateManager.setMainWindow(mockWindow); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should do nothing in development mode', async () => { + (app.isPackaged as unknown) = false; + + await updateManager.quitAndInstall(); + + expect(autoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); + + it('should not install if update not downloaded', async () => { + (app.isPackaged as unknown) = true; + + await updateManager.quitAndInstall(); + + expect(autoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); + + it('should create backup and install update', async () => { + (app.isPackaged as unknown) = true; + + // Trigger update downloaded event + const downloadedHandler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'update-downloaded', + )?.[1]; + + if (downloadedHandler) { + downloadedHandler(mockUpdateInfo); + } + + const installPromise = updateManager.quitAndInstall(); + await installPromise; + + vi.advanceTimersByTime(1000); + + expect(mockBackupService.createBackup).toHaveBeenCalledWith( + 'before-restore', + ); + expect(mockWindow.webContents.send).toHaveBeenCalledWith( + 'update:installing', + undefined, + ); + expect(autoUpdater.quitAndInstall).toHaveBeenCalledWith(false, true); + }); + + it('should continue install even if backup fails', async () => { + (app.isPackaged as unknown) = true; + mockBackupService.createBackup.mockResolvedValue({ + success: false, + error: 'Backup error', + }); + + // Trigger update downloaded event + const downloadedHandler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'update-downloaded', + )?.[1]; + + if (downloadedHandler) { + downloadedHandler(mockUpdateInfo); + } + + const installPromise = updateManager.quitAndInstall(); + await installPromise; + + vi.advanceTimersByTime(1000); + + expect(autoUpdater.quitAndInstall).toHaveBeenCalled(); + }); + + it('should continue install even if backup throws', async () => { + (app.isPackaged as unknown) = true; + mockBackupService.createBackup.mockRejectedValue( + new Error('Backup error'), + ); + + // Trigger update downloaded event + const downloadedHandler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'update-downloaded', + )?.[1]; + + if (downloadedHandler) { + downloadedHandler(mockUpdateInfo); + } + + const installPromise = updateManager.quitAndInstall(); + await installPromise; + + vi.advanceTimersByTime(1000); + + expect(autoUpdater.quitAndInstall).toHaveBeenCalled(); + }); + }); + + describe('event handlers', () => { + beforeEach(() => { + updateManager = UpdateManager.getInstance(); + updateManager.setMainWindow(mockWindow); + }); + + it('should handle checking-for-update event', () => { + const handler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'checking-for-update', + )?.[1]; + + handler(); + + expect(mockWindow.webContents.send).toHaveBeenCalledWith( + 'update:checking', + undefined, + ); + expect(updateManager.getStatus()).toBe(UpdateStatus.Checking); + }); + + it('should handle update-available event', () => { + const handler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'update-available', + )?.[1]; + + handler(mockUpdateInfo); + + expect(mockWindow.webContents.send).toHaveBeenCalledWith( + 'update:available', + expect.objectContaining({ version: '2.0.0' }), + ); + expect(updateManager.getStatus()).toBe(UpdateStatus.Available); + }); + + it('should handle update-not-available event', () => { + const handler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'update-not-available', + )?.[1]; + + handler({ version: '1.0.0' }); + + expect(mockWindow.webContents.send).toHaveBeenCalledWith( + 'update:not-available', + { version: '1.0.0' }, + ); + expect(updateManager.getStatus()).toBe(UpdateStatus.NotAvailable); + }); + + it('should handle download-progress event', () => { + const handler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'download-progress', + )?.[1]; + + const progress = { + bytesPerSecond: 1024000, + percent: 45.67, + transferred: 5000000, + total: 10000000, + }; + + handler(progress); + + expect(mockWindow.webContents.send).toHaveBeenCalledWith( + 'update:download-progress', + progress, + ); + expect(updateManager.getStatus()).toBe(UpdateStatus.Downloading); + }); + + it('should handle update-downloaded event', () => { + const handler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'update-downloaded', + )?.[1]; + + handler(mockUpdateInfo); + + expect(mockWindow.webContents.send).toHaveBeenCalledWith( + 'update:downloaded', + expect.objectContaining({ version: '2.0.0' }), + ); + expect(updateManager.getStatus()).toBe(UpdateStatus.Downloaded); + }); + + it('should handle error event', () => { + const handler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'error', + )?.[1]; + + const error = new Error('Update error'); + handler(error); + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('update:error', { + message: 'Update error', + }); + expect(updateManager.getStatus()).toBe(UpdateStatus.Error); + }); + + it('should not send events if window is destroyed', () => { + mockWindow.isDestroyed.mockReturnValue(true); + + const handler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'checking-for-update', + )?.[1]; + + handler(); + + expect(mockWindow.webContents.send).not.toHaveBeenCalled(); + }); + }); + + describe('settings management', () => { + beforeEach(() => { + updateManager = UpdateManager.getInstance(); + }); + + it('should get settings with last check date', () => { + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue( + JSON.stringify({ + autoCheckEnabled: true, + lastCheckDate: '2026-02-01T00:00:00.000Z', + }), + ); + + const settings = updateManager.getSettings(); + + expect(settings.autoCheckEnabled).toBe(true); + expect(settings.lastCheckDate).toBeInstanceOf(Date); + }); + + it('should set settings', async () => { + await updateManager.setSettings({ autoCheckEnabled: false }); + + expect(fs.writeFileSync).toHaveBeenCalled(); + const settings = updateManager.getSettings(); + expect(settings.autoCheckEnabled).toBe(false); + }); + + it('should handle save settings error', async () => { + (fs.writeFileSync as Mock).mockImplementation(() => { + throw new Error('Write error'); + }); + + await expect( + updateManager.setSettings({ autoCheckEnabled: false }), + ).resolves.not.toThrow(); + }); + }); + + describe('getStatus and getUpdateInfo', () => { + beforeEach(() => { + updateManager = UpdateManager.getInstance(); + }); + + it('should return current status', () => { + expect(updateManager.getStatus()).toBe(UpdateStatus.Idle); + }); + + it('should return update info when available', () => { + const handler = (autoUpdater.on as Mock).mock.calls.find( + (call) => call[0] === 'update-available', + )?.[1]; + + handler(mockUpdateInfo); + + const info = updateManager.getUpdateInfo(); + expect(info).toEqual( + expect.objectContaining({ + version: '2.0.0', + }), + ); + }); + + it('should return null if no update info', () => { + expect(updateManager.getUpdateInfo()).toBeNull(); + }); + }); +}); diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 98ef7c5..ea1e94f 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -181,6 +181,41 @@ describe('App', () => { expect(messageService.add).not.toHaveBeenCalled(); })); + + it('should handle download update', async () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + fixture.detectChanges(); + + mockUpdateService.downloadUpdate.and.returnValue(Promise.resolve()); + + await app.onDownloadUpdate(); + + expect(mockUpdateService.downloadUpdate).toHaveBeenCalled(); + }); + + it('should handle install update', async () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + fixture.detectChanges(); + + mockUpdateService.installUpdate.and.returnValue(Promise.resolve()); + + await app.onInstallUpdate(); + + expect(mockUpdateService.installUpdate).toHaveBeenCalled(); + }); + + it('should close update dialog', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + fixture.detectChanges(); + + app.updateDialogVisible.set(true); + app.onCloseUpdateDialog(); + + expect(app.updateDialogVisible()).toBe(false); + }); }); describe('App without electronAPI', () => { @@ -200,3 +235,66 @@ describe('App without electronAPI', () => { expect(app).toBeTruthy(); }); }); + +describe('App update notifications', () => { + it('should show notification when update becomes available', fakeAsync(() => { + const updateSignal = signal<{ + version: string; + releaseName?: string; + releaseNotes?: string; + releaseDate: string; + } | null>(null); + + const mockUpdateService = jasmine.createSpyObj('UpdateService', [ + 'checkForUpdates', + 'downloadUpdate', + 'installUpdate', + ]); + Object.defineProperty(mockUpdateService, 'updateAvailable', { + get: () => updateSignal, + }); + Object.defineProperty(mockUpdateService, 'downloadProgress', { + get: () => signal(null), + }); + Object.defineProperty(mockUpdateService, 'isChecking', { + get: () => signal(false), + }); + Object.defineProperty(mockUpdateService, 'isDownloading', { + get: () => signal(false), + }); + Object.defineProperty(mockUpdateService, 'updateDownloaded', { + get: () => signal(false), + }); + + TestBed.configureTestingModule({ + imports: [App, TranslateModule.forRoot()], + providers: [ + MessageService, + { provide: UpdateService, useValue: mockUpdateService }, + ], + }); + + const fixture = TestBed.createComponent(App); + const messageService = TestBed.inject(MessageService); + spyOn(messageService, 'add'); + fixture.detectChanges(); + + // Trigger the effect by setting update info + updateSignal.set({ + version: '2.0.0', + releaseName: 'Version 2.0.0', + releaseNotes: 'New features', + releaseDate: '2026-02-01', + }); + fixture.detectChanges(); + tick(); + + expect(messageService.add).toHaveBeenCalledWith( + jasmine.objectContaining({ + severity: 'info', + sticky: true, + data: { action: 'viewUpdate' }, + }), + ); + })); +}); From 905abd78d667aaac119b047a7a3f99eb82e90d35 Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Fri, 6 Feb 2026 00:31:10 +0100 Subject: [PATCH 13/13] feat: add getAppVersion method to update service and IPC handlers --- electron/src/preload/preload.ts | 2 ++ electron/src/services/ipc/update-handlers.ts | 17 +++++++++++++- .../src/services/updater/update-manager.ts | 5 ++--- .../open-settings-updates.html | 2 +- .../open-settings-updates.ts | 22 +++++++++++++------ src/app/services/update/update.service.ts | 22 +++++++++++++++++-- src/types/electron.d.ts | 3 +++ 7 files changed, 59 insertions(+), 14 deletions(-) diff --git a/electron/src/preload/preload.ts b/electron/src/preload/preload.ts index 0871ea7..c9d696a 100644 --- a/electron/src/preload/preload.ts +++ b/electron/src/preload/preload.ts @@ -432,6 +432,8 @@ try { ipcRenderer.invoke('update:download'), installUpdate: (): Promise => ipcRenderer.invoke('update:install'), + getAppVersion: (): Promise => + ipcRenderer.invoke('update:get-app-version'), getUpdateSettings: (): Promise => ipcRenderer.invoke('update:get-settings'), setUpdateSettings: ( diff --git a/electron/src/services/ipc/update-handlers.ts b/electron/src/services/ipc/update-handlers.ts index 9ae8d3b..aad5650 100644 --- a/electron/src/services/ipc/update-handlers.ts +++ b/electron/src/services/ipc/update-handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron'; +import { app, ipcMain } from 'electron'; import { UpdateManager } from '../updater/update-manager.js'; import { UpdateSettings } from '../../interfaces/update.interface.js'; @@ -114,4 +114,19 @@ export const setupUpdateHandlers = (): void => { }; } }); + + /** + * Get current application version. + */ + ipcMain.handle('update:get-app-version', async () => { + try { + return { success: true, version: app.getVersion() }; + } catch (error) { + console.error('[IPC] Failed to get app version:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); }; diff --git a/electron/src/services/updater/update-manager.ts b/electron/src/services/updater/update-manager.ts index 7fb9d99..8f03052 100644 --- a/electron/src/services/updater/update-manager.ts +++ b/electron/src/services/updater/update-manager.ts @@ -1,5 +1,5 @@ import electronUpdater from 'electron-updater'; -import { BrowserWindow } from 'electron'; +import { app, BrowserWindow } from 'electron'; import { UpdateInfo, UpdateSettings, @@ -12,7 +12,6 @@ const { autoUpdater } = electronUpdater; type ElectronUpdateInfo = electronUpdater.UpdateInfo; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { app } from 'electron'; import { BackupService } from '../backup/backup.service.js'; /** @@ -31,7 +30,7 @@ export interface UpdateManagerConfig { export class UpdateManager { private static instance: UpdateManager; private mainWindow: BrowserWindow | null = null; - private config: UpdateManagerConfig; + private readonly config: UpdateManagerConfig; private currentStatus: UpdateStatus = UpdateStatus.Idle; private updateInfo: UpdateInfo | null = null; private readonly settingsPath: string; diff --git a/src/app/pages/open-settings-updates/open-settings-updates.html b/src/app/pages/open-settings-updates/open-settings-updates.html index 384b935..f7de3a3 100644 --- a/src/app/pages/open-settings-updates/open-settings-updates.html +++ b/src/app/pages/open-settings-updates/open-settings-updates.html @@ -17,7 +17,7 @@

{{ "update.currentVersion" | translate }}: - {{ currentVersion }} + {{ currentVersion() }}
diff --git a/src/app/pages/open-settings-updates/open-settings-updates.ts b/src/app/pages/open-settings-updates/open-settings-updates.ts index 9cad9a3..67b7f66 100644 --- a/src/app/pages/open-settings-updates/open-settings-updates.ts +++ b/src/app/pages/open-settings-updates/open-settings-updates.ts @@ -48,9 +48,7 @@ export class OpenSettingsUpdatesComponent implements OnInit { readonly loading = signal(false); readonly dialogVisible = signal(false); - readonly currentVersion = '1.0.0-alpha.3'; // TODO: Get from package.json or environment - - // Expose update service signals to template + readonly currentVersion = signal('Unknown'); readonly updateAvailable = this.updateService.updateAvailable; readonly isChecking = this.updateService.isChecking; readonly isDownloading = this.updateService.isDownloading; @@ -61,6 +59,16 @@ export class OpenSettingsUpdatesComponent implements OnInit { ngOnInit(): void { void this.loadSettings(); + void this.loadCurrentVersion(); + } + + private async loadCurrentVersion(): Promise { + try { + const version = await this.updateService.getAppVersion(); + this.currentVersion.set(version); + } catch { + this.currentVersion.set('Unknown'); + } } async loadSettings(): Promise { @@ -102,7 +110,7 @@ export class OpenSettingsUpdatesComponent implements OnInit { try { await this.updateService.checkForUpdates(); - // Wait a moment for the update check to complete + /* Wait a moment for the update check to complete. */ setTimeout(() => { if (this.updateAvailable()) { this.dialogVisible.set(true); @@ -141,7 +149,7 @@ export class OpenSettingsUpdatesComponent implements OnInit { async onInstall(): Promise { try { await this.updateService.installUpdate(); - // App will restart, no need for toast + /* App will restart, no need for toast. */ } catch { this.messageService.add({ severity: 'error', @@ -157,12 +165,12 @@ export class OpenSettingsUpdatesComponent implements OnInit { } openReleasesPage(): void { - // Open GitHub releases page in system browser + /* Open GitHub releases page in system browser. */ const url = 'https://github.com/altaskur/OpenTimeTracker/releases'; if (globalThis.window?.electronAPI?.openExternal) { void globalThis.window.electronAPI.openExternal(url); } else { - // Fallback for web or if API not available + /* Fallback for web or if API not available. */ window.open(url, '_blank', 'noopener,noreferrer'); } } diff --git a/src/app/services/update/update.service.ts b/src/app/services/update/update.service.ts index 2cb49b9..d517959 100644 --- a/src/app/services/update/update.service.ts +++ b/src/app/services/update/update.service.ts @@ -216,7 +216,7 @@ export class UpdateService extends BaseDatabaseService { this.settings.set(result.settings); return result.settings; } - throw new Error(result.error || 'Failed to get settings'); + throw new Error(result.error ?? 'Failed to get settings'); }); } @@ -236,11 +236,29 @@ export class UpdateService extends BaseDatabaseService { if (result.success) { this.settings.update((s) => ({ ...s, autoCheckEnabled: enabled })); } else { - throw new Error(result.error || 'Failed to update settings'); + throw new Error(result.error ?? 'Failed to update settings'); } }); } + /** + * Gets the current application version. + */ + async getAppVersion(): Promise { + if (!globalThis.window?.electronAPI?.getAppVersion) { + throw new Error('Update API not available'); + } + + return this.executeWithErrorHandling('get app version', async () => { + const result: UpdateResult = + await globalThis.window.electronAPI.getAppVersion(); + if (result.success && result.version) { + return result.version; + } + throw new Error(result.error ?? 'Failed to get app version'); + }); + } + /** * Gets current update status. */ diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 49c5ff3..d64642e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -181,6 +181,7 @@ export interface UpdateResult { settings?: UpdateSettings; status?: string; updateInfo?: UpdateInfo | null; + version?: string; } // ==================== UPDATE MODELS ==================== @@ -211,6 +212,7 @@ export interface UpdateResult { settings?: UpdateSettings; status?: string; updateInfo?: UpdateInfo | null; + version?: string; } // ==================== ELECTRON API ==================== @@ -410,6 +412,7 @@ declare global { checkForUpdates: () => Promise; downloadUpdate: () => Promise; installUpdate: () => Promise; + getAppVersion: () => Promise; getUpdateSettings: () => Promise; setUpdateSettings: ( settings: Partial,