Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 35 additions & 3 deletions src/main/companion-manager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -293,8 +293,40 @@ export class CompanionManager {
}

async requestPermission(kind: string): Promise<void> {
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',
);
}
}
}

Expand Down
25 changes: 21 additions & 4 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | 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;
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/components/PanelApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,6 +84,7 @@ export function PanelApp() {
</aside>

<main className="main">
<PermissionsBanner />
{tab === 'home' && (
<HomeTab
voiceState={voiceState}
Expand Down
60 changes: 60 additions & 0 deletions src/renderer/components/panel/PermissionsBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';

type Perms = Record<string, boolean>;

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<Perms | null>(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 (
<div className="perm-banner">
<div className="perm-banner-head">
<span className="perm-banner-title">Flicky needs a few permissions</span>
<span className="perm-banner-sub">
macOS controls access per-app. Without these, Flicky can&apos;t hear you or see your screen.
</span>
</div>
<div className="perm-banner-rows">
{missing.map((r) => (
<div className="perm-banner-row" key={r.kind}>
<div className="perm-banner-text">
<span className="perm-banner-label">{r.label}</span>
<span className="perm-banner-reason">{r.reason}</span>
</div>
<button
className="perm-banner-btn"
onClick={() => window.flicky.requestPermission(r.kind)}
>
Grant
</button>
</div>
))}
</div>
</div>
);
}
39 changes: 39 additions & 0 deletions src/renderer/styles/panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Loading