feat: onboarding window to check perms

This commit is contained in:
haritabh-z01 2025-07-07 13:52:35 +05:30
parent 0c64d43ec6
commit f8095d8ac0
14 changed files with 663 additions and 33 deletions

View file

@ -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

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Amical - Setup</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/renderer/onboarding/index.tsx"></script>
</body>
</html>

View file

@ -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 .",

View file

@ -17,25 +17,27 @@ export class AppManager {
}
async initialize(): Promise<void> {
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<void> {
@ -45,26 +47,49 @@ export class AppManager {
);
}
private async requestPermissions(): Promise<void> {
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<boolean> {
// 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<void> {
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<void> {

View file

@ -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();
});
}
}

View file

@ -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 {

View file

@ -0,0 +1,70 @@
import { contextBridge, ipcRenderer } from "electron";
import { exposeElectronTRPC } from "electron-trpc-experimental/preload";
interface OnboardingAPI {
// Permission checks
checkMicrophonePermission: () => Promise<string>;
checkAccessibilityPermission: () => Promise<boolean>;
// Permission requests
requestMicrophonePermission: () => Promise<boolean>;
requestAccessibilityPermission: () => Promise<void>;
// Navigation
completeOnboarding: () => Promise<void>;
// Window controls
quitApp: () => Promise<void>;
// System info
getPlatform: () => Promise<string>;
// External links
openExternal: (url: string) => Promise<void>;
// Logging
log: {
error: (...args: any[]) => Promise<void>;
};
}
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();
});

View file

@ -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<PermissionStatus>({
microphone: "not-determined",
accessibility: false,
});
const [platform, setPlatform] = useState<string>("");
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 (
<div className="h-screen w-screen bg-background text-foreground overflow-hidden">
<div className="h-full flex items-center justify-center p-10">
<UnifiedPermissionsStep
permissions={permissions}
platform={platform}
onComplete={handleComplete}
checkPermissions={checkPermissions}
/>
</div>
</div>
);
}

View file

@ -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<void>;
}
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 (
<div className="max-w-lg w-full space-y-6">
{/* Header with logo */}
<div className="text-center space-y-4">
<img
src="/assets/logo.svg"
alt="Amical"
className="w-20 h-20 mx-auto"
/>
<div>
<h1 className="text-2xl font-bold">Permissions Required</h1>
<p className="text-muted-foreground mt-2">
Amical needs these permissions to work properly
</p>
</div>
</div>
{/* Permission Cards */}
<div className="space-y-3">
{/* Microphone Permission */}
<Card className="p-4">
<div className="flex items-start gap-3">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${micStatus.bg}`}
>
<Mic className={`w-5 h-5 ${micStatus.color}`} />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-medium">Microphone</h3>
<div className={`flex items-center gap-1 ${micStatus.color}`}>
<micStatus.icon className="w-4 h-4" />
<span className="text-sm capitalize">
{permissions.microphone}
</span>
</div>
</div>
<p className="text-sm text-muted-foreground mt-1">
Required to transcribe your voice into text
</p>
{permissions.microphone === "denied" && (
<Button
size="sm"
variant="outline"
onClick={handleOpenMicrophoneSettings}
className="mt-3 w-full"
>
Open Microphone Settings
<ExternalLink className="w-3 h-3 ml-2" />
</Button>
)}
{permissions.microphone === "not-determined" && (
<Button
size="sm"
className="mt-3 w-full"
onClick={handleRequestMicrophone}
disabled={isRequestingMic}
>
{isRequestingMic ? "Requesting..." : "Grant Permission"}
</Button>
)}
</div>
</div>
</Card>
{/* Accessibility Permission (macOS only) */}
{platform === "darwin" && (
<Card className="p-4">
<div className="flex items-start gap-3">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${accessStatus.bg}`}
>
<Accessibility className={`w-5 h-5 ${accessStatus.color}`} />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-medium">Accessibility</h3>
<div
className={`flex items-center gap-1 ${accessStatus.color}`}
>
<accessStatus.icon className="w-4 h-4" />
<span className="text-sm">
{permissions.accessibility ? "Enabled" : "Disabled"}
</span>
</div>
</div>
<p className="text-sm text-muted-foreground mt-1">
Required for context based formating and push to talk
</p>
{!permissions.accessibility && (
<Button
size="sm"
variant="outline"
onClick={handleOpenAccessibility}
className="mt-3 w-full"
>
Open Accessibility Settings
<ExternalLink className="w-3 h-3 ml-2" />
</Button>
)}
</div>
</div>
</Card>
)}
</div>
{/* Status message */}
{isPolling && !allPermissionsGranted && (
<div className="text-center text-sm text-muted-foreground flex items-center justify-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin" />
Checking permissions...
</div>
)}
{/* Action buttons */}
<div className="flex justify-between gap-4 pt-4">
<Button
onClick={() => window.onboardingAPI.quitApp()}
variant="outline"
size="lg"
>
Quit Amical
</Button>
<Button
onClick={onComplete}
size="lg"
disabled={!allPermissionsGranted}
>
Next
</Button>
</div>
</div>
);
}

View file

@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View file

@ -0,0 +1,7 @@
import type { OnboardingAPI } from "@/types/onboarding-api";
declare global {
interface Window {
onboardingAPI: OnboardingAPI;
}
}

View file

@ -0,0 +1,26 @@
export interface OnboardingAPI {
// Permission checks
checkMicrophonePermission: () => Promise<string>;
checkAccessibilityPermission: () => Promise<boolean>;
// Permission requests
requestMicrophonePermission: () => Promise<boolean>;
requestAccessibilityPermission: () => Promise<void>;
// Navigation
completeOnboarding: () => Promise<void>;
// Window controls
quitApp: () => Promise<void>;
// System info
getPlatform: () => Promise<string>;
// External links
openExternal: (url: string) => Promise<void>;
// Logging
log: {
error: (...args: any[]) => Promise<void>;
};
}

View file

@ -0,0 +1,10 @@
import { defineConfig } from "vite";
// https://vitejs.dev/config
export default defineConfig({
resolve: {
alias: {
"@": new URL("./src", import.meta.url).pathname,
},
},
});

View file

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