diff --git a/electron/src/main/main.ts b/electron/src/main/main.ts index 430b4d4..033146f 100644 --- a/electron/src/main/main.ts +++ b/electron/src/main/main.ts @@ -3,6 +3,7 @@ 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 { UpdateService } from '../services/update/update.service.js'; let windowManager: WindowManager | null = null; let dbManager: DatabaseManager | null = null; @@ -29,7 +30,7 @@ const initializeApp = async (): Promise => { }, ); - setupIpcHandlers(dbManager, backupService); + setupIpcHandlers(dbManager, backupService, new UpdateService()); windowManager = new WindowManager(); await windowManager.createMainWindow(); }; diff --git a/electron/src/preload/preload.ts b/electron/src/preload/preload.ts index 49f8a54..9e07353 100644 --- a/electron/src/preload/preload.ts +++ b/electron/src/preload/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer, shell } from 'electron'; +import { contextBridge, ipcRenderer } from 'electron'; /** * Type definitions for database entities @@ -128,8 +128,21 @@ interface BackupResult { path?: string; } +interface GitHubRelease { + tag_name: string; + html_url: string; + body: string; + name: string; + published_at: string; +} + try { const electronAPI = { + // ... + // Release Info + getReleaseByTag: (tag: string): Promise => + ipcRenderer.invoke('get-release-by-tag', tag), + // Projects getProjects: (): Promise => ipcRenderer.invoke('get-projects'), createProject: (name: string, description?: string): Promise => @@ -395,7 +408,19 @@ try { getBackupDir: (): Promise => ipcRenderer.invoke('backup-get-dir'), // System - openExternal: (url: string): Promise => shell.openExternal(url), + openExternal: (url: string): Promise => + ipcRenderer.invoke('open-external', url), + + // Updates + checkForUpdates: (): Promise<{ + updateAvailable: boolean; + version: string; + url: string; + releaseNotes?: string; + }> => ipcRenderer.invoke('check-for-updates'), + + // App Info + getVersion: (): Promise => ipcRenderer.invoke('get-version'), }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/electron/src/services/ipc/index.ts b/electron/src/services/ipc/index.ts index 26ed1ed..552b161 100644 --- a/electron/src/services/ipc/index.ts +++ b/electron/src/services/ipc/index.ts @@ -1,21 +1,30 @@ import { DatabaseManager } from '../database/database.js'; import { BackupService } from '../backup/index.js'; +import { UpdateService } from '../update/update.service.js'; 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'; + +import { setupSystemHandlers } from './system-handlers.js'; /** * Sets up all IPC handlers */ export const setupIpcHandlers = ( - dbManager: DatabaseManager, - backupService?: BackupService, + dbManager: DatabaseManager | null, + backupService: BackupService | null, + updateService: UpdateService, ): void => { - setupDatabaseHandlers(dbManager); - setupThemeHandlers(dbManager); - setupLanguageHandlers(dbManager); + if (dbManager) { + setupDatabaseHandlers(dbManager); + setupThemeHandlers(dbManager); + setupLanguageHandlers(dbManager); + } if (backupService) { setupBackupHandlers(backupService); } + setupUpdateHandlers(updateService); + setupSystemHandlers(); }; diff --git a/electron/src/services/ipc/system-handlers.spec.ts b/electron/src/services/ipc/system-handlers.spec.ts new file mode 100644 index 0000000..90fe447 --- /dev/null +++ b/electron/src/services/ipc/system-handlers.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { ipcMain, shell } from 'electron'; +import { setupSystemHandlers } from './system-handlers.js'; + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + }, + shell: { + openExternal: vi.fn(), + }, +})); + +describe('System Handlers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should setup open-external handler', () => { + setupSystemHandlers(); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'open-external', + expect.any(Function), + ); + }); + + it('should handle open-external call', async () => { + setupSystemHandlers(); + const handler = (ipcMain.handle as Mock).mock.calls.find( + (call) => call[0] === 'open-external', + )?.[1]; + + const url = 'https://example.com'; + await handler(null, url); + + expect(shell.openExternal).toHaveBeenCalledWith(url); + }); + + it('should handle errors in open-external', async () => { + setupSystemHandlers(); + const handler = (ipcMain.handle as Mock).mock.calls.find( + (call) => call[0] === 'open-external', + )?.[1]; + + const error = new Error('Failed to open'); + (shell.openExternal as Mock).mockRejectedValue(error); + + await expect(handler(null, 'https://example.com')).rejects.toThrow(error); + }); +}); diff --git a/electron/src/services/ipc/system-handlers.ts b/electron/src/services/ipc/system-handlers.ts new file mode 100644 index 0000000..9d15e62 --- /dev/null +++ b/electron/src/services/ipc/system-handlers.ts @@ -0,0 +1,16 @@ +import { ipcMain, shell } from 'electron'; + +/** + * Sets up system-related IPC handlers + */ +export const setupSystemHandlers = (): void => { + // Open external links + ipcMain.handle('open-external', async (_event, url: string) => { + try { + await shell.openExternal(url); + } catch (error) { + console.error('Error opening external URL:', error); + throw error; + } + }); +}; 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..74f5dad --- /dev/null +++ b/electron/src/services/ipc/update-handlers.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { ipcMain, app } from 'electron'; +import { setupUpdateHandlers } from './update-handlers.js'; +import { UpdateService } from '../update/update.service.js'; + +// Mock electron modules +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + }, + app: { + getVersion: vi.fn(), + }, +})); + +describe('Update Handlers', () => { + let mockUpdateService: { + checkForUpdates: Mock; + getReleaseByTag: Mock; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockUpdateService = { + checkForUpdates: vi.fn(), + getReleaseByTag: vi.fn(), + }; + }); + + describe('setupUpdateHandlers', () => { + it('should register all IPC handlers', () => { + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + expect(ipcMain.handle).toHaveBeenCalledTimes(3); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'check-for-updates', + expect.any(Function) + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'get-version', + expect.any(Function) + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'get-release-by-tag', + expect.any(Function) + ); + }); + + it('should call updateService.checkForUpdates when check-for-updates is invoked', async () => { + const mockResult = { updateAvailable: true, version: '2.0.0', url: 'https://example.com' }; + mockUpdateService.checkForUpdates.mockResolvedValue(mockResult); + + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + const handlers = (ipcMain.handle as Mock).mock.calls; + const checkForUpdatesHandler = handlers.find( + (call) => call[0] === 'check-for-updates' + )?.[1]; + + const result = await checkForUpdatesHandler(); + + expect(mockUpdateService.checkForUpdates).toHaveBeenCalled(); + expect(result).toEqual(mockResult); + }); + + it('should return app version when get-version is invoked', async () => { + (app.getVersion as Mock).mockReturnValue('1.5.0'); + + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + const handlers = (ipcMain.handle as Mock).mock.calls; + const getVersionHandler = handlers.find( + (call) => call[0] === 'get-version' + )?.[1]; + + const result = getVersionHandler(); + + expect(app.getVersion).toHaveBeenCalled(); + expect(result).toBe('1.5.0'); + }); + + it('should call updateService.getReleaseByTag when get-release-by-tag is invoked', async () => { + const mockRelease = { body: 'Release notes for v1.2.3' }; + mockUpdateService.getReleaseByTag.mockResolvedValue(mockRelease); + + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + const handlers = (ipcMain.handle as Mock).mock.calls; + const getReleaseByTagHandler = handlers.find( + (call) => call[0] === 'get-release-by-tag' + )?.[1]; + + const result = await getReleaseByTagHandler({}, 'v1.2.3'); + + expect(mockUpdateService.getReleaseByTag).toHaveBeenCalledWith('v1.2.3'); + expect(result).toEqual(mockRelease); + }); + + it('should handle checkForUpdates when no update is available', async () => { + const mockResult = { updateAvailable: false, version: '1.0.0', url: '' }; + mockUpdateService.checkForUpdates.mockResolvedValue(mockResult); + + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + const handlers = (ipcMain.handle as Mock).mock.calls; + const checkForUpdatesHandler = handlers.find( + (call) => call[0] === 'check-for-updates' + )?.[1]; + + const result = await checkForUpdatesHandler(); + + expect(result.updateAvailable).toBe(false); + }); + + it('should handle getReleaseByTag returning null', async () => { + mockUpdateService.getReleaseByTag.mockResolvedValue(null); + + setupUpdateHandlers(mockUpdateService as unknown as UpdateService); + + const handlers = (ipcMain.handle as Mock).mock.calls; + const getReleaseByTagHandler = handlers.find( + (call) => call[0] === 'get-release-by-tag' + )?.[1]; + + const result = await getReleaseByTagHandler({}, 'v99.99.99'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/electron/src/services/ipc/update-handlers.ts b/electron/src/services/ipc/update-handlers.ts new file mode 100644 index 0000000..d755583 --- /dev/null +++ b/electron/src/services/ipc/update-handlers.ts @@ -0,0 +1,8 @@ +import { ipcMain, app } from 'electron'; +import { UpdateService } from '../update/update.service.js'; + +export const setupUpdateHandlers = (updateService: UpdateService): void => { + ipcMain.handle('check-for-updates', () => updateService.checkForUpdates()); + ipcMain.handle('get-version', () => app.getVersion()); + ipcMain.handle('get-release-by-tag', (_event, tag: string) => updateService.getReleaseByTag(tag)); +}; diff --git a/electron/src/services/menu/menu-manager.ts b/electron/src/services/menu/menu-manager.ts index eee7519..c11cb95 100644 --- a/electron/src/services/menu/menu-manager.ts +++ b/electron/src/services/menu/menu-manager.ts @@ -53,7 +53,8 @@ const menuTranslations: Record> = { maintenance: 'Mantenimiento', tags: 'Etiquetas', dayTypes: 'Tipos de Día', - taskStatuses: 'Estados de Tarea', + taskStatuses: 'Tipos de Día', + 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', }, }; @@ -121,21 +123,21 @@ export class MenuManager { const template: MenuItemConstructorOptions[] = [ ...(isMac ? [ - { - label: app.name, - submenu: [ - { role: 'about' as const }, - { type: 'separator' as const }, - { role: 'services' as const }, - { type: 'separator' as const }, - { role: 'hide' as const }, - { role: 'hideOthers' as const }, - { role: 'unhide' as const }, - { type: 'separator' as const }, - { role: 'quit' as const }, - ], - }, - ] + { + label: app.name, + submenu: [ + { role: 'about' as const }, + { type: 'separator' as const }, + { role: 'services' as const }, + { type: 'separator' as const }, + { role: 'hide' as const }, + { role: 'hideOthers' as const }, + { role: 'unhide' as const }, + { type: 'separator' as const }, + { role: 'quit' as const }, + ], + }, + ] : []), { @@ -172,13 +174,13 @@ export class MenuManager { ...(isMac ? [] : [ - { type: 'separator' as const }, - { - label: this.t('exit'), - accelerator: 'Alt+F4', - role: 'quit' as const, - }, - ]), + { type: 'separator' as const }, + { + label: this.t('exit'), + accelerator: 'Alt+F4', + role: 'quit' as const, + }, + ]), ], }, @@ -287,6 +289,12 @@ export class MenuManager { { label: this.t('help'), submenu: [ + { + label: this.t('checkForUpdates'), + click: (): void => { + this.navigateTo('/settings/updates'); + }, + }, { label: this.t('documentation'), click: (): void => { diff --git a/electron/src/services/update/update.service.spec.ts b/electron/src/services/update/update.service.spec.ts new file mode 100644 index 0000000..8b0cb29 --- /dev/null +++ b/electron/src/services/update/update.service.spec.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { app } from 'electron'; +import { UpdateService } from './update.service.js'; + +// Mock global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('UpdateService', () => { + let updateService: UpdateService; + + beforeEach(() => { + vi.clearAllMocks(); + updateService = new UpdateService(); + (app.getVersion as Mock).mockReturnValue('1.0.0'); + }); + + describe('checkForUpdates', () => { + it('should return updateAvailable true when newer version exists', async () => { + const mockRelease = { + tag_name: 'v2.0.0', + html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v2.0.0', + body: 'Release notes for v2.0.0', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(true); + expect(result.version).toBe('2.0.0'); + expect(result.url).toBe(mockRelease.html_url); + expect(result.releaseNotes).toBe(mockRelease.body); + }); + + it('should return updateAvailable false when current version is latest', async () => { + const mockRelease = { + tag_name: 'v1.0.0', + html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v1.0.0', + body: 'Current version notes', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + expect(result.version).toBe('1.0.0'); + }); + + it('should return updateAvailable false when current version is newer', async () => { + const mockRelease = { + tag_name: 'v0.9.0', + html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v0.9.0', + body: 'Old version notes', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + }); + + it('should return updateAvailable false when fetch fails', async () => { + mockFetch.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + expect(result.version).toBe(''); + expect(result.url).toBe(''); + }); + + it('should return updateAvailable false on network error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + expect(result.version).toBe(''); + expect(result.url).toBe(''); + }); + + it('should handle version tags without v prefix', async () => { + const mockRelease = { + tag_name: '2.0.0', + html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/2.0.0', + body: 'Notes', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(true); + expect(result.version).toBe('2.0.0'); + }); + }); + + describe('getReleaseByTag', () => { + it('should return release data for valid tag', async () => { + const mockRelease = { + tag_name: 'v1.0.0', + html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v1.0.0', + body: 'Release notes for v1.0.0', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.getReleaseByTag('v1.0.0'); + + expect(result).toEqual(mockRelease); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.github.com/repos/altaskur/OpenTimeTracker/releases/tags/v1.0.0', + expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': 'OpenTimeTracker/1.0.0', + 'Accept': 'application/vnd.github.v3+json', + }), + }) + ); + }); + + it('should return null when release not found', async () => { + mockFetch.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + const result = await updateService.getReleaseByTag('v99.99.99'); + + expect(result).toBeNull(); + }); + + it('should return null on network error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await updateService.getReleaseByTag('v1.0.0'); + + expect(result).toBeNull(); + }); + }); + + describe('compareVersions', () => { + // Access private method via prototype for testing + const compareVersions = (v1: string, v2: string): number => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (updateService as any).compareVersions(v1, v2); + }; + + it('should return 1 when first version is greater', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBe(1); + expect(compareVersions('1.1.0', '1.0.0')).toBe(1); + expect(compareVersions('1.0.1', '1.0.0')).toBe(1); + expect(compareVersions('1.0.0', '0.9.9')).toBe(1); + }); + + it('should return -1 when first version is less', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); + expect(compareVersions('0.9.9', '1.0.0')).toBe(-1); + }); + + it('should return 0 when versions are equal', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('2.5.3', '2.5.3')).toBe(0); + }); + + it('should handle versions with different part counts', () => { + expect(compareVersions('1.0.0', '1.0')).toBe(0); + expect(compareVersions('1.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.1', '1.0')).toBe(1); + expect(compareVersions('1.0', '1.0.1')).toBe(-1); + }); + + it('should handle versions with alpha/beta suffixes by treating non-numeric as NaN', () => { + // '1.0.0-alpha.5' becomes [1, 0, NaN] which compares as 0 when NaN + // So '1.0.0' [1,0,0] compared to '1.0.0-alpha.5' [1,0,NaN] - NaN becomes 0 + // This test documents current behavior, not ideal handling + expect(compareVersions('1.0.0', '1.0.0-alpha')).toBe(0); // NaN treated as 0 + }); + }); +}); diff --git a/electron/src/services/update/update.service.ts b/electron/src/services/update/update.service.ts new file mode 100644 index 0000000..b9ed2d2 --- /dev/null +++ b/electron/src/services/update/update.service.ts @@ -0,0 +1,92 @@ +import { app } from 'electron'; + +export interface UpdateCheckResult { + updateAvailable: boolean; + version: string; + url: string; + releaseNotes?: string; +} + +interface GitHubRelease { + tag_name: string; + html_url: string; + body: string; +} + +export class UpdateService { + private readonly GITHUB_API_URL = 'https://api.github.com/repos/altaskur/OpenTimeTracker/releases/latest'; + + async checkForUpdates(): Promise { + try { + const response = await fetch(this.GITHUB_API_URL, { + headers: { + 'User-Agent': `OpenTimeTracker/${app.getVersion()}`, + 'Accept': 'application/vnd.github.v3+json' + } + }); + + if (!response.ok) { + console.error('Failed to check for updates:', response.statusText); + return { updateAvailable: false, version: '', url: '' }; + } + + const release = await response.json() as GitHubRelease; + const latestVersion = release.tag_name.replace(/^v/, ''); + const currentVersion = app.getVersion(); + + const updateAvailable = this.compareVersions(latestVersion, currentVersion) > 0; + + return { + updateAvailable, + version: latestVersion, + url: release.html_url, + releaseNotes: release.body + }; + } catch (error) { + console.error('Error checking for updates:', error); + return { updateAvailable: false, version: '', url: '' }; + } + } + + async getReleaseByTag(tag: string): Promise { + try { + const url = `https://api.github.com/repos/altaskur/OpenTimeTracker/releases/tags/${tag}`; + const response = await fetch(url, { + headers: { + 'User-Agent': `OpenTimeTracker/${app.getVersion()}`, + 'Accept': 'application/vnd.github.v3+json' + } + }); + + if (!response.ok) { + console.error(`Failed to fetch release ${tag}:`, response.statusText); + return null; + } + + return await response.json() as GitHubRelease; + } catch (error) { + console.error(`Error fetching release ${tag}:`, error); + return null; + } + } + + /* + * Returns: + * 1 if v1 > v2 + * -1 if v1 < v2 + * 0 if v1 === v2 + */ + private compareVersions(v1: string, v2: string): number { + const p1 = v1.split('.').map(Number); + const p2 = v2.split('.').map(Number); + const len = Math.max(p1.length, p2.length); + + for (let i = 0; i < len; i++) { + const n1 = p1[i] || 0; + const n2 = p2[i] || 0; + if (n1 > n2) return 1; + if (n1 < n2) return -1; + } + return 0; + } +} diff --git a/package-lock.json b/package-lock.json index ccd5e09..7ffd08f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-time-tracker", - "version": "1.0.0-alpha.4", + "version": "1.0.0-alpha.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-time-tracker", - "version": "1.0.0-alpha.4", + "version": "1.0.0-alpha.5", "hasInstallScript": true, "license": "GPL-3.0", "dependencies": { @@ -23,7 +23,10 @@ "@primeuix/themes": "^1.2.0", "@prisma/adapter-better-sqlite3": "^7.1.0", "@prisma/client": "^7.1.0", + "@types/dompurify": "^3.0.5", "better-sqlite3": "^12.5.0", + "dompurify": "^3.3.1", + "marked": "^17.0.1", "primeflex": "^4.0.0", "primeicons": "^7.0.0", "primeng": "^20.3.0", @@ -4161,13 +4164,13 @@ } }, "node_modules/@npmcli/git/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/@npmcli/git/node_modules/lru-cache": { @@ -4333,13 +4336,13 @@ } }, "node_modules/@npmcli/promise-spawn/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/@npmcli/promise-spawn/node_modules/which": { @@ -4468,13 +4471,13 @@ } }, "node_modules/@npmcli/run-script/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/@npmcli/run-script/node_modules/lru-cache": { @@ -4609,9 +4612,9 @@ } }, "node_modules/@npmcli/run-script/node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, "license": "ISC", "dependencies": { @@ -6032,9 +6035,9 @@ } }, "node_modules/@sigstore/sign/node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, "license": "ISC", "dependencies": { @@ -6250,6 +6253,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -6313,9 +6325,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.11.tgz", - "integrity": "sha512-/Af7O8r1frCVgOz0I62jWUtMohJ0/ZQU/ZoketltOJPZpnb17yoNc9BSoVuV9qlaIXJiPNOpsfq4ByFajSArNQ==", + "version": "24.10.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz", + "integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==", "dev": true, "license": "MIT", "dependencies": { @@ -6344,6 +6356,12 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -6705,14 +6723,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "engines": { @@ -6727,14 +6745,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6745,9 +6763,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", "dev": true, "license": "MIT", "engines": { @@ -6942,9 +6960,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", "dev": true, "license": "MIT", "engines": { @@ -6956,16 +6974,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", @@ -6984,16 +7002,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7008,13 +7026,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/types": "8.55.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -7624,13 +7642,13 @@ } }, "node_modules/app-builder-lib/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/app-builder-lib/node_modules/minimatch": { @@ -7785,14 +7803,14 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -9540,6 +9558,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -9897,9 +9924,9 @@ "license": "ISC" }, "node_modules/electron/node_modules/@types/node": { - "version": "22.19.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz", - "integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==", + "version": "22.19.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz", + "integrity": "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==", "dev": true, "license": "MIT", "dependencies": { @@ -13476,6 +13503,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -14083,13 +14122,13 @@ } }, "node_modules/node-gyp/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/node-gyp/node_modules/which": { @@ -14461,9 +14500,9 @@ } }, "node_modules/npm-registry-fetch/node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, "license": "ISC", "dependencies": { @@ -14892,9 +14931,9 @@ } }, "node_modules/pacote/node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, "license": "ISC", "dependencies": { @@ -17514,9 +17553,9 @@ } }, "node_modules/tuf-js/node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 25f129d..259f44f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "overrides": { "node-forge": "^1.3.2", "hono": "4.11.8", - "lodash": "4.17.21" + "lodash": "4.17.21", + "axios": "1.13.5" }, "scripts": { "start": "ng serve", @@ -158,7 +159,10 @@ "@primeuix/themes": "^1.2.0", "@prisma/adapter-better-sqlite3": "^7.1.0", "@prisma/client": "^7.1.0", + "@types/dompurify": "^3.0.5", "better-sqlite3": "^12.5.0", + "dompurify": "^3.3.1", + "marked": "^17.0.1", "primeflex": "^4.0.0", "primeicons": "^7.0.0", "primeng": "^20.3.0", @@ -212,4 +216,4 @@ "prettier --write" ] } -} +} \ No newline at end of file diff --git a/src/app/app.html b/src/app/app.html index 1cbfd09..422b567 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,5 +1,6 @@
- + +
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 3d5a1b6..9887b38 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.spec.ts b/src/app/app.spec.ts index 21ad9f9..fa9e20d 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -3,15 +3,26 @@ import { App } from './app'; import { TranslateModule } from '@ngx-translate/core'; import { MessageService } from 'primeng/api'; import { ActionHistoryService } from './services/action-history.service'; +import { UpdateService } from './services/update.service'; +import { signal } from '@angular/core'; describe('App', () => { let mockHistoryService: jasmine.SpyObj; let mockElectronAPI: { onUndoAction: jasmine.Spy; onRedoAction: jasmine.Spy; + checkForUpdates: jasmine.Spy; + }; + let mockUpdateService: { + init: jasmine.Spy; + autoCheck: ReturnType>; + updateAvailable: ReturnType>; + checking: ReturnType>; + lastChecked: ReturnType>; }; let undoCallback: () => void; let redoCallback: () => void; + let originalElectronAPI: unknown; beforeEach(async () => { mockHistoryService = jasmine.createSpyObj('ActionHistoryService', [ @@ -60,8 +71,24 @@ describe('App', () => { .and.callFake((cb: () => void) => { redoCallback = cb; }), + checkForUpdates: jasmine + .createSpy('checkForUpdates') + .and.returnValue( + Promise.resolve({ updateAvailable: false, version: '', url: '' }), + ), + }; + + mockUpdateService = { + init: jasmine.createSpy('init'), + autoCheck: signal(true), + updateAvailable: signal(null), + checking: signal(false), + lastChecked: signal(null), }; + // Save original electronAPI and set mock + originalElectronAPI = (window as unknown as { electronAPI?: unknown }) + .electronAPI; ( window as unknown as { electronAPI?: typeof mockElectronAPI } ).electronAPI = mockElectronAPI; @@ -71,13 +98,15 @@ describe('App', () => { providers: [ MessageService, { provide: ActionHistoryService, useValue: mockHistoryService }, + { provide: UpdateService, useValue: mockUpdateService }, ], }).compileComponents(); }); afterEach(() => { - delete (window as unknown as { electronAPI?: typeof mockElectronAPI }) - .electronAPI; + // Restore original electronAPI + (window as unknown as { electronAPI?: unknown }).electronAPI = + originalElectronAPI; }); it('should create the app', () => { @@ -158,15 +187,42 @@ describe('App', () => { }); describe('App without electronAPI', () => { + let mockUpdateService: { + init: jasmine.Spy; + autoCheck: ReturnType>; + updateAvailable: ReturnType>; + checking: ReturnType>; + lastChecked: ReturnType>; + }; + let savedElectronAPI: unknown; + beforeEach(async () => { + // Save and delete electronAPI for this test suite + savedElectronAPI = (window as { electronAPI?: unknown }).electronAPI; delete (window as { electronAPI?: unknown }).electronAPI; + mockUpdateService = { + init: jasmine.createSpy('init'), + autoCheck: signal(true), + updateAvailable: signal(null), + checking: signal(false), + lastChecked: signal(null), + }; + await TestBed.configureTestingModule({ imports: [App, TranslateModule.forRoot()], - providers: [MessageService], + providers: [ + MessageService, + { provide: UpdateService, useValue: mockUpdateService }, + ], }).compileComponents(); }); + afterEach(() => { + // Restore original electronAPI + (window as { electronAPI?: unknown }).electronAPI = savedElectronAPI; + }); + it('should create without electron API', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; diff --git a/src/app/app.ts b/src/app/app.ts index 53492bb..3abbd6b 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,5 +1,6 @@ import { Component, inject, OnInit, NgZone, OnDestroy } from '@angular/core'; import { RouterOutlet, Router } from '@angular/router'; +import { UpdateBannerComponent } from './components/update-banner/update-banner.component'; import { ToastModule } from 'primeng/toast'; import { MessageService } from 'primeng/api'; import { TranslateService } from '@ngx-translate/core'; @@ -9,6 +10,7 @@ import { TranslationService, } from './services'; import { ActionHistoryService } from './services/action-history.service'; +import { UpdateService } from './services/update.service'; /** * Root component of the application. @@ -16,7 +18,8 @@ import { ActionHistoryService } from './services/action-history.service'; */ @Component({ selector: 'app-root', - imports: [RouterOutlet, ToastModule], + standalone: true, + imports: [RouterOutlet, ToastModule, UpdateBannerComponent], templateUrl: './app.html', styleUrl: './app.scss', }) @@ -28,12 +31,14 @@ export class App implements OnInit, OnDestroy { private readonly translationService = inject(TranslationService); private readonly historyService = inject(ActionHistoryService); private readonly messageService = inject(MessageService); + private readonly updateService = inject(UpdateService); private readonly translate = inject(TranslateService); private readonly ngZone = inject(NgZone); private readonly router = inject(Router); ngOnInit(): void { this.setupHistoryListeners(); + this.updateService.init(); } ngOnDestroy(): void { diff --git a/src/app/components/update-banner/update-banner.component.ts b/src/app/components/update-banner/update-banner.component.ts new file mode 100644 index 0000000..648d94f --- /dev/null +++ b/src/app/components/update-banner/update-banner.component.ts @@ -0,0 +1,42 @@ +import { Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { UpdateService } from '../../services/update.service'; +import { MessageModule } from 'primeng/message'; +import { ButtonModule } from 'primeng/button'; +import { TranslateModule } from '@ngx-translate/core'; + +@Component({ + selector: 'app-update-banner', + standalone: true, + imports: [CommonModule, MessageModule, ButtonModule, TranslateModule], + template: ` + @if (updateService.updateAvailable(); as update) { +
+
+ +
+ New version available: {{ update.version }} +
A new version of OpenTimeTracker is ready to download.
+
+
+
+ +
+
+ } + `, + styles: [` + .update-banner { + border-left: 4px solid var(--primary-color); + } + `] +}) +export class UpdateBannerComponent { + updateService = inject(UpdateService); + router = inject(Router); + + viewDetails() { + this.router.navigate(['/settings/updates']); + } +} diff --git a/src/app/components/update-dialog/update-dialog.component.ts b/src/app/components/update-dialog/update-dialog.component.ts new file mode 100644 index 0000000..ec5f56c --- /dev/null +++ b/src/app/components/update-dialog/update-dialog.component.ts @@ -0,0 +1,214 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DialogModule } from 'primeng/dialog'; +import { ButtonModule } from 'primeng/button'; +import { TranslateModule } from '@ngx-translate/core'; +import { UpdateCheckResult } from '../../../types/electron'; +import { SafeMarkdownPipe } from '../../pipes/safe-markdown.pipe'; + +@Component({ + selector: 'app-update-dialog', + standalone: true, + imports: [ + CommonModule, + DialogModule, + ButtonModule, + TranslateModule, + SafeMarkdownPipe, + ], + template: ` + + @if (updateInfo) { +
+ + @if (!isCurrentVersion) { +
+ +
+
+ {{ 'settings.updates.newVersion' | translate }}: + {{ updateInfo.version }} +
+
+ {{ 'settings.updates.currentVersion' | translate }}: + {{ currentVersion }} +
+
+
+ } @else { +
+ +
+ {{ updateInfo.version }} +
+
+ } + + + @if (releaseDate) { +
+ + {{ 'settings.updates.releaseDate' | translate }}: + {{ releaseDate | date: 'medium' }} +
+ } + + + @if (updateInfo.releaseNotes) { +
+

+ {{ 'settings.updates.whatIsNew' | translate }} +

+
+
+ } +
+ + + +
+ @if (!isCurrentVersion) { + + + + + } @else { + + + } +
+
+ } +
+ `, + styles: [ + ` + .markdown-content { + :deep(h1), + :deep(h2), + :deep(h3), + :deep(h4), + :deep(h5), + :deep(h6) { + margin-top: 0.5rem; + margin-bottom: 0.5rem; + } + + :deep(p) { + margin: 0.5rem 0; + } + + :deep(ul), + :deep(ol) { + margin: 0.5rem 0; + padding-left: 2rem; + } + + :deep(li) { + margin: 0.25rem 0; + } + + :deep(code) { + background: var(--surface-100); + padding: 0.125rem 0.25rem; + border-radius: 4px; + font-size: 0.9em; + } + + :deep(pre) { + background: var(--surface-100); + padding: 1rem; + border-radius: 8px; + overflow-x: auto; + } + + :deep(a) { + color: var(--primary-color); + text-decoration: underline; + cursor: pointer; + } + + :deep(blockquote) { + border-left: 4px solid var(--primary-color); + margin: 0.5rem 0; + padding-left: 1rem; + color: var(--text-color-secondary); + } + } + `, + ], +}) +export class UpdateDialogComponent { + @Input() visible = false; + @Input() updateInfo: UpdateCheckResult | null = null; + @Input() currentVersion = ''; + @Input() releaseDate: Date | null = null; + @Input() isCurrentVersion = false; + + @Output() visibleChange = new EventEmitter(); + @Output() download = new EventEmitter(); + + onClose() { + this.visible = false; + this.visibleChange.emit(false); + } + + onDownload() { + this.download.emit(); + this.onClose(); + } + + handleLinkClick(event: Event) { + const target = event.target as HTMLElement; + const anchor = target.closest('a'); + + if (anchor && anchor.href) { + event.preventDefault(); + if (globalThis.window?.electronAPI) { + globalThis.window.electronAPI.openExternal(anchor.href); + } else { + window.open(anchor.href, '_blank'); + } + } + } +} diff --git a/src/app/pages/open-settings-updates/open-settings-updates.spec.ts b/src/app/pages/open-settings-updates/open-settings-updates.spec.ts new file mode 100644 index 0000000..a897335 --- /dev/null +++ b/src/app/pages/open-settings-updates/open-settings-updates.spec.ts @@ -0,0 +1,259 @@ +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing'; +import { OpenSettingsUpdatesComponent } from './open-settings-updates'; +import { UpdateService } from '../../services/update.service'; +import { MessageService } from 'primeng/api'; +import { signal } from '@angular/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('OpenSettingsUpdatesComponent', () => { + let component: OpenSettingsUpdatesComponent; + let fixture: ComponentFixture; + let updateServiceMock: Partial & { + toggleAutoCheck: jasmine.Spy; + checkForUpdates: jasmine.Spy; + openDownloadPage: jasmine.Spy; + getReleaseByTag: jasmine.Spy; + }; + let originalElectronAPI: unknown; + + beforeEach(async () => { + updateServiceMock = { + autoCheck: signal(true), + updateAvailable: signal(null), + checking: signal(false), + toggleAutoCheck: jasmine.createSpy('toggleAutoCheck'), + checkForUpdates: jasmine + .createSpy('checkForUpdates') + .and.returnValue(Promise.resolve(null)), + openDownloadPage: jasmine.createSpy('openDownloadPage'), + getReleaseByTag: jasmine.createSpy('getReleaseByTag').and.returnValue( + Promise.resolve({ + tag_name: 'v1.0.0', + html_url: 'https://example.com/releases/v1.0.0', + body: 'Release notes', + name: 'Release v1.0.0', + published_at: '2023-01-01T00:00:00Z', + }), + ), + lastChecked: signal(null), + }; + + // Save original electronAPI and mock + originalElectronAPI = (window as unknown as { electronAPI?: unknown }) + .electronAPI; + ( + window as unknown as { electronAPI: { getVersion: jasmine.Spy } } + ).electronAPI = { + getVersion: jasmine + .createSpy('getVersion') + .and.returnValue(Promise.resolve('1.0.0')), + }; + + await TestBed.configureTestingModule({ + imports: [OpenSettingsUpdatesComponent, TranslateModule.forRoot()], + providers: [ + provideNoopAnimations(), + { provide: UpdateService, useValue: updateServiceMock }, + MessageService, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(OpenSettingsUpdatesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + // Restore original electronAPI + (window as unknown as { electronAPI?: unknown }).electronAPI = + originalElectronAPI; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should toggle auto check', () => { + component.updateService.toggleAutoCheck(false); + expect(updateServiceMock.toggleAutoCheck).toHaveBeenCalledWith(false); + }); + + it('should call checkNow', async () => { + await component.checkNow(); + expect(updateServiceMock.checkForUpdates).toHaveBeenCalledWith(true); + }); + + it('should fetch current version on init', async () => { + await fixture.whenStable(); + expect(component.currentVersion).toBe('1.0.0'); + }); + + it('should fetch release notes for current version', async () => { + await fixture.whenStable(); + expect(updateServiceMock.getReleaseByTag).toHaveBeenCalledWith('v1.0.0'); + expect(component.currentVersionReleaseNotes).toBe('Release notes'); + }); + + it('should handle version already starting with v', async () => { + ( + window as unknown as { electronAPI: { getVersion: jasmine.Spy } } + ).electronAPI.getVersion.and.returnValue(Promise.resolve('v2.0.0')); + + await component.ngOnInit(); + await fixture.whenStable(); + + expect(updateServiceMock.getReleaseByTag).toHaveBeenCalledWith('v2.0.0'); + }); + + it('should set lastCheckResult after checkNow', async () => { + const mockResult = { updateAvailable: false, version: '1.0.0', url: '' }; + updateServiceMock.checkForUpdates.and.returnValue( + Promise.resolve(mockResult), + ); + + await component.checkNow(); + + expect(component.lastCheckResult).toEqual(mockResult); + }); + + it('should reset lastCheckResult before check', async () => { + component.lastCheckResult = { + updateAvailable: true, + version: '2.0.0', + url: 'url', + }; + updateServiceMock.checkForUpdates.and.returnValue(Promise.resolve(null)); + + await component.checkNow(); + + expect(component.lastCheckResult).toBeNull(); + }); + + it('should call openDownloadPage', () => { + component.handleDownload(); + expect(updateServiceMock.openDownloadPage).toHaveBeenCalled(); + }); + + it('should open GitHub releases via electronAPI if available', () => { + const event = new Event('click'); + spyOn(event, 'preventDefault'); + const mockElectronAPI = { + openExternal: jasmine.createSpy('openExternal'), + }; + (window as unknown as { electronAPI: typeof mockElectronAPI }).electronAPI = + mockElectronAPI; + + component.openGitHubReleases(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockElectronAPI.openExternal).toHaveBeenCalledWith( + 'https://github.com/altaskur/OpenTimeTracker/releases', + ); + }); + + it('should open GitHub releases via window.open if electronAPI is not available', () => { + const event = new Event('click'); + spyOn(event, 'preventDefault'); + const win = window as unknown as { electronAPI?: unknown }; + const originalElectronAPI = win.electronAPI; + delete win.electronAPI; + spyOn(window, 'open'); + + component.openGitHubReleases(event); + + expect(window.open).toHaveBeenCalledWith( + 'https://github.com/altaskur/OpenTimeTracker/releases', + '_blank', + 'noopener', + ); + + // Restore + win.electronAPI = originalElectronAPI; + }); + + it('should show current version notes', fakeAsync(() => { + component.currentVersionReleaseNotes = null; + updateServiceMock.getReleaseByTag.and.returnValue( + Promise.resolve({ + tag_name: 'v1.0.0', + html_url: 'url', + body: 'Notes', + name: 'Name', + published_at: 'date', + }), + ); + + component.showCurrentVersionNotes(); + tick(); + + expect(component.currentVersionDialogVisible).toBeTrue(); + expect(updateServiceMock.getReleaseByTag).toHaveBeenCalled(); + })); +}); + +describe('OpenSettingsUpdatesComponent without release notes', () => { + let component: OpenSettingsUpdatesComponent; + let fixture: ComponentFixture; + let updateServiceMock: Partial & { + toggleAutoCheck: jasmine.Spy; + checkForUpdates: jasmine.Spy; + openDownloadPage: jasmine.Spy; + getReleaseByTag: jasmine.Spy; + }; + let originalElectronAPI: unknown; + + beforeEach(async () => { + updateServiceMock = { + autoCheck: signal(true), + updateAvailable: signal(null), + checking: signal(false), + toggleAutoCheck: jasmine.createSpy('toggleAutoCheck'), + checkForUpdates: jasmine + .createSpy('checkForUpdates') + .and.returnValue(Promise.resolve(null)), + openDownloadPage: jasmine.createSpy('openDownloadPage'), + getReleaseByTag: jasmine + .createSpy('getReleaseByTag') + .and.returnValue(Promise.resolve(null)), + lastChecked: signal(null), + }; + + originalElectronAPI = (window as unknown as { electronAPI?: unknown }) + .electronAPI; + ( + window as unknown as { electronAPI: { getVersion: jasmine.Spy } } + ).electronAPI = { + getVersion: jasmine + .createSpy('getVersion') + .and.returnValue(Promise.resolve('1.0.0')), + }; + + await TestBed.configureTestingModule({ + imports: [OpenSettingsUpdatesComponent, TranslateModule.forRoot()], + providers: [ + provideNoopAnimations(), + { provide: UpdateService, useValue: updateServiceMock }, + MessageService, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(OpenSettingsUpdatesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + (window as unknown as { electronAPI?: unknown }).electronAPI = + originalElectronAPI; + }); + + it('should handle null release notes gracefully', async () => { + await fixture.whenStable(); + expect(component.currentVersionReleaseNotes).toBeNull(); + }); +}); 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..bee7b7b --- /dev/null +++ b/src/app/pages/open-settings-updates/open-settings-updates.ts @@ -0,0 +1,302 @@ +import { + Component, + inject, + OnInit, + NgZone, + ChangeDetectorRef, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ButtonModule } from 'primeng/button'; +import { CardModule } from 'primeng/card'; +import { ToggleSwitchModule } from 'primeng/toggleswitch'; +import { MessageModule } from 'primeng/message'; +import { TranslateModule } from '@ngx-translate/core'; +import { UpdateService } from '../../services/update.service'; +import { UpdateCheckResult } from '../../../types/electron'; +import { UpdateDialogComponent } from '../../components/update-dialog/update-dialog.component'; + +@Component({ + selector: 'app-open-settings-updates', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ButtonModule, + CardModule, + ToggleSwitchModule, + MessageModule, + TranslateModule, + UpdateDialogComponent, + ], + template: ` +
+

{{ 'settings.updates.title' | translate }}

+ +
+ +
+ + +
+

+ {{ 'settings.updates.checkForUpdates' | translate }} +

+
+
+ +
+ +
+
+ + {{ 'settings.updates.currentVersion' | translate }}: + {{ currentVersion }} +
+ +
+ + +
+
+ + +
+ + +
+ + +
+
+ + + + + @if ( + lastCheckResult !== null && !lastCheckResult.updateAvailable + ) { +
+ + {{ + 'settings.updates.upToDate' | translate + }} +
+ } +
+ + + @if (updateService.updateAvailable(); as update) { + + + + + + +
+ + +
+ } +
+ + +
+ + +
+ + + @if (updateService.lastChecked(); as lastChecked) { +
+ + {{ 'settings.updates.lastChecked' | translate }}: + {{ lastChecked | date: 'medium' }} +
+ } +
+
+
+
+
+ + + + + + + + + `, + styles: [ + ` + :host { + display: block; + height: 100%; + } + `, + ], +}) +export class OpenSettingsUpdatesComponent implements OnInit { + updateService = inject(UpdateService); + private readonly ngZone = inject(NgZone); + private readonly cdr = inject(ChangeDetectorRef); + + lastCheckResult: UpdateCheckResult | null = null; + currentVersion = '...'; + dialogVisible = false; + updateReleaseDate: Date | null = null; + currentVersionDialogVisible = false; + currentVersionReleaseNotes: string | null = null; + + ngOnInit() { + if (globalThis.window?.electronAPI) { + // Use promise chain to ensure we handle the zone correctly from the start + globalThis.window.electronAPI.getVersion().then((version) => { + this.ngZone.run(() => { + this.currentVersion = version; + // Fetch notes after getting version + this.fetchCurrentVersionNotes(); + }); + }); + } + } + + async checkNow() { + this.lastCheckResult = null; + const result = await this.updateService.checkForUpdates(true); + + this.ngZone.run(async () => { + this.lastCheckResult = result; + // If update is available, show the dialog + if (result?.updateAvailable) { + await this.fetchUpdateReleaseDate(result.version); + this.showUpdateDialog(); + } + }); + } + + showUpdateDialog() { + const update = this.updateService.updateAvailable(); + if (update) { + this.fetchUpdateReleaseDate(update.version); + this.dialogVisible = true; + } + } + + async fetchUpdateReleaseDate(version: string) { + const tag = version.startsWith('v') ? version : `v${version}`; + const release = await this.updateService.getReleaseByTag(tag); + if (release && 'published_at' in release) { + this.ngZone.run(() => { + this.updateReleaseDate = new Date(release.published_at); + }); + } + } + + handleDownload() { + this.updateService.openDownloadPage(); + } + + openGitHubReleases(event: Event) { + event.preventDefault(); + const repoUrl = 'https://github.com/altaskur/OpenTimeTracker/releases'; + if (globalThis.window?.electronAPI) { + globalThis.window.electronAPI.openExternal(repoUrl); + } else { + window.open(repoUrl, '_blank', 'noopener'); + } + } + + async fetchCurrentVersionNotes() { + const tag = this.currentVersion.startsWith('v') + ? this.currentVersion + : `v${this.currentVersion}`; + const release = await this.updateService.getReleaseByTag(tag); + if (release) { + this.ngZone.run(() => { + this.currentVersionReleaseNotes = release.body; + this.cdr.detectChanges(); + }); + } + } + + showCurrentVersionNotes() { + // Use setTimeout to ensure we are in a clean cycle and force update + setTimeout(() => { + this.ngZone.run(() => { + this.currentVersionDialogVisible = true; + this.cdr.detectChanges(); + }); + }, 0); + + // Ensure we have the notes, if not fetch them again + if (!this.currentVersionReleaseNotes) { + this.fetchCurrentVersionNotes(); + } + } +} diff --git a/src/app/pipes/safe-markdown.pipe.spec.ts b/src/app/pipes/safe-markdown.pipe.spec.ts new file mode 100644 index 0000000..cb819f2 --- /dev/null +++ b/src/app/pipes/safe-markdown.pipe.spec.ts @@ -0,0 +1,67 @@ +import { SafeMarkdownPipe } from './safe-markdown.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { TestBed } from '@angular/core/testing'; + +describe('SafeMarkdownPipe', () => { + let pipe: SafeMarkdownPipe; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + DomSanitizer, + { + provide: DomSanitizer, + useValue: { + sanitize: (_context: unknown, value: unknown) => value, + bypassSecurityTrustHtml: (value: unknown) => value, + }, + }, + ], + }); + pipe = TestBed.runInInjectionContext(() => new SafeMarkdownPipe()); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should return empty string for null/undefined content', () => { + expect(pipe.transform(null)).toBe(''); + expect(pipe.transform(undefined)).toBe(''); + }); + + it('should sanitize HTML', () => { + const content = '\n**bold**'; + const result = pipe.transform(content); + expect(result).not.toContain('