Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
52003a8
chore: update icon file in build directory
alpha5611331 Jun 24, 2026
de67f26
feat: remove face swap and camera feature entirely
alpha5611331 Jun 24, 2026
398a39d
fix(macos): notarization, icon, arch, and screen-recording UX
alpha5611331 Jun 24, 2026
7a16624
fix(macos): raise minimum system version to 14.4
alpha5611331 Jun 24, 2026
ac4250e
fix(macos): add standard padding to app icon
alpha5611331 Jun 24, 2026
23a900b
fix(macos): configure for unsigned ad-hoc distribution
alpha5611331 Jun 24, 2026
7febf60
Merge branch 'main' into alpha/task46
alpha5611331 Jul 10, 2026
0ac3c4d
Merge branch 'main' into alpha/task46
alpha5611331 Jul 11, 2026
37eace8
Merge branch 'main' into alpha/task46
alpha5611331 Jul 14, 2026
db47a49
Merge branch 'main' into alpha/task46
alpha5611331 Aug 7, 2026
3e7ba80
fix(window): shrink min size, drop redundant close button on macOS
alpha5611331 Aug 7, 2026
90ae18e
Merge branch 'main' into alpha/task46
alpha5611331 Aug 7, 2026
dd14ad3
Merge branch 'main' into alpha/task46
alpha5611331 Aug 7, 2026
6b17607
Merge branch 'main' into alpha/task46
alpha5611331 Aug 7, 2026
8d7d555
feat(stealth): update macOS window button visibility in stealth mode
alpha5611331 Aug 7, 2026
d8461d0
feat(tests): enhance stealth mode tests for window button visibility
alpha5611331 Aug 7, 2026
a93620f
fix(consts): update backend URL for macOS testing and improve window …
alpha5611331 Aug 7, 2026
d1067de
Merge branch 'main' into alpha/task46
alpha5611331 Aug 7, 2026
6067390
feat(permissions): enhance screen recording permission handling and r…
alpha5611331 Aug 8, 2026
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
6 changes: 4 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file added build/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 20 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
13 changes: 7 additions & 6 deletions src/main/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 23 additions & 3 deletions src/main/ipc/permissions.ts
Original file line number Diff line number Diff line change
@@ -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,
};
});

Expand All @@ -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);
});
}
1 change: 1 addition & 0 deletions src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions src/main/services/window-control.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/components/custom/control-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 25 additions & 9 deletions src/renderer/components/custom/permission-gate-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -42,6 +43,10 @@ export default function PermissionGateDialog({
getElectron()?.permissions.openSettings(pane);
};

const relaunch = () => {
getElectron()?.permissions.relaunch();
};

const handleProceed = () => {
onOpenChange(false);
onProceed();
Expand Down Expand Up @@ -84,21 +89,32 @@ export default function PermissionGateDialog({
<PermissionRow
icon={<Monitor className="h-4 w-4" />}
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' ? (
<Button size="sm" variant="outline" onClick={() => openSettings('screen')}>
Open Settings
screenNeedsRelaunch ? (
<Button size="sm" onClick={relaunch}>
Restart App
</Button>
) : status.screen === 'denied' || status.screen === 'restricted' ? (
<div className="flex flex-col gap-1.5">
<Button size="sm" variant="outline" onClick={() => openSettings('screen')}>
Open Settings
</Button>
<Button size="sm" variant="ghost" onClick={relaunch}>
Restart App
</Button>
</div>
) : null
}
/>
Expand Down
52 changes: 28 additions & 24 deletions src/renderer/components/custom/titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,32 +132,36 @@ export default function Titlebar() {
<p>Maximize</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleClose}
aria-label="Close"
className="h-7 w-8 flex items-center justify-center rounded hover:bg-destructive/50"
style={NO_DRAG}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
d="M6 6l12 12M6 18L18 6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Close</p>
</TooltipContent>
</Tooltip>
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleClose}
aria-label="Close"
className="h-7 w-8 flex items-center justify-center rounded hover:bg-destructive/50"
style={NO_DRAG}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M6 6l12 12M6 18L18 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Close</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</>
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/hooks/use-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PermissionsStatus>(DEFAULT);
Expand Down Expand Up @@ -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 };
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/types/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
openSettings: (pane: 'microphone' | 'screen') => Promise<void>;
relaunch: () => Promise<void>;
};

// Open external URL in user's default browser
Expand Down
17 changes: 17 additions & 0 deletions test/stealth-dock.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export async function run() {

const calls = app.dockCalls;
const skipTaskbar = [];
const windowButtonVisible = [];
const handlers = {};
windowControl.setWindowReference({
isDestroyed: () => false,
Expand All @@ -45,6 +46,7 @@ export async function run() {
},
setAlwaysOnTop: () => {},
setSkipTaskbar: (skip) => skipTaskbar.push(skip),
setWindowButtonVisibility: (visible) => windowButtonVisible.push(visible),
setVisibleOnAllWorkspaces: () => {},
setIgnoreMouseEvents: () => {},
setFocusable: () => {},
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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'));
Expand Down
1 change: 1 addition & 0 deletions test/stealth-toggle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export async function run() {
},
setAlwaysOnTop: (enabled, level) => alwaysOnTop.push({ enabled, level }),
setSkipTaskbar: (skip) => skipTaskbar.push(skip),
setWindowButtonVisibility: () => {},
setVisibleOnAllWorkspaces: () => {},
setIgnoreMouseEvents: () => {},
setFocusable: () => {},
Expand Down