diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fef6d0..c3f3893 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,8 +88,10 @@ jobs: run: pnpm run build - name: Build release packages - env: - CSC_IDENTITY_AUTO_DISCOVERY: 'false' + # No signing certificate yet: mac.identity "-" in package.json forces ad-hoc signing so + # Apple Silicon builds launch. electron-builder only consults CSC_IDENTITY_AUTO_DISCOVERY + # when identity is unset, so it has no effect here - left unset for clarity, not because + # it changes this build. run: ${{ matrix.build_command }} shell: bash diff --git a/README.md b/README.md index a487fe6..b5194d4 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,17 @@ The app keeps itself off the surfaces a screen share exposes. There is no deskto Power Interview desktop client is supported on: - Windows 10/11 (x64 installer build) -- macOS (Apple Silicon and Intel release artifacts) +- macOS 14.4+ (Apple Silicon and Intel release artifacts) - 14.4 is the floor for system-audio loopback capture Release binaries are published on the [GitHub Releases](https://github.com/PowerInterviewAI/client-app/releases) page. +### Unsigned builds + +Release artifacts are not yet code-signed, so the OS will warn on first launch: + +- **macOS**: the app is ad-hoc signed (so it runs on Apple Silicon) but not notarized. If you see "app is damaged" or it is blocked, run `xattr -cr "/Applications/Power Interview AI.app"`, or right-click the app and choose Open. +- **Windows**: SmartScreen shows "Windows protected your PC". Click "More info" then "Run anyway". + ## Architecture Power Interview follows a **client-server architecture**. diff --git a/SPEC.md b/SPEC.md index 62904a3..ff51cd4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -85,7 +85,7 @@ electron-updater publishes to GitHub Releases under `PowerInterviewAI/client` (c ## Platform Support - Windows 10/11 x64 (NSIS installer) -- macOS Apple Silicon and Intel (DMG + ZIP) +- macOS 14.4+ Apple Silicon and Intel (DMG + ZIP) - 14.4 is required for system-audio loopback capture ## Project Structure diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000..224c0fd Binary files /dev/null and b/build/icon.png differ diff --git a/package.json b/package.json index c0f5e14..676fb5a 100644 --- a/package.json +++ b/package.json @@ -113,9 +113,27 @@ }, "mac": { "target": [ - "dmg", - "zip" + { + "target": "dmg", + "arch": [ + "arm64", + "x64" + ] + }, + { + "target": "zip", + "arch": [ + "arm64", + "x64" + ] + } ], + "icon": "build/icon.png", + "category": "public.app-category.productivity", + "minimumSystemVersion": "14.4.0", + "identity": "-", + "hardenedRuntime": false, + "notarize": false, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.inherit.plist", "extendInfo": { diff --git a/src/main/consts.ts b/src/main/consts.ts index a33f275..0855776 100644 --- a/src/main/consts.ts +++ b/src/main/consts.ts @@ -4,12 +4,13 @@ export const BACKEND_BASE_URL = EnvUtil.isDev() ? 'http://localhost:8080' : 'https://api.powerinterviewai.com'; -// Minimum allowed dimensions for window bounds. 540 was sized when transcription was a fixed -// 320px left column and cost width, not height. It now docks at the bottom and takes a band out -// of the same vertical budget as the suggestion panels, so the minimum grows by the dock's own -// floor plus the gap above it (TRANSCRIPT_DOCK_MIN_HEIGHT 120 + 4, both in renderer/lib/consts). -export const MIN_WIDTH = 900; -export const MIN_HEIGHT = 664; +// Minimum allowed dimensions for window bounds. The renderer degrades gracefully below its +// preferred layout - computeAvailable() in pages/main/index.tsx shrinks the transcript dock and +// suggestion panels down to their own floors (TRANSCRIPT_DOCK_MIN_HEIGHT / SUGGESTION_MIN_HEIGHT, +// both in renderer/lib/consts) instead of clipping, so this only has to leave room for the window +// chrome plus those floors, not the full preferred layout. +export const MIN_WIDTH = 840; +export const MIN_HEIGHT = 600; // Bounds a first launch starts with, before the user resizes and we persist their choice. export const DEFAULT_WIDTH = 1024; diff --git a/src/main/ipc/permissions.ts b/src/main/ipc/permissions.ts index 237305d..1e9e2b5 100644 --- a/src/main/ipc/permissions.ts +++ b/src/main/ipc/permissions.ts @@ -1,13 +1,26 @@ -import { ipcMain, shell, systemPreferences } from 'electron'; +import { app, ipcMain, shell, systemPreferences } from 'electron'; + +// macOS caches the Screen Recording grant per-process: a grant made in System Settings +// while the app is running is visible immediately via getMediaAccessStatus, but the process +// itself stays unauthorized for actual capture until relaunched. Starting a ScreenCaptureKit/ +// CoreAudioTap system-audio capture in that gap doesn't fail cleanly on every macOS build - +// it can crash coreaudiod or the audio HAL. Snapshot the status this process launched with so +// a same-session flip to 'granted' can be told apart from a grant that predates this launch. +const screenGrantedAtLaunch = + process.platform === 'darwin' + ? systemPreferences.getMediaAccessStatus('screen') === 'granted' + : true; export function registerPermissionHandlers(): void { ipcMain.handle('permissions:check-all', () => { if (process.platform !== 'darwin') { - return { mic: 'granted', screen: 'granted' }; + return { mic: 'granted', screen: 'granted', screenNeedsRelaunch: false }; } + const screen = systemPreferences.getMediaAccessStatus('screen'); return { mic: systemPreferences.getMediaAccessStatus('microphone'), - screen: systemPreferences.getMediaAccessStatus('screen'), + screen, + screenNeedsRelaunch: screen === 'granted' && !screenGrantedAtLaunch, }; }); @@ -25,4 +38,11 @@ export function registerPermissionHandlers(): void { }; if (urls[pane]) await shell.openExternal(urls[pane]).catch(() => {}); }); + + // macOS only applies a freshly-granted Screen Recording permission after the + // app is relaunched, so the first capture otherwise fails silently. + ipcMain.handle('permissions:relaunch', () => { + app.relaunch(); + app.exit(0); + }); } diff --git a/src/main/preload.cts b/src/main/preload.cts index 0d6e58b..55e8a68 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -166,6 +166,7 @@ const electronApi = { requestMicrophone: () => ipcRenderer.invoke('permissions:request-microphone'), openSettings: (pane: 'microphone' | 'screen') => ipcRenderer.invoke('permissions:open-settings', pane), + relaunch: () => ipcRenderer.invoke('permissions:relaunch'), }, openExternal: (url: string) => ipcRenderer.invoke('external:open', url), diff --git a/src/main/services/window-control.service.ts b/src/main/services/window-control.service.ts index 485d53c..3079238 100644 --- a/src/main/services/window-control.service.ts +++ b/src/main/services/window-control.service.ts @@ -106,6 +106,16 @@ function applySurfaceVisibility(): void { } catch (e) { console.warn('setSkipTaskbar failed:', e); } + + // `titleBarStyle: 'hidden'` draws the traffic lights as native chrome, independent of + // setSkipTaskbar/the Dock icon - they stay on screen in stealth mode unless hidden here too. + if (isMac) { + try { + win.setWindowButtonVisibility(!_stealth); + } catch (e) { + console.warn('setWindowButtonVisibility failed:', e); + } + } } if (isMac) applyDockVisibility(); diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index 2f92f01..45fcdcb 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -86,7 +86,9 @@ export default function ControlPanel() { if (electron) { const perms = await electron.permissions.checkAll(); const micOk = perms.mic === 'granted'; - const screenOk = perms.screen === 'granted' || perms.screen === 'not-determined'; + const screenOk = + (perms.screen === 'granted' || perms.screen === 'not-determined') && + !perms.screenNeedsRelaunch; if (!micOk || !screenOk) { setPermGateOpen(true); return; diff --git a/src/renderer/components/custom/permission-gate-dialog.tsx b/src/renderer/components/custom/permission-gate-dialog.tsx index 7039adb..9cdb946 100644 --- a/src/renderer/components/custom/permission-gate-dialog.tsx +++ b/src/renderer/components/custom/permission-gate-dialog.tsx @@ -26,6 +26,7 @@ export default function PermissionGateDialog({ proceedLabel = 'Start', }: PermissionGateDialogProps) { const { status, loading, allGranted, recheck } = usePermissions(open); + const { screenNeedsRelaunch } = status; const [requesting, setRequesting] = useState(false); const requestMic = async () => { @@ -42,6 +43,10 @@ export default function PermissionGateDialog({ getElectron()?.permissions.openSettings(pane); }; + const relaunch = () => { + getElectron()?.permissions.relaunch(); + }; + const handleProceed = () => { onOpenChange(false); onProceed(); @@ -84,21 +89,32 @@ export default function PermissionGateDialog({ } label="Screen Recording" - status={status.screen} + status={screenNeedsRelaunch ? 'not-determined' : status.screen} note={ status.screen === 'unknown' ? 'Checking...' - : status.screen === 'granted' - ? 'Access granted' - : status.screen === 'not-determined' - ? 'Will be requested when recording starts' - : 'Enable in System Settings, then click Check Again' + : screenNeedsRelaunch + ? 'Granted - restart the app to apply before starting' + : status.screen === 'granted' + ? 'Access granted' + : status.screen === 'not-determined' + ? 'Will be requested when recording starts' + : 'Enable in System Settings, then restart the app to apply' } action={ - status.screen === 'denied' || status.screen === 'restricted' ? ( - + ) : status.screen === 'denied' || status.screen === 'restricted' ? ( +
+ + +
) : null } /> diff --git a/src/renderer/components/custom/titlebar.tsx b/src/renderer/components/custom/titlebar.tsx index d21e651..77d5b79 100644 --- a/src/renderer/components/custom/titlebar.tsx +++ b/src/renderer/components/custom/titlebar.tsx @@ -132,32 +132,36 @@ export default function Titlebar() {

Maximize

+ + + + + +

Close

+
+
)} - - - - - -

