diff --git a/package-lock.json b/package-lock.json index 73301f5d..18fc2f1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.8.5", + "version": "0.8.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.8.5", + "version": "0.8.7", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -26,6 +26,7 @@ "diff": "^8.0.2", "dompurify": "^3.3.0", "electron-store": "^8.1.0", + "electron-updater": "^6.6.2", "fastify": "^4.25.2", "js-tiktoken": "^1.0.21", "mermaid": "^11.12.1", @@ -3767,7 +3768,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -6714,6 +6714,82 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.6.2.tgz", + "integrity": "sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.3.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.6.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz", + "integrity": "sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron/node_modules/@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", @@ -8644,7 +8720,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -8876,7 +8951,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lazystream": { @@ -9008,6 +9082,12 @@ "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==", + "license": "MIT" + }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", @@ -9015,6 +9095,13 @@ "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.", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -12559,7 +12646,6 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/saxes": { @@ -13412,6 +13498,12 @@ "real-require": "^0.2.0" } }, + "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==", + "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 5efa591f..f32af049 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,11 @@ "build": { "appId": "com.maestro.app", "productName": "Maestro", + "publish": { + "provider": "github", + "owner": "pedramamini", + "repo": "Maestro" + }, "directories": { "output": "release", "buildResources": "build" @@ -143,6 +148,7 @@ "diff": "^8.0.2", "dompurify": "^3.3.0", "electron-store": "^8.1.0", + "electron-updater": "^6.6.2", "fastify": "^4.25.2", "js-tiktoken": "^1.0.21", "mermaid": "^11.12.1", diff --git a/src/__tests__/renderer/components/UpdateCheckModal.test.tsx b/src/__tests__/renderer/components/UpdateCheckModal.test.tsx index 01733eeb..b036c628 100644 --- a/src/__tests__/renderer/components/UpdateCheckModal.test.tsx +++ b/src/__tests__/renderer/components/UpdateCheckModal.test.tsx @@ -17,6 +17,10 @@ import React from 'react'; if (!(window.maestro as any).updates) { (window.maestro as any).updates = { check: vi.fn(), + download: vi.fn(), + install: vi.fn(), + getStatus: vi.fn(), + onStatus: vi.fn(() => vi.fn()), // Returns an unsubscribe function }; } @@ -212,19 +216,6 @@ describe('UpdateCheckModal', () => { }); }); - it('shows upgrade instructions', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByText('How to Upgrade')).toBeInTheDocument(); - }); - }); - it('shows release notes section', async () => { render( { ); await waitFor(() => { - expect(screen.getByText('Download Latest Release')).toBeInTheDocument(); + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); }); }); - it('opens releases URL when download button clicked', async () => { + it('starts download when download button clicked', async () => { + (window.maestro.updates.download as any).mockResolvedValue({ success: true }); + render( { ); await waitFor(() => { - expect(screen.getByText('Download Latest Release')).toBeInTheDocument(); + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); }); - fireEvent.click(screen.getByText('Download Latest Release')); + fireEvent.click(screen.getByText('Download and Install Update')); - expect(window.maestro.shell.openExternal).toHaveBeenCalledWith( - 'https://github.com/pedramamini/Maestro/releases' + expect(window.maestro.updates.download).toHaveBeenCalled(); + }); + + it('shows fallback link to GitHub', async () => { + render( + ); + + await waitFor(() => { + expect(screen.getByText('Or download manually from GitHub')).toBeInTheDocument(); + }); }); }); @@ -890,4 +894,557 @@ describe('UpdateCheckModal', () => { expect(screen.queryByTestId('react-markdown')).not.toBeInTheDocument(); }); }); + + // ============================================================================= + // AUTO-UPDATE DOWNLOAD FLOW + // ============================================================================= + + describe('auto-update download flow', () => { + beforeEach(() => { + // Reset download mock + (window.maestro.updates.download as any).mockReset(); + (window.maestro.updates.install as any).mockReset(); + }); + + it('calls download API when download button is clicked', async () => { + (window.maestro.updates.download as any).mockResolvedValue({ success: true }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Download and Install Update')); + + expect(window.maestro.updates.download).toHaveBeenCalledTimes(1); + }); + + it('shows downloading state when download starts', async () => { + // Make download take some time + (window.maestro.updates.download as any).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100)) + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Download and Install Update')); + + // Should show downloading state + await waitFor(() => { + expect(screen.getByText('Downloading...')).toBeInTheDocument(); + }); + }); + + it('disables download button while downloading', async () => { + (window.maestro.updates.download as any).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100)) + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + const downloadButton = screen.getByText('Download and Install Update').closest('button'); + fireEvent.click(downloadButton!); + + await waitFor(() => { + const downloadingButton = screen.getByText('Downloading...').closest('button'); + expect(downloadingButton).toBeDisabled(); + }); + }); + + it('disables refresh button while downloading', async () => { + (window.maestro.updates.download as any).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100)) + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Download and Install Update')); + + await waitFor(() => { + expect(screen.getByText('Downloading...')).toBeInTheDocument(); + }); + + const refreshButton = screen.getByTitle('Refresh'); + expect(refreshButton).toBeDisabled(); + }); + + it('shows download error when download fails', async () => { + (window.maestro.updates.download as any).mockResolvedValue({ + success: false, + error: 'Download failed: network error', + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Download and Install Update')); + + await waitFor(() => { + expect(screen.getByText('Download failed')).toBeInTheDocument(); + expect(screen.getByText('Download failed: network error')).toBeInTheDocument(); + }); + }); + + it('shows manual download link on error', async () => { + (window.maestro.updates.download as any).mockResolvedValue({ + success: false, + error: 'Some error', + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Download and Install Update')); + + await waitFor(() => { + expect(screen.getByText('Download manually from GitHub')).toBeInTheDocument(); + }); + }); + + it('opens GitHub releases when manual download link is clicked after error', async () => { + (window.maestro.updates.download as any).mockResolvedValue({ + success: false, + error: 'Some error', + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Download and Install Update')); + + await waitFor(() => { + expect(screen.getByText('Download manually from GitHub')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Download manually from GitHub')); + + expect(window.maestro.shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/pedramamini/Maestro/releases' + ); + }); + + it('subscribes to status updates on mount', async () => { + render( + + ); + + expect(window.maestro.updates.onStatus).toHaveBeenCalled(); + }); + + it('unsubscribes from status updates on unmount', async () => { + const mockUnsubscribe = vi.fn(); + (window.maestro.updates.onStatus as any).mockReturnValue(mockUnsubscribe); + + const { unmount } = render( + + ); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + }); + + // ============================================================================= + // AUTO-UPDATE INSTALL FLOW + // ============================================================================= + + describe('auto-update install flow', () => { + it('calls install API when restart button is clicked', async () => { + // Simulate downloaded state via onStatus callback + let statusCallback: ((status: any) => void) | null = null; + (window.maestro.updates.onStatus as any).mockImplementation((cb: any) => { + statusCallback = cb; + return vi.fn(); + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + // Simulate update downloaded status + statusCallback!({ status: 'downloaded', info: { version: '1.1.0' } }); + + await waitFor(() => { + expect(screen.getByText('Restart to Update')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Restart to Update')); + + expect(window.maestro.updates.install).toHaveBeenCalled(); + }); + + it('shows restart button after download completes', async () => { + let statusCallback: ((status: any) => void) | null = null; + (window.maestro.updates.onStatus as any).mockImplementation((cb: any) => { + statusCallback = cb; + return vi.fn(); + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + // Simulate download completed + statusCallback!({ status: 'downloaded', info: { version: '1.1.0' } }); + + await waitFor(() => { + expect(screen.getByText('Restart to Update')).toBeInTheDocument(); + }); + + // Download button should be replaced + expect(screen.queryByText('Download and Install Update')).not.toBeInTheDocument(); + }); + + it('restart button has success styling', async () => { + let statusCallback: ((status: any) => void) | null = null; + (window.maestro.updates.onStatus as any).mockImplementation((cb: any) => { + statusCallback = cb; + return vi.fn(); + }); + + const theme = createMockTheme(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + statusCallback!({ status: 'downloaded', info: { version: '1.1.0' } }); + + await waitFor(() => { + const restartButton = screen.getByText('Restart to Update').closest('button'); + expect(restartButton).toHaveStyle({ backgroundColor: theme.colors.success }); + }); + }); + }); + + // ============================================================================= + // DOWNLOAD PROGRESS + // ============================================================================= + + describe('download progress', () => { + it('shows progress bar during download', async () => { + let statusCallback: ((status: any) => void) | null = null; + (window.maestro.updates.onStatus as any).mockImplementation((cb: any) => { + statusCallback = cb; + return vi.fn(); + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + // Simulate downloading with progress + statusCallback!({ + status: 'downloading', + progress: { percent: 50, bytesPerSecond: 1000000, total: 10000000, transferred: 5000000 }, + }); + + await waitFor(() => { + expect(screen.getByText('Downloading update...')).toBeInTheDocument(); + expect(screen.getByText('50%')).toBeInTheDocument(); + }); + }); + + it('shows download speed', async () => { + let statusCallback: ((status: any) => void) | null = null; + (window.maestro.updates.onStatus as any).mockImplementation((cb: any) => { + statusCallback = cb; + return vi.fn(); + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + statusCallback!({ + status: 'downloading', + progress: { percent: 25, bytesPerSecond: 1048576, total: 10485760, transferred: 2621440 }, + }); + + await waitFor(() => { + expect(screen.getByText('1 MB/s')).toBeInTheDocument(); + }); + }); + + it('shows transferred and total bytes', async () => { + let statusCallback: ((status: any) => void) | null = null; + (window.maestro.updates.onStatus as any).mockImplementation((cb: any) => { + statusCallback = cb; + return vi.fn(); + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + statusCallback!({ + status: 'downloading', + progress: { percent: 50, bytesPerSecond: 500000, total: 10485760, transferred: 5242880 }, + }); + + await waitFor(() => { + expect(screen.getByText('5 MB / 10 MB')).toBeInTheDocument(); + }); + }); + + it('updates progress bar width based on percent', async () => { + let statusCallback: ((status: any) => void) | null = null; + (window.maestro.updates.onStatus as any).mockImplementation((cb: any) => { + statusCallback = cb; + return vi.fn(); + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + statusCallback!({ + status: 'downloading', + progress: { percent: 75, bytesPerSecond: 1000000, total: 10000000, transferred: 7500000 }, + }); + + await waitFor(() => { + expect(screen.getByText('75%')).toBeInTheDocument(); + }); + + // Check progress bar width + const progressContainer = screen.getByText('75%').closest('div')?.parentElement; + const progressBar = progressContainer?.querySelector('[class*="h-full"]'); + expect(progressBar).toHaveStyle({ width: '75%' }); + }); + + it('handles error status from onStatus callback', async () => { + let statusCallback: ((status: any) => void) | null = null; + (window.maestro.updates.onStatus as any).mockImplementation((cb: any) => { + statusCallback = cb; + return vi.fn(); + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + // Simulate error during download + statusCallback!({ + status: 'error', + error: 'Connection timed out', + }); + + await waitFor(() => { + expect(screen.getByText('Download failed')).toBeInTheDocument(); + expect(screen.getByText('Connection timed out')).toBeInTheDocument(); + }); + }); + }); + + // ============================================================================= + // FALLBACK LINK + // ============================================================================= + + describe('fallback link', () => { + it('always shows fallback link when update is available', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Or download manually from GitHub')).toBeInTheDocument(); + }); + }); + + it('opens GitHub when fallback link is clicked', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Or download manually from GitHub')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Or download manually from GitHub')); + + expect(window.maestro.shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/pedramamini/Maestro/releases' + ); + }); + + it('fallback link is visible during download', async () => { + let statusCallback: ((status: any) => void) | null = null; + (window.maestro.updates.onStatus as any).mockImplementation((cb: any) => { + statusCallback = cb; + return vi.fn(); + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + statusCallback!({ + status: 'downloading', + progress: { percent: 50, bytesPerSecond: 1000000, total: 10000000, transferred: 5000000 }, + }); + + await waitFor(() => { + expect(screen.getByText('Or download manually from GitHub')).toBeInTheDocument(); + }); + }); + + it('fallback link is visible after download completes', async () => { + let statusCallback: ((status: any) => void) | null = null; + (window.maestro.updates.onStatus as any).mockImplementation((cb: any) => { + statusCallback = cb; + return vi.fn(); + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Download and Install Update')).toBeInTheDocument(); + }); + + statusCallback!({ status: 'downloaded', info: { version: '1.1.0' } }); + + await waitFor(() => { + expect(screen.getByText('Or download manually from GitHub')).toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/main/auto-updater.ts b/src/main/auto-updater.ts new file mode 100644 index 00000000..d2e2541d --- /dev/null +++ b/src/main/auto-updater.ts @@ -0,0 +1,132 @@ +/** + * Auto-updater module for Maestro + * Uses electron-updater to download and install updates from GitHub releases + */ + +import { autoUpdater, UpdateInfo, ProgressInfo } from 'electron-updater'; +import { BrowserWindow, ipcMain } from 'electron'; +import { logger } from './utils/logger'; + +// Don't auto-download - we want user to initiate +autoUpdater.autoDownload = false; +autoUpdater.autoInstallOnAppQuit = true; + +export interface UpdateStatus { + status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; + info?: UpdateInfo; + progress?: ProgressInfo; + error?: string; +} + +let mainWindow: BrowserWindow | null = null; +let currentStatus: UpdateStatus = { status: 'idle' }; + +/** + * Initialize the auto-updater and set up event handlers + */ +export function initAutoUpdater(window: BrowserWindow): void { + mainWindow = window; + + // Update available + autoUpdater.on('update-available', (info: UpdateInfo) => { + logger.info(`Update available: ${info.version}`, 'AutoUpdater'); + currentStatus = { status: 'available', info }; + sendStatusToRenderer(); + }); + + // No update available + autoUpdater.on('update-not-available', (info: UpdateInfo) => { + logger.debug(`No update available (current: ${info.version})`, 'AutoUpdater'); + currentStatus = { status: 'not-available', info }; + sendStatusToRenderer(); + }); + + // Download progress + autoUpdater.on('download-progress', (progress: ProgressInfo) => { + currentStatus = { ...currentStatus, status: 'downloading', progress }; + sendStatusToRenderer(); + }); + + // Update downloaded + autoUpdater.on('update-downloaded', (info: UpdateInfo) => { + logger.info(`Update downloaded: ${info.version}`, 'AutoUpdater'); + currentStatus = { status: 'downloaded', info }; + sendStatusToRenderer(); + }); + + // Error + autoUpdater.on('error', (err: Error) => { + logger.error(`Auto-update error: ${err.message}`, 'AutoUpdater'); + currentStatus = { status: 'error', error: err.message }; + sendStatusToRenderer(); + }); + + // Set up IPC handlers + setupIpcHandlers(); +} + +/** + * Send current status to renderer + */ +function sendStatusToRenderer(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('updates:status', currentStatus); + } +} + +/** + * Set up IPC handlers for update operations + */ +function setupIpcHandlers(): void { + // Check for updates using electron-updater (different from manual GitHub API check) + ipcMain.handle('updates:checkAutoUpdater', async () => { + try { + currentStatus = { status: 'checking' }; + sendStatusToRenderer(); + const result = await autoUpdater.checkForUpdates(); + return { success: true, updateInfo: result?.updateInfo }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + currentStatus = { status: 'error', error: errorMessage }; + sendStatusToRenderer(); + return { success: false, error: errorMessage }; + } + }); + + // Download update + ipcMain.handle('updates:download', async () => { + try { + currentStatus = { status: 'downloading', progress: { percent: 0, bytesPerSecond: 0, total: 0, transferred: 0, delta: 0 } }; + sendStatusToRenderer(); + await autoUpdater.downloadUpdate(); + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + currentStatus = { status: 'error', error: errorMessage }; + sendStatusToRenderer(); + return { success: false, error: errorMessage }; + } + }); + + // Install update (quit and install) + ipcMain.handle('updates:install', () => { + autoUpdater.quitAndInstall(false, true); + }); + + // Get current status + ipcMain.handle('updates:getStatus', () => { + return currentStatus; + }); +} + +/** + * Manually trigger update check (can be called from main process) + */ +export async function checkForUpdatesManual(): Promise { + try { + const result = await autoUpdater.checkForUpdates(); + return result?.updateInfo || null; + } catch { + return null; + } +} diff --git a/src/main/history-manager.ts b/src/main/history-manager.ts index 5b6fce60..1fdd0831 100644 --- a/src/main/history-manager.ts +++ b/src/main/history-manager.ts @@ -412,6 +412,42 @@ export class HistoryManager { return paginateEntries(entries, options); } + /** + * Update sessionName for all entries matching a given claudeSessionId. + * This is used when a tab is renamed to retroactively update past history entries. + */ + updateSessionNameByClaudeSessionId(claudeSessionId: string, sessionName: string): number { + const sessions = this.listSessionsWithHistory(); + let updatedCount = 0; + + for (const sessionId of sessions) { + const filePath = this.getSessionFilePath(sessionId); + if (!fs.existsSync(filePath)) continue; + + try { + const data: HistoryFileData = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + let modified = false; + + for (const entry of data.entries) { + if (entry.claudeSessionId === claudeSessionId && entry.sessionName !== sessionName) { + entry.sessionName = sessionName; + modified = true; + updatedCount++; + } + } + + if (modified) { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + logger.debug(`Updated ${updatedCount} entries for claudeSessionId ${claudeSessionId} in session ${sessionId}`, LOG_CONTEXT); + } + } catch (error) { + logger.warn(`Failed to update sessionName in session ${sessionId}: ${error}`, LOG_CONTEXT); + } + } + + return updatedCount; + } + /** * Clear all sessions for a specific project */ diff --git a/src/main/index.ts b/src/main/index.ts index 37c035f1..f9e03af9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import Store from 'electron-store'; import { getHistoryManager } from './history-manager'; import { registerGitHandlers, registerAutorunHandlers, registerPlaybooksHandlers, registerHistoryHandlers, registerAgentsHandlers, registerProcessHandlers, registerPersistenceHandlers, registerSystemHandlers, setupLoggerEventForwarding } from './ipc/handlers'; import { DEMO_MODE, DEMO_DATA_PATH, CLAUDE_SESSION_PARSE_LIMITS, CLAUDE_PRICING } from './constants'; +import { initAutoUpdater } from './auto-updater'; import { SessionStatsCache, GlobalStatsCache, @@ -533,6 +534,12 @@ function createWindow() { logger.info('Browser window closed', 'Window'); mainWindow = null; }); + + // Initialize auto-updater (only in production) + if (process.env.NODE_ENV !== 'development') { + initAutoUpdater(mainWindow); + logger.info('Auto-updater initialized', 'Window'); + } } // Set up global error handlers for uncaught exceptions diff --git a/src/main/ipc/handlers/history.ts b/src/main/ipc/handlers/history.ts index c55194e8..2cbe3c18 100644 --- a/src/main/ipc/handlers/history.ts +++ b/src/main/ipc/handlers/history.ts @@ -166,6 +166,13 @@ export function registerHistoryHandlers(): void { return false; }); + // Update sessionName for all entries matching a claudeSessionId (used when renaming tabs) + ipcMain.handle('history:updateSessionName', async (_event, claudeSessionId: string, sessionName: string) => { + const count = historyManager.updateSessionNameByClaudeSessionId(claudeSessionId, sessionName); + logger.info(`Updated sessionName for ${count} history entries with claudeSessionId ${claudeSessionId}`, LOG_CONTEXT); + return count; + }); + // NEW: Get history file path for AI context integration ipcMain.handle('history:getFilePath', async (_event, sessionId: string) => { return historyManager.getHistoryFilePath(sessionId); diff --git a/src/main/preload.ts b/src/main/preload.ts index 5ec597bb..d1fbe57a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -375,6 +375,26 @@ contextBridge.exposeInMainWorld('maestro', { releasesUrl: string; error?: string; }>, + // Auto-updater APIs (electron-updater) + download: () => ipcRenderer.invoke('updates:download') as Promise<{ success: boolean; error?: string }>, + install: () => ipcRenderer.invoke('updates:install') as Promise, + getStatus: () => ipcRenderer.invoke('updates:getStatus') as Promise<{ + status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; + info?: { version: string }; + progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number }; + error?: string; + }>, + // Subscribe to update status changes + onStatus: (callback: (status: { + status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; + info?: { version: string }; + progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number }; + error?: string; + }) => void) => { + const handler = (_: any, status: any) => callback(status); + ipcRenderer.on('updates:status', handler); + return () => ipcRenderer.removeListener('updates:status', handler); + }, }, // Logger API @@ -523,6 +543,9 @@ contextBridge.exposeInMainWorld('maestro', { ipcRenderer.invoke('history:delete', entryId, sessionId), update: (entryId: string, updates: { validated?: boolean }, sessionId?: string) => ipcRenderer.invoke('history:update', entryId, updates, sessionId), + // Update sessionName for all entries matching a claudeSessionId (used when renaming tabs) + updateSessionName: (claudeSessionId: string, sessionName: string) => + ipcRenderer.invoke('history:updateSessionName', claudeSessionId, sessionName), // NEW: Get history file path for AI context integration getFilePath: (sessionId: string) => ipcRenderer.invoke('history:getFilePath', sessionId), @@ -876,6 +899,20 @@ export interface MaestroAPI { releasesUrl: string; error?: string; }>; + download: () => Promise<{ success: boolean; error?: string }>; + install: () => Promise; + getStatus: () => Promise<{ + status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; + info?: { version: string }; + progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number }; + error?: string; + }>; + onStatus: (callback: (status: { + status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; + info?: { version: string }; + progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number }; + error?: string; + }) => void) => () => void; }; logger: { log: (level: string, message: string, context?: string, data?: unknown) => Promise; @@ -1079,6 +1116,8 @@ export interface MaestroAPI { clear: (projectPath?: string) => Promise; delete: (entryId: string, sessionId?: string) => Promise; update: (entryId: string, updates: { validated?: boolean }, sessionId?: string) => Promise; + // Update sessionName for all entries matching a claudeSessionId (used when renaming tabs) + updateSessionName: (claudeSessionId: string, sessionName: string) => Promise; onExternalChange: (handler: () => void) => () => void; reload: () => Promise; // NEW: Get history file path for AI context integration diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index bc5996c4..bd01ccbf 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4745,6 +4745,11 @@ export default function MaestroConsole() { tab.claudeSessionId, newName || '' ).catch(err => console.error('Failed to persist tab name:', err)); + // Also update past history entries with this claudeSessionId + window.maestro.history.updateSessionName( + tab.claudeSessionId, + newName || '' + ).catch(err => console.error('Failed to update history session names:', err)); } return { ...s, diff --git a/src/renderer/components/UpdateCheckModal.tsx b/src/renderer/components/UpdateCheckModal.tsx index 6744f09a..97406fd6 100644 --- a/src/renderer/components/UpdateCheckModal.tsx +++ b/src/renderer/components/UpdateCheckModal.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { X, Download, ExternalLink, Loader2, CheckCircle2, AlertCircle, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react'; +import React, { useEffect, useRef, useState } from 'react'; +import { X, Download, ExternalLink, Loader2, CheckCircle2, AlertCircle, RefreshCw, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react'; import type { Theme } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -23,6 +23,13 @@ interface UpdateCheckResult { error?: string; } +interface UpdateStatus { + status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; + info?: { version: string }; + progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number }; + error?: string; +} + interface UpdateCheckModalProps { theme: Theme; onClose: () => void; @@ -36,6 +43,10 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) { const [result, setResult] = useState(null); const [expandedReleases, setExpandedReleases] = useState>(new Set()); + // Auto-updater state + const [downloadStatus, setDownloadStatus] = useState({ status: 'idle' }); + const [downloadError, setDownloadError] = useState(null); + const onCloseRef = useRef(onClose); onCloseRef.current = onClose; @@ -44,8 +55,20 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) { checkForUpdates(); }, []); + // Subscribe to update status changes + useEffect(() => { + const unsubscribe = window.maestro.updates.onStatus((status) => { + setDownloadStatus(status); + if (status.status === 'error' && status.error) { + setDownloadError(status.error); + } + }); + return () => unsubscribe(); + }, []); + const checkForUpdates = async () => { setLoading(true); + setDownloadError(null); try { const updateResult = await window.maestro.updates.check(); setResult(updateResult); @@ -90,6 +113,29 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) { }); }; + const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + }; + + const handleDownloadUpdate = async () => { + setDownloadError(null); + setDownloadStatus({ status: 'downloading', progress: { percent: 0, bytesPerSecond: 0, total: 0, transferred: 0 } }); + + const downloadResult = await window.maestro.updates.download(); + if (!downloadResult.success && downloadResult.error) { + setDownloadError(downloadResult.error); + setDownloadStatus({ status: 'error', error: downloadResult.error }); + } + }; + + const handleInstallUpdate = () => { + window.maestro.updates.install(); + }; + // Register layer on mount useEffect(() => { const id = registerLayer({ @@ -112,6 +158,9 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) { }; }, [registerLayer, unregisterLayer]); + const isDownloading = downloadStatus.status === 'downloading'; + const isDownloaded = downloadStatus.status === 'downloaded'; + return (
- {/* Upgrade Instructions */} -
-
- How to Upgrade -
-
-

Upgrading is simple - just download and replace the app binary:

-
    -
  1. Download the latest release for your platform
  2. -
  3. Replace the existing Maestro app
  4. -
  5. Restart Maestro
  6. -
-

- All your data (sessions, settings, history) will persist automatically. -

-
-
- {/* Release Notes */}
@@ -313,16 +341,100 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
- {/* Download Button */} - + {/* Download Error */} + {downloadError && ( +
+
+ + Download failed +
+

{downloadError}

+ +
+ )} + + {/* Download Progress */} + {isDownloading && downloadStatus.progress && ( +
+
+ Downloading update... + {Math.round(downloadStatus.progress.percent)}% +
+
+
+
+
+ {formatBytes(downloadStatus.progress.transferred)} / {formatBytes(downloadStatus.progress.total)} + {formatBytes(downloadStatus.progress.bytesPerSecond)}/s +
+
+ )} + + {/* Action Buttons */} +
+ {isDownloaded ? ( + + ) : ( + + )} + + {/* Fallback link */} + +
) : (