diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 0d6f6c2..2c8536e 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -356,6 +356,7 @@ const config: ForgeConfig = { new MakerDMG( { // macOS DMG files will be named like: Amical-0.0.1-arm64.dmg + icon: "./assets/logo.icns", }, ["darwin"], ), @@ -378,6 +379,11 @@ const config: ForgeConfig = { config: "vite.preload.config.mts", target: "preload", }, + { + entry: "src/main/onboarding-preload.ts", + config: "vite.onboarding-preload.config.mts", + target: "preload", + }, ], renderer: [ { @@ -388,6 +394,10 @@ const config: ForgeConfig = { name: "widget_window", config: "vite.widget.config.mts", }, + { + name: "onboarding_window", + config: "vite.onboarding.config.mts", + }, ], }), // Fuses are used to enable/disable various Electron functionality diff --git a/apps/desktop/onboarding.html b/apps/desktop/onboarding.html new file mode 100644 index 0000000..b991dd0 --- /dev/null +++ b/apps/desktop/onboarding.html @@ -0,0 +1,12 @@ + + + + + + Amical - Setup + + +
+ + + \ No newline at end of file diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 92e5cf0..360fac0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,11 +10,15 @@ }, "scripts": { "start": "pnpm build:swift-helper && electron-forge start", + "start:onboarding": "FORCE_ONBOARDING=true pnpm start", "package": "pnpm build:swift-helper && electron-forge package", "make": "pnpm build:swift-helper && electron-forge make --platform=darwin --arch=arm64,x64", "make:dmg": "pnpm build:swift-helper && electron-forge make --targets=@electron-forge/maker-dmg --platform=darwin --arch=arm64", + "make:dmg:arm64": "pnpm build:swift-helper && electron-forge make --targets=@electron-forge/maker-dmg --platform=darwin --arch=arm64", "make:dmg:x64": "pnpm build:swift-helper && electron-forge make --targets=@electron-forge/maker-dmg --platform=darwin --arch=x64", + "make:zip:arm64": "pnpm build:swift-helper && electron-forge make --targets=@electron-forge/maker-zip --platform=darwin --arch=arm64", "make:zip:x64": "pnpm build:swift-helper && electron-forge make --targets=@electron-forge/maker-zip --platform=darwin --arch=x64", + "package:arm64": "pnpm build:swift-helper && electron-forge package --platform=darwin --arch=arm64", "package:x64": "pnpm build:swift-helper && electron-forge package --platform=darwin --arch=x64", "publish": "electron-forge publish", "lint": "eslint --ext .ts,.tsx .", diff --git a/apps/desktop/src/main/core/app-manager.ts b/apps/desktop/src/main/core/app-manager.ts index 23dec8f..881edb4 100644 --- a/apps/desktop/src/main/core/app-manager.ts +++ b/apps/desktop/src/main/core/app-manager.ts @@ -17,25 +17,27 @@ export class AppManager { } async initialize(): Promise { - try { - await this.initializeDatabase(); + await this.initializeDatabase(); - await this.requestPermissions(); - await this.serviceManager.initialize(); + const needsOnboarding = await this.checkNeedsOnboarding(); + + await this.serviceManager.initialize(); + + if (needsOnboarding) { + await this.showOnboarding(); + } else { await this.setupWindows(); - await this.setupMenu(); - - // Setup event handlers - this.eventHandlers = new EventHandlers(this); - this.eventHandlers.setupEventHandlers(); - - // Auto-update is now handled by update-electron-app in main.ts - - logger.main.info("Application initialized successfully"); - } catch (error) { - logger.main.error("Error initializing app:", error); - throw error; } + + await this.setupMenu(); + + // Setup event handlers + this.eventHandlers = new EventHandlers(this); + this.eventHandlers.setupEventHandlers(); + + // Auto-update is now handled by update-electron-app in main.ts + + logger.main.info("Application initialized successfully"); } private async initializeDatabase(): Promise { @@ -45,26 +47,49 @@ export class AppManager { ); } - private async requestPermissions(): Promise { - if (process.platform === "darwin") { - const accessibilityEnabled = - systemPreferences.isTrustedAccessibilityClient(false); - if (!accessibilityEnabled) { - logger.main.debug( - "Please enable accessibility permissions in System Preferences > Security & Privacy > Privacy > Accessibility", - ); - } + private async checkNeedsOnboarding(): Promise { + // Force show onboarding for development testing + if (process.env.FORCE_ONBOARDING === "true") { + logger.main.info("Forcing onboarding window for testing"); + return true; } - const microphoneEnabled = + if (process.platform !== "darwin") { + // For non-macOS platforms, we might still want to check microphone + const microphoneStatus = + systemPreferences.getMediaAccessStatus("microphone"); + return microphoneStatus !== "granted"; + } + + // Check both microphone and accessibility permissions on macOS + const microphoneStatus = systemPreferences.getMediaAccessStatus("microphone"); - logger.main.info("Microphone access status:", { - status: microphoneEnabled, + const accessibilityStatus = + systemPreferences.isTrustedAccessibilityClient(false); + + logger.main.info("Permission status:", { + microphone: microphoneStatus, + accessibility: accessibilityStatus, }); - if (microphoneEnabled !== "granted") { - await systemPreferences.askForMediaAccess("microphone"); - } + return microphoneStatus !== "granted" || !accessibilityStatus; + } + + private async showOnboarding(): Promise { + this.windowManager.createOnboardingWindow(); + + // The onboarding window will handle the permission flow + // and call back to complete setup when done + } + + completeOnboarding(): void { + logger.main.info( + "Onboarding completed, restarting app for permissions to take effect", + ); + + // Relaunch the app to ensure all permissions take effect + app.relaunch(); + app.quit(); } private async setupWindows(): Promise { diff --git a/apps/desktop/src/main/core/event-handlers.ts b/apps/desktop/src/main/core/event-handlers.ts index 33df47e..d5dd320 100644 --- a/apps/desktop/src/main/core/event-handlers.ts +++ b/apps/desktop/src/main/core/event-handlers.ts @@ -1,7 +1,7 @@ import { HelperEvent } from "@amical/types"; import { AppManager } from "./app-manager"; import { logger } from "../logger"; -import { ipcMain, shell } from "electron"; +import { ipcMain, shell, systemPreferences, app } from "electron"; export class EventHandlers { private appManager: AppManager; @@ -13,6 +13,7 @@ export class EventHandlers { setupEventHandlers(): void { this.setupSwiftBridgeEventHandlers(); this.setupGeneralIPCHandlers(); + this.setupOnboardingIPCHandlers(); // Note: Audio IPC handlers are now managed by RecordingService } @@ -53,4 +54,50 @@ export class EventHandlers { logger.main.debug("Opening external URL", { url }); }); } + + private setupOnboardingIPCHandlers(): void { + // Permission checks + ipcMain.handle("onboarding:check-microphone-permission", async () => { + return systemPreferences.getMediaAccessStatus("microphone"); + }); + + ipcMain.handle("onboarding:check-accessibility-permission", async () => { + if (process.platform !== "darwin") { + return true; // Non-macOS platforms don't need accessibility permission + } + return systemPreferences.isTrustedAccessibilityClient(false); + }); + + // Permission requests + ipcMain.handle("onboarding:request-microphone-permission", async () => { + const status = await systemPreferences.askForMediaAccess("microphone"); + logger.main.info("Microphone permission request result:", status); + return status; + }); + + ipcMain.handle("onboarding:request-accessibility-permission", async () => { + if (process.platform !== "darwin") { + return; // Non-macOS platforms don't need accessibility permission + } + // This will prompt the user to open System Preferences + systemPreferences.isTrustedAccessibilityClient(true); + }); + + // Navigation + ipcMain.handle("onboarding:complete", async () => { + logger.main.info("Onboarding completed"); + this.appManager.completeOnboarding(); + }); + + // System info + ipcMain.handle("onboarding:get-platform", async () => { + return process.platform; + }); + + // Quit app + ipcMain.handle("onboarding:quit-app", async () => { + logger.main.info("Quitting app from onboarding"); + app.quit(); + }); + } } diff --git a/apps/desktop/src/main/core/window-manager.ts b/apps/desktop/src/main/core/window-manager.ts index 39e644b..3c008e9 100644 --- a/apps/desktop/src/main/core/window-manager.ts +++ b/apps/desktop/src/main/core/window-manager.ts @@ -6,10 +6,12 @@ import { ServiceManager } from "../managers/service-manager"; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; declare const MAIN_WINDOW_VITE_NAME: string; declare const WIDGET_WINDOW_VITE_NAME: string; +declare const ONBOARDING_WINDOW_VITE_NAME: string; export class WindowManager { private mainWindow: BrowserWindow | null = null; private widgetWindow: BrowserWindow | null = null; + private onboardingWindow: BrowserWindow | null = null; private widgetDisplayId: number | null = null; private cursorPollingInterval: NodeJS.Timeout | null = null; @@ -144,6 +146,65 @@ export class WindowManager { logger.main.info("Widget window shown"); } + createOnboardingWindow(): void { + if (this.onboardingWindow && !this.onboardingWindow.isDestroyed()) { + this.onboardingWindow.show(); + this.onboardingWindow.focus(); + return; + } + + this.onboardingWindow = new BrowserWindow({ + width: 700, + height: 600, + frame: false, + resizable: false, + center: true, + modal: true, + webPreferences: { + preload: path.join(__dirname, "onboarding-preload.js"), + nodeIntegration: false, + contextIsolation: true, + }, + }); + + if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { + const devUrl = new URL(MAIN_WINDOW_VITE_DEV_SERVER_URL); + devUrl.pathname = "onboarding.html"; + this.onboardingWindow.loadURL(devUrl.toString()); + } else { + this.onboardingWindow.loadFile( + path.join( + __dirname, + `../renderer/${ONBOARDING_WINDOW_VITE_NAME}/onboarding.html`, + ), + ); + } + + this.onboardingWindow.on("closed", () => { + this.onboardingWindow = null; + }); + + // Disable main window while onboarding is open + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.setEnabled(false); + } + + logger.main.info("Onboarding window created"); + } + + closeOnboardingWindow(): void { + if (this.onboardingWindow && !this.onboardingWindow.isDestroyed()) { + this.onboardingWindow.close(); + } + + // Re-enable main window + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.setEnabled(true); + this.mainWindow.show(); + this.mainWindow.focus(); + } + } + private setupDisplayChangeNotifications(): void { // Set up comprehensive display event listeners screen.on("display-added", () => this.handleDisplayChange("display-added")); @@ -267,8 +328,12 @@ export class WindowManager { return this.widgetWindow; } + getOnboardingWindow(): BrowserWindow | null { + return this.onboardingWindow; + } + getAllWindows(): (BrowserWindow | null)[] { - return [this.mainWindow, this.widgetWindow]; + return [this.mainWindow, this.widgetWindow, this.onboardingWindow]; } openAllDevTools(): void { diff --git a/apps/desktop/src/main/onboarding-preload.ts b/apps/desktop/src/main/onboarding-preload.ts new file mode 100644 index 0000000..ba9f623 --- /dev/null +++ b/apps/desktop/src/main/onboarding-preload.ts @@ -0,0 +1,70 @@ +import { contextBridge, ipcRenderer } from "electron"; +import { exposeElectronTRPC } from "electron-trpc-experimental/preload"; + +interface OnboardingAPI { + // Permission checks + checkMicrophonePermission: () => Promise; + checkAccessibilityPermission: () => Promise; + + // Permission requests + requestMicrophonePermission: () => Promise; + requestAccessibilityPermission: () => Promise; + + // Navigation + completeOnboarding: () => Promise; + + // Window controls + quitApp: () => Promise; + + // System info + getPlatform: () => Promise; + + // External links + openExternal: (url: string) => Promise; + + // Logging + log: { + error: (...args: any[]) => Promise; + }; +} + +const api: OnboardingAPI = { + // Permission checks + checkMicrophonePermission: () => + ipcRenderer.invoke("onboarding:check-microphone-permission"), + + checkAccessibilityPermission: () => + ipcRenderer.invoke("onboarding:check-accessibility-permission"), + + // Permission requests + requestMicrophonePermission: () => + ipcRenderer.invoke("onboarding:request-microphone-permission"), + + requestAccessibilityPermission: () => + ipcRenderer.invoke("onboarding:request-accessibility-permission"), + + // Navigation + completeOnboarding: () => ipcRenderer.invoke("onboarding:complete"), + + // Window controls + quitApp: () => ipcRenderer.invoke("onboarding:quit-app"), + + // System info + getPlatform: () => ipcRenderer.invoke("onboarding:get-platform"), + + // External links + openExternal: (url: string) => ipcRenderer.invoke("open-external", url), + + // Logging + log: { + error: (...args: any[]) => + ipcRenderer.invoke("log-message", "error", "onboarding", ...args), + }, +}; + +contextBridge.exposeInMainWorld("onboardingAPI", api); + +// Expose tRPC for electron-trpc-experimental +process.once("loaded", async () => { + exposeElectronTRPC(); +}); diff --git a/apps/desktop/src/renderer/onboarding/App.tsx b/apps/desktop/src/renderer/onboarding/App.tsx new file mode 100644 index 0000000..67024e4 --- /dev/null +++ b/apps/desktop/src/renderer/onboarding/App.tsx @@ -0,0 +1,50 @@ +import React, { useState, useEffect } from "react"; +import { UnifiedPermissionsStep } from "./components/UnifiedPermissionsStep"; + +interface PermissionStatus { + microphone: "granted" | "denied" | "not-determined"; + accessibility: boolean; +} + +export function App() { + const [permissions, setPermissions] = useState({ + microphone: "not-determined", + accessibility: false, + }); + const [platform, setPlatform] = useState(""); + + useEffect(() => { + // Check initial permissions and platform + checkPermissions(); + window.onboardingAPI.getPlatform().then(setPlatform); + }, []); + + const checkPermissions = async () => { + const [micStatus, accessStatus] = await Promise.all([ + window.onboardingAPI.checkMicrophonePermission(), + window.onboardingAPI.checkAccessibilityPermission(), + ]); + + setPermissions({ + microphone: micStatus as "granted" | "denied" | "not-determined", + accessibility: accessStatus, + }); + }; + + const handleComplete = () => { + window.onboardingAPI.completeOnboarding(); + }; + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/onboarding/components/UnifiedPermissionsStep.tsx b/apps/desktop/src/renderer/onboarding/components/UnifiedPermissionsStep.tsx new file mode 100644 index 0000000..ee5ee4a --- /dev/null +++ b/apps/desktop/src/renderer/onboarding/components/UnifiedPermissionsStep.tsx @@ -0,0 +1,252 @@ +import React, { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + CheckCircle, + AlertCircle, + Mic, + Accessibility, + ExternalLink, + RefreshCw, +} from "lucide-react"; + +interface UnifiedPermissionsStepProps { + permissions: { + microphone: "granted" | "denied" | "not-determined"; + accessibility: boolean; + }; + platform: string; + onComplete: () => void; + checkPermissions: () => Promise; +} + +export function UnifiedPermissionsStep({ + permissions, + platform, + onComplete, + checkPermissions, +}: UnifiedPermissionsStepProps) { + const [isRequestingMic, setIsRequestingMic] = useState(false); + const [isPolling, setIsPolling] = useState(false); + + const allPermissionsGranted = + permissions.microphone === "granted" && + (permissions.accessibility || platform !== "darwin"); + + // Poll for permission changes continuously to keep UI in sync + useEffect(() => { + // Always poll to detect permission changes in real-time + const interval = setInterval(async () => { + await checkPermissions(); + }, 2000); + + // Show polling indicator only when permissions are not all granted + setIsPolling(!allPermissionsGranted); + + return () => { + clearInterval(interval); + }; + }, [checkPermissions, allPermissionsGranted]); + + const handleRequestMicrophone = async () => { + setIsRequestingMic(true); + try { + await window.onboardingAPI.requestMicrophonePermission(); + await checkPermissions(); + } finally { + setIsRequestingMic(false); + } + }; + + const handleOpenAccessibility = async () => { + // Open System Preferences > Security & Privacy > Privacy > Accessibility + await window.onboardingAPI.openExternal( + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + ); + }; + + const handleOpenMicrophoneSettings = async () => { + // Open System Preferences > Security & Privacy > Privacy > Microphone + await window.onboardingAPI.openExternal( + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + ); + }; + + const getMicrophoneStatus = () => { + switch (permissions.microphone) { + case "granted": + return { + icon: CheckCircle, + color: "text-green-500", + bg: "bg-green-500/10", + }; + case "denied": + return { + icon: AlertCircle, + color: "text-red-500", + bg: "bg-red-500/10", + }; + default: + return { + icon: RefreshCw, + color: "text-blue-500", + bg: "bg-blue-500/10", + }; + } + }; + + const getAccessibilityStatus = () => { + if (permissions.accessibility) { + return { + icon: CheckCircle, + color: "text-green-500", + bg: "bg-green-500/10", + }; + } + return { + icon: AlertCircle, + color: "text-amber-500", + bg: "bg-amber-500/10", + }; + }; + + const micStatus = getMicrophoneStatus(); + const accessStatus = getAccessibilityStatus(); + + return ( +
+ {/* Header with logo */} +
+ Amical +
+

Permissions Required

+

+ Amical needs these permissions to work properly +

+
+
+ + {/* Permission Cards */} +
+ {/* Microphone Permission */} + +
+
+ +
+
+
+

Microphone

+
+ + + {permissions.microphone} + +
+
+

+ Required to transcribe your voice into text +

+ + {permissions.microphone === "denied" && ( + + )} + + {permissions.microphone === "not-determined" && ( + + )} +
+
+
+ + {/* Accessibility Permission (macOS only) */} + {platform === "darwin" && ( + +
+
+ +
+
+
+

Accessibility

+
+ + + {permissions.accessibility ? "Enabled" : "Disabled"} + +
+
+

+ Required for context based formating and push to talk +

+ + {!permissions.accessibility && ( + + )} +
+
+
+ )} +
+ + {/* Status message */} + {isPolling && !allPermissionsGranted && ( +
+ + Checking permissions... +
+ )} + + {/* Action buttons */} +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/onboarding/index.tsx b/apps/desktop/src/renderer/onboarding/index.tsx new file mode 100644 index 0000000..e08d536 --- /dev/null +++ b/apps/desktop/src/renderer/onboarding/index.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import "@/styles/globals.css"; + +// Handle uncaught errors +window.addEventListener("unhandledrejection", (event) => { + console.error("Unhandled promise rejection:", event.reason); + window.onboardingAPI.log.error("Unhandled promise rejection:", event.reason); +}); + +window.addEventListener("error", (event) => { + console.error("Uncaught error:", event.error); + window.onboardingAPI.log.error("Uncaught error:", event.error); +}); + +const root = ReactDOM.createRoot( + document.getElementById("root") as HTMLElement, +); + +root.render( + + + , +); diff --git a/apps/desktop/src/renderer/onboarding/types.d.ts b/apps/desktop/src/renderer/onboarding/types.d.ts new file mode 100644 index 0000000..e2acc4e --- /dev/null +++ b/apps/desktop/src/renderer/onboarding/types.d.ts @@ -0,0 +1,7 @@ +import type { OnboardingAPI } from "@/types/onboarding-api"; + +declare global { + interface Window { + onboardingAPI: OnboardingAPI; + } +} diff --git a/apps/desktop/src/types/onboarding-api.ts b/apps/desktop/src/types/onboarding-api.ts new file mode 100644 index 0000000..4dbfac3 --- /dev/null +++ b/apps/desktop/src/types/onboarding-api.ts @@ -0,0 +1,26 @@ +export interface OnboardingAPI { + // Permission checks + checkMicrophonePermission: () => Promise; + checkAccessibilityPermission: () => Promise; + + // Permission requests + requestMicrophonePermission: () => Promise; + requestAccessibilityPermission: () => Promise; + + // Navigation + completeOnboarding: () => 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/vite.onboarding-preload.config.mts b/apps/desktop/vite.onboarding-preload.config.mts new file mode 100644 index 0000000..f9afbba --- /dev/null +++ b/apps/desktop/vite.onboarding-preload.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; + +// https://vitejs.dev/config +export default defineConfig({ + resolve: { + alias: { + "@": new URL("./src", import.meta.url).pathname, + }, + }, +}); diff --git a/apps/desktop/vite.onboarding.config.mts b/apps/desktop/vite.onboarding.config.mts new file mode 100644 index 0000000..2c2cfce --- /dev/null +++ b/apps/desktop/vite.onboarding.config.mts @@ -0,0 +1,27 @@ +import { defineConfig } from "vite"; +import { resolve } from "path"; + +// https://vitejs.dev/config +export default defineConfig(async () => { + // @ts-ignore + const { default: tailwindcss } = await import("@tailwindcss/vite"); + + return { + plugins: [tailwindcss()], + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + optimizeDeps: { + exclude: ["better-sqlite3"], + }, + build: { + rollupOptions: { + input: { + main: resolve(__dirname, "onboarding.html"), + }, + }, + }, + }; +});