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
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
Loading