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' ? (
-