Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
"!**/node_modules/@img/*win32*/**",
"!**/node_modules/@img/*linux*/**"
],
"artifactName": "${productName}-${version}-${arch}.${ext}",
"category": "public.app-category.productivity",
"minimumSystemVersion": "14.4.0",
"identity": "-",
Expand Down
6 changes: 6 additions & 0 deletions src/main/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ export const BACKEND_BASE_URL = EnvUtil.isDev()
? 'http://localhost:8080'
: 'https://api.powerinterviewai.com';

// GitHub repo the release/publish workflow ships to. macOS reads releases from here directly
// (see mac-update.util.ts) since Squirrel.Mac's silent update needs real Developer ID signing,
// which this app does not have yet.
export const GITHUB_RELEASES_OWNER = 'PowerInterviewAI';
export const GITHUB_RELEASES_REPO = 'client-app';

// 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,
Expand Down
4 changes: 2 additions & 2 deletions src/main/ipc/auto-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ export function registerAutoUpdaterHandlers(): void {
}
});

ipcMain.handle('auto-updater:quit-and-install', () => {
ipcMain.handle('auto-updater:quit-and-install', async () => {
try {
autoUpdaterService.quitAndInstall();
await autoUpdaterService.quitAndInstall();
return { success: true };
} catch (error) {
console.error('[IPC] Failed to quit and install:', error);
Expand Down
2 changes: 2 additions & 0 deletions src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ interface PushNotification {
}

const electronApi = {
platform: process.platform,

onHotkeyScroll: (callback: (section: string, direction: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, section: string, direction: string) =>
callback(section, direction);
Expand Down
166 changes: 164 additions & 2 deletions src/main/services/auto-updater.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { mkdir, readdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';

import pkg from 'electron-updater';
const { autoUpdater } = pkg;

import { BrowserWindow } from 'electron';
import { app, BrowserWindow, shell } from 'electron';

import { GITHUB_RELEASES_OWNER, GITHUB_RELEASES_REPO } from '../consts.js';
import { EnvUtil } from '../utils/env.js';
import { isNewerVersion, pickMacAsset } from '../utils/mac-update.util.js';

interface GitHubRelease {
tag_name: string;
published_at: string;
body: string | null;
assets: { name: string; browser_download_url: string; digest?: string | null }[];
}

const MAC_UPDATE_DOWNLOAD_DIR_NAME = 'power-interview-ai-updates';

const MAC_UPDATE_PROGRESS_THROTTLE_MS = 200;

export interface UpdateInfo {
version: string;
Expand Down Expand Up @@ -31,6 +52,7 @@ class AutoUpdaterService {
private mainWindow: BrowserWindow | null = null;
private updateCheckInProgress = false;
private updateDownloaded = false;
private macDownloadedFilePath: string | null = null;

constructor() {
this.setupAutoUpdater();
Expand Down Expand Up @@ -110,6 +132,10 @@ class AutoUpdaterService {
return;
}

if (process.platform === 'darwin') {
return this.checkForUpdatesMac();
}

if (EnvUtil.isDev()) {
autoUpdater.forceDevUpdateConfig = true;
}
Expand All @@ -124,7 +150,143 @@ class AutoUpdaterService {
}
}

quitAndInstall(): void {
// Squirrel.Mac (electron-updater's mac install mechanism) requires the app to be signed with
// a real Developer ID certificate. This build only ad-hoc signs (see package.json mac.identity),
// so silent apply is not viable on macOS - this checks GitHub releases directly instead and
// leaves installing to the user via a downloaded .dmg (see quitAndInstall).
private async checkForUpdatesMac(): Promise<void> {
this.updateCheckInProgress = true;
this.notifyRenderer(UpdateStatus.Checking, null);

try {
const response = await fetch(
`https://api.github.com/repos/${GITHUB_RELEASES_OWNER}/${GITHUB_RELEASES_REPO}/releases/latest`,
{ headers: { Accept: 'application/vnd.github+json' } }
);

if (!response.ok) {
throw new Error(`GitHub releases request failed: ${response.status}`);
}

const release = (await response.json()) as GitHubRelease;
const latestVersion = release.tag_name.replace(/^v/, '');

if (!isNewerVersion(latestVersion, app.getVersion())) {
this.updateCheckInProgress = false;
this.notifyRenderer(UpdateStatus.NotAvailable, null);
return;
}

const info: UpdateInfo = {
version: latestVersion,
releaseDate: release.published_at,
releaseNotes: release.body ?? undefined,
};
this.notifyRenderer(UpdateStatus.Available, info);

const asset = pickMacAsset(release.assets, process.arch);
if (!asset) {
throw new Error("No macOS installer found for this Mac's architecture");
}

await this.downloadMacAsset(asset, info);
} catch (error) {
console.error('[AutoUpdater] Failed to check for updates (mac):', error);
this.updateCheckInProgress = false;
this.notifyRenderer(
UpdateStatus.Error,
null,
null,
error instanceof Error ? error.message : 'Unknown error'
);
}
}

private async downloadMacAsset(
asset: { name: string; url: string; digest: string | null },
info: UpdateInfo
): Promise<void> {
const destDir = path.join(app.getPath('temp'), MAC_UPDATE_DOWNLOAD_DIR_NAME);
const destPath = path.join(destDir, asset.name);

await mkdir(destDir, { recursive: true });
await this.clearMacUpdateDownloadDir(destDir);

const response = await fetch(asset.url);
if (!response.ok || !response.body) {
throw new Error(`Failed to download update: ${response.status}`);
}

const total = Number(response.headers.get('content-length')) || 0;
Comment thread
gitar-bot[bot] marked this conversation as resolved.
let transferred = 0;
let lastEmit = 0;
const startTime = Date.now();
const hash = createHash('sha256');

try {
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream<Uint8Array>);
source.on('data', (chunk: Buffer) => {
hash.update(chunk);
transferred += chunk.length;
const now = Date.now();
if (now - lastEmit < MAC_UPDATE_PROGRESS_THROTTLE_MS && transferred !== total) {
return;
}
lastEmit = now;
const elapsedSeconds = (now - startTime) / 1000;
this.notifyRenderer(UpdateStatus.Downloading, null, {
bytesPerSecond: elapsedSeconds > 0 ? transferred / elapsedSeconds : 0,
percent: total > 0 ? (transferred / total) * 100 : 0,
transferred,
total,
});
});

await pipeline(source, createWriteStream(destPath));

const expectedDigest = asset.digest?.replace(/^sha256:/, '') ?? null;
if (expectedDigest) {
const actualDigest = hash.digest('hex');
if (actualDigest !== expectedDigest) {
throw new Error('Downloaded update failed integrity verification');
}
} else {
console.warn(
'[AutoUpdater] No digest provided by GitHub for this asset; skipping integrity verification'
);
}
} catch (error) {
await rm(destPath, { force: true });
throw error;
}

this.macDownloadedFilePath = destPath;
this.updateDownloaded = true;
this.updateCheckInProgress = false;
this.notifyRenderer(UpdateStatus.Downloaded, info);
}

// Removes any previously downloaded installers from the update temp dir so they
// don't silently accumulate on disk across update checks.
private async clearMacUpdateDownloadDir(destDir: string): Promise<void> {
try {
const entries = await readdir(destDir);
await Promise.all(entries.map((entry) => rm(path.join(destDir, entry), { force: true })));
} catch (error) {
console.warn('[AutoUpdater] Failed to clear stale update downloads:', error);
}
}

async quitAndInstall(): Promise<void> {
if (process.platform === 'darwin') {
if (!this.macDownloadedFilePath) {
return;
}
await shell.openPath(this.macDownloadedFilePath);
app.quit();
return;
}

autoUpdater.quitAndInstall(false, true);
}

Expand Down
69 changes: 69 additions & 0 deletions src/main/utils/mac-update.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Pure helpers for the macOS manual-download update flow (see auto-updater.service.ts).
// No Electron imports here on purpose - keeps this directly unit-testable.

// Splits a version into its numeric core (e.g. "1.6.3") and an optional pre-release
// suffix (e.g. "beta.1" from "1.6.3-beta.1"). A version with a pre-release suffix is
// considered older than the same numeric core without one (per semver precedence rules).
function parseVersion(version: string): { core: number[]; prerelease: string | null } {
const [core, ...rest] = version.split('-');
const prerelease = rest.length > 0 ? rest.join('-') : null;
const coreParts = core.split('.').map((part) => {
const num = Number(part);
return Number.isFinite(num) ? num : 0;
});

return { core: coreParts, prerelease };
}

export function isNewerVersion(remote: string, current: string): boolean {
const remoteVersion = parseVersion(remote);
const currentVersion = parseVersion(current);
const length = Math.max(remoteVersion.core.length, currentVersion.core.length);

for (let i = 0; i < length; i++) {
const remotePart = remoteVersion.core[i] ?? 0;
const currentPart = currentVersion.core[i] ?? 0;
if (remotePart !== currentPart) {
return remotePart > currentPart;
}
}

if (remoteVersion.prerelease === currentVersion.prerelease) {
return false;
}

// Same numeric core: a release (no pre-release suffix) is newer than a pre-release,
// and pre-release identifiers are compared lexically as a fallback.
if (remoteVersion.prerelease === null) {
return true;
}
if (currentVersion.prerelease === null) {
return false;
}

return remoteVersion.prerelease > currentVersion.prerelease;
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.

export interface GitHubReleaseAsset {
name: string;
browser_download_url: string;
digest?: string | null;
}

export interface MacUpdateAsset {
name: string;
url: string;
digest: string | null;
}

// electron-builder's mac artifactName is pinned to `${productName}-${version}-${arch}.${ext}`
// (package.json), so every dmg unambiguously carries its arch - no defaultArch guessing.
export function pickMacAsset(assets: GitHubReleaseAsset[], arch: string): MacUpdateAsset | null {
const match = assets.find(
(asset) => asset.name.endsWith('.dmg') && asset.name.includes(`-${arch}.`)
);

return match
? { name: match.name, url: match.browser_download_url, digest: match.digest ?? null }
: null;
}
7 changes: 5 additions & 2 deletions src/renderer/components/custom/update-notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,14 @@ export function UpdateNotification() {
}

if (info) {
const isMac = window.electronAPI?.platform === 'darwin';
toast.success(`Update Downloaded: v${info.version}`, {
description: 'Click to restart and install the update.',
description: isMac
? 'Click to open the installer, then drag it into Applications.'
: 'Click to restart and install the update.',
duration: Infinity,
action: {
label: 'Restart Now',
label: isMac ? 'Open Installer' : 'Restart Now',
onClick: () => quitAndInstall(),
},
});
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/types/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export {};

declare global {
interface ElectronAPI {
// Host OS, exposed statically at preload time (no IPC round-trip)
platform: 'darwin' | 'win32' | 'linux';

// Hotkey scroll events
onHotkeyScroll: (
callback: (section: string, direction: 'up' | 'down' | 'end') => void
Expand Down
39 changes: 39 additions & 0 deletions test/mac-update-util.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Pure helpers behind the macOS manual-download update flow (auto-updater.service.ts).
* No Electron stub needed - mac-update.util.ts has no electron import.
*/
import { createChecker, loadMain } from './helpers.mjs';

export async function run() {
const { check, failures } = createChecker('mac-update-util');

const { isNewerVersion, pickMacAsset } = await loadMain('utils/mac-update.util.js');

check('newer patch version is newer', isNewerVersion('1.6.3', '1.6.2') === true);
check('newer minor version is newer', isNewerVersion('1.7.0', '1.6.9') === true);
check('newer major version is newer', isNewerVersion('2.0.0', '1.9.9') === true);
check('older version is not newer', isNewerVersion('1.6.1', '1.6.2') === false);
check('equal version is not newer', isNewerVersion('1.6.2', '1.6.2') === false);
check(
'extra trailing segment counts as newer',
isNewerVersion('1.6.2.1', '1.6.2') === true
);

const assets = [
{ name: 'Power Interview AI-1.6.3-arm64.dmg', browser_download_url: 'https://example.com/arm64.dmg' },
{ name: 'Power Interview AI-1.6.3-x64.dmg', browser_download_url: 'https://example.com/x64.dmg' },
{ name: 'Power Interview AI-1.6.3-arm64-mac.zip', browser_download_url: 'https://example.com/arm64.zip' },
{ name: 'Power Interview AI-1.6.3-x64-mac.zip', browser_download_url: 'https://example.com/x64.zip' },
];

const arm64Pick = pickMacAsset(assets, 'arm64');
check('picks the arm64 dmg, not the zip', arm64Pick?.url === 'https://example.com/arm64.dmg');

const x64Pick = pickMacAsset(assets, 'x64');
check('picks the x64 dmg, not the zip', x64Pick?.url === 'https://example.com/x64.dmg');

check('returns null when no asset matches the arch', pickMacAsset(assets, 'ia32') === null);
check('returns null for an empty asset list', pickMacAsset([], 'arm64') === null);

return failures;
}
1 change: 1 addition & 0 deletions test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ for (const module of [
'./stealth-toggle.test.mjs',
'./stealth-dock.test.mjs',
'./tools-export.test.mjs',
'./mac-update-util.test.mjs',
]) {
const { run } = await import(module);
failures.push(...(await run(userDataDir)));
Expand Down