Close

-
-
diff --git a/src/renderer/hooks/use-permissions.ts b/src/renderer/hooks/use-permissions.ts index 7dbb99e..f1c5beb 100644 --- a/src/renderer/hooks/use-permissions.ts +++ b/src/renderer/hooks/use-permissions.ts @@ -7,9 +7,10 @@ export type PermStatus = 'not-determined' | 'denied' | 'granted' | 'restricted' export interface PermissionsStatus { mic: PermStatus; screen: PermStatus; + screenNeedsRelaunch: boolean; } -const DEFAULT: PermissionsStatus = { mic: 'unknown', screen: 'unknown' }; +const DEFAULT: PermissionsStatus = { mic: 'unknown', screen: 'unknown', screenNeedsRelaunch: false }; export function usePermissions(active: boolean) { const [status, setStatus] = useState(DEFAULT); @@ -37,7 +38,9 @@ export function usePermissions(active: boolean) { const micOk = status.mic === 'granted'; // macOS 14 (Sonoma): 'not-determined' for screen = dynamic picker handles it at capture time - const screenOk = status.screen === 'granted' || status.screen === 'not-determined'; + const screenOk = + (status.screen === 'granted' || status.screen === 'not-determined') && + !status.screenNeedsRelaunch; const allGranted = micOk && screenOk; return { status, loading, allGranted, recheck: check }; diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 9bb2503..5f0a4f3 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -186,9 +186,13 @@ declare global { checkAll: () => Promise<{ mic: 'not-determined' | 'denied' | 'granted' | 'restricted' | 'unknown'; screen: 'not-determined' | 'denied' | 'granted' | 'restricted' | 'unknown'; + // True when Screen Recording shows 'granted' but was granted after this process + // launched - macOS won't apply it to a live capture until the app is relaunched. + screenNeedsRelaunch: boolean; }>; requestMicrophone: () => Promise; openSettings: (pane: 'microphone' | 'screen') => Promise; + relaunch: () => Promise; }; // Open external URL in user's default browser diff --git a/test/stealth-dock.test.mjs b/test/stealth-dock.test.mjs index 1c6c9b7..4e672f0 100644 --- a/test/stealth-dock.test.mjs +++ b/test/stealth-dock.test.mjs @@ -37,6 +37,7 @@ export async function run() { const calls = app.dockCalls; const skipTaskbar = []; + const windowButtonVisible = []; const handlers = {}; windowControl.setWindowReference({ isDestroyed: () => false, @@ -45,6 +46,7 @@ export async function run() { }, setAlwaysOnTop: () => {}, setSkipTaskbar: (skip) => skipTaskbar.push(skip), + setWindowButtonVisibility: (visible) => windowButtonVisible.push(visible), setVisibleOnAllWorkspaces: () => {}, setIgnoreMouseEvents: () => {}, setFocusable: () => {}, @@ -59,6 +61,7 @@ export async function run() { }); check('the app starts with a Dock icon', surfaceIs(calls, 'visible')); + check('the app starts with traffic lights visible', windowButtonVisible.at(-1) === true); // Five cycles back to back, all inside the one-second window, so every one of them hits the // rate limit. This is the sequence a user produces by mashing the stealth hotkey. @@ -68,11 +71,19 @@ export async function run() { `cycle ${i + 1}: entering stealth asks for the Dock icon to go`, surfaceIs(calls, 'hidden') ); + check( + `cycle ${i + 1}: entering stealth hides the traffic lights`, + windowButtonVisible.at(-1) === false + ); windowControl.disableStealth(); check( `cycle ${i + 1}: leaving stealth asks for the Dock icon back`, surfaceIs(calls, 'visible') ); + check( + `cycle ${i + 1}: leaving stealth shows the traffic lights again`, + windowButtonVisible.at(-1) === true + ); } // Settling in stealth is the case that matters: a call macOS swallowed here leaves the icon up. @@ -91,12 +102,18 @@ export async function run() { // next real toggle needs, so a no-op has to stay a no-op. const beforeEvents = calls.length; skipTaskbar.length = 0; + windowButtonVisible.length = 0; for (const event of ['show', 'restore', 'maximize', 'unmaximize']) handlers[event]?.(); check('window events touch the Dock only when the state changed', calls.length === beforeEvents); check('window events still re-assert the taskbar button', skipTaskbar.at(-1) === true); + check( + 'window events still re-assert the hidden traffic lights', + windowButtonVisible.at(-1) === false + ); windowControl.disableStealth(); check('leaving stealth asks for the Dock icon back', surfaceIs(calls, 'visible')); + check('leaving stealth shows the traffic lights', windowButtonVisible.at(-1) === true); await wait(1300); check('it settles visible', surfaceIs(calls, 'visible')); diff --git a/test/stealth-toggle.test.mjs b/test/stealth-toggle.test.mjs index faa1dda..36b8d58 100644 --- a/test/stealth-toggle.test.mjs +++ b/test/stealth-toggle.test.mjs @@ -22,6 +22,7 @@ export async function run() { }, setAlwaysOnTop: (enabled, level) => alwaysOnTop.push({ enabled, level }), setSkipTaskbar: (skip) => skipTaskbar.push(skip), + setWindowButtonVisibility: () => {}, setVisibleOnAllWorkspaces: () => {}, setIgnoreMouseEvents: () => {}, setFocusable: () => {},