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 */}
+
+

+
+
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"),
+ },
+ },
+ },
+ };
+});