diff --git a/src/__tests__/main/ipc/handlers/web.test.ts b/src/__tests__/main/ipc/handlers/web.test.ts index c74bb0180..781e250be 100644 --- a/src/__tests__/main/ipc/handlers/web.test.ts +++ b/src/__tests__/main/ipc/handlers/web.test.ts @@ -34,6 +34,7 @@ describe('web handlers', () => { let mockWebServer: any; let webServerRef: { current: any }; let mockCreateWebServer: any; + let mockSettingsStore: any; beforeEach(() => { vi.clearAllMocks(); @@ -54,12 +55,17 @@ describe('web handlers', () => { broadcastTabsChange: vi.fn(), broadcastSessionStateChange: vi.fn(), getWebClientCount: vi.fn().mockReturnValue(1), + getSecurityToken: vi.fn().mockReturnValue('mock-security-token'), start: vi.fn().mockResolvedValue({ port: 8080, url: 'http://localhost:8080' }), stop: vi.fn().mockResolvedValue(undefined), }; webServerRef = { current: mockWebServer }; mockCreateWebServer = vi.fn().mockReturnValue(mockWebServer); + mockSettingsStore = { + get: vi.fn(), + set: vi.fn(), + }; registerWebHandlers({ getWebServer: () => webServerRef.current, @@ -67,6 +73,7 @@ describe('web handlers', () => { webServerRef.current = server; }, createWebServer: mockCreateWebServer, + settingsStore: mockSettingsStore, }); }); @@ -96,6 +103,11 @@ describe('web handlers', () => { ); expect(ipcMain.handle).toHaveBeenCalledWith('live:startServer', expect.any(Function)); expect(ipcMain.handle).toHaveBeenCalledWith('live:stopServer', expect.any(Function)); + expect(ipcMain.handle).toHaveBeenCalledWith('live:persistCurrentToken', expect.any(Function)); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'live:clearPersistentToken', + expect.any(Function) + ); expect(ipcMain.handle).toHaveBeenCalledWith('live:disableAll', expect.any(Function)); expect(ipcMain.handle).toHaveBeenCalledWith('webserver:getUrl', expect.any(Function)); expect(ipcMain.handle).toHaveBeenCalledWith( @@ -346,6 +358,87 @@ describe('web handlers', () => { }); }); + describe('live:persistCurrentToken', () => { + it('should write flag before token for crash safety', async () => { + const handler = registeredHandlers.get('live:persistCurrentToken'); + const result = await handler!({}); + + expect(mockWebServer.getSecurityToken).toHaveBeenCalled(); + expect(mockSettingsStore.set).toHaveBeenCalledWith('persistentWebLink', true); + expect(mockSettingsStore.set).toHaveBeenCalledWith('webAuthToken', 'mock-security-token'); + expect(result).toEqual({ success: true }); + + // Verify crash-safe write order: flag enabled before token. + // A crash between the two writes leaves persistentWebLink=true with + // a missing token, which the factory handles by generating a fresh UUID. + const setCalls = vi.mocked(mockSettingsStore.set).mock.calls; + const flagIndex = setCalls.findIndex(([key]) => key === 'persistentWebLink'); + const tokenIndex = setCalls.findIndex(([key]) => key === 'webAuthToken'); + expect(flagIndex).toBeLessThan(tokenIndex); + }); + + it('should return failure when web server is null', async () => { + webServerRef.current = null; + + const handler = registeredHandlers.get('live:persistCurrentToken'); + const result = await handler!({}); + + expect(result).toEqual({ success: false, message: 'Web server is not running.' }); + }); + + it('should return failure when web server is not active', async () => { + mockWebServer.isActive.mockReturnValue(false); + + const handler = registeredHandlers.get('live:persistCurrentToken'); + const result = await handler!({}); + + expect(result).toEqual({ success: false, message: 'Web server is not running.' }); + expect(mockWebServer.getSecurityToken).not.toHaveBeenCalled(); + }); + + it('should return failure when settings write throws', async () => { + mockSettingsStore.set.mockImplementationOnce(() => { + throw new Error('disk full'); + }); + + const handler = registeredHandlers.get('live:persistCurrentToken'); + const result = await handler!({}); + + expect(result).toEqual({ success: false, message: 'disk full' }); + }); + }); + + describe('live:clearPersistentToken', () => { + it('should clear flag before token for crash safety', async () => { + const handler = registeredHandlers.get('live:clearPersistentToken'); + const result = await handler!({}); + + // Verify both writes are made + expect(mockSettingsStore.set).toHaveBeenCalledWith('persistentWebLink', false); + expect(mockSettingsStore.set).toHaveBeenCalledWith('webAuthToken', null); + expect(result).toEqual({ success: true }); + + // Verify crash-safe write order: flag cleared before token. + // A crash between the two writes must leave persistentWebLink=false + // so the factory ignores the stale token on next startup. + const setCalls = vi.mocked(mockSettingsStore.set).mock.calls; + const flagIndex = setCalls.findIndex(([key]) => key === 'persistentWebLink'); + const tokenIndex = setCalls.findIndex(([key]) => key === 'webAuthToken'); + expect(flagIndex).toBeLessThan(tokenIndex); + }); + + it('should return failure when settings write throws', async () => { + mockSettingsStore.set.mockImplementationOnce(() => { + throw new Error('disk full'); + }); + + const handler = registeredHandlers.get('live:clearPersistentToken'); + const result = await handler!({}); + + expect(result).toEqual({ success: false, message: 'disk full' }); + }); + }); + describe('webserver:getUrl', () => { it('should return web server URL', async () => { const handler = registeredHandlers.get('webserver:getUrl'); diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index 53edd205f..54e552deb 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -19,6 +19,7 @@ vi.mock('../../../main/web-server/WebServer', () => { return { WebServer: class MockWebServer { port: number; + securityToken: string | undefined; setGetSessionsCallback = vi.fn(); setGetSessionDetailCallback = vi.fn(); setGetThemeCallback = vi.fn(); @@ -37,8 +38,9 @@ vi.mock('../../../main/web-server/WebServer', () => { setReorderTabCallback = vi.fn(); setToggleBookmarkCallback = vi.fn(); - constructor(port: number) { + constructor(port: number, securityToken?: string) { this.port = port; + this.securityToken = securityToken; } }, }; @@ -94,11 +96,14 @@ describe('web-server/web-server-factory', () => { const values: Record = { webInterfaceUseCustomPort: false, webInterfaceCustomPort: 8080, + persistentWebLink: false, + webAuthToken: null, activeThemeId: 'dracula', customAICommands: [], }; return values[key] ?? defaultValue; }), + set: vi.fn(), }; mockSessionsStore = { @@ -199,6 +204,82 @@ describe('web-server/web-server-factory', () => { // Check that the server was created with custom port expect((server as any).port).toBe(9999); }); + + it('should not pass security token when persistentWebLink is false', () => { + vi.mocked(mockSettingsStore.get).mockImplementation((key: string, defaultValue?: any) => { + if (key === 'persistentWebLink') return false; + return defaultValue; + }); + + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + expect((server as any).securityToken).toBeUndefined(); + }); + + it('should use stored token when persistentWebLink is true and token is a valid UUID', () => { + const validUuid = '550e8400-e29b-4bd4-a716-446655440000'; + vi.mocked(mockSettingsStore.get).mockImplementation((key: string, defaultValue?: any) => { + if (key === 'persistentWebLink') return true; + if (key === 'webAuthToken') return validUuid; + return defaultValue; + }); + + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + expect((server as any).securityToken).toBe(validUuid); + }); + + it('should reject invalid stored token and generate a new UUID', () => { + vi.mocked(mockSettingsStore.get).mockImplementation((key: string, defaultValue?: any) => { + if (key === 'persistentWebLink') return true; + if (key === 'webAuthToken') return 'not-a-valid-uuid'; + return defaultValue; + }); + + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + // Should have generated a new token, not used the invalid one + expect((server as any).securityToken).not.toBe('not-a-valid-uuid'); + expect((server as any).securityToken).toBeDefined(); + expect(mockSettingsStore.set).toHaveBeenCalledWith('webAuthToken', expect.any(String)); + // Token written to settings must match the one given to the server + const storedToken = vi + .mocked(mockSettingsStore.set) + .mock.calls.find(([key]) => key === 'webAuthToken')?.[1]; + expect((server as any).securityToken).toBe(storedToken); + // Generated replacement must be a valid UUID v4 + expect(storedToken).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); + + it('should generate and store new token when persistentWebLink is true and no token exists', () => { + vi.mocked(mockSettingsStore.get).mockImplementation((key: string, defaultValue?: any) => { + if (key === 'persistentWebLink') return true; + if (key === 'webAuthToken') return null; + return defaultValue; + }); + + const createWebServer = createWebServerFactory(deps); + const server = createWebServer(); + + // Should have generated a token and stored it + expect((server as any).securityToken).toBeDefined(); + expect(typeof (server as any).securityToken).toBe('string'); + expect(mockSettingsStore.set).toHaveBeenCalledWith('webAuthToken', expect.any(String)); + // Token written to settings must match the one given to the server + const storedToken = vi + .mocked(mockSettingsStore.set) + .mock.calls.find(([key]) => key === 'webAuthToken')?.[1]; + expect((server as any).securityToken).toBe(storedToken); + // Generated token must be a valid UUID v4 + expect(storedToken).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); }); describe('callback registrations', () => { diff --git a/src/__tests__/renderer/stores/settingsStore.test.ts b/src/__tests__/renderer/stores/settingsStore.test.ts index 5f3424ea3..2ac4d4243 100644 --- a/src/__tests__/renderer/stores/settingsStore.test.ts +++ b/src/__tests__/renderer/stores/settingsStore.test.ts @@ -1636,7 +1636,138 @@ describe('settingsStore', () => { }); // ======================================================================== - // 13. Non-React Access + // 13. setPersistentWebLink race-condition and rollback tests + // ======================================================================== + + describe('setPersistentWebLink', () => { + beforeEach(() => { + useSettingsStore.setState({ persistentWebLink: false }); + }); + + it('should optimistically set persistentWebLink to true and call persistCurrentToken', async () => { + const { setPersistentWebLink } = useSettingsStore.getState(); + await setPersistentWebLink(true); + + expect(useSettingsStore.getState().persistentWebLink).toBe(true); + expect(window.maestro.live.persistCurrentToken).toHaveBeenCalledOnce(); + }); + + it('should rollback to false on soft IPC failure (result.success === false)', async () => { + vi.mocked(window.maestro.live.persistCurrentToken).mockResolvedValueOnce({ + success: false, + message: 'Web server is not running.', + }); + + const { setPersistentWebLink } = useSettingsStore.getState(); + await setPersistentWebLink(true); + + expect(useSettingsStore.getState().persistentWebLink).toBe(false); + }); + + it('should rollback to false on hard IPC failure (thrown exception)', async () => { + vi.mocked(window.maestro.live.persistCurrentToken).mockRejectedValueOnce( + new Error('IPC timeout') + ); + + const { setPersistentWebLink } = useSettingsStore.getState(); + await setPersistentWebLink(true); + + expect(useSettingsStore.getState().persistentWebLink).toBe(false); + }); + + it('should call clearPersistentToken when disabling', async () => { + useSettingsStore.setState({ persistentWebLink: true }); + + const { setPersistentWebLink } = useSettingsStore.getState(); + await setPersistentWebLink(false); + + expect(useSettingsStore.getState().persistentWebLink).toBe(false); + expect(window.maestro.live.clearPersistentToken).toHaveBeenCalledOnce(); + }); + + it('should rollback to true on clearPersistentToken hard failure (thrown exception)', async () => { + useSettingsStore.setState({ persistentWebLink: true }); + vi.mocked(window.maestro.live.clearPersistentToken).mockRejectedValueOnce( + new Error('IPC timeout') + ); + + const { setPersistentWebLink } = useSettingsStore.getState(); + await setPersistentWebLink(false); + + expect(useSettingsStore.getState().persistentWebLink).toBe(true); + }); + + it('should rollback to true on clearPersistentToken soft failure (result.success === false)', async () => { + useSettingsStore.setState({ persistentWebLink: true }); + vi.mocked(window.maestro.live.clearPersistentToken).mockResolvedValueOnce({ + success: false, + message: 'Settings write failed.', + } as any); + + const { setPersistentWebLink } = useSettingsStore.getState(); + await setPersistentWebLink(false); + + expect(useSettingsStore.getState().persistentWebLink).toBe(true); + }); + + it('should handle rapid double-toggle (enable then disable) correctly', async () => { + // Simulate enable call that resolves slowly + let resolveEnable: (value: any) => void; + const slowEnable = new Promise((resolve) => { + resolveEnable = resolve; + }); + vi.mocked(window.maestro.live.persistCurrentToken).mockReturnValueOnce(slowEnable as any); + + const { setPersistentWebLink } = useSettingsStore.getState(); + + // Start enable (will be in-flight) + const enablePromise = setPersistentWebLink(true); + // Immediately disable (supersedes the enable) + const disablePromise = setPersistentWebLink(false); + + // Resolve the slow enable after disable was called + resolveEnable!({ success: true }); + + await enablePromise; + await disablePromise; + + // Final state should reflect the last user intent: disabled + expect(useSettingsStore.getState().persistentWebLink).toBe(false); + expect(window.maestro.live.clearPersistentToken).toHaveBeenCalled(); + }); + + it('should handle rapid reverse toggle (disable then enable) correctly', async () => { + // Start with enabled state + useSettingsStore.setState({ persistentWebLink: true }); + + // Simulate disable call that resolves slowly + let resolveClear: (value: any) => void; + const slowClear = new Promise((resolve) => { + resolveClear = resolve; + }); + vi.mocked(window.maestro.live.clearPersistentToken).mockReturnValueOnce(slowClear as any); + + const { setPersistentWebLink } = useSettingsStore.getState(); + + // Start disable (will be in-flight) + const disablePromise = setPersistentWebLink(false); + // Immediately re-enable (supersedes the disable) + const enablePromise = setPersistentWebLink(true); + + // Resolve the slow clear after enable was called + resolveClear!({ success: true }); + + await disablePromise; + await enablePromise; + + // Final state should reflect the last user intent: enabled + expect(useSettingsStore.getState().persistentWebLink).toBe(true); + expect(window.maestro.live.persistCurrentToken).toHaveBeenCalled(); + }); + }); + + // ======================================================================== + // 14. Non-React Access // ======================================================================== describe('non-React access', () => { diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 035d933c6..8cb99653d 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -404,6 +404,18 @@ const mockMaestro = { importPlaybook: vi.fn().mockResolvedValue({ success: true, playbook: {}, importedDocs: [] }), onManifestChanged: vi.fn().mockReturnValue(() => {}), }, + live: { + toggle: vi.fn().mockResolvedValue({ live: false, url: null }), + getStatus: vi.fn().mockResolvedValue({ live: false, url: null }), + getDashboardUrl: vi.fn().mockResolvedValue(null), + getLiveSessions: vi.fn().mockResolvedValue([]), + broadcastActiveSession: vi.fn().mockResolvedValue(undefined), + startServer: vi.fn().mockResolvedValue({ success: true, url: 'http://localhost:3000' }), + stopServer: vi.fn().mockResolvedValue({ success: true }), + persistCurrentToken: vi.fn().mockResolvedValue({ success: true }), + clearPersistentToken: vi.fn().mockResolvedValue({ success: true }), + disableAll: vi.fn().mockResolvedValue({ success: true, count: 0 }), + }, web: { broadcastAutoRunState: vi.fn(), broadcastSessionState: vi.fn(), diff --git a/src/main/index.ts b/src/main/index.ts index 577f81530..c35ca18cd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -452,6 +452,7 @@ function setupIpcHandlers() { webServer = server; }, createWebServer, + settingsStore: store, }); // Git operations - extracted to src/main/ipc/handlers/git.ts diff --git a/src/main/ipc/handlers/web.ts b/src/main/ipc/handlers/web.ts index 3668e9d81..0c0475371 100644 --- a/src/main/ipc/handlers/web.ts +++ b/src/main/ipc/handlers/web.ts @@ -13,6 +13,8 @@ * - live:broadcastActiveSession: Broadcast active session change * - live:startServer: Start the web server * - live:stopServer: Stop the web server + * - live:persistCurrentToken: Persist the running server's token and enable persistent web link + * - live:clearPersistentToken: Clear the persisted token and disable persistent web link * - live:disableAll: Disable all live sessions and stop server * - webserver:getUrl: Get the web server URL * - webserver:getConnectedClients: Get connected client count @@ -21,10 +23,10 @@ */ import { ipcMain } from 'electron'; - import { logger } from '../../utils/logger'; import { WebServer } from '../../web-server'; import type { AITabData } from '../../web-server/services/broadcastService'; +import type { SettingsStoreInterface } from '../../stores/types'; /** * Timeout for waiting for web server to become active (ms) @@ -43,13 +45,14 @@ export interface WebHandlerDependencies { getWebServer: () => WebServer | null; setWebServer: (server: WebServer | null) => void; createWebServer: () => WebServer; + settingsStore: SettingsStoreInterface; } /** * Register all web/live-related IPC handlers. */ export function registerWebHandlers(deps: WebHandlerDependencies): void { - const { getWebServer, setWebServer, createWebServer } = deps; + const { getWebServer, setWebServer, createWebServer, settingsStore } = deps; // Broadcast user input to web clients (called when desktop sends a message) ipcMain.handle( @@ -260,6 +263,59 @@ export function registerWebHandlers(deps: WebHandlerDependencies): void { } }); + // Persist the current web server's security token and enable persistent web link. + // Flag is written first: a crash between the two writes leaves + // persistentWebLink=true with a missing/stale token, which the factory + // handles by generating and persisting a fresh UUID on next startup. + ipcMain.handle('live:persistCurrentToken', async () => { + const webServer = getWebServer(); + if (!webServer || !webServer.isActive()) { + return { success: false, message: 'Web server is not running.' }; + } + try { + const currentToken = webServer.getSecurityToken(); + settingsStore.set('persistentWebLink', true); + settingsStore.set('webAuthToken', currentToken); + logger.info( + 'Persisted current web server token and enabled persistent web link', + 'WebServer' + ); + return { success: true }; + } catch (error: any) { + // Rollback the flag so the factory doesn't read persistentWebLink=true + // with a missing token on next startup, which would silently change the URL. + try { + settingsStore.set('persistentWebLink', false); + } catch { + // Best-effort rollback — disk may be completely unavailable + } + logger.error(`Failed to persist web server token: ${error.message}`, 'WebServer'); + return { success: false, message: error.message }; + } + }); + + // Clear persistent web link token and disable the flag on the main side. + // Flag is cleared first: a crash between the two writes leaves + // persistentWebLink=false with a stale token, which the factory ignores. + ipcMain.handle('live:clearPersistentToken', async () => { + try { + settingsStore.set('persistentWebLink', false); + settingsStore.set('webAuthToken', null); + logger.info('Cleared persistent web link token and disabled flag', 'WebServer'); + return { success: true }; + } catch (error: any) { + // Rollback the flag so disk state stays consistent — prevents + // persistentWebLink=false with a stale token on next startup. + try { + settingsStore.set('persistentWebLink', true); + } catch { + // Best-effort rollback — disk may be completely unavailable + } + logger.error(`Failed to clear persistent token: ${error.message}`, 'WebServer'); + return { success: false, message: error.message }; + } + }); + // Disable all live sessions and stop the server ipcMain.handle('live:disableAll', async () => { const webServer = getWebServer(); diff --git a/src/main/preload/web.ts b/src/main/preload/web.ts index dc109a186..65eb2e18c 100644 --- a/src/main/preload/web.ts +++ b/src/main/preload/web.ts @@ -95,6 +95,8 @@ export function createLiveApi() { disableAll: () => ipcRenderer.invoke('live:disableAll'), startServer: () => ipcRenderer.invoke('live:startServer'), stopServer: () => ipcRenderer.invoke('live:stopServer'), + persistCurrentToken: () => ipcRenderer.invoke('live:persistCurrentToken'), + clearPersistentToken: () => ipcRenderer.invoke('live:clearPersistentToken'), }; } diff --git a/src/main/stores/defaults.ts b/src/main/stores/defaults.ts index 17c228215..fa84f26c4 100644 --- a/src/main/stores/defaults.ts +++ b/src/main/stores/defaults.ts @@ -60,6 +60,7 @@ export const SETTINGS_DEFAULTS: MaestroSettings = { defaultShell: getDefaultShell(), webAuthEnabled: false, webAuthToken: null, + persistentWebLink: false, webInterfaceUseCustomPort: false, webInterfaceCustomPort: 8080, sshRemotes: [], diff --git a/src/main/stores/types.ts b/src/main/stores/types.ts index 0028f49f7..2f0f88ad5 100644 --- a/src/main/stores/types.ts +++ b/src/main/stores/types.ts @@ -57,6 +57,8 @@ export interface MaestroSettings { // Web interface authentication webAuthEnabled: boolean; webAuthToken: string | null; + // Persistent web link (reuse token across restarts) + persistentWebLink: boolean; // Web interface custom port webInterfaceUseCustomPort: boolean; webInterfaceCustomPort: number; @@ -149,3 +151,17 @@ export interface AgentSessionOriginsData { > >; } + +// ============================================================================ +// Shared Store Interfaces (used across main process modules) +// ============================================================================ + +/** Generic read/write store interface for settings */ +export interface SettingsStoreInterface { + get(key: string, defaultValue?: T): T; + /** Type-safe set for known settings keys */ + set(key: K, value: MaestroSettings[K]): void; + /** Fallback for dynamic keys — used by the generic settings:set IPC handler + * in persistence.ts which accepts arbitrary key/value pairs from the renderer */ + set(key: string, value: unknown): void; +} diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index 52c0b8122..ed171bb51 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -3,7 +3,7 @@ * * Architecture: * - Single server on random port - * - Security token (UUID) generated at startup, required in all URLs + * - Security token (UUID) per startup or persistent across restarts, required in all URLs * - Routes: /$TOKEN/ (dashboard), /$TOKEN/session/:id (session view) * - Live sessions: Only sessions marked as "live" appear in dashboard * - WebSocket: Real-time updates for session state, logs, theme @@ -15,7 +15,7 @@ * http://localhost:PORT/$TOKEN/ws → WebSocket * * Security: - * - Token regenerated on each app restart + * - Token regenerated on each app restart (unless Persistent Web Link is enabled) * - Invalid/missing token redirects to website * - No access without knowing the token */ @@ -86,7 +86,7 @@ export class WebServer { private rateLimitConfig: RateLimitConfig = { ...DEFAULT_RATE_LIMIT_CONFIG }; private webAssetsPath: string | null = null; - // Security token - regenerated on each app startup + // Security token - persistent or regenerated per startup private securityToken: string; // Local IP address for generating URLs (detected at startup) @@ -107,7 +107,7 @@ export class WebServer { private staticRoutes: StaticRoutes; private wsRoute: WsRoute; - constructor(port: number = 0) { + constructor(port: number = 0, securityToken?: string) { // Use port 0 to let OS assign a random available port this.port = port; this.server = Fastify({ @@ -116,9 +116,14 @@ export class WebServer { }, }); - // Generate a new security token (UUID v4) - this.securityToken = randomUUID(); - logger.debug('Security token generated', LOG_CONTEXT); + // Use provided token (persistent mode) or generate a new one (ephemeral mode) + if (securityToken) { + this.securityToken = securityToken; + logger.debug('Using persistent security token', LOG_CONTEXT); + } else { + this.securityToken = randomUUID(); + logger.debug('Security token generated', LOG_CONTEXT); + } // Determine web assets path (production vs development) this.webAssetsPath = this.resolveWebAssetsPath(); diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index b9d9b44f0..11d864468 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -4,19 +4,19 @@ */ import { BrowserWindow, ipcMain } from 'electron'; +import { randomUUID } from 'crypto'; import { WebServer } from './WebServer'; import { getThemeById } from '../themes'; import { getHistoryManager } from '../history-manager'; import { logger } from '../utils/logger'; import { isWebContentsAvailable } from '../utils/safe-send'; import type { ProcessManager } from '../process-manager'; -import type { StoredSession } from '../stores/types'; +import type { StoredSession, SettingsStoreInterface as SettingsStore } from '../stores/types'; import type { Group } from '../../shared/types'; -/** Store interface for settings */ -interface SettingsStore { - get(key: string, defaultValue?: T): T; -} +/** UUID v4 format regex for validating stored security tokens. + * Enforces version nibble (4) and variant bits ([89ab]). */ +const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** Store interface for sessions */ interface SessionsStore { @@ -58,7 +58,36 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { const useCustomPort = settingsStore.get('webInterfaceUseCustomPort', false); const customPort = settingsStore.get('webInterfaceCustomPort', 8080); const port = useCustomPort ? customPort : 0; - const server = new WebServer(port); // Custom or random port with auto-generated security token + + // Determine security token: persistent or ephemeral + let securityToken: string | undefined; + const persistentWebLink = settingsStore.get('persistentWebLink', false); + if (persistentWebLink) { + const storedToken = settingsStore.get('webAuthToken', null); + // Validate stored token is a proper UUID before trusting it + if (storedToken && UUID_V4_REGEX.test(storedToken)) { + securityToken = storedToken; + } else { + if (storedToken) { + logger.warn( + 'Stored webAuthToken is not a valid UUID, generating new token', + 'WebServerFactory' + ); + } + securityToken = randomUUID(); + try { + settingsStore.set('webAuthToken', securityToken); + } catch (e) { + // Persist failure is non-fatal — server starts with an ephemeral token + logger.warn( + 'Failed to persist new webAuthToken, URL will not survive restart', + 'WebServerFactory' + ); + } + } + } + + const server = new WebServer(port, securityToken); // Set up callback for web server to fetch sessions list server.setGetSessionsCallback(() => { diff --git a/src/renderer/components/SessionList/LiveOverlayPanel.tsx b/src/renderer/components/SessionList/LiveOverlayPanel.tsx index c0179b52c..085cc8d50 100644 --- a/src/renderer/components/SessionList/LiveOverlayPanel.tsx +++ b/src/renderer/components/SessionList/LiveOverlayPanel.tsx @@ -1,4 +1,4 @@ -import { memo, useRef, useEffect } from 'react'; +import { memo, useRef, useEffect, useState, useCallback } from 'react'; import { Copy, ExternalLink } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; import type { Theme } from '../../types'; @@ -18,6 +18,8 @@ interface LiveOverlayPanelProps { copyFlash: string | null; setCopyFlash: (msg: string | null) => void; handleTunnelToggle: () => void; + persistentWebLink: boolean; + setPersistentWebLink: (v: boolean) => Promise; webInterfaceUseCustomPort: boolean; webInterfaceCustomPort: number; setWebInterfaceUseCustomPort: (v: boolean) => void; @@ -40,6 +42,8 @@ export const LiveOverlayPanel = memo(function LiveOverlayPanel({ copyFlash, setCopyFlash, handleTunnelToggle, + persistentWebLink, + setPersistentWebLink, webInterfaceUseCustomPort, webInterfaceCustomPort, setWebInterfaceUseCustomPort, @@ -50,10 +54,20 @@ export const LiveOverlayPanel = memo(function LiveOverlayPanel({ restartWebServer, }: LiveOverlayPanelProps) { const containerRef = useRef(null); + const [isPersistPending, setIsPersistPending] = useState(false); useEffect(() => { containerRef.current?.focus(); }, []); + const handlePersistToggle = useCallback(async () => { + setIsPersistPending(true); + try { + await setPersistentWebLink(!persistentWebLink); + } finally { + setIsPersistPending(false); + } + }, [setPersistentWebLink, persistentWebLink]); + return (
+ {/* Persistent Web Link Toggle Section */} +
+
+
+
+ Persistent Web Link +
+
+ Keep the same access token across restarts +
+
+ + {/* Toggle Switch */} + +
+
+ {/* Custom Port Toggle Section */}
diff --git a/src/renderer/components/SessionList/SessionList.tsx b/src/renderer/components/SessionList/SessionList.tsx index b8a276374..002b46958 100644 --- a/src/renderer/components/SessionList/SessionList.tsx +++ b/src/renderer/components/SessionList/SessionList.tsx @@ -114,6 +114,7 @@ function SessionListInner(props: SessionListProps) { const groupChatsExpanded = useUIStore((s) => s.groupChatsExpanded); const shortcuts = useSettingsStore((s) => s.shortcuts); const leftSidebarWidthState = useSettingsStore((s) => s.leftSidebarWidth); + const persistentWebLink = useSettingsStore((s) => s.persistentWebLink); const webInterfaceUseCustomPort = useSettingsStore((s) => s.webInterfaceUseCustomPort); const webInterfaceCustomPort = useSettingsStore((s) => s.webInterfaceCustomPort); const ungroupedCollapsed = useSettingsStore((s) => s.ungroupedCollapsed); @@ -148,6 +149,7 @@ function SessionListInner(props: SessionListProps) { ); const setSessions = useSessionStore.getState().setSessions; const setGroups = useSessionStore.getState().setGroups; + const setPersistentWebLink = useSettingsStore.getState().setPersistentWebLink; const setWebInterfaceUseCustomPort = useSettingsStore.getState().setWebInterfaceUseCustomPort; const setWebInterfaceCustomPort = useSettingsStore.getState().setWebInterfaceCustomPort; const setUngroupedCollapsed = useSettingsStore.getState().setUngroupedCollapsed; @@ -701,6 +703,8 @@ function SessionListInner(props: SessionListProps) { copyFlash={copyFlash} setCopyFlash={setCopyFlash} handleTunnelToggle={handleTunnelToggle} + persistentWebLink={persistentWebLink} + setPersistentWebLink={setPersistentWebLink} webInterfaceUseCustomPort={webInterfaceUseCustomPort} webInterfaceCustomPort={webInterfaceCustomPort} setWebInterfaceUseCustomPort={setWebInterfaceUseCustomPort} diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index b0b64c7c4..e0b263b4c 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -669,6 +669,8 @@ interface MaestroAPI { disableAll: () => Promise<{ success: boolean; count: number }>; startServer: () => Promise<{ success: boolean; url?: string; error?: string }>; stopServer: () => Promise<{ success: boolean; error?: string }>; + persistCurrentToken: () => Promise<{ success: boolean; message?: string }>; + clearPersistentToken: () => Promise<{ success: boolean; message?: string }>; }; agents: { detect: (sshRemoteId?: string) => Promise; diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 0400cfb00..19c0f94e5 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -221,6 +221,7 @@ export interface SettingsStoreState { firstAutoRunCompleted: boolean; onboardingStats: OnboardingStats; leaderboardRegistration: LeaderboardRegistration | null; + persistentWebLink: boolean; webInterfaceUseCustomPort: boolean; webInterfaceCustomPort: number; contextManagementSettings: ContextManagementSettings; @@ -295,6 +296,7 @@ export interface SettingsStoreActions { setTourCompleted: (value: boolean) => void; setFirstAutoRunCompleted: (value: boolean) => void; setLeaderboardRegistration: (value: LeaderboardRegistration | null) => void; + setPersistentWebLink: (value: boolean) => Promise; setWebInterfaceUseCustomPort: (value: boolean) => void; setWebInterfaceCustomPort: (value: number) => void; setColorBlindMode: (value: boolean) => void; @@ -387,853 +389,926 @@ export type SettingsStore = SettingsStoreState & SettingsStoreActions; // Store Implementation // ============================================================================ -export const useSettingsStore = create()((set, get) => ({ - // ============================================================================ - // State (defaults) - // ============================================================================ - - settingsLoaded: false, - conductorProfile: '', - llmProvider: 'openrouter', - modelSlug: 'anthropic/claude-3.5-sonnet', - apiKey: '', - defaultShell: 'zsh', - customShellPath: '', - shellArgs: '', - shellEnvVars: {}, - ghPath: '', - fontFamily: 'Roboto Mono, Menlo, "Courier New", monospace', - fontSize: 14, - activeThemeId: 'dracula', - customThemeColors: DEFAULT_CUSTOM_THEME_COLORS, - customThemeBaseId: 'dracula', - enterToSendAI: false, - enterToSendTerminal: true, - defaultSaveToHistory: true, - defaultShowThinking: 'off', - leftSidebarWidth: 256, - rightPanelWidth: 384, - markdownEditMode: false, - chatRawTextMode: false, - showHiddenFiles: true, - terminalWidth: 100, - logLevel: 'info', - maxLogBuffer: 5000, - maxOutputLines: 25, - osNotificationsEnabled: true, - audioFeedbackEnabled: false, - audioFeedbackCommand: 'say', - toastDuration: 20, - checkForUpdatesOnStartup: true, - enableBetaUpdates: false, - crashReportingEnabled: true, - logViewerSelectedLevels: ['debug', 'info', 'warn', 'error', 'toast'], - shortcuts: DEFAULT_SHORTCUTS, - tabShortcuts: TAB_SHORTCUTS, - customAICommands: DEFAULT_AI_COMMANDS, - totalActiveTimeMs: 0, - autoRunStats: DEFAULT_AUTO_RUN_STATS, - usageStats: DEFAULT_USAGE_STATS, - ungroupedCollapsed: false, - tourCompleted: false, - firstAutoRunCompleted: false, - onboardingStats: DEFAULT_ONBOARDING_STATS, - leaderboardRegistration: null, - webInterfaceUseCustomPort: false, - webInterfaceCustomPort: 8080, - contextManagementSettings: DEFAULT_CONTEXT_MANAGEMENT_SETTINGS, - keyboardMasteryStats: DEFAULT_KEYBOARD_MASTERY_STATS, - colorBlindMode: false, - documentGraphShowExternalLinks: false, - documentGraphMaxNodes: 50, - documentGraphPreviewCharLimit: 100, - documentGraphLayoutType: 'mindmap', - statsCollectionEnabled: true, - defaultStatsTimeRange: 'week', - preventSleepEnabled: false, - disableGpuAcceleration: false, - disableConfetti: false, - localIgnorePatterns: [...DEFAULT_LOCAL_IGNORE_PATTERNS], - localHonorGitignore: true, - sshRemoteIgnorePatterns: ['.git', '*cache*'], - sshRemoteHonorGitignore: true, - automaticTabNamingEnabled: true, - fileTabAutoRefreshEnabled: false, - suppressWindowsWarning: false, - autoScrollAiMode: false, - userMessageAlignment: 'right', - encoreFeatures: DEFAULT_ENCORE_FEATURES, - directorNotesSettings: DEFAULT_DIRECTOR_NOTES_SETTINGS, - wakatimeApiKey: '', - wakatimeEnabled: false, - wakatimeDetailedTracking: false, - useNativeTitleBar: false, - autoHideMenuBar: false, - - // ============================================================================ - // Simple Setters - // ============================================================================ - - setConductorProfile: (value) => { - const trimmed = value.slice(0, 1000); - set({ conductorProfile: trimmed }); - window.maestro.settings.set('conductorProfile', trimmed); - }, - - setLlmProvider: (value) => { - set({ llmProvider: value }); - window.maestro.settings.set('llmProvider', value); - }, - - setModelSlug: (value) => { - set({ modelSlug: value }); - window.maestro.settings.set('modelSlug', value); - }, - - setApiKey: (value) => { - set({ apiKey: value }); - window.maestro.settings.set('apiKey', value); - }, - - setDefaultShell: (value) => { - set({ defaultShell: value }); - window.maestro.settings.set('defaultShell', value); - }, - - setCustomShellPath: (value) => { - set({ customShellPath: value }); - window.maestro.settings.set('customShellPath', value); - }, - - setShellArgs: (value) => { - set({ shellArgs: value }); - window.maestro.settings.set('shellArgs', value); - }, - - setShellEnvVars: (value) => { - set({ shellEnvVars: value }); - window.maestro.settings.set('shellEnvVars', value); - }, - - setGhPath: (value) => { - set({ ghPath: value }); - window.maestro.settings.set('ghPath', value); - }, - - setFontFamily: (value) => { - set({ fontFamily: value }); - window.maestro.settings.set('fontFamily', value); - }, - - setFontSize: (value) => { - set({ fontSize: value }); - window.maestro.settings.set('fontSize', value); - }, - - setActiveThemeId: (value) => { - set({ activeThemeId: value }); - window.maestro.settings.set('activeThemeId', value); - }, - - setCustomThemeColors: (value) => { - set({ customThemeColors: value }); - window.maestro.settings.set('customThemeColors', value); - }, - - setCustomThemeBaseId: (value) => { - set({ customThemeBaseId: value }); - window.maestro.settings.set('customThemeBaseId', value); - }, - - setEnterToSendAI: (value) => { - set({ enterToSendAI: value }); - window.maestro.settings.set('enterToSendAI', value); - }, - - setEnterToSendTerminal: (value) => { - set({ enterToSendTerminal: value }); - window.maestro.settings.set('enterToSendTerminal', value); - }, - - setDefaultSaveToHistory: (value) => { - set({ defaultSaveToHistory: value }); - window.maestro.settings.set('defaultSaveToHistory', value); - }, - - setDefaultShowThinking: (value) => { - set({ defaultShowThinking: value }); - window.maestro.settings.set('defaultShowThinking', value); - }, - - setLeftSidebarWidth: (value) => { - const clamped = Math.max(256, Math.min(600, value)); - set({ leftSidebarWidth: clamped }); - window.maestro.settings.set('leftSidebarWidth', clamped); - }, - - setRightPanelWidth: (value) => { - set({ rightPanelWidth: value }); - window.maestro.settings.set('rightPanelWidth', value); - }, - - setMarkdownEditMode: (value) => { - set({ markdownEditMode: value }); - window.maestro.settings.set('markdownEditMode', value); - }, - - setChatRawTextMode: (value) => { - set({ chatRawTextMode: value }); - window.maestro.settings.set('chatRawTextMode', value); - }, - - setShowHiddenFiles: (value) => { - set({ showHiddenFiles: value }); - window.maestro.settings.set('showHiddenFiles', value); - }, - - setTerminalWidth: (value) => { - set({ terminalWidth: value }); - window.maestro.settings.set('terminalWidth', value); - }, - - setMaxOutputLines: (value) => { - set({ maxOutputLines: value }); - window.maestro.settings.set('maxOutputLines', value); - }, - - setOsNotificationsEnabled: (value) => { - set({ osNotificationsEnabled: value }); - window.maestro.settings.set('osNotificationsEnabled', value); - }, - - setAudioFeedbackEnabled: (value) => { - set({ audioFeedbackEnabled: value }); - window.maestro.settings.set('audioFeedbackEnabled', value); - }, - - setAudioFeedbackCommand: (value) => { - set({ audioFeedbackCommand: value }); - window.maestro.settings.set('audioFeedbackCommand', value); - }, - - setToastDuration: (value) => { - set({ toastDuration: value }); - window.maestro.settings.set('toastDuration', value); - }, - - setCheckForUpdatesOnStartup: (value) => { - set({ checkForUpdatesOnStartup: value }); - window.maestro.settings.set('checkForUpdatesOnStartup', value); - }, - - setEnableBetaUpdates: (value) => { - set({ enableBetaUpdates: value }); - window.maestro.settings.set('enableBetaUpdates', value); - }, - - setCrashReportingEnabled: (value) => { - set({ crashReportingEnabled: value }); - window.maestro.settings.set('crashReportingEnabled', value); - }, - - setLogViewerSelectedLevels: (value) => { - set({ logViewerSelectedLevels: value }); - window.maestro.settings.set('logViewerSelectedLevels', value); - }, - - setShortcuts: (value) => { - set({ shortcuts: value }); - window.maestro.settings.set('shortcuts', value); - }, - - setTabShortcuts: (value) => { - set({ tabShortcuts: value }); - window.maestro.settings.set('tabShortcuts', value); - }, - - setCustomAICommands: (value) => { - set({ customAICommands: value }); - window.maestro.settings.set('customAICommands', value); - }, - - setUngroupedCollapsed: (value) => { - set({ ungroupedCollapsed: value }); - window.maestro.settings.set('ungroupedCollapsed', value); - }, - - setTourCompleted: (value) => { - set({ tourCompleted: value }); - window.maestro.settings.set('tourCompleted', value); - }, - - setFirstAutoRunCompleted: (value) => { - set({ firstAutoRunCompleted: value }); - window.maestro.settings.set('firstAutoRunCompleted', value); - }, - - setLeaderboardRegistration: (value) => { - set({ leaderboardRegistration: value }); - window.maestro.settings.set('leaderboardRegistration', value); - }, - - setWebInterfaceUseCustomPort: (value) => { - set({ webInterfaceUseCustomPort: value }); - window.maestro.settings.set('webInterfaceUseCustomPort', value); - }, - - setWebInterfaceCustomPort: (value) => { - // Store the value as-is during typing; validation happens on blur/submit - set({ webInterfaceCustomPort: value }); - // Only persist valid port values - if (value >= 1024 && value <= 65535) { - window.maestro.settings.set('webInterfaceCustomPort', value); - } - }, - - setColorBlindMode: (value) => { - set({ colorBlindMode: value }); - window.maestro.settings.set('colorBlindMode', value); - }, - - setDocumentGraphShowExternalLinks: (value) => { - set({ documentGraphShowExternalLinks: value }); - window.maestro.settings.set('documentGraphShowExternalLinks', value); - }, - - setDocumentGraphMaxNodes: (value) => { - const clamped = Math.max(50, Math.min(1000, value)); - set({ documentGraphMaxNodes: clamped }); - window.maestro.settings.set('documentGraphMaxNodes', clamped); - }, - - setDocumentGraphPreviewCharLimit: (value) => { - const clamped = Math.max(50, Math.min(500, value)); - set({ documentGraphPreviewCharLimit: clamped }); - window.maestro.settings.set('documentGraphPreviewCharLimit', clamped); - }, - - setDocumentGraphLayoutType: (value) => { - const layoutType = DOCUMENT_GRAPH_LAYOUT_TYPES.includes(value) ? value : 'mindmap'; - set({ documentGraphLayoutType: layoutType }); - window.maestro.settings.set('documentGraphLayoutType', layoutType); - }, - - setStatsCollectionEnabled: (value) => { - set({ statsCollectionEnabled: value }); - window.maestro.settings.set('statsCollectionEnabled', value); - }, - - setDefaultStatsTimeRange: (value) => { - set({ defaultStatsTimeRange: value }); - window.maestro.settings.set('defaultStatsTimeRange', value); - }, - - setDisableGpuAcceleration: (value) => { - set({ disableGpuAcceleration: value }); - window.maestro.settings.set('disableGpuAcceleration', value); - }, - - setDisableConfetti: (value) => { - set({ disableConfetti: value }); - window.maestro.settings.set('disableConfetti', value); - }, - - setLocalIgnorePatterns: (value) => { - set({ localIgnorePatterns: value }); - window.maestro.settings.set('localIgnorePatterns', value); - }, - - setLocalHonorGitignore: (value) => { - set({ localHonorGitignore: value }); - window.maestro.settings.set('localHonorGitignore', value); - }, - - setSshRemoteIgnorePatterns: (value) => { - set({ sshRemoteIgnorePatterns: value }); - window.maestro.settings.set('sshRemoteIgnorePatterns', value); - }, - - setSshRemoteHonorGitignore: (value) => { - set({ sshRemoteHonorGitignore: value }); - window.maestro.settings.set('sshRemoteHonorGitignore', value); - }, - - setAutomaticTabNamingEnabled: (value) => { - set({ automaticTabNamingEnabled: value }); - window.maestro.settings.set('automaticTabNamingEnabled', value); - }, - - setFileTabAutoRefreshEnabled: (value) => { - set({ fileTabAutoRefreshEnabled: value }); - window.maestro.settings.set('fileTabAutoRefreshEnabled', value); - }, - - setSuppressWindowsWarning: (value) => { - set({ suppressWindowsWarning: value }); - window.maestro.settings.set('suppressWindowsWarning', value); - }, - - setAutoScrollAiMode: (value) => { - set({ autoScrollAiMode: value }); - window.maestro.settings.set('autoScrollAiMode', value); - }, - - setUserMessageAlignment: (value) => { - set({ userMessageAlignment: value }); - window.maestro.settings.set('userMessageAlignment', value); - }, - - setEncoreFeatures: (value) => { - set({ encoreFeatures: value }); - window.maestro.settings.set('encoreFeatures', value); - }, - - setDirectorNotesSettings: (value) => { - set({ directorNotesSettings: value }); - window.maestro.settings.set('directorNotesSettings', value); - }, - - setWakatimeApiKey: (value) => { - set({ wakatimeApiKey: value }); - window.maestro.settings.set('wakatimeApiKey', value); - }, - - setWakatimeEnabled: (value) => { - set({ wakatimeEnabled: value }); - window.maestro.settings.set('wakatimeEnabled', value); - }, - - setWakatimeDetailedTracking: (value) => { - set({ wakatimeDetailedTracking: value }); - window.maestro.settings.set('wakatimeDetailedTracking', value); - }, - - setUseNativeTitleBar: (value) => { - set({ useNativeTitleBar: value }); - window.maestro.settings.set('useNativeTitleBar', value); - }, - - setAutoHideMenuBar: (value) => { - set({ autoHideMenuBar: value }); - window.maestro.settings.set('autoHideMenuBar', value); - }, - - // ============================================================================ - // Async Setters - // ============================================================================ - - setLogLevel: async (value) => { - set({ logLevel: value }); - await window.maestro.logger.setLogLevel(value); - }, - - setMaxLogBuffer: async (value) => { - set({ maxLogBuffer: value }); - await window.maestro.logger.setMaxLogBuffer(value); - }, +export const useSettingsStore = create()((set, get) => { + /** Monotonic counter to discard stale async completions in setPersistentWebLink */ + let persistentWebLinkRequestSeq = 0; - setPreventSleepEnabled: async (value) => { - const prev = get().preventSleepEnabled; - set({ preventSleepEnabled: value }); - try { - await window.maestro.settings.set('preventSleepEnabled', value); - await window.maestro.power.setEnabled(value); - } catch (error) { - // Rollback on failure so UI stays in sync with actual power state - set({ preventSleepEnabled: prev }); - throw error; // Let Sentry capture - } - }, - - // ============================================================================ - // Standalone Active Time Actions - // ============================================================================ - - setTotalActiveTimeMs: (value) => { - set({ totalActiveTimeMs: value }); - window.maestro.settings.set('totalActiveTimeMs', value); - }, - - addTotalActiveTimeMs: (delta) => { - const prev = get().totalActiveTimeMs; - const updated = prev + delta; - set({ totalActiveTimeMs: updated }); - window.maestro.settings.set('totalActiveTimeMs', updated); - }, - - // ============================================================================ - // Usage Stats Actions - // ============================================================================ - - setUsageStats: (value) => { - const prev = get().usageStats; - const updated: MaestroUsageStats = { - maxAgents: Math.max(prev.maxAgents, value.maxAgents ?? 0), - maxDefinedAgents: Math.max(prev.maxDefinedAgents, value.maxDefinedAgents ?? 0), - maxSimultaneousAutoRuns: Math.max( - prev.maxSimultaneousAutoRuns, - value.maxSimultaneousAutoRuns ?? 0 - ), - maxSimultaneousQueries: Math.max( - prev.maxSimultaneousQueries, - value.maxSimultaneousQueries ?? 0 - ), - maxQueueDepth: Math.max(prev.maxQueueDepth, value.maxQueueDepth ?? 0), - }; - set({ usageStats: updated }); - window.maestro.settings.set('usageStats', updated); - }, - - updateUsageStats: (currentValues) => { - const prev = get().usageStats; - const updated: MaestroUsageStats = { - maxAgents: Math.max(prev.maxAgents, currentValues.maxAgents ?? 0), - maxDefinedAgents: Math.max(prev.maxDefinedAgents, currentValues.maxDefinedAgents ?? 0), - maxSimultaneousAutoRuns: Math.max( - prev.maxSimultaneousAutoRuns, - currentValues.maxSimultaneousAutoRuns ?? 0 - ), - maxSimultaneousQueries: Math.max( - prev.maxSimultaneousQueries, - currentValues.maxSimultaneousQueries ?? 0 - ), - maxQueueDepth: Math.max(prev.maxQueueDepth, currentValues.maxQueueDepth ?? 0), - }; - // Only persist if any value actually changed - if ( - updated.maxAgents !== prev.maxAgents || - updated.maxDefinedAgents !== prev.maxDefinedAgents || - updated.maxSimultaneousAutoRuns !== prev.maxSimultaneousAutoRuns || - updated.maxSimultaneousQueries !== prev.maxSimultaneousQueries || - updated.maxQueueDepth !== prev.maxQueueDepth - ) { + return { + // ============================================================================ + // State (defaults) + // ============================================================================ + + settingsLoaded: false, + conductorProfile: '', + llmProvider: 'openrouter', + modelSlug: 'anthropic/claude-3.5-sonnet', + apiKey: '', + defaultShell: 'zsh', + customShellPath: '', + shellArgs: '', + shellEnvVars: {}, + ghPath: '', + fontFamily: 'Roboto Mono, Menlo, "Courier New", monospace', + fontSize: 14, + activeThemeId: 'dracula', + customThemeColors: DEFAULT_CUSTOM_THEME_COLORS, + customThemeBaseId: 'dracula', + enterToSendAI: false, + enterToSendTerminal: true, + defaultSaveToHistory: true, + defaultShowThinking: 'off', + leftSidebarWidth: 256, + rightPanelWidth: 384, + markdownEditMode: false, + chatRawTextMode: false, + showHiddenFiles: true, + terminalWidth: 100, + logLevel: 'info', + maxLogBuffer: 5000, + maxOutputLines: 25, + osNotificationsEnabled: true, + audioFeedbackEnabled: false, + audioFeedbackCommand: 'say', + toastDuration: 20, + checkForUpdatesOnStartup: true, + enableBetaUpdates: false, + crashReportingEnabled: true, + logViewerSelectedLevels: ['debug', 'info', 'warn', 'error', 'toast'], + shortcuts: DEFAULT_SHORTCUTS, + tabShortcuts: TAB_SHORTCUTS, + customAICommands: DEFAULT_AI_COMMANDS, + totalActiveTimeMs: 0, + autoRunStats: DEFAULT_AUTO_RUN_STATS, + usageStats: DEFAULT_USAGE_STATS, + ungroupedCollapsed: false, + tourCompleted: false, + firstAutoRunCompleted: false, + onboardingStats: DEFAULT_ONBOARDING_STATS, + leaderboardRegistration: null, + persistentWebLink: false, + webInterfaceUseCustomPort: false, + webInterfaceCustomPort: 8080, + contextManagementSettings: DEFAULT_CONTEXT_MANAGEMENT_SETTINGS, + keyboardMasteryStats: DEFAULT_KEYBOARD_MASTERY_STATS, + colorBlindMode: false, + documentGraphShowExternalLinks: false, + documentGraphMaxNodes: 50, + documentGraphPreviewCharLimit: 100, + documentGraphLayoutType: 'mindmap', + statsCollectionEnabled: true, + defaultStatsTimeRange: 'week', + preventSleepEnabled: false, + disableGpuAcceleration: false, + disableConfetti: false, + localIgnorePatterns: [...DEFAULT_LOCAL_IGNORE_PATTERNS], + localHonorGitignore: true, + sshRemoteIgnorePatterns: ['.git', '*cache*'], + sshRemoteHonorGitignore: true, + automaticTabNamingEnabled: true, + fileTabAutoRefreshEnabled: false, + suppressWindowsWarning: false, + autoScrollAiMode: false, + userMessageAlignment: 'right', + encoreFeatures: DEFAULT_ENCORE_FEATURES, + directorNotesSettings: DEFAULT_DIRECTOR_NOTES_SETTINGS, + wakatimeApiKey: '', + wakatimeEnabled: false, + wakatimeDetailedTracking: false, + useNativeTitleBar: false, + autoHideMenuBar: false, + + // ============================================================================ + // Simple Setters + // ============================================================================ + + setConductorProfile: (value) => { + const trimmed = value.slice(0, 1000); + set({ conductorProfile: trimmed }); + window.maestro.settings.set('conductorProfile', trimmed); + }, + + setLlmProvider: (value) => { + set({ llmProvider: value }); + window.maestro.settings.set('llmProvider', value); + }, + + setModelSlug: (value) => { + set({ modelSlug: value }); + window.maestro.settings.set('modelSlug', value); + }, + + setApiKey: (value) => { + set({ apiKey: value }); + window.maestro.settings.set('apiKey', value); + }, + + setDefaultShell: (value) => { + set({ defaultShell: value }); + window.maestro.settings.set('defaultShell', value); + }, + + setCustomShellPath: (value) => { + set({ customShellPath: value }); + window.maestro.settings.set('customShellPath', value); + }, + + setShellArgs: (value) => { + set({ shellArgs: value }); + window.maestro.settings.set('shellArgs', value); + }, + + setShellEnvVars: (value) => { + set({ shellEnvVars: value }); + window.maestro.settings.set('shellEnvVars', value); + }, + + setGhPath: (value) => { + set({ ghPath: value }); + window.maestro.settings.set('ghPath', value); + }, + + setFontFamily: (value) => { + set({ fontFamily: value }); + window.maestro.settings.set('fontFamily', value); + }, + + setFontSize: (value) => { + set({ fontSize: value }); + window.maestro.settings.set('fontSize', value); + }, + + setActiveThemeId: (value) => { + set({ activeThemeId: value }); + window.maestro.settings.set('activeThemeId', value); + }, + + setCustomThemeColors: (value) => { + set({ customThemeColors: value }); + window.maestro.settings.set('customThemeColors', value); + }, + + setCustomThemeBaseId: (value) => { + set({ customThemeBaseId: value }); + window.maestro.settings.set('customThemeBaseId', value); + }, + + setEnterToSendAI: (value) => { + set({ enterToSendAI: value }); + window.maestro.settings.set('enterToSendAI', value); + }, + + setEnterToSendTerminal: (value) => { + set({ enterToSendTerminal: value }); + window.maestro.settings.set('enterToSendTerminal', value); + }, + + setDefaultSaveToHistory: (value) => { + set({ defaultSaveToHistory: value }); + window.maestro.settings.set('defaultSaveToHistory', value); + }, + + setDefaultShowThinking: (value) => { + set({ defaultShowThinking: value }); + window.maestro.settings.set('defaultShowThinking', value); + }, + + setLeftSidebarWidth: (value) => { + const clamped = Math.max(256, Math.min(600, value)); + set({ leftSidebarWidth: clamped }); + window.maestro.settings.set('leftSidebarWidth', clamped); + }, + + setRightPanelWidth: (value) => { + set({ rightPanelWidth: value }); + window.maestro.settings.set('rightPanelWidth', value); + }, + + setMarkdownEditMode: (value) => { + set({ markdownEditMode: value }); + window.maestro.settings.set('markdownEditMode', value); + }, + + setChatRawTextMode: (value) => { + set({ chatRawTextMode: value }); + window.maestro.settings.set('chatRawTextMode', value); + }, + + setShowHiddenFiles: (value) => { + set({ showHiddenFiles: value }); + window.maestro.settings.set('showHiddenFiles', value); + }, + + setTerminalWidth: (value) => { + set({ terminalWidth: value }); + window.maestro.settings.set('terminalWidth', value); + }, + + setMaxOutputLines: (value) => { + set({ maxOutputLines: value }); + window.maestro.settings.set('maxOutputLines', value); + }, + + setOsNotificationsEnabled: (value) => { + set({ osNotificationsEnabled: value }); + window.maestro.settings.set('osNotificationsEnabled', value); + }, + + setAudioFeedbackEnabled: (value) => { + set({ audioFeedbackEnabled: value }); + window.maestro.settings.set('audioFeedbackEnabled', value); + }, + + setAudioFeedbackCommand: (value) => { + set({ audioFeedbackCommand: value }); + window.maestro.settings.set('audioFeedbackCommand', value); + }, + + setToastDuration: (value) => { + set({ toastDuration: value }); + window.maestro.settings.set('toastDuration', value); + }, + + setCheckForUpdatesOnStartup: (value) => { + set({ checkForUpdatesOnStartup: value }); + window.maestro.settings.set('checkForUpdatesOnStartup', value); + }, + + setEnableBetaUpdates: (value) => { + set({ enableBetaUpdates: value }); + window.maestro.settings.set('enableBetaUpdates', value); + }, + + setCrashReportingEnabled: (value) => { + set({ crashReportingEnabled: value }); + window.maestro.settings.set('crashReportingEnabled', value); + }, + + setLogViewerSelectedLevels: (value) => { + set({ logViewerSelectedLevels: value }); + window.maestro.settings.set('logViewerSelectedLevels', value); + }, + + setShortcuts: (value) => { + set({ shortcuts: value }); + window.maestro.settings.set('shortcuts', value); + }, + + setTabShortcuts: (value) => { + set({ tabShortcuts: value }); + window.maestro.settings.set('tabShortcuts', value); + }, + + setCustomAICommands: (value) => { + set({ customAICommands: value }); + window.maestro.settings.set('customAICommands', value); + }, + + setUngroupedCollapsed: (value) => { + set({ ungroupedCollapsed: value }); + window.maestro.settings.set('ungroupedCollapsed', value); + }, + + setTourCompleted: (value) => { + set({ tourCompleted: value }); + window.maestro.settings.set('tourCompleted', value); + }, + + setFirstAutoRunCompleted: (value) => { + set({ firstAutoRunCompleted: value }); + window.maestro.settings.set('firstAutoRunCompleted', value); + }, + + setLeaderboardRegistration: (value) => { + set({ leaderboardRegistration: value }); + window.maestro.settings.set('leaderboardRegistration', value); + }, + + setPersistentWebLink: async (value) => { + const requestSeq = ++persistentWebLinkRequestSeq; + // Optimistic update — immediately reflect user intent in UI + set({ persistentWebLink: value }); + if (value) { + try { + // persistCurrentToken writes both webAuthToken and persistentWebLink + // on the main side — the factory ignores webAuthToken unless + // persistentWebLink is also true, so partial writes are safe + const result = await window.maestro.live.persistCurrentToken(); + if (requestSeq !== persistentWebLinkRequestSeq) { + // Stale: another call was made while this IPC was in-flight. + // The IPC handler already wrote the token and flag in main — + // only clear them if the user's latest intent was to disable. + // Note: the superseding disable call may have already issued its + // own clearPersistentToken, making this a redundant but harmless + // second call — the handler is idempotent. + if (!get().persistentWebLink) { + try { + await window.maestro.live.clearPersistentToken(); + } catch (clearError) { + console.error('[Settings] Failed to clear stale persistent web link:', clearError); + } + } + return; + } + if (!result.success) { + // Rollback optimistic update on soft failure + set({ persistentWebLink: false }); + console.warn('[Settings] Failed to persist web link token:', result.message); + } + } catch (error) { + if (requestSeq === persistentWebLinkRequestSeq) { + // Rollback optimistic update on hard failure + set({ persistentWebLink: false }); + console.error('[Settings] Failed to persist web link token:', error); + } + } + } else { + try { + // Atomically clear both keys on the main side + const result = await window.maestro.live.clearPersistentToken(); + if (requestSeq !== persistentWebLinkRequestSeq) { + // Stale: user re-enabled while this clear was in-flight. + // The enable path will handle persisting — nothing to undo here. + return; + } + if (!result.success) { + // Rollback optimistic update on soft failure + set({ persistentWebLink: true }); + console.warn('[Settings] Failed to clear persistent web link:', result.message); + } + } catch (error) { + if (requestSeq === persistentWebLinkRequestSeq) { + // Clear failed — rollback Zustand to match main-side state + set({ persistentWebLink: true }); + console.error('[Settings] Failed to clear persistent web link:', error); + } + // else: stale — a newer call is in charge, nothing to do + } + } + }, + + setWebInterfaceUseCustomPort: (value) => { + set({ webInterfaceUseCustomPort: value }); + window.maestro.settings.set('webInterfaceUseCustomPort', value); + }, + + setWebInterfaceCustomPort: (value) => { + // Store the value as-is during typing; validation happens on blur/submit + set({ webInterfaceCustomPort: value }); + // Only persist valid port values + if (value >= 1024 && value <= 65535) { + window.maestro.settings.set('webInterfaceCustomPort', value); + } + }, + + setColorBlindMode: (value) => { + set({ colorBlindMode: value }); + window.maestro.settings.set('colorBlindMode', value); + }, + + setDocumentGraphShowExternalLinks: (value) => { + set({ documentGraphShowExternalLinks: value }); + window.maestro.settings.set('documentGraphShowExternalLinks', value); + }, + + setDocumentGraphMaxNodes: (value) => { + const clamped = Math.max(50, Math.min(1000, value)); + set({ documentGraphMaxNodes: clamped }); + window.maestro.settings.set('documentGraphMaxNodes', clamped); + }, + + setDocumentGraphPreviewCharLimit: (value) => { + const clamped = Math.max(50, Math.min(500, value)); + set({ documentGraphPreviewCharLimit: clamped }); + window.maestro.settings.set('documentGraphPreviewCharLimit', clamped); + }, + + setDocumentGraphLayoutType: (value) => { + const layoutType = DOCUMENT_GRAPH_LAYOUT_TYPES.includes(value) ? value : 'mindmap'; + set({ documentGraphLayoutType: layoutType }); + window.maestro.settings.set('documentGraphLayoutType', layoutType); + }, + + setStatsCollectionEnabled: (value) => { + set({ statsCollectionEnabled: value }); + window.maestro.settings.set('statsCollectionEnabled', value); + }, + + setDefaultStatsTimeRange: (value) => { + set({ defaultStatsTimeRange: value }); + window.maestro.settings.set('defaultStatsTimeRange', value); + }, + + setDisableGpuAcceleration: (value) => { + set({ disableGpuAcceleration: value }); + window.maestro.settings.set('disableGpuAcceleration', value); + }, + + setDisableConfetti: (value) => { + set({ disableConfetti: value }); + window.maestro.settings.set('disableConfetti', value); + }, + + setLocalIgnorePatterns: (value) => { + set({ localIgnorePatterns: value }); + window.maestro.settings.set('localIgnorePatterns', value); + }, + + setLocalHonorGitignore: (value) => { + set({ localHonorGitignore: value }); + window.maestro.settings.set('localHonorGitignore', value); + }, + + setSshRemoteIgnorePatterns: (value) => { + set({ sshRemoteIgnorePatterns: value }); + window.maestro.settings.set('sshRemoteIgnorePatterns', value); + }, + + setSshRemoteHonorGitignore: (value) => { + set({ sshRemoteHonorGitignore: value }); + window.maestro.settings.set('sshRemoteHonorGitignore', value); + }, + + setAutomaticTabNamingEnabled: (value) => { + set({ automaticTabNamingEnabled: value }); + window.maestro.settings.set('automaticTabNamingEnabled', value); + }, + + setFileTabAutoRefreshEnabled: (value) => { + set({ fileTabAutoRefreshEnabled: value }); + window.maestro.settings.set('fileTabAutoRefreshEnabled', value); + }, + + setSuppressWindowsWarning: (value) => { + set({ suppressWindowsWarning: value }); + window.maestro.settings.set('suppressWindowsWarning', value); + }, + + setAutoScrollAiMode: (value) => { + set({ autoScrollAiMode: value }); + window.maestro.settings.set('autoScrollAiMode', value); + }, + + setUserMessageAlignment: (value) => { + set({ userMessageAlignment: value }); + window.maestro.settings.set('userMessageAlignment', value); + }, + + setEncoreFeatures: (value) => { + set({ encoreFeatures: value }); + window.maestro.settings.set('encoreFeatures', value); + }, + + setDirectorNotesSettings: (value) => { + set({ directorNotesSettings: value }); + window.maestro.settings.set('directorNotesSettings', value); + }, + + setWakatimeApiKey: (value) => { + set({ wakatimeApiKey: value }); + window.maestro.settings.set('wakatimeApiKey', value); + }, + + setWakatimeEnabled: (value) => { + set({ wakatimeEnabled: value }); + window.maestro.settings.set('wakatimeEnabled', value); + }, + + setWakatimeDetailedTracking: (value) => { + set({ wakatimeDetailedTracking: value }); + window.maestro.settings.set('wakatimeDetailedTracking', value); + }, + + setUseNativeTitleBar: (value) => { + set({ useNativeTitleBar: value }); + window.maestro.settings.set('useNativeTitleBar', value); + }, + + setAutoHideMenuBar: (value) => { + set({ autoHideMenuBar: value }); + window.maestro.settings.set('autoHideMenuBar', value); + }, + + // ============================================================================ + // Async Setters + // ============================================================================ + + setLogLevel: async (value) => { + set({ logLevel: value }); + await window.maestro.logger.setLogLevel(value); + }, + + setMaxLogBuffer: async (value) => { + set({ maxLogBuffer: value }); + await window.maestro.logger.setMaxLogBuffer(value); + }, + + setPreventSleepEnabled: async (value) => { + const prev = get().preventSleepEnabled; + set({ preventSleepEnabled: value }); + try { + await window.maestro.settings.set('preventSleepEnabled', value); + await window.maestro.power.setEnabled(value); + } catch (error) { + // Rollback on failure so UI stays in sync with actual power state + set({ preventSleepEnabled: prev }); + throw error; // Let Sentry capture + } + }, + + // ============================================================================ + // Standalone Active Time Actions + // ============================================================================ + + setTotalActiveTimeMs: (value) => { + set({ totalActiveTimeMs: value }); + window.maestro.settings.set('totalActiveTimeMs', value); + }, + + addTotalActiveTimeMs: (delta) => { + const prev = get().totalActiveTimeMs; + const updated = prev + delta; + set({ totalActiveTimeMs: updated }); + window.maestro.settings.set('totalActiveTimeMs', updated); + }, + + // ============================================================================ + // Usage Stats Actions + // ============================================================================ + + setUsageStats: (value) => { + const prev = get().usageStats; + const updated: MaestroUsageStats = { + maxAgents: Math.max(prev.maxAgents, value.maxAgents ?? 0), + maxDefinedAgents: Math.max(prev.maxDefinedAgents, value.maxDefinedAgents ?? 0), + maxSimultaneousAutoRuns: Math.max( + prev.maxSimultaneousAutoRuns, + value.maxSimultaneousAutoRuns ?? 0 + ), + maxSimultaneousQueries: Math.max( + prev.maxSimultaneousQueries, + value.maxSimultaneousQueries ?? 0 + ), + maxQueueDepth: Math.max(prev.maxQueueDepth, value.maxQueueDepth ?? 0), + }; + set({ usageStats: updated }); window.maestro.settings.set('usageStats', updated); - } - set({ usageStats: updated }); - }, - - // ============================================================================ - // Auto-run Stats Actions - // ============================================================================ - - setAutoRunStats: (value) => { - set({ autoRunStats: value }); - window.maestro.settings.set('autoRunStats', value); - }, - - recordAutoRunComplete: (elapsedTimeMs) => { - const prev = get().autoRunStats; - - // Don't add to cumulative time - it was already added incrementally during the run - // Just check current badge level in case a badge wasn't triggered during incremental updates - const newBadgeLevelCalc = getBadgeLevelForTime(prev.cumulativeTimeMs); - - // Check if this would be a new badge (edge case: badge threshold crossed between updates) - let newBadgeLevel: number | null = null; - if (newBadgeLevelCalc > prev.lastBadgeUnlockLevel) { - newBadgeLevel = newBadgeLevelCalc; - } - - // Check if this is a new longest run record - const isNewRecord = elapsedTimeMs > prev.longestRunMs; - - // Build updated badge history if new badge unlocked - let updatedBadgeHistory = prev.badgeHistory || []; - if (newBadgeLevel !== null) { - updatedBadgeHistory = [ - ...updatedBadgeHistory, - { level: newBadgeLevel, unlockedAt: Date.now() }, - ]; - } - - const updated: AutoRunStats = { - cumulativeTimeMs: prev.cumulativeTimeMs, // Already updated incrementally - longestRunMs: isNewRecord ? elapsedTimeMs : prev.longestRunMs, - longestRunTimestamp: isNewRecord ? Date.now() : prev.longestRunTimestamp, - totalRuns: prev.totalRuns + 1, - currentBadgeLevel: newBadgeLevelCalc, - lastBadgeUnlockLevel: newBadgeLevel !== null ? newBadgeLevelCalc : prev.lastBadgeUnlockLevel, - lastAcknowledgedBadgeLevel: prev.lastAcknowledgedBadgeLevel ?? 0, - badgeHistory: updatedBadgeHistory, - }; - - set({ autoRunStats: updated }); - window.maestro.settings.set('autoRunStats', updated); - - return { newBadgeLevel, isNewRecord }; - }, - - updateAutoRunProgress: (deltaMs) => { - const prev = get().autoRunStats; - - // Add the delta to cumulative time - const newCumulativeTime = prev.cumulativeTimeMs + deltaMs; - const newBadgeLevelCalc = getBadgeLevelForTime(newCumulativeTime); - - // Check if this unlocks a new badge - let newBadgeLevel: number | null = null; - if (newBadgeLevelCalc > prev.lastBadgeUnlockLevel) { - newBadgeLevel = newBadgeLevelCalc; - } - - // Build updated badge history if new badge unlocked - let updatedBadgeHistory = prev.badgeHistory || []; - if (newBadgeLevel !== null) { - updatedBadgeHistory = [ - ...updatedBadgeHistory, - { level: newBadgeLevel, unlockedAt: Date.now() }, - ]; - } - - const updated: AutoRunStats = { - cumulativeTimeMs: newCumulativeTime, - longestRunMs: prev.longestRunMs, // Don't update until run completes - longestRunTimestamp: prev.longestRunTimestamp, - totalRuns: prev.totalRuns, // Don't increment - run not complete yet - currentBadgeLevel: newBadgeLevelCalc, - lastBadgeUnlockLevel: newBadgeLevel !== null ? newBadgeLevelCalc : prev.lastBadgeUnlockLevel, - lastAcknowledgedBadgeLevel: prev.lastAcknowledgedBadgeLevel ?? 0, - badgeHistory: updatedBadgeHistory, - }; - - set({ autoRunStats: updated }); - window.maestro.settings.set('autoRunStats', updated); - - // Note: isNewRecord is always false during progress - we don't know total run time yet - return { newBadgeLevel, isNewRecord: false }; - }, + }, + + updateUsageStats: (currentValues) => { + const prev = get().usageStats; + const updated: MaestroUsageStats = { + maxAgents: Math.max(prev.maxAgents, currentValues.maxAgents ?? 0), + maxDefinedAgents: Math.max(prev.maxDefinedAgents, currentValues.maxDefinedAgents ?? 0), + maxSimultaneousAutoRuns: Math.max( + prev.maxSimultaneousAutoRuns, + currentValues.maxSimultaneousAutoRuns ?? 0 + ), + maxSimultaneousQueries: Math.max( + prev.maxSimultaneousQueries, + currentValues.maxSimultaneousQueries ?? 0 + ), + maxQueueDepth: Math.max(prev.maxQueueDepth, currentValues.maxQueueDepth ?? 0), + }; + // Only persist if any value actually changed + if ( + updated.maxAgents !== prev.maxAgents || + updated.maxDefinedAgents !== prev.maxDefinedAgents || + updated.maxSimultaneousAutoRuns !== prev.maxSimultaneousAutoRuns || + updated.maxSimultaneousQueries !== prev.maxSimultaneousQueries || + updated.maxQueueDepth !== prev.maxQueueDepth + ) { + window.maestro.settings.set('usageStats', updated); + } + set({ usageStats: updated }); + }, - acknowledgeBadge: (level) => { - const prev = get().autoRunStats; - const updated: AutoRunStats = { - ...prev, - lastAcknowledgedBadgeLevel: Math.max(level, prev.lastAcknowledgedBadgeLevel ?? 0), - }; - set({ autoRunStats: updated }); - window.maestro.settings.set('autoRunStats', updated); - }, + // ============================================================================ + // Auto-run Stats Actions + // ============================================================================ - getUnacknowledgedBadgeLevel: () => { - const stats = get().autoRunStats; - const acknowledged = stats.lastAcknowledgedBadgeLevel ?? 0; - const current = stats.currentBadgeLevel; - if (current > acknowledged) { - return current; - } - return null; - }, + setAutoRunStats: (value) => { + set({ autoRunStats: value }); + window.maestro.settings.set('autoRunStats', value); + }, - // ============================================================================ - // Onboarding Stats Actions - // ============================================================================ + recordAutoRunComplete: (elapsedTimeMs) => { + const prev = get().autoRunStats; - setOnboardingStats: (value) => { - set({ onboardingStats: value }); - window.maestro.settings.set('onboardingStats', value); - }, + // Don't add to cumulative time - it was already added incrementally during the run + // Just check current badge level in case a badge wasn't triggered during incremental updates + const newBadgeLevelCalc = getBadgeLevelForTime(prev.cumulativeTimeMs); - recordWizardStart: () => { - const prev = get().onboardingStats; - const updated: OnboardingStats = { - ...prev, - wizardStartCount: prev.wizardStartCount + 1, - }; - set({ onboardingStats: updated }); - window.maestro.settings.set('onboardingStats', updated); - }, + // Check if this would be a new badge (edge case: badge threshold crossed between updates) + let newBadgeLevel: number | null = null; + if (newBadgeLevelCalc > prev.lastBadgeUnlockLevel) { + newBadgeLevel = newBadgeLevelCalc; + } - recordWizardComplete: (durationMs, conversationExchanges, phasesGenerated, tasksGenerated) => { - const prev = get().onboardingStats; - const newCompletionCount = prev.wizardCompletionCount + 1; - const newTotalDuration = prev.totalWizardDurationMs + durationMs; - const newTotalExchanges = prev.totalConversationExchanges + conversationExchanges; - const newTotalPhases = prev.totalPhasesGenerated + phasesGenerated; - const newTotalTasks = prev.totalTasksGenerated + tasksGenerated; - - const updated: OnboardingStats = { - ...prev, - wizardCompletionCount: newCompletionCount, - totalWizardDurationMs: newTotalDuration, - averageWizardDurationMs: Math.round(newTotalDuration / newCompletionCount), - lastWizardCompletedAt: Date.now(), - - // Conversation stats - totalConversationExchanges: newTotalExchanges, - totalConversationsCompleted: prev.totalConversationsCompleted + 1, - averageConversationExchanges: - newCompletionCount > 0 ? Math.round((newTotalExchanges / newCompletionCount) * 10) / 10 : 0, - - // Phase generation stats - totalPhasesGenerated: newTotalPhases, - averagePhasesPerWizard: - newCompletionCount > 0 ? Math.round((newTotalPhases / newCompletionCount) * 10) / 10 : 0, - totalTasksGenerated: newTotalTasks, - averageTasksPerPhase: - newTotalPhases > 0 ? Math.round((newTotalTasks / newTotalPhases) * 10) / 10 : 0, - }; - set({ onboardingStats: updated }); - window.maestro.settings.set('onboardingStats', updated); - }, + // Check if this is a new longest run record + const isNewRecord = elapsedTimeMs > prev.longestRunMs; - recordWizardAbandon: () => { - const prev = get().onboardingStats; - const updated: OnboardingStats = { - ...prev, - wizardAbandonCount: prev.wizardAbandonCount + 1, - }; - set({ onboardingStats: updated }); - window.maestro.settings.set('onboardingStats', updated); - }, + // Build updated badge history if new badge unlocked + let updatedBadgeHistory = prev.badgeHistory || []; + if (newBadgeLevel !== null) { + updatedBadgeHistory = [ + ...updatedBadgeHistory, + { level: newBadgeLevel, unlockedAt: Date.now() }, + ]; + } - recordWizardResume: () => { - const prev = get().onboardingStats; - const updated: OnboardingStats = { - ...prev, - wizardResumeCount: prev.wizardResumeCount + 1, - }; - set({ onboardingStats: updated }); - window.maestro.settings.set('onboardingStats', updated); - }, + const updated: AutoRunStats = { + cumulativeTimeMs: prev.cumulativeTimeMs, // Already updated incrementally + longestRunMs: isNewRecord ? elapsedTimeMs : prev.longestRunMs, + longestRunTimestamp: isNewRecord ? Date.now() : prev.longestRunTimestamp, + totalRuns: prev.totalRuns + 1, + currentBadgeLevel: newBadgeLevelCalc, + lastBadgeUnlockLevel: + newBadgeLevel !== null ? newBadgeLevelCalc : prev.lastBadgeUnlockLevel, + lastAcknowledgedBadgeLevel: prev.lastAcknowledgedBadgeLevel ?? 0, + badgeHistory: updatedBadgeHistory, + }; - recordTourStart: () => { - const prev = get().onboardingStats; - const updated: OnboardingStats = { - ...prev, - tourStartCount: prev.tourStartCount + 1, - }; - set({ onboardingStats: updated }); - window.maestro.settings.set('onboardingStats', updated); - }, + set({ autoRunStats: updated }); + window.maestro.settings.set('autoRunStats', updated); - recordTourComplete: (stepsViewed) => { - const prev = get().onboardingStats; - const newCompletionCount = prev.tourCompletionCount + 1; - const newTotalStepsViewed = prev.tourStepsViewedTotal + stepsViewed; - const totalTours = newCompletionCount + prev.tourSkipCount; - - const updated: OnboardingStats = { - ...prev, - tourCompletionCount: newCompletionCount, - tourStepsViewedTotal: newTotalStepsViewed, - averageTourStepsViewed: - totalTours > 0 ? Math.round((newTotalStepsViewed / totalTours) * 10) / 10 : stepsViewed, - }; - set({ onboardingStats: updated }); - window.maestro.settings.set('onboardingStats', updated); - }, - - recordTourSkip: (stepsViewed) => { - const prev = get().onboardingStats; - const newSkipCount = prev.tourSkipCount + 1; - const newTotalStepsViewed = prev.tourStepsViewedTotal + stepsViewed; - const totalTours = prev.tourCompletionCount + newSkipCount; - - const updated: OnboardingStats = { - ...prev, - tourSkipCount: newSkipCount, - tourStepsViewedTotal: newTotalStepsViewed, - averageTourStepsViewed: - totalTours > 0 ? Math.round((newTotalStepsViewed / totalTours) * 10) / 10 : stepsViewed, - }; - set({ onboardingStats: updated }); - window.maestro.settings.set('onboardingStats', updated); - }, + return { newBadgeLevel, isNewRecord }; + }, - getOnboardingAnalytics: () => { - const stats = get().onboardingStats; - const totalWizardAttempts = stats.wizardStartCount; - const totalTourAttempts = stats.tourStartCount; - - return { - wizardCompletionRate: - totalWizardAttempts > 0 - ? Math.round((stats.wizardCompletionCount / totalWizardAttempts) * 100) - : 0, - tourCompletionRate: - totalTourAttempts > 0 - ? Math.round((stats.tourCompletionCount / totalTourAttempts) * 100) - : 0, - averageConversationExchanges: stats.averageConversationExchanges, - averagePhasesPerWizard: stats.averagePhasesPerWizard, - }; - }, + updateAutoRunProgress: (deltaMs) => { + const prev = get().autoRunStats; - // ============================================================================ - // Context Management Actions - // ============================================================================ + // Add the delta to cumulative time + const newCumulativeTime = prev.cumulativeTimeMs + deltaMs; + const newBadgeLevelCalc = getBadgeLevelForTime(newCumulativeTime); - setContextManagementSettings: (value) => { - set({ contextManagementSettings: value }); - window.maestro.settings.set('contextManagementSettings', value); - }, - - updateContextManagementSettings: (partial) => { - const prev = get().contextManagementSettings; - const updated = { ...prev, ...partial }; - set({ contextManagementSettings: updated }); - window.maestro.settings.set('contextManagementSettings', updated); - }, + // Check if this unlocks a new badge + let newBadgeLevel: number | null = null; + if (newBadgeLevelCalc > prev.lastBadgeUnlockLevel) { + newBadgeLevel = newBadgeLevelCalc; + } - // ============================================================================ - // Keyboard Mastery Actions - // ============================================================================ + // Build updated badge history if new badge unlocked + let updatedBadgeHistory = prev.badgeHistory || []; + if (newBadgeLevel !== null) { + updatedBadgeHistory = [ + ...updatedBadgeHistory, + { level: newBadgeLevel, unlockedAt: Date.now() }, + ]; + } - setKeyboardMasteryStats: (value) => { - set({ keyboardMasteryStats: value }); - window.maestro.settings.set('keyboardMasteryStats', value); - }, + const updated: AutoRunStats = { + cumulativeTimeMs: newCumulativeTime, + longestRunMs: prev.longestRunMs, // Don't update until run completes + longestRunTimestamp: prev.longestRunTimestamp, + totalRuns: prev.totalRuns, // Don't increment - run not complete yet + currentBadgeLevel: newBadgeLevelCalc, + lastBadgeUnlockLevel: + newBadgeLevel !== null ? newBadgeLevelCalc : prev.lastBadgeUnlockLevel, + lastAcknowledgedBadgeLevel: prev.lastAcknowledgedBadgeLevel ?? 0, + badgeHistory: updatedBadgeHistory, + }; - recordShortcutUsage: (shortcutId) => { - const currentStats = get().keyboardMasteryStats; + set({ autoRunStats: updated }); + window.maestro.settings.set('autoRunStats', updated); - // Skip if already tracked - if (currentStats.usedShortcuts.includes(shortcutId)) { - return { newLevel: null }; - } + // Note: isNewRecord is always false during progress - we don't know total run time yet + return { newBadgeLevel, isNewRecord: false }; + }, - // Add new shortcut to the list - const updatedShortcuts = [...currentStats.usedShortcuts, shortcutId]; + acknowledgeBadge: (level) => { + const prev = get().autoRunStats; + const updated: AutoRunStats = { + ...prev, + lastAcknowledgedBadgeLevel: Math.max(level, prev.lastAcknowledgedBadgeLevel ?? 0), + }; + set({ autoRunStats: updated }); + window.maestro.settings.set('autoRunStats', updated); + }, + + getUnacknowledgedBadgeLevel: () => { + const stats = get().autoRunStats; + const acknowledged = stats.lastAcknowledgedBadgeLevel ?? 0; + const current = stats.currentBadgeLevel; + if (current > acknowledged) { + return current; + } + return null; + }, + + // ============================================================================ + // Onboarding Stats Actions + // ============================================================================ + + setOnboardingStats: (value) => { + set({ onboardingStats: value }); + window.maestro.settings.set('onboardingStats', value); + }, + + recordWizardStart: () => { + const prev = get().onboardingStats; + const updated: OnboardingStats = { + ...prev, + wizardStartCount: prev.wizardStartCount + 1, + }; + set({ onboardingStats: updated }); + window.maestro.settings.set('onboardingStats', updated); + }, + + recordWizardComplete: (durationMs, conversationExchanges, phasesGenerated, tasksGenerated) => { + const prev = get().onboardingStats; + const newCompletionCount = prev.wizardCompletionCount + 1; + const newTotalDuration = prev.totalWizardDurationMs + durationMs; + const newTotalExchanges = prev.totalConversationExchanges + conversationExchanges; + const newTotalPhases = prev.totalPhasesGenerated + phasesGenerated; + const newTotalTasks = prev.totalTasksGenerated + tasksGenerated; + + const updated: OnboardingStats = { + ...prev, + wizardCompletionCount: newCompletionCount, + totalWizardDurationMs: newTotalDuration, + averageWizardDurationMs: Math.round(newTotalDuration / newCompletionCount), + lastWizardCompletedAt: Date.now(), + + // Conversation stats + totalConversationExchanges: newTotalExchanges, + totalConversationsCompleted: prev.totalConversationsCompleted + 1, + averageConversationExchanges: + newCompletionCount > 0 + ? Math.round((newTotalExchanges / newCompletionCount) * 10) / 10 + : 0, + + // Phase generation stats + totalPhasesGenerated: newTotalPhases, + averagePhasesPerWizard: + newCompletionCount > 0 ? Math.round((newTotalPhases / newCompletionCount) * 10) / 10 : 0, + totalTasksGenerated: newTotalTasks, + averageTasksPerPhase: + newTotalPhases > 0 ? Math.round((newTotalTasks / newTotalPhases) * 10) / 10 : 0, + }; + set({ onboardingStats: updated }); + window.maestro.settings.set('onboardingStats', updated); + }, + + recordWizardAbandon: () => { + const prev = get().onboardingStats; + const updated: OnboardingStats = { + ...prev, + wizardAbandonCount: prev.wizardAbandonCount + 1, + }; + set({ onboardingStats: updated }); + window.maestro.settings.set('onboardingStats', updated); + }, + + recordWizardResume: () => { + const prev = get().onboardingStats; + const updated: OnboardingStats = { + ...prev, + wizardResumeCount: prev.wizardResumeCount + 1, + }; + set({ onboardingStats: updated }); + window.maestro.settings.set('onboardingStats', updated); + }, + + recordTourStart: () => { + const prev = get().onboardingStats; + const updated: OnboardingStats = { + ...prev, + tourStartCount: prev.tourStartCount + 1, + }; + set({ onboardingStats: updated }); + window.maestro.settings.set('onboardingStats', updated); + }, + + recordTourComplete: (stepsViewed) => { + const prev = get().onboardingStats; + const newCompletionCount = prev.tourCompletionCount + 1; + const newTotalStepsViewed = prev.tourStepsViewedTotal + stepsViewed; + const totalTours = newCompletionCount + prev.tourSkipCount; + + const updated: OnboardingStats = { + ...prev, + tourCompletionCount: newCompletionCount, + tourStepsViewedTotal: newTotalStepsViewed, + averageTourStepsViewed: + totalTours > 0 ? Math.round((newTotalStepsViewed / totalTours) * 10) / 10 : stepsViewed, + }; + set({ onboardingStats: updated }); + window.maestro.settings.set('onboardingStats', updated); + }, + + recordTourSkip: (stepsViewed) => { + const prev = get().onboardingStats; + const newSkipCount = prev.tourSkipCount + 1; + const newTotalStepsViewed = prev.tourStepsViewedTotal + stepsViewed; + const totalTours = prev.tourCompletionCount + newSkipCount; + + const updated: OnboardingStats = { + ...prev, + tourSkipCount: newSkipCount, + tourStepsViewedTotal: newTotalStepsViewed, + averageTourStepsViewed: + totalTours > 0 ? Math.round((newTotalStepsViewed / totalTours) * 10) / 10 : stepsViewed, + }; + set({ onboardingStats: updated }); + window.maestro.settings.set('onboardingStats', updated); + }, + + getOnboardingAnalytics: () => { + const stats = get().onboardingStats; + const totalWizardAttempts = stats.wizardStartCount; + const totalTourAttempts = stats.tourStartCount; + + return { + wizardCompletionRate: + totalWizardAttempts > 0 + ? Math.round((stats.wizardCompletionCount / totalWizardAttempts) * 100) + : 0, + tourCompletionRate: + totalTourAttempts > 0 + ? Math.round((stats.tourCompletionCount / totalTourAttempts) * 100) + : 0, + averageConversationExchanges: stats.averageConversationExchanges, + averagePhasesPerWizard: stats.averagePhasesPerWizard, + }; + }, + + // ============================================================================ + // Context Management Actions + // ============================================================================ + + setContextManagementSettings: (value) => { + set({ contextManagementSettings: value }); + window.maestro.settings.set('contextManagementSettings', value); + }, + + updateContextManagementSettings: (partial) => { + const prev = get().contextManagementSettings; + const updated = { ...prev, ...partial }; + set({ contextManagementSettings: updated }); + window.maestro.settings.set('contextManagementSettings', updated); + }, + + // ============================================================================ + // Keyboard Mastery Actions + // ============================================================================ + + setKeyboardMasteryStats: (value) => { + set({ keyboardMasteryStats: value }); + window.maestro.settings.set('keyboardMasteryStats', value); + }, + + recordShortcutUsage: (shortcutId) => { + const currentStats = get().keyboardMasteryStats; + + // Skip if already tracked + if (currentStats.usedShortcuts.includes(shortcutId)) { + return { newLevel: null }; + } - // Calculate new percentage and level - const percentage = (updatedShortcuts.length / TOTAL_SHORTCUTS_COUNT) * 100; - const newLevelIndex = getLevelIndex(percentage); + // Add new shortcut to the list + const updatedShortcuts = [...currentStats.usedShortcuts, shortcutId]; - // Check if user leveled up - const newLevel = newLevelIndex > currentStats.currentLevel ? newLevelIndex : null; + // Calculate new percentage and level + const percentage = (updatedShortcuts.length / TOTAL_SHORTCUTS_COUNT) * 100; + const newLevelIndex = getLevelIndex(percentage); - const updated: KeyboardMasteryStats = { - usedShortcuts: updatedShortcuts, - currentLevel: newLevelIndex, - lastLevelUpTimestamp: newLevel !== null ? Date.now() : currentStats.lastLevelUpTimestamp, - lastAcknowledgedLevel: currentStats.lastAcknowledgedLevel, - }; + // Check if user leveled up + const newLevel = newLevelIndex > currentStats.currentLevel ? newLevelIndex : null; - set({ keyboardMasteryStats: updated }); - window.maestro.settings.set('keyboardMasteryStats', updated); + const updated: KeyboardMasteryStats = { + usedShortcuts: updatedShortcuts, + currentLevel: newLevelIndex, + lastLevelUpTimestamp: newLevel !== null ? Date.now() : currentStats.lastLevelUpTimestamp, + lastAcknowledgedLevel: currentStats.lastAcknowledgedLevel, + }; - return { newLevel }; - }, + set({ keyboardMasteryStats: updated }); + window.maestro.settings.set('keyboardMasteryStats', updated); - acknowledgeKeyboardMasteryLevel: (level) => { - const prev = get().keyboardMasteryStats; - const updated: KeyboardMasteryStats = { - ...prev, - lastAcknowledgedLevel: Math.max(level, prev.lastAcknowledgedLevel), - }; - set({ keyboardMasteryStats: updated }); - window.maestro.settings.set('keyboardMasteryStats', updated); - }, + return { newLevel }; + }, - getUnacknowledgedKeyboardMasteryLevel: () => { - const stats = get().keyboardMasteryStats; - const acknowledged = stats.lastAcknowledgedLevel; - const current = stats.currentLevel; - if (current > acknowledged) { - return current; - } - return null; - }, -})); + acknowledgeKeyboardMasteryLevel: (level) => { + const prev = get().keyboardMasteryStats; + const updated: KeyboardMasteryStats = { + ...prev, + lastAcknowledgedLevel: Math.max(level, prev.lastAcknowledgedLevel), + }; + set({ keyboardMasteryStats: updated }); + window.maestro.settings.set('keyboardMasteryStats', updated); + }, + + getUnacknowledgedKeyboardMasteryLevel: () => { + const stats = get().keyboardMasteryStats; + const acknowledged = stats.lastAcknowledgedLevel; + const current = stats.currentLevel; + if (current > acknowledged) { + return current; + } + return null; + }, + }; +}); // ============================================================================ // Selectors @@ -1590,6 +1665,9 @@ export async function loadAllSettings(): Promise { 'leaderboardRegistration' ] as LeaderboardRegistration | null; + if (allSettings['persistentWebLink'] !== undefined) + patch.persistentWebLink = allSettings['persistentWebLink'] as boolean; + if (allSettings['webInterfaceUseCustomPort'] !== undefined) patch.webInterfaceUseCustomPort = allSettings['webInterfaceUseCustomPort'] as boolean; @@ -1800,6 +1878,7 @@ export function getSettingsActions() { recordTourSkip: state.recordTourSkip, getOnboardingAnalytics: state.getOnboardingAnalytics, setLeaderboardRegistration: state.setLeaderboardRegistration, + setPersistentWebLink: state.setPersistentWebLink, setWebInterfaceUseCustomPort: state.setWebInterfaceUseCustomPort, setWebInterfaceCustomPort: state.setWebInterfaceCustomPort, setContextManagementSettings: state.setContextManagementSettings,