diff --git a/package.json b/package.json index c8c84c2..42b1004 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "build": "npm run build:main && npm run build:renderer", "build:main": "tsc -p tsconfig.main.json", "build:renderer": "vite build", - "start": "electron dist/main/main/index.js", + "start": "VITE_DEV_SERVER=1 electron dist/main/main/index.js", + "start:prod": "electron dist/main/main/index.js", "lint": "eslint src --ext .ts,.tsx", "typecheck": "tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.renderer.json --noEmit", "package": "npm run build && electron-builder", diff --git a/src/main/companion-manager.ts b/src/main/companion-manager.ts index 7c8be60..affe63d 100644 --- a/src/main/companion-manager.ts +++ b/src/main/companion-manager.ts @@ -1,4 +1,4 @@ -import { app, systemPreferences } from 'electron'; +import { app, systemPreferences, shell, desktopCapturer } from 'electron'; import { ClaudeAPI } from './services/claude-api'; import { OpenAIAPI } from './services/openai-api'; import { OllamaAPI } from './services/ollama-api'; @@ -293,8 +293,40 @@ export class CompanionManager { } async requestPermission(kind: string): Promise { - if (process.platform === 'darwin' && kind === 'microphone') { - await systemPreferences.askForMediaAccess('microphone'); + if (process.platform !== 'darwin') return; + + if (kind === 'microphone') { + const status = systemPreferences.getMediaAccessStatus('microphone'); + if (status === 'not-determined') { + await systemPreferences.askForMediaAccess('microphone'); + } else if (status === 'denied' || status === 'restricted') { + // The OS only shows the prompt once; after denial the user must + // re-enable us in System Settings. Deeplink straight to the pane. + shell.openExternal( + 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone', + ); + } + return; + } + + if (kind === 'screen') { + const status = systemPreferences.getMediaAccessStatus('screen'); + if (status === 'not-determined') { + // No askForMediaAccess equivalent for screen — but actually + // *attempting* a capture provokes the system prompt the first time. + try { + await desktopCapturer.getSources({ + types: ['screen'], + thumbnailSize: { width: 1, height: 1 }, + }); + } catch (err) { + console.error('[Flicky] screen permission probe failed:', err); + } + } else if (status === 'denied' || status === 'restricted') { + shell.openExternal( + 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture', + ); + } } } diff --git a/src/main/index.ts b/src/main/index.ts index 28ec65f..7265d77 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -181,15 +181,32 @@ app.whenReady().then(() => { } // Register global push-to-talk shortcut. - // globalShortcut fires repeatedly on key-repeat while held, so we - // debounce: the first press starts recording, subsequent repeats are - // ignored, and we stop recording after the shortcut hasn't fired for - // a short window (meaning the key was released). + // + // Windows/Linux: globalShortcut fires repeatedly on OS key-repeat while + // the accelerator is held, so we debounce — first press starts recording, + // subsequent repeats are ignored, and we stop after no fires for 250 ms + // (meaning the key was released). + // + // macOS: globalShortcut fires exactly once on press; the OS does not + // repeat global accelerators and Electron exposes no key-up event. We + // can't implement true press-and-hold without a native key-event hook, + // so fall back to toggle: first tap starts, second tap stops. let pttDebounceTimer: ReturnType | null = null; let pttActive = false; let currentShortcut = ''; + const isMac = process.platform === 'darwin'; const pttHandler = () => { + if (isMac) { + if (!pttActive) { + pttActive = true; + companion.startPushToTalk(); + } else { + pttActive = false; + companion.stopPushToTalk(); + } + return; + } if (pttDebounceTimer) { clearTimeout(pttDebounceTimer); pttDebounceTimer = null; diff --git a/src/renderer/components/PanelApp.tsx b/src/renderer/components/PanelApp.tsx index aac7f9f..2b79c2b 100644 --- a/src/renderer/components/PanelApp.tsx +++ b/src/renderer/components/PanelApp.tsx @@ -6,6 +6,7 @@ import { MindTab } from './panel/MindTab'; import { VoiceTab } from './panel/VoiceTab'; import { EarTab } from './panel/EarTab'; import { GeneralTab } from './panel/GeneralTab'; +import { PermissionsBanner } from './panel/PermissionsBanner'; import { CursorIcon } from './CursorIcon'; type Tab = 'home' | 'chats' | 'mind' | 'voice' | 'ear' | 'general'; @@ -83,6 +84,7 @@ export function PanelApp() {
+ {tab === 'home' && ( ; + +const ROWS: Array<{ kind: 'microphone' | 'screen'; label: string; reason: string }> = [ + { + kind: 'microphone', + label: 'Microphone', + reason: 'so Flicky can hear you when you push to talk', + }, + { + kind: 'screen', + label: 'Screen Recording', + reason: 'so Flicky can see your screen and point at things', + }, +]; + +export function PermissionsBanner() { + const [perms, setPerms] = useState(null); + + useEffect(() => { + if (process.platform !== 'darwin') return; + window.flicky.getPermissions().then(setPerms); + const unsub = window.flicky.onPermissionStatus(setPerms); + return () => { unsub(); }; + }, []); + + if (process.platform !== 'darwin') return null; + if (!perms) return null; + + const missing = ROWS.filter((r) => !perms[r.kind]); + if (missing.length === 0) return null; + + return ( +
+
+ Flicky needs a few permissions + + macOS controls access per-app. Without these, Flicky can't hear you or see your screen. + +
+
+ {missing.map((r) => ( +
+
+ {r.label} + {r.reason} +
+ +
+ ))} +
+
+ ); +} diff --git a/src/renderer/styles/panel.css b/src/renderer/styles/panel.css index dbe6877..567e073 100644 --- a/src/renderer/styles/panel.css +++ b/src/renderer/styles/panel.css @@ -1125,3 +1125,42 @@ button.provider-pick:hover { background: var(--fl-border); border-radius: 999px; } .catalog-scroll::-webkit-scrollbar-thumb:hover { background: var(--fl-border-strong); } + +/* ── Permissions banner ─────────────────────────────────────────── */ +.perm-banner { + margin: 18px 28px 0; + padding: 14px 16px; + border: 1px solid var(--fl-warn); + background: var(--fl-warn-bg); + border-radius: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} +.perm-banner-head { display: flex; flex-direction: column; gap: 2px; } +.perm-banner-title { + font-size: 13px; font-weight: 600; color: var(--fl-ink); +} +.perm-banner-sub { + font-size: 12px; color: var(--fl-muted); +} +.perm-banner-rows { display: flex; flex-direction: column; gap: 6px; } +.perm-banner-row { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; padding: 8px 10px; + background: rgba(0,0,0,0.18); border-radius: 8px; +} +.perm-banner-text { display: flex; flex-direction: column; gap: 1px; } +.perm-banner-label { + font-size: 12.5px; font-weight: 600; color: var(--fl-ink-soft); +} +.perm-banner-reason { + font-size: 11.5px; color: var(--fl-muted); +} +.perm-banner-btn { + font-size: 12px; font-weight: 600; + color: #1a1a1f; background: var(--fl-warn); + padding: 6px 14px; border-radius: 6px; + border: none; cursor: pointer; +} +.perm-banner-btn:hover { filter: brightness(1.08); }