diff --git a/.env.example b/.env.example deleted file mode 100644 index c2335ab..0000000 --- a/.env.example +++ /dev/null @@ -1,60 +0,0 @@ -# Amical Desktop Application Environment Variables -# Copy this file to .env.local and update with your values - -# ============================================ -# ONBOARDING CONFIGURATION -# ============================================ - -# Force onboarding flow for all users (development/testing) -FORCE_ONBOARDING=false - -# Skip specific onboarding screens (feature flags) -ONBOARDING_SKIP_WELCOME=false -ONBOARDING_SKIP_FEATURES=false -ONBOARDING_SKIP_DISCOVERY=false -ONBOARDING_SKIP_MODELS=false - -# Default model selection -# DEFAULT_MODEL_TYPE=cloud - -# ============================================ -# ANALYTICS & TELEMETRY -# ============================================ - -# Enable/disable telemetry -TELEMETRY_ENABLED=true - -# PostHog configuration -POSTHOG_HOST=https://app.posthog.com -POSTHOG_API_KEY=your-posthog-api-key-here - -# ============================================ -# DEVELOPMENT SETTINGS -# ============================================ - -# Environment -NODE_ENV=development - -# Logging -LOG_LEVEL=info - -# Mock system specs for testing (JSON string) -# MOCK_SYSTEM_SPECS='{"memory_total_gb":8,"cpu_cores":4}' - -# ============================================ -# BUILD & PACKAGING -# ============================================ - -# Skip code signing (macOS development) -SKIP_CODESIGNING=false - -# Skip notarization (macOS development) -SKIP_NOTARIZATION=false - -# ============================================ -# NATIVE HELPERS -# ============================================ - -# Path to native helpers (auto-detected by default) -# SWIFT_HELPER_PATH=/path/to/swift-helper -# WINDOWS_HELPER_PATH=/path/to/windows-helper \ No newline at end of file diff --git a/apps/desktop/src/components/shortcut-input.tsx b/apps/desktop/src/components/shortcut-input.tsx index 64f34c3..6fc9cb2 100644 --- a/apps/desktop/src/components/shortcut-input.tsx +++ b/apps/desktop/src/components/shortcut-input.tsx @@ -137,6 +137,9 @@ export function ShortcutInput({ }; // Subscribe to key events when recording + // Note: activeKeys closure is fresh on each render because useSubscription + // updates its callback reference, so previousKeys correctly captures the + // previous state value when onData fires. api.settings.activeKeysUpdates.useSubscription(undefined, { enabled: isRecordingShortcut, onData: (keys: string[]) => { diff --git a/apps/desktop/src/main/core/app-manager.ts b/apps/desktop/src/main/core/app-manager.ts index 90c35ac..dd76fc6 100644 --- a/apps/desktop/src/main/core/app-manager.ts +++ b/apps/desktop/src/main/core/app-manager.ts @@ -1,23 +1,28 @@ -import { app } from "electron"; +import { app, ipcMain, shell } from "electron"; import { initializeDatabase } from "../../db"; import { logger } from "../logger"; import { WindowManager } from "./window-manager"; import { setupApplicationMenu } from "../menu"; import { ServiceManager } from "../managers/service-manager"; -import { EventHandlers } from "./event-handlers"; import { TrayManager } from "../managers/tray-manager"; +import { createIPCHandler } from "electron-trpc-experimental/main"; +import { router } from "../../trpc/router"; +import { createContext } from "../../trpc/context"; +import type { OnboardingService } from "../../services/onboarding-service"; +import type { RecordingManager } from "../managers/recording-manager"; +import type { RecordingState } from "../../types/recording"; +import type { SettingsService } from "../../services/settings-service"; export class AppManager { - private windowManager: WindowManager; + private windowManager!: WindowManager; private serviceManager: ServiceManager; - private eventHandlers: EventHandlers | null = null; private trayManager: TrayManager; + private trpcHandler!: ReturnType; constructor() { - this.windowManager = new WindowManager(); - this.serviceManager = ServiceManager.createInstance(); - this.serviceManager.setWindowManager(this.windowManager); + this.serviceManager = ServiceManager.getInstance(); this.trayManager = TrayManager.getInstance(); + // WindowManager created in initialize() after deps are ready } handleDeepLink(url: string): void { @@ -37,9 +42,7 @@ export class AppManager { if (code) { // Get AuthService and complete the OAuth flow const authService = this.serviceManager.getService("authService"); - if (authService) { - authService.handleAuthCallback(code, state); - } + authService.handleAuthCallback(code, state); } } @@ -54,22 +57,42 @@ export class AppManager { await this.serviceManager.initialize(); - // Initialize OnboardingManager with WindowManager reference - this.serviceManager.initializeOnboardingManager(this.windowManager); + // Initialize tRPC handler (services must be ready first) + this.trpcHandler = createIPCHandler({ + router, + windows: [], + createContext: async () => createContext(this.serviceManager), + }); + logger.main.info("tRPC handler initialized"); - // Check if onboarding is needed using OnboardingService (single source of truth) + // Create WindowManager now that all deps are ready + const settingsService = this.serviceManager.getService("settingsService"); + this.windowManager = new WindowManager(settingsService, this.trpcHandler); + + // Register WindowManager with ServiceManager for getService("windowManager") + this.serviceManager.setWindowManager(this.windowManager); + + // Get onboarding service and subscribe to lifecycle events const onboardingService = this.serviceManager.getService("onboardingService"); - const onboardingCheck = await onboardingService!.checkNeedsOnboarding(); + this.setupOnboardingEventListeners(onboardingService); + + // Subscribe to recording state changes for widget visibility + const recordingManager = this.serviceManager.getService("recordingManager"); + this.setupRecordingEventListeners(recordingManager); + + // Check if onboarding is needed using OnboardingService (single source of truth) + const onboardingCheck = await onboardingService.checkNeedsOnboarding(); // Sync auto-launch setting with OS on startup - const settingsService = this.serviceManager.getService("settingsService"); - if (settingsService) { - settingsService.syncAutoLaunch(); - logger.main.info("Auto-launch setting synced with OS"); - } + settingsService.syncAutoLaunch(); + logger.main.info("Auto-launch setting synced with OS"); + + // Subscribe to settings changes for window updates + this.setupSettingsEventListeners(settingsService); if (onboardingCheck.needed) { + await onboardingService.startOnboardingFlow(); this.windowManager.createOrShowOnboardingWindow(); } else { await this.setupWindows(); @@ -77,13 +100,15 @@ export class AppManager { await this.setupMenu(); - // Setup event handlers - this.eventHandlers = new EventHandlers(this); - this.eventHandlers.setupEventHandlers(); - // Initialize tray this.trayManager.initialize(this.windowManager); + // Setup IPC handlers + ipcMain.handle("open-external", async (_event, url: string) => { + await shell.openExternal(url); + logger.main.debug("Opening external URL", { url }); + }); + // Auto-update is now handled by update-electron-app in main.ts logger.main.info("Application initialized successfully"); @@ -96,10 +121,100 @@ export class AppManager { ); } + private setupOnboardingEventListeners( + onboardingService: OnboardingService, + ): void { + // Handle onboarding completion + onboardingService.on("completed", () => { + const shouldRelaunch = process.env.NODE_ENV !== "development"; + logger.main.info("Onboarding completed event received", { + shouldRelaunch, + }); + + this.windowManager.closeOnboardingWindow(); + + if (shouldRelaunch) { + // Production: relaunch app to reinitialize with new settings + logger.main.info("Relaunching app after onboarding completion"); + app.relaunch(); + app.quit(); + } else { + // Development: just show the main app windows + logger.main.info("Dev mode: showing main app windows after onboarding"); + this.setupWindows(); + } + }); + + // Handle onboarding cancellation + onboardingService.on("cancelled", () => { + logger.main.info("Onboarding cancelled event received, quitting app"); + this.windowManager.closeOnboardingWindow(); + app.quit(); + }); + + logger.main.info("Onboarding event listeners set up"); + } + + private setupRecordingEventListeners( + recordingManager: RecordingManager, + ): void { + recordingManager.on("state-changed", (state: RecordingState) => { + this.updateWidgetVisibility(state === "idle").catch((error) => { + logger.main.error("Failed to update widget visibility", error); + }); + }); + + logger.main.info("Recording state listener connected in AppManager"); + } + + private setupSettingsEventListeners(settingsService: SettingsService): void { + // Handle preference changes (widget visibility) + settingsService.on( + "preferences-changed", + async ({ + showWidgetWhileInactiveChanged, + }: { + showWidgetWhileInactiveChanged: boolean; + }) => { + if (showWidgetWhileInactiveChanged) { + const recordingManager = + this.serviceManager.getService("recordingManager"); + const isIdle = recordingManager.getState() === "idle"; + await this.updateWidgetVisibility(isIdle); + } + }, + ); + + // Handle theme changes + settingsService.on("theme-changed", async () => { + await this.windowManager.updateAllWindowThemes(); + }); + + logger.main.info("Settings event listeners set up"); + } + + private async updateWidgetVisibility(isIdle: boolean): Promise { + const settingsService = this.serviceManager.getService("settingsService"); + const preferences = await settingsService.getPreferences(); + + if (preferences.showWidgetWhileInactive || !isIdle) { + this.windowManager.showWidget(); + } else { + this.windowManager.hideWidget(); + } + } + private async setupWindows(): Promise { - this.windowManager.createWidgetWindow(); + await this.windowManager.createWidgetWindow(); + + // AppManager decides initial widget visibility based on settings + const settingsService = this.serviceManager.getService("settingsService"); + const preferences = await settingsService.getPreferences(); + if (preferences.showWidgetWhileInactive) { + this.windowManager.showWidget(); + } + this.windowManager.createOrShowMainWindow(); - // tRPC handler is now set up in WindowManager when windows are created if (app.dock) { app.dock @@ -129,30 +244,6 @@ export class AppManager { ); } - getWindowManager(): WindowManager { - return this.windowManager; - } - - getServiceManager(): ServiceManager { - return this.serviceManager; - } - - getTranscriptionService() { - return this.serviceManager.getService("transcriptionService"); - } - - getNativeBridge() { - return this.serviceManager.getService("nativeBridge"); - } - - getAutoUpdaterService() { - return this.serviceManager.getService("autoUpdaterService"); - } - - getEventHandlers(): EventHandlers | null { - return this.eventHandlers; - } - async cleanup(): Promise { await this.serviceManager.cleanup(); if (this.windowManager) { @@ -188,14 +279,36 @@ export class AppManager { } async handleActivate(): Promise { + // If onboarding is in progress, just focus that window + const onboardingWindow = this.windowManager.getOnboardingWindow(); + if (onboardingWindow && !onboardingWindow.isDestroyed()) { + onboardingWindow.show(); + onboardingWindow.focus(); + return; + } + + // Normal activation logic for main app const allWindows = this.windowManager.getAllWindows(); if (allWindows.every((w) => !w || w.isDestroyed())) { + // All windows destroyed - recreate widget with proper visibility await this.windowManager.createWidgetWindow(); + const settingsService = this.serviceManager.getService("settingsService"); + const preferences = await settingsService.getPreferences(); + if (preferences.showWidgetWhileInactive) { + this.windowManager.showWidget(); + } } else { const widgetWindow = this.windowManager.getWidgetWindow(); if (!widgetWindow || widgetWindow.isDestroyed()) { + // Widget destroyed - recreate with proper visibility await this.windowManager.createWidgetWindow(); + const settingsService = + this.serviceManager.getService("settingsService"); + const preferences = await settingsService.getPreferences(); + if (preferences.showWidgetWhileInactive) { + this.windowManager.showWidget(); + } } else { widgetWindow.show(); } diff --git a/apps/desktop/src/main/core/event-handlers.ts b/apps/desktop/src/main/core/event-handlers.ts deleted file mode 100644 index 12570af..0000000 --- a/apps/desktop/src/main/core/event-handlers.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { HelperEvent } from "@amical/types"; -import { AppManager } from "./app-manager"; -import { logger } from "../logger"; -import { ipcMain, shell } from "electron"; -import NotesService from "../../services/notes-service"; - -export class EventHandlers { - private appManager: AppManager; - - constructor(appManager: AppManager) { - this.appManager = appManager; - } - - setupEventHandlers(): void { - this.setupNativeBridgeEventHandlers(); - this.setupGeneralIPCHandlers(); - this.setupNotesIPCHandlers(); - // Note: Audio IPC handlers are now managed by RecordingService - // Note: Onboarding IPC handlers removed - now using tRPC - } - - private setupNativeBridgeEventHandlers(): void { - try { - const nativeBridge = this.appManager.getNativeBridge(); - if (!nativeBridge) { - logger.main.warn("Native bridge not available for event handlers"); - return; - } - - // Handle non-shortcut related events only - nativeBridge.on("helperEvent", (event: HelperEvent) => { - logger.swift.debug("Received helperEvent from native bridge", { - event, - }); - - // Let ShortcutManager handle all key-related events - // This handler can process other helper events if needed - }); - - nativeBridge.on("error", (error: Error) => { - logger.main.error("Native bridge error:", error); - }); - - nativeBridge.on("close", (code: number | null) => { - logger.swift.warn("Native helper process closed", { code }); - }); - } catch (error) { - logger.main.warn("Native bridge not available for event handlers"); - } - } - - private setupGeneralIPCHandlers(): void { - // Handle opening external links - ipcMain.handle("open-external", async (event, url: string) => { - await shell.openExternal(url); - logger.main.debug("Opening external URL", { url }); - }); - } - - private setupNotesIPCHandlers(): void { - const notesService = NotesService.getInstance(); - - // Save yjs update - ipcMain.handle( - "notes:saveYjsUpdate", - async (event, noteId: number, update: ArrayBuffer) => { - try { - // Convert ArrayBuffer to Uint8Array - const updateArray = new Uint8Array(update); - await notesService.saveYjsUpdate(noteId, updateArray); - logger.main.debug("Saved yjs update", { - noteId, - updateSize: updateArray.length, - }); - } catch (error) { - logger.main.error("Failed to save yjs update", error); - throw error; - } - }, - ); - - // Load all yjs updates for a note - ipcMain.handle("notes:loadYjsUpdates", async (event, noteId: number) => { - try { - const updates = await notesService.loadYjsUpdates(noteId); - logger.main.debug("Loaded yjs updates", { - noteId, - count: updates.length, - }); - // Convert Uint8Array[] to ArrayBuffer[] for IPC transfer - return updates.map((u) => u.buffer); - } catch (error) { - logger.main.error("Failed to load yjs updates", error); - throw error; - } - }); - } -} diff --git a/apps/desktop/src/main/core/window-manager.ts b/apps/desktop/src/main/core/window-manager.ts index a5b6bf2..62b391d 100644 --- a/apps/desktop/src/main/core/window-manager.ts +++ b/apps/desktop/src/main/core/window-manager.ts @@ -7,9 +7,8 @@ import { } from "electron"; import path from "node:path"; import { logger } from "../logger"; -import { ServiceManager } from "../managers/service-manager"; -import type { RecordingManager } from "../managers/recording-manager"; -import type { RecordingState } from "../../types/recording"; +import type { SettingsService } from "../../services/settings-service"; +import type { createIPCHandler } from "electron-trpc-experimental/main"; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; declare const MAIN_WINDOW_VITE_NAME: string; @@ -24,40 +23,34 @@ export class WindowManager { private cursorPollingInterval: NodeJS.Timeout | null = null; private themeListenerSetup: boolean = false; + constructor( + private settingsService: SettingsService, + private trpcHandler: ReturnType, + ) { + logger.main.info("WindowManager created with dependencies"); + } + private async getThemeColors(): Promise<{ backgroundColor: string; symbolColor: string; }> { - try { - const settingsService = - ServiceManager.getInstance()?.getService("settingsService"); - if (!settingsService) { - // Default to light theme if service unavailable - return { backgroundColor: "#ffffff", symbolColor: "#000000" }; - } + const uiSettings = await this.settingsService.getUISettings(); + const theme = uiSettings?.theme || "system"; - const uiSettings = await settingsService.getUISettings(); - const theme = uiSettings?.theme || "system"; - - // Determine if we should use dark colors - let isDark = false; - if (theme === "dark") { - isDark = true; - } else if (theme === "light") { - isDark = false; - } else if (theme === "system") { - isDark = nativeTheme.shouldUseDarkColors; - } - - // Return appropriate colors - return isDark - ? { backgroundColor: "#171717", symbolColor: "#fafafa" } - : { backgroundColor: "#ffffff", symbolColor: "#171717" }; - } catch (error) { - logger.main.error("Failed to get theme colors:", error); - // Default to light theme on error - return { backgroundColor: "#ffffff", symbolColor: "#000000" }; + // Determine if we should use dark colors + let isDark = false; + if (theme === "dark") { + isDark = true; + } else if (theme === "light") { + isDark = false; + } else if (theme === "system") { + isDark = nativeTheme.shouldUseDarkColors; } + + // Return appropriate colors + return isDark + ? { backgroundColor: "#171717", symbolColor: "#fafafa" } + : { backgroundColor: "#ffffff", symbolColor: "#171717" }; } async updateAllWindowThemes(): Promise { @@ -83,10 +76,7 @@ export class WindowManager { // Listen for system theme changes nativeTheme.on("updated", async () => { - const settingsService = - ServiceManager.getInstance()!.getService("settingsService")!; - - const uiSettings = await settingsService.getUISettings(); + const uiSettings = await this.settingsService.getUISettings(); const theme = uiSettings?.theme || "system"; // Only update if theme is set to "system" @@ -142,9 +132,7 @@ export class WindowManager { this.mainWindow.on("close", () => { // Detach window before it's destroyed - ServiceManager.getInstance()! - .getTRPCHandler()! - .detachWindow(this.mainWindow!); + this.trpcHandler.detachWindow(this.mainWindow!); }); this.mainWindow.on("closed", () => { @@ -152,9 +140,7 @@ export class WindowManager { this.mainWindow = null; }); - ServiceManager.getInstance()! - .getTRPCHandler()! - .attachWindow(this.mainWindow!); + this.trpcHandler.attachWindow(this.mainWindow!); } async createWidgetWindow(): Promise { @@ -171,7 +157,6 @@ export class WindowManager { width, height, frame: false, - titleBarStyle: "hidden", transparent: true, alwaysOnTop: true, resizable: false, @@ -212,9 +197,7 @@ export class WindowManager { this.widgetWindow.on("close", () => { // Detach window before it's destroyed - ServiceManager.getInstance()! - .getTRPCHandler()! - .detachWindow(this.widgetWindow!); + this.trpcHandler.detachWindow(this.widgetWindow!); }); this.widgetWindow.on("closed", () => { @@ -234,22 +217,11 @@ export class WindowManager { this.setupDisplayChangeNotifications(); // Update tRPC handler with new window - ServiceManager.getInstance()! - .getTRPCHandler()! - .attachWindow(this.widgetWindow!); + this.trpcHandler.attachWindow(this.widgetWindow!); - // Check preference to determine initial visibility - const settingsService = - ServiceManager.getInstance()!.getService("settingsService")!; - const preferences = await settingsService.getPreferences(); - if (preferences.showWidgetWhileInactive) { - this.widgetWindow.show(); - logger.main.info("Widget window shown (showWidgetWhileInactive: true)"); - } else { - logger.main.info( - "Widget window created but hidden (showWidgetWhileInactive: false)", - ); - } + logger.main.info( + "Widget window created (visibility controlled by AppManager)", + ); } createOrShowOnboardingWindow(): void { @@ -264,7 +236,7 @@ export class WindowManager { this.onboardingWindow = new BrowserWindow({ width: 800, - height: 900, + height: 928, frame: false, titleBarStyle: "hidden", resizable: false, @@ -290,6 +262,10 @@ export class WindowManager { ); } + this.onboardingWindow.on("close", () => { + this.trpcHandler.detachWindow(this.onboardingWindow!); + }); + this.onboardingWindow.on("closed", () => { this.onboardingWindow = null; }); @@ -299,6 +275,7 @@ export class WindowManager { this.mainWindow.setEnabled(false); } + this.trpcHandler.attachWindow(this.onboardingWindow!); logger.main.info("Onboarding window created"); } @@ -315,47 +292,16 @@ export class WindowManager { } } - async updateWidgetVisibility(isIdle: boolean): Promise { - if (!this.widgetWindow || this.widgetWindow.isDestroyed()) { - return; - } - - const settingsService = - ServiceManager.getInstance()!.getService("settingsService")!; - - const preferences = await settingsService.getPreferences(); - - if (preferences.showWidgetWhileInactive) { + showWidget(): void { + if (this.widgetWindow && !this.widgetWindow.isDestroyed()) { this.widgetWindow.showInactive(); - return; } + } - if (isIdle) { + hideWidget(): void { + if (this.widgetWindow && !this.widgetWindow.isDestroyed()) { this.widgetWindow.hide(); - return; } - - this.widgetWindow.showInactive(); - } - - setupRecordingStateListener(recordingManager: RecordingManager): void { - recordingManager.on("state-changed", (state: RecordingState) => { - const isIdle = state === "idle"; - this.updateWidgetVisibility(isIdle).catch((error) => { - logger.main.error("Failed to update widget visibility", error); - }); - }); - logger.main.info( - "Widget visibility listener connected to recording state changes", - ); - } - - async syncWidgetVisibility(): Promise { - const recordingManager = - ServiceManager.getInstance()!.getService("recordingManager")!; - const recordingState = recordingManager.getState(); - const isIdle = recordingState === "idle"; - await this.updateWidgetVisibility(isIdle); } private setupDisplayChangeNotifications(): void { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 3e46ed3..f5aefff 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -53,38 +53,46 @@ if (app.isPackaged && isWindows()) { const appManager = new AppManager(); -// Store the deep link URL for processing after app is ready -let deeplinkingUrl: string | null = null; +// Track initialization state for deep link handling +let isInitialized = false; +let pendingDeepLink: string | null = null; // Handle protocol on macOS app.on("open-url", (event, url) => { event.preventDefault(); - if (app.isReady()) { + if (isInitialized) { appManager.handleDeepLink(url); } else { - deeplinkingUrl = url; + pendingDeepLink = url; } }); // Handle when another instance tries to start (Windows/Linux deep link handling) app.on("second-instance", (_event, commandLine) => { // Someone tried to run a second instance, we should focus our window instead. - appManager.handleSecondInstance(); + if (isInitialized) { + appManager.handleSecondInstance(); + } // Check if this is a protocol launch on Windows/Linux const url = commandLine.find((arg) => arg.startsWith("amical://")); if (url) { - appManager.handleDeepLink(url); + if (isInitialized) { + appManager.handleDeepLink(url); + } else { + pendingDeepLink = url; + } } }); -app.whenReady().then(() => { - appManager.initialize(); +app.whenReady().then(async () => { + await appManager.initialize(); + isInitialized = true; - // Process any deep link that was received before app was ready - if (deeplinkingUrl) { - appManager.handleDeepLink(deeplinkingUrl); - deeplinkingUrl = null; + // Process any deep link that was received before initialization completed + if (pendingDeepLink) { + appManager.handleDeepLink(pendingDeepLink); + pendingDeepLink = null; } }); app.on("will-quit", () => appManager.cleanup()); diff --git a/apps/desktop/src/main/managers/onboarding-manager.ts b/apps/desktop/src/main/managers/onboarding-manager.ts deleted file mode 100644 index f0e8cc8..0000000 --- a/apps/desktop/src/main/managers/onboarding-manager.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { app } from "electron"; -import { logger } from "../logger"; -import type { WindowManager } from "../core/window-manager"; -import type { OnboardingService } from "../../services/onboarding-service"; -import type { OnboardingState } from "../../types/onboarding"; - -export class OnboardingManager { - private windowManager: WindowManager; - private onboardingService: OnboardingService; - private isOnboardingInProgress = false; - - constructor( - windowManager: WindowManager, - onboardingService: OnboardingService, - ) { - this.windowManager = windowManager; - this.onboardingService = onboardingService; - } - - /** - * Initialize onboarding manager - */ - async initialize(): Promise { - logger.main.info("Initializing OnboardingManager"); - // Any initialization logic can go here - } - - /** - * Start the onboarding flow - */ - async startOnboarding(): Promise { - if (this.isOnboardingInProgress) { - logger.main.warn("Onboarding already in progress"); - return; - } - - this.isOnboardingInProgress = true; - logger.main.info("Starting onboarding flow"); - - // Create and show the onboarding window - await this.windowManager.createOrShowOnboardingWindow(); - - // Track onboarding started event - this.onboardingService.trackOnboardingStarted(process.platform); - } - - /** - * Complete the onboarding process - */ - async completeOnboarding(finalState: OnboardingState): Promise { - try { - logger.main.info("Completing onboarding"); - - // Save the final state - await this.onboardingService.completeOnboarding(finalState); - - this.isOnboardingInProgress = false; - - // Close onboarding window - const onboardingWindow = this.windowManager.getOnboardingWindow(); - if (onboardingWindow && !onboardingWindow.isDestroyed()) { - onboardingWindow.close(); - } - - // Determine if we need to relaunch - const isDevelopment = process.env.NODE_ENV === "development"; - - if (isDevelopment) { - // In development, reload windows - logger.main.info("Development mode: Reloading windows"); - await this.reloadWindows(); - } else { - // In production, relaunch the app - logger.main.info("Production mode: Relaunching app"); - this.relaunchApp(); - } - } catch (error) { - logger.main.error("Error completing onboarding:", error); - throw error; - } - } - - /** - * Handle onboarding cancellation - */ - async cancelOnboarding(): Promise { - logger.main.info("Onboarding cancelled"); - - this.isOnboardingInProgress = false; - - // Track abandonment event - const currentState = await this.onboardingService.getOnboardingState(); - const lastScreen = - currentState?.lastVisitedScreen || - currentState?.skippedScreens?.[currentState.skippedScreens.length - 1] || - "unknown"; - this.onboardingService.trackOnboardingAbandoned(lastScreen); - - // Close the onboarding window - const onboardingWindow = this.windowManager.getOnboardingWindow(); - if (onboardingWindow && !onboardingWindow.isDestroyed()) { - onboardingWindow.close(); - } - - // Quit the app since onboarding was not completed - app.quit(); - } - - /** - * Reload windows in development mode - */ - private async reloadWindows(): Promise { - try { - // Create main window - await this.windowManager.createOrShowMainWindow(); - - // Create widget window if enabled - const settings = await this.onboardingService.getOnboardingState(); - if (settings?.featureInterests?.includes("contextual_dictation" as any)) { - await this.windowManager.createWidgetWindow(); - } - } catch (error) { - logger.main.error("Error reloading windows:", error); - } - } - - /** - * Relaunch the application in production mode - */ - private relaunchApp(): void { - app.relaunch(); - app.quit(); - } - - /** - * Check if onboarding is currently in progress - */ - isInProgress(): boolean { - return this.isOnboardingInProgress; - } - - /** - * Get the current onboarding state - */ - async getState(): Promise { - return this.onboardingService.getOnboardingState(); - } - - /** - * Update onboarding preferences - */ - async updatePreferences(preferences: any): Promise { - return this.onboardingService.savePreferences(preferences); - } - - /** - * Get system model recommendation - */ - async getSystemRecommendation(): Promise { - return this.onboardingService.getSystemRecommendation(); - } - - /** - * Get feature flags for onboarding - */ - getFeatureFlags(): any { - return this.onboardingService.getFeatureFlags(); - } -} diff --git a/apps/desktop/src/main/managers/recording-manager.ts b/apps/desktop/src/main/managers/recording-manager.ts index 6b10833..bb4320a 100644 --- a/apps/desktop/src/main/managers/recording-manager.ts +++ b/apps/desktop/src/main/managers/recording-manager.ts @@ -1,14 +1,13 @@ import { ipcMain, app, dialog } from "electron"; import { EventEmitter } from "node:events"; import { logger, logPerformance } from "../logger"; -import { ServiceManager } from "./service-manager"; +import type { ServiceManager } from "@/main/managers/service-manager"; import type { RecordingState } from "../../types/recording"; import { Mutex } from "async-mutex"; -import type { ShortcutManager } from "../services/shortcut-manager"; +import type { ShortcutManager } from "./shortcut-manager"; import { StreamingWavWriter } from "../../utils/streaming-wav-writer"; import * as fs from "node:fs"; import * as path from "node:path"; -import { appContextStore } from "@/stores/app-context"; export type RecordingMode = "idle" | "ptt" | "hands-free"; @@ -164,15 +163,6 @@ export class RecordingManager extends EventEmitter { const transcriptionService = this.serviceManager.getService( "transcriptionService", ); - if (!transcriptionService) { - logger.audio.error("Transcription service not available"); - // Show error dialog - dialog.showErrorBox( - "Recording Failed", - "Transcription service is not available. Please restart the application.", - ); - return; - } const hasModels = await transcriptionService.isModelAvailable(); const modelCheckDuration = performance.now() - modelCheckStartTime; @@ -214,8 +204,9 @@ export class RecordingManager extends EventEmitter { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); this.currentSessionId = `session-${timestamp}`; - // Get accessibility context from global store (async, not awaited) - appContextStore.refreshAccessibilityData(); + // Refresh accessibility context from NativeBridge (async, not awaited) + const nativeBridge = this.serviceManager.getService("nativeBridge"); + nativeBridge?.refreshAccessibilityContext(); logger.audio.info( "RecordingManager: Triggered accessibility context refresh (async)", ); @@ -240,7 +231,6 @@ export class RecordingManager extends EventEmitter { // Mute system audio (async, non-blocking) const muteStartTime = performance.now(); - const nativeBridge = this.serviceManager.getService("nativeBridge"); if (nativeBridge) { nativeBridge .call("muteSystemAudio", {}) diff --git a/apps/desktop/src/main/managers/service-manager.ts b/apps/desktop/src/main/managers/service-manager.ts index aa077cf..73ed7f2 100644 --- a/apps/desktop/src/main/managers/service-manager.ts +++ b/apps/desktop/src/main/managers/service-manager.ts @@ -1,28 +1,24 @@ import { logger } from "../logger"; -import { ModelManagerService } from "../../services/model-manager"; +import { ModelService } from "../../services/model-service"; import { TranscriptionService } from "../../services/transcription-service"; import { SettingsService } from "../../services/settings-service"; import { NativeBridge } from "../../services/platform/native-bridge-service"; import { AutoUpdaterService } from "../services/auto-updater"; import { RecordingManager } from "./recording-manager"; import { VADService } from "../../services/vad-service"; -import { ShortcutManager } from "../services/shortcut-manager"; +import { ShortcutManager } from "./shortcut-manager"; import { WindowManager } from "../core/window-manager"; -import { createIPCHandler } from "electron-trpc-experimental/main"; -import { router } from "../../trpc/router"; -import { createContext } from "../../trpc/context"; import { isMacOS, isWindows } from "../../utils/platform"; import { TelemetryService } from "../../services/telemetry-service"; import { AuthService } from "../../services/auth-service"; import { OnboardingService } from "../../services/onboarding-service"; -import { OnboardingManager } from "./onboarding-manager"; /** * Service map for type-safe service access */ export interface ServiceMap { telemetryService: TelemetryService; - modelManagerService: ModelManagerService; + modelService: ModelService; transcriptionService: TranscriptionService; settingsService: SettingsService; authService: AuthService; @@ -33,7 +29,6 @@ export interface ServiceMap { shortcutManager: ShortcutManager; windowManager: WindowManager; onboardingService: OnboardingService; - onboardingManager: OnboardingManager; } /** @@ -44,7 +39,7 @@ export class ServiceManager { private isInitialized = false; private telemetryService: TelemetryService | null = null; - private modelManagerService: ModelManagerService | null = null; + private modelService: ModelService | null = null; private transcriptionService: TranscriptionService | null = null; private settingsService: SettingsService | null = null; private authService: AuthService | null = null; @@ -56,8 +51,6 @@ export class ServiceManager { private recordingManager: RecordingManager | null = null; private shortcutManager: ShortcutManager | null = null; private windowManager: WindowManager | null = null; - private onboardingManager: OnboardingManager | null = null; - private trpcHandler: ReturnType | null = null; async initialize(): Promise { if (this.isInitialized) { @@ -79,13 +72,12 @@ export class ServiceManager { this.initializeRecordingManager(); await this.initializeShortcutManager(); this.initializeAutoUpdater(); - this.initializeTRPCHandler(); this.isInitialized = true; logger.main.info("Services initialized successfully"); } catch (error) { logger.main.error("Failed to initialize services:", error); - // Don't throw here - allow app to start even if some services fail + throw error; } } @@ -121,26 +113,13 @@ export class ServiceManager { logger.main.info("Onboarding service initialized"); } - initializeOnboardingManager(windowManager: WindowManager): void { - if (!this.onboardingService) { - logger.main.warn("Onboarding service not available for manager"); - return; - } - - this.onboardingManager = new OnboardingManager( - windowManager, - this.onboardingService, - ); - logger.main.info("Onboarding manager initialized"); - } - private async initializeModelServices(): Promise { // Initialize Model Manager Service if (!this.settingsService) { throw new Error("Settings service not initialized"); } - this.modelManagerService = new ModelManagerService(this.settingsService); - await this.modelManagerService.initialize(); + this.modelService = new ModelService(this.settingsService); + await this.modelService.initialize(); } private async initializeVADService(): Promise { @@ -156,7 +135,7 @@ export class ServiceManager { private async initializeAIServices(): Promise { try { - if (!this.modelManagerService) { + if (!this.modelService) { throw new Error("Model manager service not initialized"); } @@ -165,10 +144,11 @@ export class ServiceManager { } this.transcriptionService = new TranscriptionService( - this.modelManagerService, + this.modelService, this.vadService!, this.settingsService, this.telemetryService!, + this.nativeBridge, ); await this.transcriptionService.initialize(); @@ -228,11 +208,6 @@ export class ServiceManager { // Connect shortcut events to recording manager this.recordingManager.setupShortcutListeners(this.shortcutManager); - // Connect widget visibility to recording state changes - if (this.windowManager && this.recordingManager) { - this.windowManager.setupRecordingStateListener(this.recordingManager); - } - logger.main.info("Shortcut manager initialized"); } @@ -240,56 +215,33 @@ export class ServiceManager { this.autoUpdaterService = new AutoUpdaterService(); } - private initializeTRPCHandler(): void { - // Initialize with empty windows array, windows will be added later - this.trpcHandler = createIPCHandler({ - router, - windows: [], - createContext: async () => createContext(this), - }); - logger.main.info("tRPC handler initialized"); - } - - getTRPCHandler(): ReturnType | null { - if (!this.isInitialized) { - throw new Error( - "ServiceManager not initialized. Call initialize() first.", - ); - } - if (!this.trpcHandler) { - throw new Error("TRPCHandler failed to initialize"); - } - return this.trpcHandler; - } - getLogger() { return logger; } - getService(serviceName: K): ServiceMap[K] | null { + getService(serviceName: K): ServiceMap[K] { if (!this.isInitialized) { throw new Error( "ServiceManager not initialized. Call initialize() first.", ); } - const services: Partial = { - telemetryService: this.telemetryService ?? undefined, - modelManagerService: this.modelManagerService ?? undefined, - transcriptionService: this.transcriptionService ?? undefined, - settingsService: this.settingsService ?? undefined, - authService: this.authService ?? undefined, - vadService: this.vadService ?? undefined, - nativeBridge: this.nativeBridge ?? undefined, - autoUpdaterService: this.autoUpdaterService ?? undefined, - recordingManager: this.recordingManager ?? undefined, - shortcutManager: this.shortcutManager ?? undefined, - windowManager: this.windowManager ?? undefined, - onboardingService: this.onboardingService ?? undefined, - onboardingManager: this.onboardingManager ?? undefined, + const services: ServiceMap = { + telemetryService: this.telemetryService!, + modelService: this.modelService!, + transcriptionService: this.transcriptionService!, + settingsService: this.settingsService!, + authService: this.authService!, + vadService: this.vadService!, + nativeBridge: this.nativeBridge!, + autoUpdaterService: this.autoUpdaterService!, + recordingManager: this.recordingManager!, + shortcutManager: this.shortcutManager!, + windowManager: this.windowManager!, + onboardingService: this.onboardingService!, }; - return services[serviceName] ?? null; + return services[serviceName]; } async cleanup(): Promise { @@ -301,9 +253,9 @@ export class ServiceManager { logger.main.info("Cleaning up recording manager..."); await this.recordingManager.cleanup(); } - if (this.modelManagerService) { + if (this.modelService) { logger.main.info("Cleaning up model downloads..."); - this.modelManagerService.cleanup(); + this.modelService.cleanup(); } if (this.vadService) { @@ -326,19 +278,11 @@ export class ServiceManager { return this.onboardingService; } - getOnboardingManager(): OnboardingManager | null { - return this.onboardingManager; - } - getSettingsService(): SettingsService | null { return this.settingsService; } - static getInstance(): ServiceManager | null { - return ServiceManager.instance; - } - - static createInstance(): ServiceManager { + static getInstance(): ServiceManager { if (!ServiceManager.instance) { ServiceManager.instance = new ServiceManager(); } diff --git a/apps/desktop/src/main/services/shortcut-manager.ts b/apps/desktop/src/main/managers/shortcut-manager.ts similarity index 100% rename from apps/desktop/src/main/services/shortcut-manager.ts rename to apps/desktop/src/main/managers/shortcut-manager.ts diff --git a/apps/desktop/src/pipeline/providers/transcription/whisper-provider.ts b/apps/desktop/src/pipeline/providers/transcription/whisper-provider.ts index ed9b0a5..00162fb 100644 --- a/apps/desktop/src/pipeline/providers/transcription/whisper-provider.ts +++ b/apps/desktop/src/pipeline/providers/transcription/whisper-provider.ts @@ -3,7 +3,7 @@ import { TranscribeParams, } from "../../core/pipeline-types"; import { logger } from "../../../main/logger"; -import { ModelManagerService } from "../../../services/model-manager"; +import { ModelService } from "../../../services/model-service"; import { SimpleForkWrapper } from "./simple-fork-wrapper"; import * as path from "path"; import { app } from "electron"; @@ -11,7 +11,7 @@ import { app } from "electron"; export class WhisperProvider implements TranscriptionProvider { readonly name = "whisper-local"; - private modelManager: ModelManagerService; + private modelService: ModelService; private workerWrapper: SimpleForkWrapper | null = null; // Frame aggregation state @@ -48,8 +48,8 @@ export class WhisperProvider implements TranscriptionProvider { private readonly SPEECH_PROBABILITY_THRESHOLD = 0.2; // Threshold for speech detection private readonly IGNORE_FULLY_SILENT_CHUNKS = true; - constructor(modelManager: ModelManagerService) { - this.modelManager = modelManager; + constructor(modelService: ModelService) { + this.modelService = modelService; } /** @@ -328,7 +328,7 @@ export class WhisperProvider implements TranscriptionProvider { await this.workerWrapper.initialize(); } - const modelPath = await this.modelManager.getBestAvailableModelPath(); + const modelPath = await this.modelService.getBestAvailableModelPath(); if (!modelPath) { throw new Error( "No Whisper models available. Please download a model first.", diff --git a/apps/desktop/src/renderer/main/pages/settings/ai-models/index.tsx b/apps/desktop/src/renderer/main/pages/settings/ai-models/index.tsx index 9ab5e49..1100282 100644 --- a/apps/desktop/src/renderer/main/pages/settings/ai-models/index.tsx +++ b/apps/desktop/src/renderer/main/pages/settings/ai-models/index.tsx @@ -4,12 +4,13 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import SpeechTab from "./tabs/SpeechTab"; import LanguageTab from "./tabs/LanguageTab"; import EmbeddingTab from "./tabs/EmbeddingTab"; -import { useNavigate } from "@tanstack/react-router"; -import { Route } from "../../../routes/settings/ai-models"; +import { useNavigate, getRouteApi } from "@tanstack/react-router"; + +const routeApi = getRouteApi("/settings/ai-models"); export default function AIModelsSettingsPage() { const navigate = useNavigate(); - const { tab } = Route.useSearch(); + const { tab } = routeApi.useSearch(); return (
diff --git a/apps/desktop/src/renderer/onboarding/App.tsx b/apps/desktop/src/renderer/onboarding/App.tsx index 5f4713e..9b9ff93 100644 --- a/apps/desktop/src/renderer/onboarding/App.tsx +++ b/apps/desktop/src/renderer/onboarding/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { api } from "@/trpc/react"; import { useOnboardingState } from "./hooks/useOnboardingState"; import { ProgressIndicator } from "./components/shared/ProgressIndicator"; @@ -49,21 +49,16 @@ export function App() { const { state, isLoading, savePreferences, completeOnboarding } = useOnboardingState(); + // Ref to hold stable reference to savePreferences (avoids infinite loop in useEffect) + const savePreferencesRef = useRef(savePreferences); + savePreferencesRef.current = savePreferences; + + // Ref to ensure initialization only runs once (prevents re-running on dependency changes) + const hasInitialized = useRef(false); + // tRPC queries const featureFlagsQuery = api.onboarding.getFeatureFlags.useQuery(); const skippedScreensQuery = api.onboarding.getSkippedScreens.useQuery(); - - // Telemetry mutations - const trackOnboardingStarted = - api.onboarding.trackOnboardingStarted.useMutation(); - const trackOnboardingScreenViewed = - api.onboarding.trackOnboardingScreenViewed.useMutation(); - const trackOnboardingFeaturesSelected = - api.onboarding.trackOnboardingFeaturesSelected.useMutation(); - const trackOnboardingDiscoverySelected = - api.onboarding.trackOnboardingDiscoverySelected.useMutation(); - const trackOnboardingModelSelected = - api.onboarding.trackOnboardingModelSelected.useMutation(); const utils = api.useUtils(); // Screen order - can be modified based on feature flags @@ -129,8 +124,15 @@ export function App() { await checkPermissionsWithResult(); }, [checkPermissionsWithResult]); - // Initialize platform and permissions + // Initialize platform and permissions (runs once when state is ready) useEffect(() => { + // Wait for state to be ready before initializing + if (isLoading) return; + + // Skip if already initialized (prevents re-running when dependencies change) + if (hasInitialized.current) return; + hasInitialized.current = true; + const initialize = async () => { // Check initial permissions and platform // Use fresh results directly to avoid race condition @@ -165,48 +167,27 @@ export function App() { setCurrentScreen(state.lastVisitedScreen as OnboardingScreen); } } - - // Track onboarding started event (T034) - trackOnboardingStarted.mutate({ - platform: platformResult, - resumed: !!state?.lastVisitedScreen, - resumedFrom: state?.lastVisitedScreen, - }); }; initialize(); }, [ + isLoading, checkPermissionsWithResult, - trackOnboardingStarted, utils, state?.lastVisitedScreen, getActiveScreens, ]); - // Save current screen for resume capability + // Save current screen for resume capability (telemetry tracked in backend) useEffect(() => { if (currentScreen !== OnboardingScreen.Welcome) { // Don't save Welcome screen, start from there if no progress - // lastVisitedScreen is not in OnboardingPreferences type, but is saved to state - savePreferences({ + // Use ref to avoid dependency on savePreferences which changes identity on mutation state + savePreferencesRef.current({ lastVisitedScreen: currentScreen, - } as Partial); + }); } - }, [currentScreen, savePreferences]); - - // Track screen views (T035) - useEffect(() => { - trackOnboardingScreenViewed.mutate({ - screen: currentScreen, - index: getCurrentScreenIndex(), - total: getTotalScreens(), - }); - }, [ - currentScreen, - trackOnboardingScreenViewed, - getCurrentScreenIndex, - getTotalScreens, - ]); + }, [currentScreen]); // Navigation functions (T028 - Back navigation) const navigateBack = useCallback(() => { @@ -229,60 +210,41 @@ export function App() { }, [currentScreen, getActiveScreens]); // Save preferences and navigate - const handleSaveAndContinue = async ( + const handleSaveAndContinue = ( newPreferences: Partial, ) => { - try { - // Merge with existing preferences - const updatedPreferences = { ...preferences, ...newPreferences }; - setPreferences(updatedPreferences); + // Merge with existing preferences + const updatedPreferences = { ...preferences, ...newPreferences }; + setPreferences(updatedPreferences); - // Save to backend (T030 - handled by hook) - await savePreferences(newPreferences); + // Navigate immediately for responsive UX + navigateNext(); - // Navigate to next screen - navigateNext(); - } catch (error) { + // Save to backend in background (non-blocking) + // Preferences are already in React state, final completion will persist everything + savePreferences(newPreferences).catch((error) => { console.error("Failed to save preferences:", error); // Error is already handled by the hook with toast - } + }); }; - // Handle feature interests selection (T036) - const handleFeatureInterests = async (interests: FeatureInterest[]) => { - trackOnboardingFeaturesSelected.mutate({ - features: interests, - count: interests.length, - }); - - await handleSaveAndContinue({ featureInterests: interests }); + // Handle feature interests selection (telemetry tracked in backend) + const handleFeatureInterests = (interests: FeatureInterest[]) => { + handleSaveAndContinue({ featureInterests: interests }); }; - // Handle discovery source selection (T037) - const handleDiscoverySource = async ( - source: DiscoverySource, - details?: string, - ) => { - trackOnboardingDiscoverySelected.mutate({ - source, - details, - }); - + // Handle discovery source selection (telemetry tracked in backend) + const handleDiscoverySource = (source: DiscoverySource, details?: string) => { setDiscoveryDetails(details || ""); - await handleSaveAndContinue({ discoverySource: source }); + handleSaveAndContinue({ discoverySource: source }); }; - // Handle model selection (T038) - const handleModelSelection = async ( + // Handle model selection (telemetry tracked in backend) + const handleModelSelection = ( modelType: ModelType, recommendationFollowed: boolean, ) => { - trackOnboardingModelSelected.mutate({ - model_type: modelType, - recommendation_followed: recommendationFollowed, - }); - - await handleSaveAndContinue({ + handleSaveAndContinue({ selectedModelType: modelType, modelRecommendation: state?.modelRecommendation ? { ...state.modelRecommendation, followed: recommendationFollowed } @@ -372,6 +334,7 @@ export function App() { return ( ); diff --git a/apps/desktop/src/renderer/onboarding/components/screens/CompletionScreen.tsx b/apps/desktop/src/renderer/onboarding/components/screens/CompletionScreen.tsx index fdf5fe4..4eaf4ff 100644 --- a/apps/desktop/src/renderer/onboarding/components/screens/CompletionScreen.tsx +++ b/apps/desktop/src/renderer/onboarding/components/screens/CompletionScreen.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { OnboardingLayout } from "../shared/OnboardingLayout"; import { NavigationButtons } from "../shared/NavigationButtons"; @@ -8,8 +9,11 @@ import { OnboardingShortcutInput } from "../shared/OnboardingShortcutInput"; import { CheckCircle, Settings, Info } from "lucide-react"; import { FeatureInterest, ModelType } from "../../../../types/onboarding"; +const DISCORD_URL = "https://amical.ai/community"; + interface CompletionScreenProps { onComplete: () => void; + onBack: () => void; preferences: { featureInterests?: FeatureInterest[]; modelType?: ModelType; @@ -21,24 +25,25 @@ interface CompletionScreenProps { */ export function CompletionScreen({ onComplete, + onBack, preferences, }: CompletionScreenProps) { return ( - + } + footer={ + + } + >
- {/* Success Message */} -
-
- -
-
-

You're all set!

-

- Your voice transcription assistant is ready to use -

-
-
- {/* Quick Configuration */}

@@ -52,10 +57,35 @@ export function CompletionScreen({

+ {/* Community */} + +
+
+ Discord +
+
+

Join our Community

+

+ Get help, share feedback, and connect with other users +

+
+ +
+
+ {/* Next Steps */} - -

You're All Set!

-
+ +

You're All Set!

+

@@ -86,15 +116,6 @@ export function CompletionScreen({ " Your selected local model is ready to use offline."}

- - {/* Complete Button */} -
); diff --git a/apps/desktop/src/renderer/onboarding/components/screens/DiscoverySourceScreen.tsx b/apps/desktop/src/renderer/onboarding/components/screens/DiscoverySourceScreen.tsx index 554790b..cb58ea8 100644 --- a/apps/desktop/src/renderer/onboarding/components/screens/DiscoverySourceScreen.tsx +++ b/apps/desktop/src/renderer/onboarding/components/screens/DiscoverySourceScreen.tsx @@ -80,6 +80,16 @@ export function DiscoverySourceScreen({ + } >
{/* Discovery Sources */} @@ -117,16 +127,6 @@ export function DiscoverySourceScreen({

)} - - {/* Navigation */} -
); diff --git a/apps/desktop/src/renderer/onboarding/components/screens/ModelSelectionScreen.tsx b/apps/desktop/src/renderer/onboarding/components/screens/ModelSelectionScreen.tsx index 29d677a..6d935b7 100644 --- a/apps/desktop/src/renderer/onboarding/components/screens/ModelSelectionScreen.tsx +++ b/apps/desktop/src/renderer/onboarding/components/screens/ModelSelectionScreen.tsx @@ -7,7 +7,7 @@ import { NavigationButtons } from "../shared/NavigationButtons"; import { ModelSetupModal } from "./ModelSetupModal"; import { useSystemRecommendation } from "../../hooks/useSystemRecommendation"; import { ModelType } from "../../../../types/onboarding"; -import { Cloud, HardDrive, Sparkles, Check } from "lucide-react"; +import { Cloud, Laptop, Sparkles, Check, X, Star } from "lucide-react"; import { toast } from "sonner"; interface ModelSelectionScreenProps { @@ -41,34 +41,26 @@ export function ModelSelectionScreen({ { id: ModelType.Cloud, title: "Amical Cloud", - subtitle: "Fast cloud transcription", + subtitle: "Fast, more accurate, and free - no setup needed", description: - "Process audio using Amical's cloud servers for fast and accurate transcription. Your audio is never persisted on our servers.", - pros: ["Fast processing", "High accuracy", "No local resources needed"], - cons: [ - "Requires internet", - "Requires Amical account", - "Usage limits may apply", - ], + "Ideal if you want the best accuracy or your device can't run local models.\nSecure processing; audio is never stored.", + pros: ["Free", "Fast", "More accurate", "No setup needed"], + cons: ["Needs internet & login"], icon: Cloud, iconBg: "bg-blue-500/10", iconColor: "text-blue-500", }, { id: ModelType.Local, - title: "Local Models (Whisper)", - subtitle: "Private and offline", + title: "Local Models", + subtitle: "Private, offline, and free - runs fully on your device.", description: - "OpenAI's Whisper models running directly on your device. Complete privacy with no data leaving your computer.", - pros: ["Complete privacy", "Works offline", "No account required"], - cons: [ - "Requires more RAM/CPU", - "Slower than cloud", - "Initial download required", - ], - icon: HardDrive, - iconBg: "bg-green-500/10", - iconColor: "text-green-500", + "Great for privacy-focused users with capable hardware. No login required.", + pros: ["Full privacy", "Works offline"], + cons: ["Uses device resources"], + icon: Laptop, + iconBg: "bg-slate-500/10", + iconColor: "text-slate-500", }, ]; @@ -101,8 +93,6 @@ export function ModelSelectionScreen({ onNext(selectedModel, followedRecommendation); }; - const getModelById = (id: ModelType) => models.find((m) => m.id === id); - // Check if any setup is complete const canContinue = selectedModel && setupComplete[selectedModel]; @@ -110,8 +100,16 @@ export function ModelSelectionScreen({ + } > -
+
{/* System Recommendation */} {recommendation && !isLoading && ( @@ -156,9 +154,9 @@ export function ModelSelectionScreen({
- +
-
+

{model.title}

{isRecommended && ( @@ -167,9 +165,7 @@ export function ModelSelectionScreen({ )}
-

- {model.subtitle} -

+

{model.subtitle}

{isComplete && ( @@ -180,7 +176,7 @@ export function ModelSelectionScreen({
{/* Description */} -

+

{model.description}

@@ -192,7 +188,10 @@ export function ModelSelectionScreen({

    {model.pros.map((pro, i) => ( -
  • • {pro}
  • +
  • + + {pro} +
  • ))}
@@ -202,7 +201,10 @@ export function ModelSelectionScreen({

    {model.cons.map((con, i) => ( -
  • • {con}
  • +
  • + + {con} +
  • ))}
@@ -214,22 +216,33 @@ export function ModelSelectionScreen({ })}
- {/* Navigation */} - + {/* Settings Note */} +
+ +

+ You can change your model later in Settings — nothing is permanent. +

+
{/* Setup Modal */} {selectedModel && ( setShowSetupModal(false)} + onClose={(wasCompleted) => { + setShowSetupModal(false); + // Deselect if setup wasn't completed + if (!wasCompleted && !setupComplete[selectedModel]) { + setSelectedModel(null); + } + }} modelType={selectedModel} - onSetupComplete={handleSetupComplete} + onContinue={() => { + handleSetupComplete(); + const followedRecommendation = + recommendation?.suggested === selectedModel; + onNext(selectedModel, followedRecommendation); + }} /> )} diff --git a/apps/desktop/src/renderer/onboarding/components/screens/ModelSetupModal.tsx b/apps/desktop/src/renderer/onboarding/components/screens/ModelSetupModal.tsx index e8dd916..ba93cfd 100644 --- a/apps/desktop/src/renderer/onboarding/components/screens/ModelSetupModal.tsx +++ b/apps/desktop/src/renderer/onboarding/components/screens/ModelSetupModal.tsx @@ -5,6 +5,7 @@ import { DialogHeader, DialogTitle, DialogDescription, + DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; @@ -15,9 +16,9 @@ import { toast } from "sonner"; interface ModelSetupModalProps { isOpen: boolean; - onClose: () => void; + onClose: (wasCompleted?: boolean) => void; modelType: ModelType; - onSetupComplete: () => void; + onContinue: () => void; // Called when setup completes - auto-advances to next step } /** @@ -29,7 +30,7 @@ export function ModelSetupModal({ isOpen, onClose, modelType, - onSetupComplete, + onContinue, }: ModelSetupModalProps) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -41,6 +42,7 @@ export function ModelSetupModal({ } | null>(null); const [modelAlreadyInstalled, setModelAlreadyInstalled] = useState(false); const [installedModelName, setInstalledModelName] = useState(""); + const [downloadComplete, setDownloadComplete] = useState(false); // tRPC mutations and utils const utils = api.useUtils(); @@ -50,8 +52,7 @@ export function ModelSetupModal({ const authStatus = await utils.auth.getAuthStatus.fetch(); if (authStatus.isAuthenticated) { toast.success("Successfully authenticated!"); - onSetupComplete(); - onClose(); + onContinue(); } else { setError("Authentication failed. Please try again."); } @@ -85,8 +86,7 @@ export function ModelSetupModal({ }); if (data.progress.progress === 100) { - onSetupComplete(); - onClose(); + setDownloadComplete(true); } } }, @@ -129,14 +129,9 @@ export function ModelSetupModal({ ); if (whisperModels.length > 0) { - // Model already exists, mark as complete + // Model already exists - user must click Done to confirm setModelAlreadyInstalled(true); setInstalledModelName(whisperModels[0].name || whisperModels[0].id); - onSetupComplete(); - // Don't close immediately to show the success state - setTimeout(() => { - onClose(); - }, 2000); } else if (!isLoading && !downloadProgress) { // No existing model, start download startDownload(); @@ -155,47 +150,21 @@ export function ModelSetupModal({ return ( <> - Sign in to Amical + Sign in required - Sign in with your Amical account to use cloud transcription + Cloud transcription needs authentication to continue. -
- {error && ( -
- - {error} -
- )} - - - -

- Don't have an account?{" "} - -

- -
-

- Your audio is processed in real-time and never stored on our - servers. -

-
-
+ + ); } @@ -205,28 +174,32 @@ export function ModelSetupModal({ <> - {modelAlreadyInstalled + {modelAlreadyInstalled || downloadComplete ? "Local Model Ready" : "Downloading Local Model"} - {modelAlreadyInstalled - ? "Your system already has a Whisper model installed" + {modelAlreadyInstalled || downloadComplete + ? "Ready for private, offline transcription." : "Setting up Whisper Tiny for private, offline transcription"}
- {modelAlreadyInstalled ? ( - // Show success state when model is already installed -
+ {modelAlreadyInstalled || downloadComplete ? ( + // Show success state when model is ready +
-

Model Already Installed

+

+ {modelAlreadyInstalled + ? "Model Already Installed" + : "Download Complete"} +

- Using: {installedModelName} + Using: {installedModelName || "whisper-tiny"}

@@ -265,21 +238,27 @@ export function ModelSetupModal({ )}
)} - - {downloadProgress < 100 && ( - - )} )}
+ + + + + ); }; return ( - !open && onClose()}> + !open && onClose(false)}> {renderContent()} ); diff --git a/apps/desktop/src/renderer/onboarding/components/screens/PermissionsScreen.tsx b/apps/desktop/src/renderer/onboarding/components/screens/PermissionsScreen.tsx index 054a0f5..7bb1000 100644 --- a/apps/desktop/src/renderer/onboarding/components/screens/PermissionsScreen.tsx +++ b/apps/desktop/src/renderer/onboarding/components/screens/PermissionsScreen.tsx @@ -134,6 +134,16 @@ export function PermissionsScreen({ + } >
{/* Status Summary */} @@ -250,7 +260,8 @@ export function PermissionsScreen({

Accessibility Access

- Required for global keyboard shortcuts (macOS only) + Required for pasting transcription and global keyboard + shortcuts (macOS only)

{permissions.accessibility ? ( @@ -291,16 +302,6 @@ export function PermissionsScreen({ )}
- - {/* Navigation */} -
); diff --git a/apps/desktop/src/renderer/onboarding/components/screens/WelcomeScreen.tsx b/apps/desktop/src/renderer/onboarding/components/screens/WelcomeScreen.tsx index a8db378..e54994c 100644 --- a/apps/desktop/src/renderer/onboarding/components/screens/WelcomeScreen.tsx +++ b/apps/desktop/src/renderer/onboarding/components/screens/WelcomeScreen.tsx @@ -4,7 +4,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { OnboardingLayout } from "../shared/OnboardingLayout"; import { NavigationButtons } from "../shared/NavigationButtons"; -import { Mic, FileText, Users } from "lucide-react"; +import { Mic, FileText, Users, Command } from "lucide-react"; import { FeatureInterest } from "../../../../types/onboarding"; import { toast } from "sonner"; @@ -49,6 +49,13 @@ export function WelcomeScreen({ "Record and transcribe meetings and conversations with high accuracy", icon: Users, }, + { + id: FeatureInterest.VoiceCommands, + title: "Voice Commands", + description: + "Control your apps hands-free - act on tasks with natural voice commands", + icon: Command, + }, ]; const handleToggleInterest = (interest: FeatureInterest) => { @@ -56,9 +63,9 @@ export function WelcomeScreen({ if (newInterests.has(interest)) { newInterests.delete(interest); } else { - // Maximum 3 interests - if (newInterests.size >= 3) { - toast.error("You can select up to 3 features"); + // Maximum 4 interests + if (newInterests.size >= 4) { + toast.error("You can select up to 4 features"); return; } newInterests.add(interest); @@ -80,6 +87,25 @@ export function WelcomeScreen({ + + {onSkip && ( +
+ +
+ )} + + } >
{/* Feature Selection Cards */} @@ -121,8 +147,9 @@ export function WelcomeScreen({

{feature.title}

- {feature.id === - FeatureInterest.MeetingTranscriptions && ( + {(feature.id === + FeatureInterest.MeetingTranscriptions || + feature.id === FeatureInterest.VoiceCommands) && (

- Flexible preferences: Don't - worry about getting it perfect – you can change all preferences - anytime in Settings. + Your choices help personalize setup — all features remain available + anytime.

- - {/* Navigation */} - - - {/* Skip Option */} - {onSkip && ( -
- -
- )}
); diff --git a/apps/desktop/src/renderer/onboarding/components/shared/NavigationButtons.tsx b/apps/desktop/src/renderer/onboarding/components/shared/NavigationButtons.tsx index c5e9bbe..0efba3c 100644 --- a/apps/desktop/src/renderer/onboarding/components/shared/NavigationButtons.tsx +++ b/apps/desktop/src/renderer/onboarding/components/shared/NavigationButtons.tsx @@ -37,7 +37,7 @@ export function NavigationButtons({ return (
-
+ {/* Scrollable content area */} +
{/* Header */} {(title || subtitle) && (
{title && ( -

+

+ {titleIcon} {title}

)} @@ -45,6 +51,9 @@ export function OnboardingLayout({ {children}
+ + {/* Footer - pinned to bottom */} + {footer &&
{footer}
}
); } diff --git a/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx b/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx index 74ac426..0308a13 100644 --- a/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx +++ b/apps/desktop/src/renderer/onboarding/components/shared/OnboardingShortcutInput.tsx @@ -1,18 +1,15 @@ -import React, { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; +import { useState, useEffect } from "react"; import { Label } from "@/components/ui/label"; -import { X, Pencil } from "lucide-react"; +import { ShortcutInput } from "@/components/shortcut-input"; import { api } from "@/trpc/react"; -const MODIFIER_KEYS = ["Cmd", "Win", "Ctrl", "Alt", "Shift", "Fn"]; - /** - * Simplified shortcut input component for onboarding - Push to Talk only + * Push to Talk shortcut input for onboarding + * Wraps ShortcutInput with label and handles data fetching/saving */ export function OnboardingShortcutInput() { const [pushToTalkShortcut, setPushToTalkShortcut] = useState(""); const [isRecording, setIsRecording] = useState(false); - const [activeKeys, setActiveKeys] = useState([]); const utils = api.useUtils(); const shortcutsQuery = api.settings.getShortcuts.useQuery(); @@ -21,8 +18,6 @@ export function OnboardingShortcutInput() { utils.settings.getShortcuts.invalidate(); }, }); - const setRecordingStateMutation = - api.settings.setShortcutRecordingState.useMutation(); // Load current shortcut useEffect(() => { @@ -31,47 +26,14 @@ export function OnboardingShortcutInput() { } }, [shortcutsQuery.data]); - const handleStartRecording = () => { - setIsRecording(true); - setActiveKeys([]); - setRecordingStateMutation.mutate(true); + const handleShortcutChange = (shortcut: string) => { + setPushToTalkShortcut(shortcut); + setShortcutMutation.mutate({ + type: "pushToTalk", + shortcut: shortcut, + }); }; - const handleCancelRecording = () => { - setIsRecording(false); - setActiveKeys([]); - setRecordingStateMutation.mutate(false); - }; - - // Subscribe to key events when recording - api.settings.activeKeysUpdates.useSubscription(undefined, { - enabled: isRecording, - onData: (keys: string[]) => { - const previousKeys = activeKeys; - setActiveKeys(keys); - - // When any key is released, validate and save - if (previousKeys.length > 0 && keys.length < previousKeys.length) { - // Check if it has at least one modifier key - const hasModifier = previousKeys.some((key) => - MODIFIER_KEYS.includes(key), - ); - - if (hasModifier) { - const shortcut = previousKeys.join("+"); - setPushToTalkShortcut(shortcut); - setShortcutMutation.mutate({ - type: "pushToTalk", - shortcut: shortcut, - }); - } - - setIsRecording(false); - setRecordingStateMutation.mutate(false); - } - }, - }); - return (
@@ -83,64 +45,12 @@ export function OnboardingShortcutInput() {

- {isRecording ? ( -
- {activeKeys.length > 0 ? ( -
- {activeKeys.map((key, index) => ( - - {key} - - ))} -
- ) : ( - - Press keys... - - )} - -
- ) : ( -
- {pushToTalkShortcut ? ( - <> - - {pushToTalkShortcut} - - - - ) : ( - - )} -
- )} +
); diff --git a/apps/desktop/src/renderer/onboarding/hooks/useOnboardingState.ts b/apps/desktop/src/renderer/onboarding/hooks/useOnboardingState.ts index 3c230c2..38277a2 100644 --- a/apps/desktop/src/renderer/onboarding/hooks/useOnboardingState.ts +++ b/apps/desktop/src/renderer/onboarding/hooks/useOnboardingState.ts @@ -3,10 +3,6 @@ import { api } from "@/trpc/react"; import type { OnboardingState, OnboardingPreferences, - OnboardingScreen, - FeatureInterest, - DiscoverySource, - ModelType, } from "../../../types/onboarding"; import { toast } from "sonner"; @@ -31,8 +27,6 @@ export function useOnboardingState(): UseOnboardingStateReturn { const getStateQuery = api.onboarding.getState.useQuery(); const savePreferencesMutation = api.onboarding.savePreferences.useMutation(); const completeMutation = api.onboarding.complete.useMutation(); - const trackOnboardingCompleted = - api.onboarding.trackOnboardingCompleted.useMutation(); const resetMutation = api.onboarding.reset.useMutation(); // Load initial state @@ -100,33 +94,15 @@ export function useOnboardingState(): UseOnboardingStateReturn { throw new Error("Failed to complete onboarding"); } - // Track completion event - trackOnboardingCompleted.mutate({ - version: finalState.completedVersion, - features_selected: finalState.featureInterests || [], - discovery_source: finalState.discoverySource, - model_type: finalState.selectedModelType, - recommendation_followed: - finalState.modelRecommendation?.followed || false, - skipped_screens: finalState.skippedScreens, - }); - - // Handle relaunch if needed - if (result.shouldRelaunch) { - toast.success("Onboarding complete! Restarting application..."); - // The app will relaunch automatically from the main process - } else { - toast.success("Onboarding complete!"); - // In development, just reload - window.location.reload(); - } + // Main process handles window closing and app relaunch + toast.success("Onboarding complete!"); } catch (err) { console.error("Failed to complete onboarding:", err); toast.error("Failed to complete onboarding. Please try again."); throw err; } }, - [completeMutation, trackOnboardingCompleted], + [completeMutation], ); // Reset onboarding (for testing) diff --git a/apps/desktop/src/renderer/onboarding/hooks/useSystemRecommendation.ts b/apps/desktop/src/renderer/onboarding/hooks/useSystemRecommendation.ts index 794e4b4..dcdbc9a 100644 --- a/apps/desktop/src/renderer/onboarding/hooks/useSystemRecommendation.ts +++ b/apps/desktop/src/renderer/onboarding/hooks/useSystemRecommendation.ts @@ -1,4 +1,3 @@ -import { useEffect, useState } from "react"; import { api } from "@/trpc/react"; import type { ModelRecommendation } from "../../../types/onboarding"; @@ -13,19 +12,10 @@ interface UseSystemRecommendationReturn { * Analyzes system specs and provides intelligent recommendations */ export function useSystemRecommendation(): UseSystemRecommendationReturn { - const [recommendation, setRecommendation] = - useState(null); - const query = api.onboarding.getSystemRecommendation.useQuery(); - useEffect(() => { - if (query.data) { - setRecommendation(query.data); - } - }, [query.data]); - return { - recommendation, + recommendation: query.data ?? null, isLoading: query.isLoading, error: query.error as Error | null, }; diff --git a/apps/desktop/src/services/data/settings-repository.ts b/apps/desktop/src/services/data/settings-repository.ts deleted file mode 100644 index 41f1b49..0000000 --- a/apps/desktop/src/services/data/settings-repository.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { FormatterConfig } from "../../types/formatter"; -import { - getSettingsSection, - updateSettingsSection, - getAppSettings, - updateAppSettings, -} from "../../db/app-settings"; -import type { AppSettingsData } from "../../db/schema"; - -/** - * Database-backed settings service with typed configuration - */ -export class SettingsService { - private static instance: SettingsService; - - private constructor() {} - - static getInstance(): SettingsService { - if (!SettingsService.instance) { - SettingsService.instance = new SettingsService(); - } - return SettingsService.instance; - } - - /** - * Get formatter configuration - */ - async getFormatterConfig(): Promise { - const formatterConfig = await getSettingsSection("formatterConfig"); - return formatterConfig || null; - } - - /** - * Set formatter configuration - */ - async setFormatterConfig(config: FormatterConfig): Promise { - await updateSettingsSection("formatterConfig", config); - } - - /** - * Get all app settings - */ - async getAllSettings(): Promise { - return await getAppSettings(); - } - - /** - * Update multiple settings at once - */ - async updateSettings( - settings: Partial, - ): Promise { - return await updateAppSettings(settings); - } - - /** - * Get UI settings - */ - async getUISettings(): Promise { - return await getSettingsSection("ui"); - } - - /** - * Update UI settings - */ - async setUISettings(uiSettings: AppSettingsData["ui"]): Promise { - await updateSettingsSection("ui", uiSettings); - } - - /** - * Get transcription settings - */ - async getTranscriptionSettings(): Promise { - return await getSettingsSection("transcription"); - } - - /** - * Update transcription settings - */ - async setTranscriptionSettings( - transcriptionSettings: AppSettingsData["transcription"], - ): Promise { - await updateSettingsSection("transcription", transcriptionSettings); - } - - /** - * Get recording settings - */ - async getRecordingSettings(): Promise { - return await getSettingsSection("recording"); - } - - /** - * Update recording settings - */ - async setRecordingSettings( - recordingSettings: AppSettingsData["recording"], - ): Promise { - await updateSettingsSection("recording", recordingSettings); - } -} diff --git a/apps/desktop/src/services/model-manager.ts b/apps/desktop/src/services/model-service.ts similarity index 99% rename from apps/desktop/src/services/model-manager.ts rename to apps/desktop/src/services/model-service.ts index 6449aac..537ea07 100644 --- a/apps/desktop/src/services/model-manager.ts +++ b/apps/desktop/src/services/model-service.ts @@ -56,7 +56,7 @@ interface ModelManagerEvents { ) => void; } -class ModelManagerService extends EventEmitter { +class ModelService extends EventEmitter { private state: ModelManagerState; private modelsDirectory: string; private settingsService: SettingsService; @@ -1149,4 +1149,4 @@ class ModelManagerService extends EventEmitter { } } -export { ModelManagerService }; +export { ModelService }; diff --git a/apps/desktop/src/services/notes-service.ts b/apps/desktop/src/services/notes-service.ts index 26a2edf..088bb63 100644 --- a/apps/desktop/src/services/notes-service.ts +++ b/apps/desktop/src/services/notes-service.ts @@ -12,6 +12,7 @@ import { replaceYjsUpdates, } from "../db/notes"; import * as Y from "yjs"; +import { ipcMain } from "electron"; import { logger } from "../main/logger"; export interface NoteCreateOptions { @@ -31,10 +32,43 @@ class NotesService { private compactionTask: cron.ScheduledTask | null = null; private constructor() { - // Set up cron job for daily compaction + this.setupIPCHandlers(); this.setupCompactionCron(); } + private setupIPCHandlers(): void { + ipcMain.handle( + "notes:saveYjsUpdate", + async (_event, noteId: number, update: ArrayBuffer) => { + try { + const updateArray = new Uint8Array(update); + await this.saveYjsUpdate(noteId, updateArray); + logger.main.debug("Saved yjs update", { + noteId, + updateSize: updateArray.length, + }); + } catch (error) { + logger.main.error("Failed to save yjs update", error); + throw error; + } + }, + ); + + ipcMain.handle("notes:loadYjsUpdates", async (_event, noteId: number) => { + try { + const updates = await this.loadYjsUpdates(noteId); + logger.main.debug("Loaded yjs updates", { + noteId, + count: updates.length, + }); + return updates.map((u) => u.buffer); + } catch (error) { + logger.main.error("Failed to load yjs updates", error); + throw error; + } + }); + } + public static getInstance(): NotesService { if (!NotesService.instance) { NotesService.instance = new NotesService(); diff --git a/apps/desktop/src/services/onboarding-service.ts b/apps/desktop/src/services/onboarding-service.ts index a5abc45..0ac6fc3 100644 --- a/apps/desktop/src/services/onboarding-service.ts +++ b/apps/desktop/src/services/onboarding-service.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "events"; import { systemPreferences } from "electron"; import { logger } from "../main/logger"; import type { SettingsService } from "./settings-service"; @@ -5,13 +6,13 @@ import type { TelemetryService } from "./telemetry-service"; import type { AppSettingsData } from "../db/schema"; import { OnboardingScreen, + FeatureInterest, type OnboardingState, type OnboardingPreferences, type ModelRecommendation, type ModelType, type OnboardingFeatureFlags, type SystemSpecs, - type FeatureInterest, type DiscoverySource, } from "../types/onboarding"; @@ -35,16 +36,18 @@ type OnboardingStateDb = { }; }; -export class OnboardingService { +export class OnboardingService extends EventEmitter { private static instance: OnboardingService | null = null; private settingsService: SettingsService; private telemetryService: TelemetryService; private currentState: Partial = {}; + private isOnboardingInProgress = false; constructor( settingsService: SettingsService, telemetryService: TelemetryService, ) { + super(); this.settingsService = settingsService; this.telemetryService = telemetryService; } @@ -175,27 +178,56 @@ export class OnboardingService { /** * Save user preferences during onboarding * T030, T031 - Implements savePreferences with partial progress saving + * Also tracks telemetry for each preference type */ async savePreferences(preferences: OnboardingPreferences): Promise { try { const updates: Partial = {}; + // Track screen view when lastVisitedScreen changes + if (preferences.lastVisitedScreen !== undefined) { + updates.lastVisitedScreen = preferences.lastVisitedScreen; + this.telemetryService.trackOnboardingScreenViewed({ + screen: preferences.lastVisitedScreen, + index: 0, // Index not available here, but screen name is sufficient + total: 5, + }); + } + + // Track feature interests selection if (preferences.featureInterests !== undefined) { updates.featureInterests = preferences.featureInterests; + this.telemetryService.trackOnboardingFeaturesSelected({ + features: preferences.featureInterests, + count: preferences.featureInterests.length, + }); } + + // Track discovery source selection if (preferences.discoverySource !== undefined) { updates.discoverySource = preferences.discoverySource; + this.telemetryService.trackOnboardingDiscoverySelected({ + source: preferences.discoverySource, + }); } + + // Track model selection if (preferences.selectedModelType !== undefined) { updates.selectedModelType = preferences.selectedModelType; + this.telemetryService.trackOnboardingModelSelected({ + model_type: preferences.selectedModelType, + recommendation_followed: + preferences.modelRecommendation?.followed ?? false, + }); } + if (preferences.modelRecommendation !== undefined) { updates.modelRecommendation = preferences.modelRecommendation; } // T032 - Save partial progress after each screen await this.savePartialProgress(updates); - logger.main.info("Saved onboarding preferences:", preferences); + logger.main.debug("Saved onboarding preferences:", preferences); } catch (error) { logger.main.error("Failed to save preferences:", error); throw error; @@ -229,32 +261,6 @@ export class OnboardingService { } } - /** - * Read onboarding progress from database - * T033 - Database read method for onboarding state - */ - async readOnboardingProgress(): Promise { - try { - return await this.getOnboardingState(); - } catch (error) { - logger.main.error("Failed to read onboarding progress:", error); - return null; - } - } - - /** - * Write onboarding progress to database - * T033 - Database write method for onboarding state - */ - async writeOnboardingProgress(state: OnboardingState): Promise { - try { - await this.saveOnboardingState(state); - } catch (error) { - logger.main.error("Failed to write onboarding progress:", error); - throw error; - } - } - /** * Complete the onboarding process */ @@ -439,14 +445,6 @@ export class OnboardingService { */ async getSystemRecommendation(): Promise { try { - // Check for mock system specs (for testing) - if (process.env.MOCK_SYSTEM_SPECS) { - const mockSpecs = JSON.parse( - process.env.MOCK_SYSTEM_SPECS, - ) as SystemSpecs; - return this.calculateModelRecommendation(mockSpecs); - } - // Get real system info from telemetry service const systemInfo = this.telemetryService.getSystemInfo(); if (!systemInfo) { @@ -543,4 +541,78 @@ export class OnboardingService { throw error; } } + + // ============================================ + // Flow methods (event-driven architecture) + // ============================================ + + /** + * Check if onboarding is currently in progress + */ + isInProgress(): boolean { + return this.isOnboardingInProgress; + } + + /** + * Start the onboarding flow + * Note: Window creation is handled by AppManager + */ + async startOnboardingFlow(): Promise { + if (this.isOnboardingInProgress) { + logger.main.warn("Onboarding already in progress"); + return; + } + + this.isOnboardingInProgress = true; + logger.main.info("Starting onboarding flow"); + + // Track onboarding started event + this.trackOnboardingStarted(process.platform); + } + + /** + * Complete the onboarding flow + * Emits "completed" event - AppManager handles window transitions + */ + async completeOnboardingFlow(finalState: OnboardingState): Promise { + try { + logger.main.info("Completing onboarding flow"); + + // Save the final state + await this.completeOnboarding(finalState); + + this.isOnboardingInProgress = false; + + // Emit event - AppManager listens and handles window transitions + this.emit("completed"); + + logger.main.info("Onboarding completed, emitted event"); + } catch (error) { + logger.main.error("Error completing onboarding flow:", error); + throw error; + } + } + + /** + * Cancel the onboarding flow + * Emits "cancelled" event - AppManager handles window close and app quit + */ + async cancelOnboardingFlow(): Promise { + logger.main.info("Onboarding cancelled"); + + this.isOnboardingInProgress = false; + + // Track abandonment event + const currentState = await this.getOnboardingState(); + const lastScreen = + currentState?.lastVisitedScreen || + currentState?.skippedScreens?.[currentState.skippedScreens.length - 1] || + "unknown"; + this.trackOnboardingAbandoned(lastScreen); + + // Emit event - AppManager listens and handles window close + app quit + this.emit("cancelled"); + + logger.main.info("Onboarding cancelled, emitted event"); + } } diff --git a/apps/desktop/src/services/platform/native-bridge-service.ts b/apps/desktop/src/services/platform/native-bridge-service.ts index 7961728..0bf26db 100644 --- a/apps/desktop/src/services/platform/native-bridge-service.ts +++ b/apps/desktop/src/services/platform/native-bridge-service.ts @@ -69,6 +69,7 @@ export class NativeBridge extends EventEmitter { >(); private helperPath: string; private logger = createScopedLogger("native-bridge"); + private accessibilityContext: GetAccessibilityContextResult | null = null; constructor() { super(); @@ -350,6 +351,36 @@ export class NativeBridge extends EventEmitter { } } + /** + * Refresh the cached accessibility context from the native helper. + * This is called asynchronously when recording starts. + */ + async refreshAccessibilityContext(): Promise { + try { + const context = await this.call("getAccessibilityContext", { + editableOnly: false, + }); + this.accessibilityContext = context; + this.logger.debug("Accessibility context refreshed", { + hasApplication: !!context.context?.application?.name, + hasFocusedElement: !!context.context?.focusedElement?.role, + hasTextSelection: !!context.context?.textSelection?.selectedText, + hasWindow: !!context.context?.windowInfo?.title, + }); + } catch (error) { + this.logger.error("Failed to refresh accessibility context", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Get the cached accessibility context. + */ + getAccessibilityContext(): GetAccessibilityContextResult | null { + return this.accessibilityContext; + } + // Typed event emitter methods on( event: E, diff --git a/apps/desktop/src/services/settings-service.ts b/apps/desktop/src/services/settings-service.ts index 29521de..4368eb2 100644 --- a/apps/desktop/src/services/settings-service.ts +++ b/apps/desktop/src/services/settings-service.ts @@ -1,4 +1,5 @@ import { app } from "electron"; +import { EventEmitter } from "events"; import { FormatterConfig } from "../types/formatter"; import { getSettingsSection, @@ -23,8 +24,10 @@ export interface AppPreferences { showWidgetWhileInactive: boolean; } -export class SettingsService { - constructor() {} +export class SettingsService extends EventEmitter { + constructor() { + super(); + } /** * Get formatter configuration @@ -73,6 +76,11 @@ export class SettingsService { */ async setUISettings(uiSettings: AppSettingsData["ui"]): Promise { await updateSettingsSection("ui", uiSettings); + + // Emit event if theme changed (AppManager will handle window updates) + if (uiSettings?.theme !== undefined) { + this.emit("theme-changed", { theme: uiSettings.theme }); + } } /** @@ -301,6 +309,13 @@ export class SettingsService { ) { this.syncAutoLaunch(); } + + // Emit event for listeners (AppManager will handle window updates) + this.emit("preferences-changed", { + changes: preferences, + showWidgetWhileInactiveChanged: + preferences.showWidgetWhileInactive !== undefined, + }); } /** diff --git a/apps/desktop/src/services/transcription-service.ts b/apps/desktop/src/services/transcription-service.ts index 8369e64..8a14890 100644 --- a/apps/desktop/src/services/transcription-service.ts +++ b/apps/desktop/src/services/transcription-service.ts @@ -8,10 +8,10 @@ import { createDefaultContext } from "../pipeline/core/context"; import { WhisperProvider } from "../pipeline/providers/transcription/whisper-provider"; import { AmicalCloudProvider } from "../pipeline/providers/transcription/amical-cloud-provider"; import { OpenRouterProvider } from "../pipeline/providers/formatting/openrouter-formatter"; -import { ModelManagerService } from "../services/model-manager"; +import { ModelService } from "../services/model-service"; import { SettingsService } from "../services/settings-service"; -import { appContextStore } from "../stores/app-context"; import { TelemetryService } from "../services/telemetry-service"; +import type { NativeBridge } from "./platform/native-bridge-service"; import { createTranscription } from "../db/transcriptions"; import { logger } from "../main/logger"; import { v4 as uuid } from "uuid"; @@ -35,30 +35,31 @@ export class TranscriptionService { private vadMutex: Mutex; private transcriptionMutex: Mutex; private telemetryService: TelemetryService; - private modelManagerService: ModelManagerService; + private modelService: ModelService; private modelWasPreloaded: boolean = false; constructor( - modelManagerService: ModelManagerService, + modelService: ModelService, vadService: VADService, settingsService: SettingsService, telemetryService: TelemetryService, + private nativeBridge: NativeBridge | null, ) { - this.whisperProvider = new WhisperProvider(modelManagerService); + this.whisperProvider = new WhisperProvider(modelService); this.cloudProvider = new AmicalCloudProvider(); this.vadService = vadService; this.settingsService = settingsService; this.vadMutex = new Mutex(); this.transcriptionMutex = new Mutex(); this.telemetryService = telemetryService; - this.modelManagerService = modelManagerService; + this.modelService = modelService; } /** * Select the appropriate transcription provider based on the selected model */ private async selectProvider(): Promise { - const selectedModelId = await this.modelManagerService.getSelectedModel(); + const selectedModelId = await this.modelService.getSelectedModel(); if (!selectedModelId) { // Default to whisper if no model selected @@ -81,14 +82,8 @@ export class TranscriptionService { } async initialize(): Promise { - if (this.vadService) { - logger.transcription.info("Using VAD service"); - } else { - logger.transcription.warn("VAD service not available"); - } - // Check if the selected model is a cloud model - const selectedModelId = await this.modelManagerService.getSelectedModel(); + const selectedModelId = await this.modelService.getSelectedModel(); const model = selectedModelId ? AVAILABLE_MODELS.find((m) => m.id === selectedModelId) : null; @@ -158,8 +153,8 @@ export class TranscriptionService { */ public async isModelAvailable(): Promise { try { - const modelManager = this.whisperProvider["modelManager"]; - const availableModels = await modelManager.getValidDownloadedModels(); + const modelService = this.whisperProvider["modelService"]; + const availableModels = await modelService.getValidDownloadedModels(); return Object.keys(availableModels).length > 0; } catch (error) { logger.transcription.error("Failed to check model availability:", error); @@ -288,9 +283,9 @@ export class TranscriptionService { accumulatedTranscription: [], }; - // Get accessibility context from global store + // Get accessibility context from NativeBridge streamingContext.sharedData.accessibilityContext = - appContextStore.getAccessibilityContext(); + this.nativeBridge?.getAccessibilityContext() ?? null; session = { context: streamingContext, @@ -464,7 +459,7 @@ export class TranscriptionService { ? completionTime - session.recordingStartedAt : undefined; - const selectedModel = await this.modelManagerService.getSelectedModel(); + const selectedModel = await this.modelService.getSelectedModel(); const audioDurationSeconds = session.context.sharedData.audioMetadata?.duration; diff --git a/apps/desktop/src/stores/app-context.ts b/apps/desktop/src/stores/app-context.ts deleted file mode 100644 index 8399615..0000000 --- a/apps/desktop/src/stores/app-context.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { GetAccessibilityContextResult } from "@amical/types"; -import { ServiceManager } from "../main/managers/service-manager"; -import { logger } from "../main/logger"; - -class AppContextStore { - private accessibilityContext: GetAccessibilityContextResult | null = null; - - async refreshAccessibilityData(): Promise { - try { - const serviceManager = ServiceManager.getInstance(); - if (!serviceManager) return; // Silent fail - - const nativeBridge = serviceManager.getService("nativeBridge"); - if (!nativeBridge) { - logger.main.warn("Native bridge not available"); - return; - } - const context = await nativeBridge.call("getAccessibilityContext", { - editableOnly: false, - }); - this.accessibilityContext = context; - - logger.main.debug("Accessibility context refreshed", { - hasApplication: !!context.context?.application?.name, - hasFocusedElement: !!context.context?.focusedElement?.role, - hasTextSelection: !!context.context?.textSelection?.selectedText, - hasWindow: !!context.context?.windowInfo?.title, - }); - } catch (error) { - logger.main.error("Failed to refresh accessibility context", { - error: error instanceof Error ? error.message : String(error), - }); - } - } - - getAccessibilityContext(): GetAccessibilityContextResult | null { - return this.accessibilityContext; - } -} - -export const appContextStore = new AppContextStore(); diff --git a/apps/desktop/src/trpc/routers/auth.ts b/apps/desktop/src/trpc/routers/auth.ts index bbf12c1..dd1b34f 100644 --- a/apps/desktop/src/trpc/routers/auth.ts +++ b/apps/desktop/src/trpc/routers/auth.ts @@ -6,7 +6,7 @@ import { AuthState } from "../../services/auth-service"; export const authRouter = createRouter({ // Get current auth status getAuthStatus: procedure.query(async ({ ctx }) => { - const authService = ctx.serviceManager.getService("authService")!; + const authService = ctx.serviceManager.getService("authService"); const authState = await authService.getAuthState(); const isAuthenticated = await authService.isAuthenticated(); @@ -20,7 +20,7 @@ export const authRouter = createRouter({ // Initiate login flow login: procedure.mutation(async ({ ctx }) => { - const authService = ctx.serviceManager.getService("authService")!; + const authService = ctx.serviceManager.getService("authService"); await authService.login(); @@ -33,7 +33,7 @@ export const authRouter = createRouter({ // Logout logout: procedure.mutation(async ({ ctx }) => { - const authService = ctx.serviceManager.getService("authService")!; + const authService = ctx.serviceManager.getService("authService"); await authService.logout(); @@ -45,7 +45,7 @@ export const authRouter = createRouter({ // Check if authenticated (for UI updates) isAuthenticated: procedure.query(async ({ ctx }) => { - const authService = ctx.serviceManager.getService("authService")!; + const authService = ctx.serviceManager.getService("authService"); return await authService.isAuthenticated(); }), @@ -61,7 +61,7 @@ export const authRouter = createRouter({ userName: string | null; error?: string; }>((emit) => { - const authService = ctx.serviceManager.getService("authService")!; + const authService = ctx.serviceManager.getService("authService"); // Define handlers once (not in a loop) const handleAuthenticated = async (authState: AuthState) => { @@ -121,14 +121,12 @@ export const authRouter = createRouter({ // Check if cloud model requires auth isCloudModelSelected: procedure.query(async ({ ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - )!; - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { return false; } - const selectedModelId = await modelManagerService.getSelectedModel(); + const selectedModelId = await modelService.getSelectedModel(); if (!selectedModelId) { return false; } diff --git a/apps/desktop/src/trpc/routers/models.ts b/apps/desktop/src/trpc/routers/models.ts index 07bb54c..123624b 100644 --- a/apps/desktop/src/trpc/routers/models.ts +++ b/apps/desktop/src/trpc/routers/models.ts @@ -20,10 +20,8 @@ export const modelsRouter = createRouter({ }), ) .query(async ({ input, ctx }): Promise => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not available"); } @@ -31,12 +29,11 @@ export const modelsRouter = createRouter({ if (input.type === "speech") { // Return all available whisper models as Model type // We need to convert from AvailableWhisperModel to Model format - const availableModels = modelManagerService.getAvailableModels(); - const downloadedModels = - await modelManagerService.getDownloadedModels(); + const availableModels = modelService.getAvailableModels(); + const downloadedModels = await modelService.getDownloadedModels(); // Check authentication status for cloud model filtering - const authService = ctx.serviceManager.getService("authService")!; + const authService = ctx.serviceManager.getService("authService"); const isAuthenticated = await authService.isAuthenticated(); // Map available models to Model format using downloaded data if available @@ -88,7 +85,7 @@ export const modelsRouter = createRouter({ } // For language/embedding models (provider models) - let models = await modelManagerService.getSyncedProviderModels(); + let models = await modelService.getSyncedProviderModels(); // Filter by provider if specified if (input.provider) { @@ -116,22 +113,18 @@ export const modelsRouter = createRouter({ // Legacy endpoints (kept for backward compatibility) getAvailableModels: procedure.query( async ({ ctx }): Promise => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - return modelManagerService?.getAvailableModels() || []; + const modelService = ctx.serviceManager.getService("modelService"); + return modelService?.getAvailableModels() || []; }, ), getDownloadedModels: procedure.query( async ({ ctx }): Promise> => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not available"); } - return await modelManagerService.getDownloadedModels(); + return await modelService.getDownloadedModels(); }, ), @@ -139,11 +132,9 @@ export const modelsRouter = createRouter({ isModelDownloaded: procedure .input(z.object({ modelId: z.string() })) .query(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - return modelManagerService - ? await modelManagerService.isModelDownloaded(input.modelId) + const modelService = ctx.serviceManager.getService("modelService"); + return modelService + ? await modelService.isModelDownloaded(input.modelId) : false; }), @@ -151,105 +142,81 @@ export const modelsRouter = createRouter({ getDownloadProgress: procedure .input(z.object({ modelId: z.string() })) .query(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - return modelManagerService?.getDownloadProgress(input.modelId) || null; + const modelService = ctx.serviceManager.getService("modelService"); + return modelService?.getDownloadProgress(input.modelId) || null; }), // Get active downloads getActiveDownloads: procedure.query( async ({ ctx }): Promise => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - return modelManagerService?.getActiveDownloads() || []; + const modelService = ctx.serviceManager.getService("modelService"); + return modelService?.getActiveDownloads() || []; }, ), // Get models directory getModelsDirectory: procedure.query(async ({ ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - return modelManagerService?.getModelsDirectory() || ""; + const modelService = ctx.serviceManager.getService("modelService"); + return modelService?.getModelsDirectory() || ""; }), // Transcription model selection methods isTranscriptionAvailable: procedure.query(async ({ ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - return modelManagerService - ? await modelManagerService.isAvailable() - : false; + const modelService = ctx.serviceManager.getService("modelService"); + return modelService ? await modelService.isAvailable() : false; }), getTranscriptionModels: procedure.query(async ({ ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - return modelManagerService - ? await modelManagerService.getAvailableModelsForTranscription() + const modelService = ctx.serviceManager.getService("modelService"); + return modelService + ? await modelService.getAvailableModelsForTranscription() : []; }), getSelectedModel: procedure.query(async ({ ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - return modelManagerService - ? await modelManagerService.getSelectedModel() - : null; + const modelService = ctx.serviceManager.getService("modelService"); + return modelService ? await modelService.getSelectedModel() : null; }), // Mutations downloadModel: procedure .input(z.object({ modelId: z.string() })) .mutation(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - return await modelManagerService.downloadModel(input.modelId); + return await modelService.downloadModel(input.modelId); }), cancelDownload: procedure .input(z.object({ modelId: z.string() })) .mutation(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - return modelManagerService.cancelDownload(input.modelId); + return modelService.cancelDownload(input.modelId); }), deleteModel: procedure .input(z.object({ modelId: z.string() })) .mutation(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - return modelManagerService.deleteModel(input.modelId); + return modelService.deleteModel(input.modelId); }), setSelectedModel: procedure .input(z.object({ modelId: z.string().nullable() })) .mutation(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - await modelManagerService.setSelectedModel(input.modelId); + await modelService.setSelectedModel(input.modelId); // Notify transcription service about model change const transcriptionService = ctx.serviceManager.getService( @@ -266,64 +233,52 @@ export const modelsRouter = createRouter({ validateOpenRouterConnection: procedure .input(z.object({ apiKey: z.string() })) .mutation(async ({ input, ctx }): Promise => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - return await modelManagerService.validateOpenRouterConnection( - input.apiKey, - ); + return await modelService.validateOpenRouterConnection(input.apiKey); }), validateOllamaConnection: procedure .input(z.object({ url: z.string() })) .mutation(async ({ input, ctx }): Promise => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - return await modelManagerService.validateOllamaConnection(input.url); + return await modelService.validateOllamaConnection(input.url); }), // Provider model fetching fetchOpenRouterModels: procedure .input(z.object({ apiKey: z.string() })) .query(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - return await modelManagerService.fetchOpenRouterModels(input.apiKey); + return await modelService.fetchOpenRouterModels(input.apiKey); }), fetchOllamaModels: procedure .input(z.object({ url: z.string() })) .query(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - return await modelManagerService.fetchOllamaModels(input.url); + return await modelService.fetchOllamaModels(input.url); }), // Provider model database sync getSyncedProviderModels: procedure.query( async ({ ctx }): Promise => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - return await modelManagerService.getSyncedProviderModels(); + return await modelService.getSyncedProviderModels(); }, ), @@ -335,13 +290,11 @@ export const modelsRouter = createRouter({ }), ) .mutation(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - await modelManagerService.syncProviderModelsToDatabase( + await modelService.syncProviderModelsToDatabase( input.provider, input.models, ); @@ -356,20 +309,18 @@ export const modelsRouter = createRouter({ }), ) .query(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } switch (input.type) { case "speech": - return await modelManagerService.getSelectedModel(); + return await modelService.getSelectedModel(); case "language": - return await modelManagerService.getDefaultLanguageModel(); + return await modelService.getDefaultLanguageModel(); case "embedding": - return await modelManagerService.getDefaultEmbeddingModel(); + return await modelService.getDefaultEmbeddingModel(); } }), @@ -381,16 +332,14 @@ export const modelsRouter = createRouter({ }), ) .mutation(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } switch (input.type) { case "speech": - await modelManagerService.setSelectedModel(input.modelId); + await modelService.setSelectedModel(input.modelId); // Notify transcription service about model change const transcriptionService = ctx.serviceManager.getService( "transcriptionService", @@ -400,10 +349,10 @@ export const modelsRouter = createRouter({ } break; case "language": - await modelManagerService.setDefaultLanguageModel(input.modelId); + await modelService.setDefaultLanguageModel(input.modelId); break; case "embedding": - await modelManagerService.setDefaultEmbeddingModel(input.modelId); + await modelService.setDefaultEmbeddingModel(input.modelId); break; } return true; @@ -411,48 +360,40 @@ export const modelsRouter = createRouter({ // Legacy endpoints (kept for backward compatibility, can be removed later) getDefaultLanguageModel: procedure.query(async ({ ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - return await modelManagerService.getDefaultLanguageModel(); + return await modelService.getDefaultLanguageModel(); }), setDefaultLanguageModel: procedure .input(z.object({ modelId: z.string().nullable() })) .mutation(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - await modelManagerService.setDefaultLanguageModel(input.modelId); + await modelService.setDefaultLanguageModel(input.modelId); return true; }), getDefaultEmbeddingModel: procedure.query(async ({ ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - return await modelManagerService.getDefaultEmbeddingModel(); + return await modelService.getDefaultEmbeddingModel(); }), setDefaultEmbeddingModel: procedure .input(z.object({ modelId: z.string().nullable() })) .mutation(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } - await modelManagerService.setDefaultEmbeddingModel(input.modelId); + await modelService.setDefaultEmbeddingModel(input.modelId); return true; }), @@ -460,15 +401,13 @@ export const modelsRouter = createRouter({ removeProviderModel: procedure .input(z.object({ modelId: z.string() })) .mutation(async ({ input, ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } // Find the model to get its provider - const allModels = await modelManagerService.getSyncedProviderModels(); + const allModels = await modelService.getSyncedProviderModels(); const model = allModels.find((m) => m.id === input.modelId); if (!model) { @@ -481,15 +420,13 @@ export const modelsRouter = createRouter({ // Remove provider endpoints removeOpenRouterProvider: procedure.mutation(async ({ ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } // Remove all OpenRouter models from database - await modelManagerService.removeProviderModels("OpenRouter"); + await modelService.removeProviderModels("OpenRouter"); // Clear OpenRouter config from settings const settingsService = ctx.serviceManager.getService("settingsService"); @@ -499,7 +436,7 @@ export const modelsRouter = createRouter({ delete updatedConfig.openRouter; // Clear default if it's an OpenRouter model - const allModels = await modelManagerService.getSyncedProviderModels(); + const allModels = await modelService.getSyncedProviderModels(); const openRouterModels = allModels.filter( (m) => m.provider === "OpenRouter", ); @@ -519,15 +456,13 @@ export const modelsRouter = createRouter({ }), removeOllamaProvider: procedure.mutation(async ({ ctx }) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } // Remove all Ollama models from database - await modelManagerService.removeProviderModels("Ollama"); + await modelService.removeProviderModels("Ollama"); // Clear Ollama config from settings const settingsService = ctx.serviceManager.getService("settingsService"); @@ -537,7 +472,7 @@ export const modelsRouter = createRouter({ delete updatedConfig.ollama; // Clear defaults if they're Ollama models - const allModels = await modelManagerService.getSyncedProviderModels(); + const allModels = await modelService.getSyncedProviderModels(); const ollamaModels = allModels.filter((m) => m.provider === "Ollama"); if ( @@ -570,10 +505,8 @@ export const modelsRouter = createRouter({ onDownloadProgress: procedure.subscription(({ ctx }) => { return observable<{ modelId: string; progress: DownloadProgress }>( (emit) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } @@ -584,11 +517,11 @@ export const modelsRouter = createRouter({ emit.next({ modelId, progress }); }; - modelManagerService.on("download-progress", handleDownloadProgress); + modelService.on("download-progress", handleDownloadProgress); // Cleanup function return () => { - modelManagerService?.off("download-progress", handleDownloadProgress); + modelService?.off("download-progress", handleDownloadProgress); }; }, ); @@ -601,10 +534,8 @@ export const modelsRouter = createRouter({ modelId: string; downloadedModel: Model; }>((emit) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } @@ -615,11 +546,11 @@ export const modelsRouter = createRouter({ emit.next({ modelId, downloadedModel }); }; - modelManagerService.on("download-complete", handleDownloadComplete); + modelService.on("download-complete", handleDownloadComplete); // Cleanup function return () => { - modelManagerService?.off("download-complete", handleDownloadComplete); + modelService?.off("download-complete", handleDownloadComplete); }; }); }), @@ -628,10 +559,8 @@ export const modelsRouter = createRouter({ // eslint-disable-next-line deprecation/deprecation onDownloadError: procedure.subscription(({ ctx }) => { return observable<{ modelId: string; error: string }>((emit) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } @@ -639,11 +568,11 @@ export const modelsRouter = createRouter({ emit.next({ modelId, error: error.message }); }; - modelManagerService.on("download-error", handleDownloadError); + modelService.on("download-error", handleDownloadError); // Cleanup function return () => { - modelManagerService?.off("download-error", handleDownloadError); + modelService?.off("download-error", handleDownloadError); }; }); }), @@ -652,10 +581,8 @@ export const modelsRouter = createRouter({ // eslint-disable-next-line deprecation/deprecation onDownloadCancelled: procedure.subscription(({ ctx }) => { return observable<{ modelId: string }>((emit) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } @@ -663,11 +590,11 @@ export const modelsRouter = createRouter({ emit.next({ modelId }); }; - modelManagerService.on("download-cancelled", handleDownloadCancelled); + modelService.on("download-cancelled", handleDownloadCancelled); // Cleanup function return () => { - modelManagerService?.off("download-cancelled", handleDownloadCancelled); + modelService?.off("download-cancelled", handleDownloadCancelled); }; }); }), @@ -676,10 +603,8 @@ export const modelsRouter = createRouter({ // eslint-disable-next-line deprecation/deprecation onModelDeleted: procedure.subscription(({ ctx }) => { return observable<{ modelId: string }>((emit) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } @@ -687,11 +612,11 @@ export const modelsRouter = createRouter({ emit.next({ modelId }); }; - modelManagerService.on("model-deleted", handleModelDeleted); + modelService.on("model-deleted", handleModelDeleted); // Cleanup function return () => { - modelManagerService?.off("model-deleted", handleModelDeleted); + modelService?.off("model-deleted", handleModelDeleted); }; }); }), @@ -709,10 +634,8 @@ export const modelsRouter = createRouter({ | "cleared"; modelType: "speech" | "language" | "embedding"; }>((emit) => { - const modelManagerService = ctx.serviceManager.getService( - "modelManagerService", - ); - if (!modelManagerService) { + const modelService = ctx.serviceManager.getService("modelService"); + if (!modelService) { throw new Error("Model manager service not initialized"); } @@ -729,11 +652,11 @@ export const modelsRouter = createRouter({ emit.next({ oldModelId, newModelId, reason, modelType }); }; - modelManagerService.on("selection-changed", handleSelectionChanged); + modelService.on("selection-changed", handleSelectionChanged); // Cleanup function return () => { - modelManagerService?.off("selection-changed", handleSelectionChanged); + modelService?.off("selection-changed", handleSelectionChanged); }; }); }), diff --git a/apps/desktop/src/trpc/routers/onboarding.ts b/apps/desktop/src/trpc/routers/onboarding.ts index 7461fed..0aa39ba 100644 --- a/apps/desktop/src/trpc/routers/onboarding.ts +++ b/apps/desktop/src/trpc/routers/onboarding.ts @@ -1,18 +1,11 @@ import { z } from "zod"; import { systemPreferences, shell, app } from "electron"; import { createRouter, procedure } from "../trpc"; -import { ServiceManager } from "../../main/managers/service-manager"; import { OnboardingPreferencesSchema, OnboardingStateSchema, - ModelTypeSchema, - FeatureInterestSchema, - DiscoverySourceSchema, - OnboardingScreenSchema, - type OnboardingState, type ModelRecommendation, type OnboardingFeatureFlags, - type OnboardingPreferences, } from "../../types/onboarding"; import { logger } from "../../main/logger"; @@ -24,9 +17,9 @@ export const onboardingRouter = createRouter({ /** * Get current onboarding state from database */ - getState: procedure.query(async () => { + getState: procedure.query(async ({ ctx }) => { try { - const serviceManager = ServiceManager.getInstance(); + const { serviceManager } = ctx; if (!serviceManager) { logger.main.warn("ServiceManager not available"); return null; @@ -50,9 +43,9 @@ export const onboardingRouter = createRouter({ * Get system recommendation for model selection */ getSystemRecommendation: procedure.query( - async (): Promise => { + async ({ ctx }): Promise => { try { - const serviceManager = ServiceManager.getInstance(); + const { serviceManager } = ctx; if (!serviceManager) { throw new Error("ServiceManager not available"); } @@ -75,9 +68,9 @@ export const onboardingRouter = createRouter({ /** * Check if onboarding is needed */ - needsOnboarding: procedure.query(async () => { + needsOnboarding: procedure.query(async ({ ctx }) => { try { - const serviceManager = ServiceManager.getInstance(); + const { serviceManager } = ctx; if (!serviceManager) { // If service manager not available, assume onboarding not needed return { @@ -123,9 +116,9 @@ export const onboardingRouter = createRouter({ * Get feature flags for screen visibility */ getFeatureFlags: procedure.query( - async (): Promise => { + async ({ ctx }): Promise => { try { - const serviceManager = ServiceManager.getInstance(); + const { serviceManager } = ctx; if (!serviceManager) { // Return all screens enabled by default return { @@ -172,9 +165,12 @@ export const onboardingRouter = createRouter({ savePreferences: procedure .input(OnboardingPreferencesSchema) .mutation( - async ({ input }): Promise<{ success: boolean; message?: string }> => { + async ({ + input, + ctx, + }): Promise<{ success: boolean; message?: string }> => { try { - const serviceManager = ServiceManager.getInstance(); + const { serviceManager } = ctx; if (!serviceManager) { throw new Error("ServiceManager not available"); } @@ -198,168 +194,54 @@ export const onboardingRouter = createRouter({ }, ), - /** - * Track onboarding started event - */ - trackOnboardingStarted: procedure - .input( - z.object({ - platform: z.string(), - resumed: z.boolean(), - resumedFrom: z.string().optional(), - }), - ) - .mutation(async ({ input }): Promise => { - const serviceManager = ServiceManager.getInstance(); - const telemetryService = serviceManager?.getService("telemetryService"); - telemetryService?.trackOnboardingStarted(input); - }), - - /** - * Track onboarding screen viewed event - */ - trackOnboardingScreenViewed: procedure - .input( - z.object({ - screen: z.string(), - index: z.number(), - total: z.number(), - }), - ) - .mutation(async ({ input }): Promise => { - const serviceManager = ServiceManager.getInstance(); - const telemetryService = serviceManager?.getService("telemetryService"); - telemetryService?.trackOnboardingScreenViewed(input); - }), - - /** - * Track onboarding features selected event - */ - trackOnboardingFeaturesSelected: procedure - .input( - z.object({ - features: z.array(z.string()), - count: z.number(), - }), - ) - .mutation(async ({ input }): Promise => { - const serviceManager = ServiceManager.getInstance(); - const telemetryService = serviceManager?.getService("telemetryService"); - telemetryService?.trackOnboardingFeaturesSelected(input); - }), - - /** - * Track onboarding discovery selected event - */ - trackOnboardingDiscoverySelected: procedure - .input( - z.object({ - source: z.string(), - details: z.string().optional(), - }), - ) - .mutation(async ({ input }): Promise => { - const serviceManager = ServiceManager.getInstance(); - const telemetryService = serviceManager?.getService("telemetryService"); - telemetryService?.trackOnboardingDiscoverySelected(input); - }), - - /** - * Track onboarding model selected event - */ - trackOnboardingModelSelected: procedure - .input( - z.object({ - model_type: z.string(), - recommendation_followed: z.boolean(), - }), - ) - .mutation(async ({ input }): Promise => { - const serviceManager = ServiceManager.getInstance(); - const telemetryService = serviceManager?.getService("telemetryService"); - telemetryService?.trackOnboardingModelSelected(input); - }), - - /** - * Track onboarding completed event - */ - trackOnboardingCompleted: procedure - .input( - z.object({ - version: z.number(), - features_selected: z.array(z.string()), - discovery_source: z.string().optional(), - model_type: z.string(), - recommendation_followed: z.boolean(), - skipped_screens: z.array(z.string()).optional(), - }), - ) - .mutation(async ({ input }): Promise => { - const serviceManager = ServiceManager.getInstance(); - const telemetryService = serviceManager?.getService("telemetryService"); - telemetryService?.trackOnboardingCompleted(input); - }), - /** * Complete onboarding and save final state */ complete: procedure .input(OnboardingStateSchema) - .mutation( - async ({ - input, - }): Promise<{ success: boolean; shouldRelaunch: boolean }> => { - try { - const serviceManager = ServiceManager.getInstance(); - if (!serviceManager) { - throw new Error("ServiceManager not available"); - } - const onboardingService = serviceManager.getOnboardingService(); - const onboardingManager = serviceManager.getOnboardingManager(); - - if (!onboardingService || !onboardingManager) { - throw new Error("Onboarding services not available"); - } - - // Complete onboarding through the manager (handles window closing and relaunching) - await onboardingManager.completeOnboarding(input); - - // Determine if app needs to relaunch - const isDevelopment = process.env.NODE_ENV === "development"; - const shouldRelaunch = !isDevelopment; - - logger.main.info("Onboarding completed successfully", { - shouldRelaunch, - state: input, - }); - - return { - success: true, - shouldRelaunch, - }; - } catch (error) { - logger.main.error("Failed to complete onboarding:", error); - throw error; + .mutation(async ({ input, ctx }): Promise<{ success: boolean }> => { + try { + const { serviceManager } = ctx; + if (!serviceManager) { + throw new Error("ServiceManager not available"); } - }, - ), + const onboardingService = serviceManager.getOnboardingService(); + + if (!onboardingService) { + throw new Error("OnboardingService not available"); + } + + // Complete onboarding through the service + // AppManager handles window closing and relaunch decision + await onboardingService.completeOnboardingFlow(input); + + logger.main.info("Onboarding completed successfully", { + state: input, + }); + + return { success: true }; + } catch (error) { + logger.main.error("Failed to complete onboarding:", error); + throw error; + } + }), /** * Cancel onboarding */ - cancel: procedure.mutation(async () => { + cancel: procedure.mutation(async ({ ctx }) => { try { - const serviceManager = ServiceManager.getInstance(); + const { serviceManager } = ctx; if (!serviceManager) { throw new Error("ServiceManager not available"); } - const onboardingManager = serviceManager.getOnboardingManager(); + const onboardingService = serviceManager.getOnboardingService(); - if (!onboardingManager) { - throw new Error("OnboardingManager not available"); + if (!onboardingService) { + throw new Error("OnboardingService not available"); } - await onboardingManager.cancelOnboarding(); + await onboardingService.cancelOnboardingFlow(); return { success: true }; } catch (error) { @@ -371,9 +253,9 @@ export const onboardingRouter = createRouter({ /** * Reset onboarding state (for testing) */ - reset: procedure.mutation(async () => { + reset: procedure.mutation(async ({ ctx }) => { try { - const serviceManager = ServiceManager.getInstance(); + const { serviceManager } = ctx; if (!serviceManager) { throw new Error("ServiceManager not available"); } @@ -396,9 +278,9 @@ export const onboardingRouter = createRouter({ /** * Get skipped screens based on feature flags */ - getSkippedScreens: procedure.query(async () => { + getSkippedScreens: procedure.query(async ({ ctx }) => { try { - const serviceManager = ServiceManager.getInstance(); + const { serviceManager } = ctx; if (!serviceManager) { return []; } diff --git a/apps/desktop/src/trpc/routers/settings.ts b/apps/desktop/src/trpc/routers/settings.ts index 49dc774..65e5a76 100644 --- a/apps/desktop/src/trpc/routers/settings.ts +++ b/apps/desktop/src/trpc/routers/settings.ts @@ -541,14 +541,7 @@ export const settingsRouter = createRouter({ } await settingsService.setPreferences(input); - - // Sync widget visibility if preference changed - if (input.showWidgetWhileInactive !== undefined) { - const windowManager = ctx.serviceManager.getService("windowManager"); - if (windowManager) { - await windowManager.syncWidgetVisibility(); - } - } + // Window updates are handled via settings events in AppManager return true; }), @@ -570,12 +563,7 @@ export const settingsRouter = createRouter({ ...currentUISettings, theme: input.theme, }); - - // Update all window themes immediately - const windowManager = ctx.serviceManager.getService("windowManager"); - if (windowManager) { - await windowManager.updateAllWindowThemes(); - } + // Window updates are handled via settings events in AppManager const logger = ctx.serviceManager.getLogger(); if (logger) { diff --git a/apps/desktop/src/types/onboarding-api.ts b/apps/desktop/src/types/onboarding-api.ts deleted file mode 100644 index 86bf520..0000000 --- a/apps/desktop/src/types/onboarding-api.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface OnboardingAPI { - // Permission checks - checkMicrophonePermission: () => Promise; - checkAccessibilityPermission: () => Promise; - - // Permission requests - requestMicrophonePermission: () => Promise; - requestAccessibilityPermission: () => Promise; - - // Window controls - quitApp: () => Promise; - - // System info - getPlatform: () => Promise; - - // External links - openExternal: (url: string) => Promise; - - // Logging - log: { - error: (...args: any[]) => Promise; - }; -} diff --git a/apps/desktop/src/types/onboarding.ts b/apps/desktop/src/types/onboarding.ts index 8e99379..fed5545 100644 --- a/apps/desktop/src/types/onboarding.ts +++ b/apps/desktop/src/types/onboarding.ts @@ -21,6 +21,7 @@ export enum FeatureInterest { ContextualDictation = "contextual_dictation", NoteTaking = "note_taking", MeetingTranscriptions = "meeting_transcriptions", + VoiceCommands = "voice_commands", } export enum DiscoverySource { @@ -39,13 +40,6 @@ export enum ModelType { Local = "local", } -export enum PermissionStatus { - Granted = "granted", - Denied = "denied", - NotDetermined = "not-determined", - Restricted = "restricted", -} - // ============================================================================ // Data Types // ============================================================================ @@ -71,6 +65,7 @@ export interface OnboardingPreferences { discoverySource?: DiscoverySource; selectedModelType?: ModelType; modelRecommendation?: ModelRecommendation & { followed: boolean }; + lastVisitedScreen?: OnboardingScreen; } export interface OnboardingState { @@ -88,30 +83,6 @@ export interface OnboardingState { }; } -export interface AnalyticsEvent { - eventName: string; - properties: Record; -} - -// ============================================================================ -// Navigation Types -// ============================================================================ - -export interface NavigationState { - currentScreen: OnboardingScreen; - completedScreens: OnboardingScreen[]; - availableScreens: OnboardingScreen[]; - canGoBack: boolean; - canGoNext: boolean; -} - -export interface ScreenTransition { - from: OnboardingScreen; - to: OnboardingScreen; - action: "next" | "back" | "skip"; - timestamp: number; -} - // ============================================================================ // Feature Flags // ============================================================================ @@ -139,7 +110,7 @@ export const OnboardingStateSchema = z.object({ completedVersion: z.number().min(1), completedAt: z.string().datetime(), skippedScreens: z.array(OnboardingScreenSchema).optional(), - featureInterests: z.array(FeatureInterestSchema).max(3).optional(), + featureInterests: z.array(FeatureInterestSchema).optional(), discoverySource: DiscoverySourceSchema.optional(), selectedModelType: ModelTypeSchema, modelRecommendation: z @@ -152,127 +123,15 @@ export const OnboardingStateSchema = z.object({ }); export const OnboardingPreferencesSchema = z.object({ - featureInterests: z.array(FeatureInterestSchema).max(3).optional(), + featureInterests: z.array(FeatureInterestSchema).optional(), discoverySource: DiscoverySourceSchema.optional(), selectedModelType: ModelTypeSchema.optional(), - followedRecommendation: z.boolean().optional(), + modelRecommendation: z + .object({ + suggested: ModelTypeSchema, + reason: z.string(), + followed: z.boolean(), + }) + .optional(), + lastVisitedScreen: OnboardingScreenSchema.optional(), }); - -// ============================================================================ -// Type Guards -// ============================================================================ - -export function isValidOnboardingState(data: unknown): data is OnboardingState { - return OnboardingStateSchema.safeParse(data).success; -} - -export function isValidOnboardingPreferences( - data: unknown, -): data is OnboardingPreferences { - return OnboardingPreferencesSchema.safeParse(data).success; -} - -export function isSkippableScreen(screen: OnboardingScreen): boolean { - return ( - screen !== OnboardingScreen.Permissions && - screen !== OnboardingScreen.Completion - ); -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -export function getScreenOrder(): OnboardingScreen[] { - return [ - OnboardingScreen.Welcome, - OnboardingScreen.Permissions, - OnboardingScreen.DiscoverySource, - OnboardingScreen.ModelSelection, - OnboardingScreen.Completion, - ]; -} - -export function getNextScreen( - current: OnboardingScreen, - skippedScreens: OnboardingScreen[] = [], -): OnboardingScreen | null { - const order = getScreenOrder(); - const currentIndex = order.indexOf(current); - - for (let i = currentIndex + 1; i < order.length; i++) { - const nextScreen = order[i]; - if (!skippedScreens.includes(nextScreen)) { - return nextScreen; - } - } - - return null; -} - -export function getPreviousScreen( - current: OnboardingScreen, - skippedScreens: OnboardingScreen[] = [], -): OnboardingScreen | null { - const order = getScreenOrder(); - const currentIndex = order.indexOf(current); - - for (let i = currentIndex - 1; i >= 0; i--) { - const prevScreen = order[i]; - if (!skippedScreens.includes(prevScreen)) { - return prevScreen; - } - } - - return null; -} - -export function calculateProgress( - currentScreen: OnboardingScreen, - skippedScreens: OnboardingScreen[] = [], -): { current: number; total: number; percentage: number } { - const order = getScreenOrder(); - const activeScreens = order.filter((s) => !skippedScreens.includes(s)); - const currentIndex = activeScreens.indexOf(currentScreen) + 1; - const total = activeScreens.length; - - return { - current: currentIndex, - total, - percentage: Math.round((currentIndex / total) * 100), - }; -} - -// ============================================================================ -// Display Helpers -// ============================================================================ - -export const FEATURE_INTEREST_LABELS: Record = { - [FeatureInterest.ContextualDictation]: "Contextual Dictation", - [FeatureInterest.NoteTaking]: "Note Taking", - [FeatureInterest.MeetingTranscriptions]: "Meeting Transcriptions", -}; - -export const DISCOVERY_SOURCE_LABELS: Record = { - [DiscoverySource.SearchEngine]: "Search Engine (Google, Bing, etc.)", - [DiscoverySource.SocialMedia]: "Social Media (Twitter, LinkedIn, etc.)", - [DiscoverySource.WordOfMouth]: "Friend or Colleague", - [DiscoverySource.Advertisement]: "Online Advertisement", - [DiscoverySource.GitHub]: "GitHub", - [DiscoverySource.AIAssistant]: "AI Assistant", - [DiscoverySource.BlogArticle]: "Blog or Article", - [DiscoverySource.Other]: "Other", -}; - -export const MODEL_TYPE_LABELS: Record = { - [ModelType.Cloud]: "Cloud Processing", - [ModelType.Local]: "Local Processing", -}; - -export const SCREEN_TITLES: Record = { - [OnboardingScreen.Welcome]: "Welcome to Amical", - [OnboardingScreen.Permissions]: "Grant Permissions", - [OnboardingScreen.DiscoverySource]: "How did you find us?", - [OnboardingScreen.ModelSelection]: "Choose your processing mode", - [OnboardingScreen.Completion]: "You're all set!", -}; diff --git a/packages/native-helpers/swift-helper/Sources/SwiftHelper/main.swift b/packages/native-helpers/swift-helper/Sources/SwiftHelper/main.swift index cbb802b..a526828 100644 --- a/packages/native-helpers/swift-helper/Sources/SwiftHelper/main.swift +++ b/packages/native-helpers/swift-helper/Sources/SwiftHelper/main.swift @@ -1,3 +1,4 @@ +import ApplicationServices import CoreGraphics import Foundation