diff --git a/package.json b/package.json index 56a131c..8d5623f 100644 --- a/package.json +++ b/package.json @@ -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": "-", diff --git a/src/main/consts.ts b/src/main/consts.ts index 0855776..de84c7c 100644 --- a/src/main/consts.ts +++ b/src/main/consts.ts @@ -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, diff --git a/src/main/ipc/auto-updater.ts b/src/main/ipc/auto-updater.ts index 1209b38..523c469 100644 --- a/src/main/ipc/auto-updater.ts +++ b/src/main/ipc/auto-updater.ts @@ -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); diff --git a/src/main/preload.cts b/src/main/preload.cts index 55e8a68..7859e24 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -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); diff --git a/src/main/services/auto-updater.service.ts b/src/main/services/auto-updater.service.ts index 95fad47..1edf15a 100644 --- a/src/main/services/auto-updater.service.ts +++ b/src/main/services/auto-updater.service.ts @@ -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; @@ -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(); @@ -110,6 +132,10 @@ class AutoUpdaterService { return; } + if (process.platform === 'darwin') { + return this.checkForUpdatesMac(); + } + if (EnvUtil.isDev()) { autoUpdater.forceDevUpdateConfig = true; } @@ -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 { + 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 { + 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; + 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); + 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 { + 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 { + if (process.platform === 'darwin') { + if (!this.macDownloadedFilePath) { + return; + } + await shell.openPath(this.macDownloadedFilePath); + app.quit(); + return; + } + autoUpdater.quitAndInstall(false, true); } diff --git a/src/main/utils/mac-update.util.ts b/src/main/utils/mac-update.util.ts new file mode 100644 index 0000000..4e1af9b --- /dev/null +++ b/src/main/utils/mac-update.util.ts @@ -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; +} + +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; +} diff --git a/src/renderer/components/custom/update-notification.tsx b/src/renderer/components/custom/update-notification.tsx index d160def..b82a3dc 100644 --- a/src/renderer/components/custom/update-notification.tsx +++ b/src/renderer/components/custom/update-notification.tsx @@ -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(), }, }); diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 5f0a4f3..41bf5d7 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -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 diff --git a/test/mac-update-util.test.mjs b/test/mac-update-util.test.mjs new file mode 100644 index 0000000..6fbcd88 --- /dev/null +++ b/test/mac-update-util.test.mjs @@ -0,0 +1,76 @@ +/** + * 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 + ); + + // A release (no suffix) outranks a pre-release of the same numeric core, per semver. + check( + 'a release is newer than a pre-release of the same core', + isNewerVersion('1.7.0', '1.7.0-beta.1') === true + ); + check( + 'a pre-release is not newer than the release it precedes', + isNewerVersion('1.7.0-beta.1', '1.7.0') === false + ); + check( + 'a newer pre-release core still outranks an older release', + isNewerVersion('1.7.0-beta.1', '1.6.9') === true + ); + check( + 'later pre-release identifiers are newer than earlier ones', + isNewerVersion('1.7.0-beta.2', '1.7.0-beta.1') === true + ); + check( + 'identical pre-release tags are not newer', + isNewerVersion('1.7.0-beta.1', '1.7.0-beta.1') === false + ); + + const assets = [ + { + name: 'Power Interview AI-1.6.3-arm64.dmg', + browser_download_url: 'https://example.com/arm64.dmg', + digest: 'sha256:arm64digest', + }, + { + name: 'Power Interview AI-1.6.3-x64.dmg', + browser_download_url: 'https://example.com/x64.dmg', + digest: 'sha256:x64digest', + }, + { 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'); + check('carries the digest through for verification', arm64Pick?.digest === 'sha256:arm64digest'); + + 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); + + const noDigestPick = pickMacAsset( + [{ name: 'Power Interview AI-1.6.3-arm64.dmg', browser_download_url: 'https://example.com/arm64.dmg' }], + 'arm64' + ); + check('defaults digest to null when GitHub has not provided one', noDigestPick?.digest === null); + + return failures; +} diff --git a/test/run.mjs b/test/run.mjs index 39252f7..319a83a 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -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)));