diff --git a/.changeset/desktop-design-port.md b/.changeset/desktop-design-port.md new file mode 100644 index 00000000..2ddae9ce --- /dev/null +++ b/.changeset/desktop-design-port.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Match the desktop app's sidebar, collapse animation, empty-state visuals, and typography to the desktop design. diff --git a/.changeset/desktop-update-settings.md b/.changeset/desktop-update-settings.md new file mode 100644 index 00000000..3421e89d --- /dev/null +++ b/.changeset/desktop-update-settings.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Add a Desktop app section to web settings with automatic updates on by default, a manual update check, and a restart-to-update action. diff --git a/.changeset/journal-question-events.md b/.changeset/journal-question-events.md new file mode 100644 index 00000000..a8b29582 --- /dev/null +++ b/.changeset/journal-question-events.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix sessions failing to load with an invalid event journal error after questions or approvals were resolved. diff --git a/.changeset/node-20-support.md b/.changeset/node-20-support.md new file mode 100644 index 00000000..b2b73a50 --- /dev/null +++ b/.changeset/node-20-support.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Run on Node 20 and newer by only re-executing for FFI support on Node 26.4+. diff --git a/.changeset/sdk-event-union.md b/.changeset/sdk-event-union.md new file mode 100644 index 00000000..d743e7b5 --- /dev/null +++ b/.changeset/sdk-event-union.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code-sdk": major +--- + +Add question, approval, and prompt lifecycle events to the SDK session event types. diff --git a/.changeset/session-listing-resilience.md b/.changeset/session-listing-resilience.md new file mode 100644 index 00000000..d9caf272 --- /dev/null +++ b/.changeset/session-listing-resilience.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Skip invalid sessions during listing instead of failing the whole list. diff --git a/.changeset/status-bar-update-notice.md b/.changeset/status-bar-update-notice.md new file mode 100644 index 00000000..59788ea3 --- /dev/null +++ b/.changeset/status-bar-update-notice.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Highlight the update notice in the terminal status bar with the warning color. diff --git a/.changeset/web-brand-refresh.md b/.changeset/web-brand-refresh.md new file mode 100644 index 00000000..03d6bd88 --- /dev/null +++ b/.changeset/web-brand-refresh.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Refresh the web UI accent color and show the animated mascot on workflow cards, the activity spinner, and the empty state. diff --git a/.changeset/web-transcript-dedupe.md b/.changeset/web-transcript-dedupe.md new file mode 100644 index 00000000..3571a45e --- /dev/null +++ b/.changeset/web-transcript-dedupe.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix duplicated streamed transcript copies and lost paragraph breaks in the web UI. diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 00000000..9c8f863b --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,74 @@ +name: Desktop Release + +on: + push: + tags: ['desktop-v*'] + workflow_dispatch: {} + +permissions: + contents: write + +concurrency: + group: desktop-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + mac: + runs-on: macos-15 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4 + with: + fetch-depth: 0 + persist-credentials: true + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Stamp desktop version for tag builds + if: startsWith(github.ref, 'refs/tags/desktop-v') + env: + TAG_NAME: ${{ github.ref_name }} + run: | + export DESKTOP_VERSION="${TAG_NAME#desktop-v}" + node -e 'const fs = require("node:fs"); const path = "apps/desktop/package.json"; const packageJson = JSON.parse(fs.readFileSync(path, "utf8")); packageJson.version = process.env.DESKTOP_VERSION; fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);' + + - name: Build workspace + run: pnpm --workspace-root run build + + - name: Stage desktop runtime + working-directory: apps/desktop + run: node --import tsx scripts/stage-runtime.ts + + # On a desktop-v* tag, --publish always creates or updates the draft-or-release + # for that tag; contents: write makes GITHUB_TOKEN sufficient. + # Without Developer ID signing secrets, electron-builder publishes an + # ad-hoc/self-signed app. macOS auto-update will not accept unsigned updates, + # but this still proves packaging and the feed shape. + - name: Package and publish desktop release + working-directory: apps/desktop + run: pnpm exec electron-builder --mac dmg zip --publish always + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Upload macOS artifacts for manual runs + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7 + with: + name: desktop-macos + path: | + apps/desktop/dist/*.dmg + apps/desktop/dist/*.zip + apps/desktop/dist/latest-mac.yml + if-no-files-found: error diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 00000000..0572e176 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,75 @@ +# Pythinker Desktop + +The desktop app supervises the existing loopback Web Host and keeps it alive from the system tray when its window is closed. + +## Development + +Install dependencies, then use the single desktop development command. It builds the Host and client packages, Web frontend, and Electron main process before launching the application: + +```sh +pnpm run dev:desktop +``` + +Closing the window hides it. Use the tray menu to restore the window or quit the application. Explicit quit waits for the Host process to stop and escalates termination after the bounded Host grace period. + +The desktop app accepts only the readiness URL emitted by `pythinker server run` for `127.0.0.1` or `localhost`. Navigation stays on that origin; HTTP and HTTPS links open in the system browser. + +Native chrome follows the host platform. macOS uses a frameless inset title bar, traffic lights, and sidebar vibrancy; its collapsed sidebar is 90px wide, with centered controls whose top edge aligns with the expanded logo row below the traffic lights. Windows retains its system frame, shadow, resize and Snap behavior, and Windows 11 rounded corners while a hidden title bar places the native caption buttons in the Session header's first row; the Windows sidebar has no traffic-light inset. The empty part of that row remains draggable, its controls remain clickable, and a resident drag band covers the same row when no Session header is visible. Windows acrylic and macOS vibrancy reach only the sidebar, while conversation and details stay opaque. Linux keeps a frameless window and an opaque sidebar fallback. + +## Packaging + +The local packaging command performs the complete repository build, stages the Host's closed production dependency tree, and creates an unpacked application for the current platform. A separate manual build is not required: + +```sh +pnpm run package:desktop +``` + +Packaged applications run the staged `@pymodel/pythinker-code` CLI in a separate process through Electron's Node mode. The application therefore retains the supervised-Host lifecycle without shipping a second Node executable. An `afterPack` check rejects the package before signing when the staged CLI entry or Web frontend entry is absent. Both macOS and Windows use the exact tracked `apps/desktop/build/icon.png` source; the repository does not preprocess or commit platform-specific icon variants. + +### Signed macOS DMG + +The macOS distribution command requires a valid `Developer ID Application` identity whose certificate and private key are both installed in the build user's Keychain. It also requires one complete notarization credential source. A Keychain profile keeps the app-specific password out of the repository and shell history: + +```sh +xcrun notarytool store-credentials "pythinker-notary" --apple-id "" --team-id "" +``` + +`notarytool` requests the secret interactively. Build the signed, hardened-runtime, notarized DMG with the stored profile: + +```sh +APPLE_KEYCHAIN_PROFILE=pythinker-notary pnpm run dist:mac:desktop +``` + +An existing secrets file can supply `MAC_CERT_P12_BASE64`, `MACOS_SIGN_IDENTITY`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID` without importing the certificate into the persistent Keychain: + +```sh +node --env-file=/absolute/path/to/macos-signing-secrets.env --import tsx apps/desktop/scripts/release-mac.ts +``` + +Electron Builder imports that Base64 PKCS#12 certificate into its temporary Keychain and removes it when the build finishes. The wrapper keeps signing and notarization variables out of the repository-build and runtime-staging subprocesses, then passes them only to Electron Builder. The secrets file and its path are never tracked. + +The release preflight runs before the repository build. It fails if the host is not macOS, the supplied identity is not a `Developer ID Application` identity, signing credentials are incomplete, signing discovery is disabled, or notarization credentials are missing or incomplete. Without the PKCS#12 group, it requires a usable `Developer ID Application` identity and private key in the Keychain. Instead of a Keychain profile, the command accepts the complete Apple ID group (`APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID`) or App Store Connect API key group (`APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`). + +After a successful build, mount the generated DMG and verify the installed application signature, Gatekeeper assessment, and stapled notarization ticket: + +```sh +DMG_PATH="$(find apps/desktop/dist -maxdepth 1 -type f -name '*.dmg' -print -quit)" +MOUNT_POINT="$(mktemp -d)" +hdiutil attach "$DMG_PATH" -mountpoint "$MOUNT_POINT" -nobrowse -readonly +APP_PATH="$MOUNT_POINT/Pythinker.app" +codesign --verify --deep --strict --verbose=2 "$APP_PATH" +spctl --assess --type execute --verbose=4 "$APP_PATH" +xcrun stapler validate "$APP_PATH" +hdiutil detach "$MOUNT_POINT" +rmdir "$MOUNT_POINT" +``` + +## Known limitations + +The first desktop assembly uses a loopback HTTP Host. The renderer and Host protocol remain unchanged so the application can replace the transport with the IPC carrier reserved by the GUI architecture without changing product features. + +The signed installer path currently targets macOS. Windows and Linux packaging creates unpacked applications; their installer formats and distribution signing remain release work. + +## Model Experience + +The desktop shell does not add model-visible input. The reused Web profile continues to own its existing Web runtime context. diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png new file mode 100644 index 00000000..63724942 Binary files /dev/null and b/apps/desktop/build/icon.png differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..4683e506 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,80 @@ +{ + "name": "@pymodel/pythinker-desktop", + "description": "Pythinker desktop application with tray-owned Host lifecycle", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/main.js", + "scripts": { + "build": "tsc -p tsconfig.json && tsdown", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tests/tsconfig.json", + "dev": "pnpm -C ../pythinker-code run build && tsc -p tsconfig.json && tsdown && electron .", + "package": "pnpm --workspace-root run build && node --import tsx scripts/stage-runtime.ts && electron-builder --dir", + "dist": "pnpm --workspace-root run build && node --import tsx scripts/stage-runtime.ts && electron-builder", + "dist:mac": "node --import tsx scripts/release-mac.ts" + }, + "license": "MIT", + "devDependencies": { + "@pymodel/pythinker-telemetry": "workspace:*", + "@types/node": "^26.1.2", + "electron": "43.4.0", + "electron-builder": "26.15.3", + "electron-updater": "^6.8.9", + "tsdown": "0.22.3", + "typescript": "6.0.3", + "vitest": "4.1.9" + }, + "build": { + "appId": "com.pythinker.desktop", + "productName": "Pythinker", + "publish": [ + { + "provider": "github", + "owner": "PyModel", + "repo": "pythinker-code" + } + ], + "afterPack": "./scripts/verify-packaged-runtime.ts", + "asar": true, + "files": [ + "dist/main.js", + "dist/preload.cjs", + "package.json" + ], + "extraResources": [ + { + "from": "resources", + "to": "desktop-resources" + }, + { + "from": "runtime-host/package.json", + "to": "host/package.json" + }, + { + "from": "runtime-host/node_modules", + "to": "host/node_modules" + } + ], + "mac": { + "category": "public.app-category.developer-tools", + "hardenedRuntime": true, + "icon": "build/icon.png", + "notarize": true, + "target": [ + "dir" + ] + }, + "win": { + "icon": "build/icon.png", + "target": [ + "dir" + ] + }, + "linux": { + "category": "Development", + "target": [ + "dir" + ] + } + } +} diff --git a/apps/desktop/resources/splash/idle-00.png b/apps/desktop/resources/splash/idle-00.png new file mode 100644 index 00000000..043931cd Binary files /dev/null and b/apps/desktop/resources/splash/idle-00.png differ diff --git a/apps/desktop/resources/splash/idle-01.png b/apps/desktop/resources/splash/idle-01.png new file mode 100644 index 00000000..7885c6b2 Binary files /dev/null and b/apps/desktop/resources/splash/idle-01.png differ diff --git a/apps/desktop/resources/splash/idle-02.png b/apps/desktop/resources/splash/idle-02.png new file mode 100644 index 00000000..e0a47280 Binary files /dev/null and b/apps/desktop/resources/splash/idle-02.png differ diff --git a/apps/desktop/resources/splash/idle-03.png b/apps/desktop/resources/splash/idle-03.png new file mode 100644 index 00000000..d5e1a18b Binary files /dev/null and b/apps/desktop/resources/splash/idle-03.png differ diff --git a/apps/desktop/resources/splash/idle-04.png b/apps/desktop/resources/splash/idle-04.png new file mode 100644 index 00000000..d60429ef Binary files /dev/null and b/apps/desktop/resources/splash/idle-04.png differ diff --git a/apps/desktop/resources/splash/idle-05.png b/apps/desktop/resources/splash/idle-05.png new file mode 100644 index 00000000..5c037b50 Binary files /dev/null and b/apps/desktop/resources/splash/idle-05.png differ diff --git a/apps/desktop/resources/splash/splash.html b/apps/desktop/resources/splash/splash.html new file mode 100644 index 00000000..8821506d --- /dev/null +++ b/apps/desktop/resources/splash/splash.html @@ -0,0 +1,41 @@ + + + + + + + + + Pythinker + + + diff --git a/apps/desktop/resources/tray/trayIdle@2x.png b/apps/desktop/resources/tray/trayIdle@2x.png new file mode 100644 index 00000000..825226ea Binary files /dev/null and b/apps/desktop/resources/tray/trayIdle@2x.png differ diff --git a/apps/desktop/resources/tray/trayRun-00@2x.png b/apps/desktop/resources/tray/trayRun-00@2x.png new file mode 100644 index 00000000..556d65e4 Binary files /dev/null and b/apps/desktop/resources/tray/trayRun-00@2x.png differ diff --git a/apps/desktop/resources/tray/trayRun-01@2x.png b/apps/desktop/resources/tray/trayRun-01@2x.png new file mode 100644 index 00000000..a32d6aea Binary files /dev/null and b/apps/desktop/resources/tray/trayRun-01@2x.png differ diff --git a/apps/desktop/resources/tray/trayRun-02@2x.png b/apps/desktop/resources/tray/trayRun-02@2x.png new file mode 100644 index 00000000..2856b629 Binary files /dev/null and b/apps/desktop/resources/tray/trayRun-02@2x.png differ diff --git a/apps/desktop/resources/tray/trayRun-03@2x.png b/apps/desktop/resources/tray/trayRun-03@2x.png new file mode 100644 index 00000000..cdec6d7d Binary files /dev/null and b/apps/desktop/resources/tray/trayRun-03@2x.png differ diff --git a/apps/desktop/resources/tray/trayRun-04@2x.png b/apps/desktop/resources/tray/trayRun-04@2x.png new file mode 100644 index 00000000..b813677c Binary files /dev/null and b/apps/desktop/resources/tray/trayRun-04@2x.png differ diff --git a/apps/desktop/resources/tray/trayRun-05@2x.png b/apps/desktop/resources/tray/trayRun-05@2x.png new file mode 100644 index 00000000..81123c9d Binary files /dev/null and b/apps/desktop/resources/tray/trayRun-05@2x.png differ diff --git a/apps/desktop/resources/tray/trayRun-06@2x.png b/apps/desktop/resources/tray/trayRun-06@2x.png new file mode 100644 index 00000000..9d1c08a2 Binary files /dev/null and b/apps/desktop/resources/tray/trayRun-06@2x.png differ diff --git a/apps/desktop/resources/tray/trayRun-07@2x.png b/apps/desktop/resources/tray/trayRun-07@2x.png new file mode 100644 index 00000000..88a43795 Binary files /dev/null and b/apps/desktop/resources/tray/trayRun-07@2x.png differ diff --git a/apps/desktop/runtime-host/package.json b/apps/desktop/runtime-host/package.json new file mode 100644 index 00000000..3fc7f0f0 --- /dev/null +++ b/apps/desktop/runtime-host/package.json @@ -0,0 +1,10 @@ +{ + "name": "@pymodel/pythinker-desktop-runtime", + "description": "Dependency-only deploy root for the packaged desktop Host", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@pymodel/pythinker-code": "workspace:*" + } +} diff --git a/apps/desktop/scripts/release-mac.ts b/apps/desktop/scripts/release-mac.ts new file mode 100644 index 00000000..2a844b22 --- /dev/null +++ b/apps/desktop/scripts/release-mac.ts @@ -0,0 +1,63 @@ +/** Build a signed and notarized macOS DMG from validated release credentials. */ + +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { adaptMacReleaseEnvironment, assertMacReleaseReady } from './release-preflight' + +const RELEASE_VARIABLES = [ + 'APPLE_API_ISSUER', 'APPLE_API_KEY', 'APPLE_API_KEY_ID', + 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_ID', 'APPLE_KEYCHAIN', + 'APPLE_KEYCHAIN_PROFILE', 'APPLE_TEAM_ID', 'CSC_KEY_PASSWORD', + 'CSC_LINK', 'CSC_NAME', 'MACOS_SIGN_IDENTITY', 'MAC_CERT_P12_BASE64', +] as const + +function sanitizedEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const sanitized = { ...env } + for (const name of RELEASE_VARIABLES) delete sanitized[name] + return sanitized +} + +function listCodeSigningIdentities(): string { + const result = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`security find-identity exited with ${String(result.status)}`) + return result.stdout +} + +function run(command: string, args: readonly string[], cwd: string, env: NodeJS.ProcessEnv): void { + const result = spawnSync(command, args, { cwd, env, stdio: 'inherit' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`) +} + +/** Build the macOS artifact while exposing release secrets only to Electron Builder. */ +export function releaseMac(): void { + const releaseEnvironment = adaptMacReleaseEnvironment(process.env) + const result = assertMacReleaseReady({ + env: releaseEnvironment, + platform: process.platform, + listCodeSigningIdentities, + }) + console.log( + `macOS release preflight passed: ${result.identity}; signing via ${result.signing}; notarization via ${result.notarization}`, + ) + const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') + const buildEnvironment = sanitizedEnvironment(releaseEnvironment) + run('pnpm', ['--workspace-root', 'run', 'build'], desktopRoot, buildEnvironment) + run('node', ['--import', 'tsx', 'scripts/stage-runtime.ts'], desktopRoot, buildEnvironment) + run('pnpm', [ + 'exec', 'electron-builder', '--mac', 'dmg', + '--config.forceCodeSigning=true', '--config.mac.notarize=true', + ], desktopRoot, releaseEnvironment) +} + +const invokedPath = process.argv[1] +if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) { + try { + releaseMac() + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } +} diff --git a/apps/desktop/scripts/release-preflight.ts b/apps/desktop/scripts/release-preflight.ts new file mode 100644 index 00000000..a6504873 --- /dev/null +++ b/apps/desktop/scripts/release-preflight.ts @@ -0,0 +1,225 @@ +/** Fail-loud checks required before a signed and notarized macOS desktop release. */ + +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const DEVELOPER_ID_PREFIX = 'Developer ID Application:' +const P12_DATA_PREFIX = 'data:application/x-pkcs12;base64,' + +type NotarizationCredentialSource = 'api-key' | 'apple-id' | 'keychain-profile' +type SigningCredentialSource = 'keychain' | 'p12' + +/** Injectable process inputs for the macOS release preflight. */ +export interface MacReleasePreflightOptions { + /** Environment inherited by electron-builder. */ + readonly env: NodeJS.ProcessEnv + /** Platform that will run electron-builder. */ + readonly platform: NodeJS.Platform + /** Return the valid code-signing identities visible to the build process. */ + readonly listCodeSigningIdentities: () => string +} + +/** Safe release facts confirmed by the preflight. */ +export interface MacReleasePreflightResult { + /** Developer ID Application identity that electron-builder may select. */ + readonly identity: string + /** Credential mechanism that electron-builder will use for notarization. */ + readonly notarization: NotarizationCredentialSource + /** Credential mechanism that electron-builder will use for signing. */ + readonly signing: SigningCredentialSource +} + +function environmentValue(env: NodeJS.ProcessEnv, name: string): string | undefined { + const value = env[name]?.trim() + return value === '' ? undefined : value +} + +function normalizeP12Base64(value: string): string { + const compact = value.replaceAll(/\s/g, '') + if ( + compact.length === 0 + || compact.length % 4 !== 0 + || !/^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/.test(compact) + ) { + throw new Error('MAC_CERT_P12_BASE64 must contain a valid Base64-encoded PKCS#12 file') + } + const decoded = Buffer.from(compact, 'base64') + if (decoded.length === 0 || decoded[0] !== 0x30) { + throw new Error('MAC_CERT_P12_BASE64 must contain a Base64-encoded PKCS#12 file') + } + return compact +} + +function normalizeSigningIdentity(value: string): string { + return value.replaceAll(/\\([ ()])/g, '$1') +} + +function electronBuilderIdentity(identity: string): string { + return identity.slice(DEVELOPER_ID_PREFIX.length).trim() +} + +/** + * Map the desktop release secret names to Electron Builder's signing variables. + * @param env - Environment loaded by Node or inherited from the caller. + * @returns A copy suitable for the Electron Builder subprocess. + */ +export function adaptMacReleaseEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const adapted = { ...env } + const p12 = environmentValue(env, 'MAC_CERT_P12_BASE64') + const identityValue = environmentValue(env, 'MACOS_SIGN_IDENTITY') + const identity = identityValue === undefined ? undefined : normalizeSigningIdentity(identityValue) + if (p12 === undefined && identity === undefined) return adapted + if (p12 === undefined) throw new Error('Incomplete macOS signing credentials: missing MAC_CERT_P12_BASE64') + if (identity === undefined) throw new Error('Incomplete macOS signing credentials: missing MACOS_SIGN_IDENTITY') + if (environmentValue(env, 'CSC_KEY_PASSWORD') === undefined) { + throw new Error('Incomplete macOS signing credentials: missing CSC_KEY_PASSWORD') + } + if (!identity.startsWith(DEVELOPER_ID_PREFIX)) { + throw new Error('MACOS_SIGN_IDENTITY must select a Developer ID Application identity') + } + if (environmentValue(env, 'CSC_LINK') !== undefined) { + throw new Error('Set MAC_CERT_P12_BASE64 or CSC_LINK for macOS signing, not both') + } + const configuredIdentity = environmentValue(env, 'CSC_NAME') + const builderIdentity = electronBuilderIdentity(identity) + if ( + configuredIdentity !== undefined + && configuredIdentity !== identity + && configuredIdentity !== builderIdentity + ) { + throw new Error('MACOS_SIGN_IDENTITY and CSC_NAME select different signing identities') + } + + adapted['CSC_LINK'] = `${P12_DATA_PREFIX}${normalizeP12Base64(p12)}` + adapted['CSC_NAME'] = builderIdentity + delete adapted['MAC_CERT_P12_BASE64'] + delete adapted['MACOS_SIGN_IDENTITY'] + return adapted +} + +function resolveCredentialGroup( + env: NodeJS.ProcessEnv, + names: readonly string[], + source: NotarizationCredentialSource, +): NotarizationCredentialSource | undefined { + const present = names.filter(name => environmentValue(env, name) !== undefined) + if (present.length === 0) return undefined + if (present.length !== names.length) { + const missing = names.filter(name => !present.includes(name)) + throw new Error(`Incomplete macOS notarization credentials: missing ${missing.join(', ')}`) + } + return source +} + +function resolveNotarizationCredentials(env: NodeJS.ProcessEnv): NotarizationCredentialSource { + const appleId = resolveCredentialGroup( + env, + ['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'], + 'apple-id', + ) + if (appleId !== undefined) return appleId + + const apiKey = resolveCredentialGroup( + env, + ['APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER'], + 'api-key', + ) + if (apiKey !== undefined) return apiKey + + const keychainProfile = environmentValue(env, 'APPLE_KEYCHAIN_PROFILE') + if (keychainProfile !== undefined) return 'keychain-profile' + if (environmentValue(env, 'APPLE_KEYCHAIN') !== undefined) { + throw new Error('Incomplete macOS notarization credentials: missing APPLE_KEYCHAIN_PROFILE') + } + throw new Error( + 'macOS notarization credentials are required: set APPLE_KEYCHAIN_PROFILE, the Apple ID trio, or the App Store Connect API key trio', + ) +} + +function developerIdApplications(output: string): readonly string[] { + return [...output.matchAll(/"(Developer ID Application:[^"]+)"/g)].map(match => match[1]!) +} + +/** + * Assert that macOS distribution signing and notarization cannot be skipped. + * @param options - Process inputs and code-signing identity lookup. + * @returns The non-secret release selections confirmed by the checks. + */ +export function assertMacReleaseReady(options: MacReleasePreflightOptions): MacReleasePreflightResult { + if (options.platform !== 'darwin') { + throw new Error('The signed macOS release must be built on macOS') + } + if (environmentValue(options.env, 'CSC_IDENTITY_AUTO_DISCOVERY') === 'false') { + throw new Error('CSC_IDENTITY_AUTO_DISCOVERY=false would disable macOS release signing') + } + + const configuredIdentity = environmentValue(options.env, 'CSC_NAME') + if (configuredIdentity?.startsWith('Apple Development:') === true) { + throw new Error('CSC_NAME must select a Developer ID Application identity for a macOS release') + } + if (configuredIdentity?.startsWith(DEVELOPER_ID_PREFIX) === true) { + throw new Error('CSC_NAME must omit the Developer ID Application certificate-type prefix') + } + const cscLink = environmentValue(options.env, 'CSC_LINK') + let identity: string + let signing: SigningCredentialSource + if (cscLink !== undefined) { + if (environmentValue(options.env, 'CSC_KEY_PASSWORD') === undefined) { + throw new Error('CSC_KEY_PASSWORD is required when CSC_LINK supplies a macOS signing certificate') + } + if (configuredIdentity === undefined) { + throw new Error('CSC_NAME is required when CSC_LINK supplies a macOS signing certificate') + } + identity = `${DEVELOPER_ID_PREFIX} ${configuredIdentity}` + signing = 'p12' + } else { + const identities = developerIdApplications(options.listCodeSigningIdentities()) + if (identities.length === 0) { + throw new Error('A valid Developer ID Application certificate with its private key is required in the Keychain') + } + const matchedIdentity = configuredIdentity === undefined + ? identities[0]! + : identities.find(candidate => electronBuilderIdentity(candidate) === configuredIdentity) + if (matchedIdentity === undefined) { + throw new Error(`CSC_NAME does not match a valid Keychain identity: ${configuredIdentity}`) + } + identity = matchedIdentity + signing = 'keychain' + } + + return { + identity, + notarization: resolveNotarizationCredentials(options.env), + signing, + } +} + +function listCodeSigningIdentities(): string { + const result = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) { + throw new Error(`security find-identity exited with ${String(result.status)}: ${result.stderr.trim()}`) + } + return result.stdout +} + +function main(): void { + try { + const env = adaptMacReleaseEnvironment(process.env) + const result = assertMacReleaseReady({ + env, + platform: process.platform, + listCodeSigningIdentities, + }) + console.log( + `macOS release preflight passed: ${result.identity}; signing via ${result.signing}; notarization via ${result.notarization}`, + ) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } +} + +const invokedPath = process.argv[1] +if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) main() diff --git a/apps/desktop/scripts/stage-runtime.ts b/apps/desktop/scripts/stage-runtime.ts new file mode 100644 index 00000000..fa52c274 --- /dev/null +++ b/apps/desktop/scripts/stage-runtime.ts @@ -0,0 +1,99 @@ +/** Materialize the packaged desktop Host dependency closure. */ + +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve, sep } from 'node:path' + +const desktopRoot = resolve(import.meta.dirname, '..') +const repositoryRoot = resolve(desktopRoot, '../..') +const staging = join(desktopRoot, 'runtime-host') +const deployPackage = '@pymodel/pythinker-code' +const entry = join(staging, 'node_modules/@pymodel/pythinker-code/dist/launcher.mjs') +const frontend = join(staging, 'node_modules/@pymodel/pythinker-code/dist-web/index.html') +const workspaceState = join(repositoryRoot, 'node_modules/.pnpm-workspace-state-v1.json') + +async function run(command: string, args: readonly string[]): Promise { + await new Promise((accept, reject) => { + const child = spawn(command, args, { cwd: repositoryRoot, env: { ...process.env, CI: 'true' }, stdio: 'inherit' }) + child.once('error', reject) + child.once('exit', (code, signal) => { + if (code === 0) accept() + else reject(new Error(`desktop runtime staging failed (${code === null ? `signal ${String(signal)}` : `exit ${String(code)}`}): ${command} ${args.join(' ')}`)) + }) + }) +} + +async function findSymlink(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + const metadata = await lstat(path) + if (metadata.isSymbolicLink()) return path + if (metadata.isDirectory()) { + const nested = await findSymlink(path) + if (nested !== undefined) return nested + } + } + return undefined +} + +async function materializeLinks(): Promise { + const nodeModules = join(staging, 'node_modules') + for (let link = await findSymlink(nodeModules); link !== undefined; link = await findSymlink(nodeModules)) { + const segments = link.slice(nodeModules.length + 1).split(sep) + const bin = segments.lastIndexOf('.bin') + if (bin >= 0) { + await rm(join(nodeModules, ...segments.slice(0, bin + 1)), { recursive: true, force: true }) + continue + } + const source = await realpath(link) + await rm(link, { recursive: true, force: true }) + await cp(source, link, { + recursive: true, + dereference: true, + filter: path => path !== join(source, 'node_modules') && !path.startsWith(join(source, 'node_modules') + sep), + }) + } +} + +async function deploy(target: string): Promise { + const savedWorkspaceState = existsSync(workspaceState) ? await readFile(workspaceState) : undefined + try { + await run(process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', [ + '--config.verify-deps-before-run=false', '--filter', deployPackage, 'deploy', '--legacy', '--prod', + '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', target, + ]) + } finally { + if (savedWorkspaceState === undefined) await rm(workspaceState, { force: true }) + else await writeFile(workspaceState, savedWorkspaceState) + } +} + +async function main(): Promise { + const deployed = await mkdtemp(join(tmpdir(), 'pythinker-desktop-runtime-')) + try { + await deploy(deployed) + await rm(join(staging, 'node_modules'), { recursive: true, force: true }) + await mkdir(staging, { recursive: true }) + await cp(join(deployed, 'node_modules'), join(staging, 'node_modules'), { + recursive: true, + }) + const runtimePackage = join(staging, 'node_modules/@pymodel/pythinker-code') + await mkdir(runtimePackage, { recursive: true }) + for (const name of ['package.json', 'dist', 'dist-web']) { + await cp(join(deployed, name), join(runtimePackage, name), { + recursive: true, + dereference: true, + }) + } + await materializeLinks() + } finally { + await rm(deployed, { recursive: true, force: true }) + } + if (!existsSync(entry)) throw new Error(`desktop Host entry missing after staging: ${entry}`) + if (!existsSync(frontend)) throw new Error(`desktop Web frontend missing after staging: ${frontend}`) + console.log(`desktop runtime staged at ${staging}`) +} + +await main() diff --git a/apps/desktop/scripts/verify-packaged-runtime.ts b/apps/desktop/scripts/verify-packaged-runtime.ts new file mode 100644 index 00000000..6deb0908 --- /dev/null +++ b/apps/desktop/scripts/verify-packaged-runtime.ts @@ -0,0 +1,26 @@ +/** Reject a packaged desktop shell that omitted the staged Host entrypoints. */ + +import { access } from 'node:fs/promises' +import { join } from 'node:path' +import type { AfterPackContext } from 'electron-builder' + +const REQUIRED_HOST_FILES = [ + ['@pymodel', 'pythinker-code', 'dist', 'launcher.mjs'], + ['@pymodel', 'pythinker-code', 'dist-web', 'index.html'], +] as const + +/** + * Verify the Host files required before the signed application can start. + * @param context - Electron Builder's completed application directory. + * @returns A promise that rejects when a staged Host entrypoint is absent. + */ +export async function afterPack(context: AfterPackContext): Promise { + const resources = context.electronPlatformName === 'darwin' + ? join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources') + : join(context.appOutDir, 'resources') + for (const segments of REQUIRED_HOST_FILES) { + await access(join(resources, 'host', 'node_modules', ...segments)) + } +} + +export default afterPack diff --git a/apps/desktop/src/host-supervisor.ts b/apps/desktop/src/host-supervisor.ts new file mode 100644 index 00000000..bc09e555 --- /dev/null +++ b/apps/desktop/src/host-supervisor.ts @@ -0,0 +1,318 @@ +/** Supervise the loopback Web Host used by the first desktop application. */ + +import { spawn, type ChildProcessByStdio } from 'node:child_process' +import type { Readable } from 'node:stream' + +const READINESS_PREFIX = 'Pythinker server: ' +const DEFAULT_READINESS_TIMEOUT_MS = 90_000 +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000 +const MAX_STARTUP_OUTPUT_CHARS = 32_768 + +/** Incremental parser for the Web Host's canonical readiness line. */ +export interface ReadinessParser { + /** + * Consume one stdout chunk. + * @param chunk - Text emitted by the Host. + * @returns The loopback URL once a complete readiness line is observed. + */ + push(chunk: string): string | undefined + /** + * Finish the stream and require a readiness line. + * @returns The parsed loopback URL. + */ + finalize(): string +} + +/** Assert and normalize one readiness line. */ +function parseReadinessLine(line: string): string | undefined { + if (!line.startsWith(READINESS_PREFIX)) return undefined + const token = line.slice(READINESS_PREFIX.length).split(/\s/u, 1)[0] + if (token === undefined) throw new Error(`desktop Host readiness line has no URL: ${line}`) + + let url: URL + try { + url = new URL(token) + } catch { + throw new Error(`desktop Host readiness URL is invalid: ${token}`) + } + const port = Number(url.port) + if (url.protocol !== 'http:' + || (url.hostname !== '127.0.0.1' && url.hostname !== 'localhost') + || url.pathname !== '/' + || url.search !== '' + || url.hash !== '' + || !Number.isInteger(port) + || port < 1 + || port > 65_535) { + throw new Error(`desktop Host readiness URL must be loopback HTTP with an explicit port: ${token}`) + } + return url.origin +} + +/** + * Create a line parser whose result is stable after readiness. + * @returns A fresh incremental parser. + */ +export function createReadinessParser(): ReadinessParser { + let pending = '' + let readyUrl: string | undefined + + const accept = (line: string): string | undefined => { + const parsed = parseReadinessLine(line.replace(/\r$/u, '')) + if (parsed === undefined) return undefined + if (readyUrl !== undefined && parsed !== readyUrl) { + throw new Error(`desktop Host emitted conflicting readiness URLs: ${readyUrl} and ${parsed}`) + } + readyUrl = parsed + return readyUrl + } + + return { + push(chunk) { + pending += chunk + for (;;) { + const newline = pending.indexOf('\n') + if (newline === -1) return readyUrl + const line = pending.slice(0, newline) + pending = pending.slice(newline + 1) + const parsed = accept(line) + if (parsed !== undefined) return parsed + } + }, + finalize() { + if (pending !== '') accept(pending) + if (readyUrl === undefined) throw new Error('desktop Host exited before emitting its readiness URL') + return readyUrl + }, + } +} + +/** Child process operations the supervisor owns. */ +export interface HostChild { + readonly pid?: number + readonly stdout: { onData(listener: (chunk: string) => void): () => void } + readonly stderr: { onData(listener: (chunk: string) => void): () => void } + onExit(listener: (code: number | null, signal: NodeJS.Signals | null) => void): () => void + onError(listener: (error: Error) => void): () => void + kill(signal: 'SIGTERM' | 'SIGKILL'): void +} + +/** Configuration and platform operations for one Host supervisor. */ +export interface HostSupervisorOptions { + /** Spawn one Host process. */ + readonly spawnHost: () => HostChild + /** Maximum startup time before the Host is terminated. */ + readonly readinessTimeoutMs?: number + /** Grace after SIGTERM before SIGKILL. */ + readonly shutdownTimeoutMs?: number + /** Receives bounded Host output for desktop diagnostics. */ + readonly log?: (line: string) => void + /** Called when a ready Host exits outside an application-owned shutdown. */ + readonly onUnexpectedExit?: (detail: { code: number | null; signal: NodeJS.Signals | null }) => void +} + +/** Handle for the desktop-owned Host process. */ +export interface HostSupervisor { + /** Start once, or join the in-flight start. */ + start(): Promise + /** Gracefully stop once, escalating after the configured timeout. */ + shutdown(): Promise +} + +interface Deferred { + readonly promise: Promise + readonly resolve: (value: T) => void + readonly reject: (error: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + +/** + * Create a single-owner Host supervisor. + * @param options - Child-process operations and bounded lifecycle timings. + * @returns A supervisor that coalesces concurrent start and shutdown calls. + */ +export function createHostSupervisor(options: HostSupervisorOptions): HostSupervisor { + const readinessTimeoutMs = options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS + const shutdownTimeoutMs = options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS + let child: HostChild | undefined + let startPromise: Promise | undefined + let shutdownPromise: Promise | undefined + let exited: Promise | undefined + let exitResult: Deferred | undefined + let ready = false + let shuttingDown = false + let output = '' + + const appendOutput = (chunk: string): void => { + output = `${output}${chunk}`.slice(-MAX_STARTUP_OUTPUT_CHARS) + options.log?.(chunk) + } + + const start = (): Promise => { + if (startPromise !== undefined) return startPromise + if (shutdownPromise !== undefined) return Promise.reject(new Error('desktop Host cannot start after shutdown')) + + startPromise = new Promise((resolve, reject) => { + const parser = createReadinessParser() + const spawned = options.spawnHost() + child = spawned + exitResult = deferred() + exited = exitResult.promise + let settled = false + const startupCleanups: Array<() => void> = [] + + const cleanupStartup = (): void => { + clearTimeout(timer) + for (const dispose of startupCleanups.splice(0)) dispose() + } + const fail = (error: unknown): void => { + if (settled) return + settled = true + cleanupStartup() + const diagnostic = output === '' ? '' : `\nHost output:\n${output}` + reject(new Error(`${error instanceof Error ? error.message : String(error)}${diagnostic}`)) + } + const acceptChunk = (chunk: string): void => { + appendOutput(chunk) + try { + const url = parser.push(chunk) + if (url === undefined || settled) return + settled = true + ready = true + cleanupStartup() + resolve(url) + } catch (error) { + fail(error) + spawned.kill('SIGTERM') + } + } + + const timer = setTimeout(() => { + fail(new Error(`desktop Host readiness timed out after ${String(readinessTimeoutMs)}ms`)) + spawned.kill('SIGTERM') + }, readinessTimeoutMs) + startupCleanups.push(spawned.stdout.onData(acceptChunk), spawned.stderr.onData(appendOutput)) + spawned.onError((error) => { + fail(new Error(`desktop Host failed to spawn: ${error.message}`)) + exitResult?.resolve() + }) + spawned.onExit((code, signal) => { + exitResult?.resolve() + if (ready) { + if (!shuttingDown) options.onUnexpectedExit?.({ code, signal }) + return + } + fail(new Error(`desktop Host exited before readiness (code ${String(code)}, signal ${String(signal)})`)) + }) + }) + return startPromise + } + + const shutdown = (): Promise => { + if (shutdownPromise !== undefined) return shutdownPromise + shutdownPromise = (async () => { + const spawned = child + if (spawned === undefined) return + shuttingDown = true + spawned.kill('SIGTERM') + const closed = exited ?? Promise.resolve() + let timer: ReturnType | undefined + const outcome = await Promise.race([ + closed.then(() => 'closed' as const), + new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => { + resolve('timeout') + }, shutdownTimeoutMs) + }), + ]) + if (timer !== undefined) clearTimeout(timer) + if (outcome === 'timeout') { + spawned.kill('SIGKILL') + await closed + } + })() + return shutdownPromise + } + + return { start, shutdown } +} + +/** Options for the real Pythinker server child. */ +export interface SpawnPythinkerServerOptions { + /** Node-compatible executable selected by the desktop app. */ + readonly nodeExecutable: string + /** Built Pythinker CLI entry. */ + readonly cliEntry: string + /** Working directory inherited by user-created sessions and tools. */ + readonly cwd: string + /** Frozen environment for the Host process. */ + readonly env: NodeJS.ProcessEnv + /** Run the Electron executable as its bundled Node runtime. */ + readonly electronRunAsNode?: boolean +} + +function streamAdapter(stream: NodeJS.ReadableStream): HostChild['stdout'] { + return { + onData(listener) { + const accept = (chunk: string | Buffer): void => { listener(chunk.toString()) } + stream.on('data', accept) + return () => { stream.off('data', accept) } + }, + } +} + +/** + * Spawn the production Pythinker server on an OS-assigned loopback port. + * @param options - Node runtime, built CLI and process environment. + * @returns The child handle consumed by {@link createHostSupervisor}. + */ +export function spawnPythinkerServer(options: SpawnPythinkerServerOptions): HostChild { + const env = options.electronRunAsNode + ? { ...options.env, ELECTRON_RUN_AS_NODE: '1' } + : options.env + const process = spawn(options.nodeExecutable, [ + options.cliEntry, + 'server', + 'run', + '--foreground', + '--port', + '0', + '--log-level', + 'error', + ], { + cwd: options.cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + return nodeChildAdapter(process) +} + +/** Adapt Node's event overloads to the supervisor's explicit ownership API. */ +function nodeChildAdapter(child: ChildProcessByStdio): HostChild { + return { + ...(child.pid === undefined ? {} : { pid: child.pid }), + stdout: streamAdapter(child.stdout), + stderr: streamAdapter(child.stderr), + onExit(listener) { + child.on('exit', listener) + return () => { child.off('exit', listener) } + }, + onError(listener) { + child.on('error', listener) + return () => { child.off('error', listener) } + }, + kill(signal) { + child.kill(signal) + }, + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts new file mode 100644 index 00000000..fece9925 --- /dev/null +++ b/apps/desktop/src/main.ts @@ -0,0 +1,386 @@ +/** Electron application shell for the loopback Pythinker Web Host. */ + +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { + app, + BrowserWindow, + dialog, + ipcMain, + Menu, + nativeImage, + session, + shell, + Tray, + type Event, + type MenuItemConstructorOptions, +} from 'electron' +import { + flushTelemetrySync, + initializeTelemetry, + installCrashHandlers, + setCrashPhase, + track, +} from '@pymodel/pythinker-telemetry' +import { createHostSupervisor, spawnPythinkerServer, type HostSupervisor } from './host-supervisor' +import { createSplashWindow } from './splash' +import { + checkForUpdatesNow, + getUpdateState, + initUpdater, + quitAndInstallNow, + setAutoUpdate, +} from './updater' +import { createDesktopLifecycle, type DesktopLifecycle } from './window-lifecycle' + +const APP_NAME = 'Pythinker' +const WINDOW_WIDTH = 1440 +const WINDOW_HEIGHT = 920 +const DESKTOP_DIR = resolve(import.meta.dirname, '..') +const REPOSITORY_ROOT = resolve(DESKTOP_DIR, '../..') +const TELEMETRY_APP_NAME = 'pythinker-desktop' + +interface TrayImages { + readonly idle: Electron.NativeImage + readonly running: readonly Electron.NativeImage[] +} + +let mainWindow: BrowserWindow | undefined +let tray: Tray | undefined +let host: HostSupervisor | undefined +let lifecycle: DesktopLifecycle | undefined +let hostOrigin: string | undefined +let bootQuitPromise: Promise | undefined +let stopTrayAnimation: (() => void) | undefined +let quitReleased = false +let quitTelemetrySent = false + +function desktopDeviceId(homeDir: string): string { + const path = join(homeDir, 'device_id') + try { + const existing = readFileSync(path, 'utf8').trim() + if (existing !== '') return existing + } catch { + // A missing or unreadable id gets a fresh in-memory value. + } + const id = randomUUID() + try { + writeFileSync(path, id, { encoding: 'utf8', mode: 0o600 }) + } catch { + // Telemetry can still use the in-memory id. + } + return id +} + +function initializeDesktopTelemetry(): void { + const homeDir = app.getPath('userData') + initializeTelemetry({ + homeDir, + deviceId: desktopDeviceId(homeDir), + appName: TELEMETRY_APP_NAME, + version: app.getVersion(), + uiMode: 'desktop', + }) + installCrashHandlers() + track('desktop_app_start', { + platform: process.platform, + arch: process.arch, + packaged: app.isPackaged, + }) +} + +/** Resolve artifacts from the checkout in development and resourcesPath when packaged. */ +function hostPaths(): { nodeExecutable: string; cliEntry: string; cwd: string; electronRunAsNode: boolean } { + if (!app.isPackaged) { + return { + nodeExecutable: process.env['PYTHINKER_DESKTOP_NODE_EXECUTABLE'] ?? 'node', + cliEntry: join(REPOSITORY_ROOT, 'apps/pythinker-code/dist/launcher.mjs'), + cwd: process.cwd(), + electronRunAsNode: false, + } + } + return { + nodeExecutable: process.execPath, + cliEntry: join(process.resourcesPath, 'host/node_modules/@pymodel/pythinker-code/dist/launcher.mjs'), + cwd: app.getPath('home'), + electronRunAsNode: true, + } +} + +function assertHostArtifacts(paths: ReturnType): void { + if (paths.nodeExecutable.includes('/') && !existsSync(paths.nodeExecutable)) { + throw new Error(`desktop Node runtime is missing: ${paths.nodeExecutable}`) + } + if (!existsSync(paths.cliEntry)) { + throw new Error(`desktop Host entry is missing: ${paths.cliEntry}; run pnpm run build first`) + } +} + +function desktopResources(name: 'splash' | 'tray'): string { + return app.isPackaged + ? join(process.resourcesPath, 'desktop-resources', name) + : join(DESKTOP_DIR, 'resources', name) +} + +function loadTrayImages(): TrayImages { + const resources = desktopResources('tray') + return { + idle: nativeImage.createFromPath(join(resources, 'trayIdle@2x.png')), + running: Array.from({ length: 8 }, (_, index) => nativeImage.createFromPath( + join(resources, `trayRun-${String(index).padStart(2, '0')}@2x.png`), + )), + } +} + +function startTrayAnimation(images: TrayImages): () => void { + let index = 0 + const timer = setInterval(() => { + const image = images.running[index] + if (image !== undefined) tray?.setImage(image) + index = (index + 1) % images.running.length + }, 125) + return () => { + clearInterval(timer) + tray?.setImage(images.idle) + } +} + +function isExternalUrl(raw: string): boolean { + try { + const url = new URL(raw) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + +function hasOrigin(raw: string, expected: string): boolean { + try { + return new URL(raw).origin === expected + } catch { + return false + } +} + +function assertTrustedSender(event: Electron.IpcMainInvokeEvent): void { + const frame = event.senderFrame + if (hostOrigin === undefined || frame === null || frame !== event.sender.mainFrame || !hasOrigin(frame.url, hostOrigin)) { + throw new Error('desktop update IPC rejected an untrusted sender') + } +} + +/** Install navigation and permission policy before the first renderer loads. */ +function hardenSession(): void { + const desktopSession = session.defaultSession + desktopSession.setPermissionCheckHandler(() => false) + desktopSession.setPermissionRequestHandler((_webContents, _permission, callback) => { callback(false) }) +} + +async function createMainWindow(): Promise { + const origin = hostOrigin + if (origin === undefined) throw new Error('desktop Host is not ready') + const window = new BrowserWindow({ + width: WINDOW_WIDTH, + height: WINDOW_HEIGHT, + minWidth: 960, + minHeight: 640, + show: false, + autoHideMenuBar: true, + frame: process.platform === 'win32', + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden', + ...(process.platform === 'darwin' ? {} : { + titleBarOverlay: { + color: '#00000000', + symbolColor: '#7f858f', + height: 44, + }, + }), + ...(process.platform === 'darwin' ? { + trafficLightPosition: { x: 16, y: 18 }, + vibrancy: 'sidebar' as const, + visualEffectState: 'followWindow' as const, + } : {}), + ...(process.platform === 'win32' ? { + backgroundMaterial: 'acrylic' as const, + hasShadow: true, + roundedCorners: true, + thickFrame: true, + } : { + transparent: true, + backgroundColor: '#00000000', + }), + title: APP_NAME, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + preload: join(app.getAppPath(), 'dist', 'preload.cjs'), + sandbox: true, + webSecurity: true, + }, + }) + mainWindow = window + initUpdater(() => mainWindow, track) + window.on('close', (event) => { lifecycle?.onWindowClose(event) }) + window.on('closed', () => { + if (mainWindow === window) mainWindow = undefined + }) + window.webContents.on('will-navigate', (event, url) => { + if (hasOrigin(url, origin)) return + event.preventDefault() + if (isExternalUrl(url)) void shell.openExternal(url) + }) + window.webContents.setWindowOpenHandler(({ url }) => { + if (isExternalUrl(url)) void shell.openExternal(url) + return { action: 'deny' } + }) + const rendererUrl = new URL(origin) + rendererUrl.searchParams.set('pythinker-desktop-platform', process.platform) + await window.loadURL(rendererUrl.href) + if (!lifecycle?.isQuitting) window.show() + return window +} + +function showWindowSafely(): void { + void lifecycle?.showWindow().catch((error: unknown) => { + console.error('desktop window failed to open:', error) + }) +} + +ipcMain.handle('pythinker:update:get', (event) => { + assertTrustedSender(event) + return getUpdateState() +}) +ipcMain.handle('pythinker:update:set-auto', (event, enabled: unknown) => { + assertTrustedSender(event) + if (typeof enabled !== 'boolean') throw new TypeError('automatic updates must be a boolean') + return setAutoUpdate(enabled) +}) +ipcMain.handle('pythinker:update:check', (event) => { + assertTrustedSender(event) + return checkForUpdatesNow() +}) +ipcMain.handle('pythinker:update:install', (event) => { + assertTrustedSender(event) + return quitAndInstallNow() +}) + +function createTray(images: TrayImages): void { + tray = new Tray(images.idle) + tray.setToolTip(APP_NAME) + const template: MenuItemConstructorOptions[] = [ + { label: 'Open Pythinker', click: showWindowSafely }, + { type: 'separator' }, + { label: 'Quit', click: () => { void requestAppQuit() } }, + ] + tray.setContextMenu(Menu.buildFromTemplate(template)) + tray.on('click', showWindowSafely) +} + +function releaseAppQuit(): void { + stopTrayAnimation?.() + stopTrayAnimation = undefined + quitReleased = true + tray?.destroy() + tray = undefined + app.quit() +} + +/** Join explicit quit requests even while the Host or window is still starting. */ +function requestAppQuit(): Promise { + if (lifecycle !== undefined) return lifecycle.requestQuit() + bootQuitPromise ??= (host?.shutdown() ?? Promise.resolve()).catch((error: unknown) => { + console.error('desktop shutdown failed:', error) + }).then(() => { + releaseAppQuit() + }) + return bootQuitPromise +} + +async function boot(): Promise { + if (bootQuitPromise !== undefined) return + initializeDesktopTelemetry() + const paths = hostPaths() + assertHostArtifacts(paths) + const splash = createSplashWindow(desktopResources('splash')) + const destroySplash = (): void => { + if (!splash.isDestroyed()) splash.destroy() + } + + try { + const trayFrames = loadTrayImages() + createTray(trayFrames) + stopTrayAnimation = startTrayAnimation(trayFrames) + host = createHostSupervisor({ + spawnHost: () => spawnPythinkerServer({ + ...paths, + env: { + ...process.env, + PYTHINKER_DESKTOP: '1', + }, + }), + log: chunk => process.stderr.write(chunk), + onUnexpectedExit: ({ code, signal }) => { + console.error(`desktop Host exited unexpectedly (code ${String(code)}, signal ${String(signal)})`) + void requestAppQuit() + }, + }) + try { + hostOrigin = await host.start() + track('desktop_server_ready') + } catch (error) { + track('desktop_server_failed') + throw error + } + stopTrayAnimation?.() + stopTrayAnimation = undefined + hardenSession() + lifecycle = createDesktopLifecycle({ + getWindow: () => mainWindow, + createWindow: createMainWindow, + disposeHost: async () => { await host?.shutdown() }, + quit: releaseAppQuit, + reportError: (error) => { console.error('desktop shutdown failed:', error) }, + }) + await lifecycle.showWindow() + setCrashPhase('runtime') + destroySplash() + } catch (error) { + stopTrayAnimation?.() + stopTrayAnimation = undefined + destroySplash() + throw error + } +} + +if (!app.requestSingleInstanceLock()) { + app.quit() +} else { + app.on('second-instance', showWindowSafely) + app.on('activate', showWindowSafely) + app.on('window-all-closed', () => { + // Tray and Host own application lifetime on every platform. + }) + app.on('before-quit', (event: Event) => { + if (!quitTelemetrySent) { + quitTelemetrySent = true + setCrashPhase('shutdown') + track('desktop_app_quit') + flushTelemetrySync() + } + if (quitReleased) return + event.preventDefault() + void requestAppQuit() + }) + app.whenReady().then(boot).catch(async (error: unknown) => { + console.error('desktop startup failed:', error) + if (bootQuitPromise === undefined) { + await dialog.showMessageBox({ + type: 'error', + title: `${APP_NAME} failed to start`, + message: error instanceof Error ? error.message : String(error), + }) + } + await requestAppQuit() + }) +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts new file mode 100644 index 00000000..0c2c2d3c --- /dev/null +++ b/apps/desktop/src/preload.ts @@ -0,0 +1,14 @@ +import { contextBridge, ipcRenderer } from 'electron' + +contextBridge.exposeInMainWorld('pythinkerDesktop', { + platform: process.platform, + getUpdateState: () => ipcRenderer.invoke('pythinker:update:get'), + setAutoUpdate: (enabled: boolean) => ipcRenderer.invoke('pythinker:update:set-auto', enabled), + checkForUpdates: () => ipcRenderer.invoke('pythinker:update:check'), + quitAndInstall: () => ipcRenderer.invoke('pythinker:update:install'), + onUpdateState: (cb: (state: unknown) => void) => { + const listener = (_event: unknown, state: unknown) => cb(state) + ipcRenderer.on('pythinker:update:state', listener) + return () => ipcRenderer.removeListener('pythinker:update:state', listener) + }, +}) diff --git a/apps/desktop/src/splash.ts b/apps/desktop/src/splash.ts new file mode 100644 index 00000000..b9b9abfe --- /dev/null +++ b/apps/desktop/src/splash.ts @@ -0,0 +1,24 @@ +import { join } from 'node:path' +import { BrowserWindow } from 'electron' + +/** Create the transparent animated boot splash. */ +export function createSplashWindow(resourcesDir: string): BrowserWindow { + const window = new BrowserWindow({ + width: 280, + height: 300, + frame: false, + transparent: true, + backgroundColor: '#00000000', + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + center: true, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + void window.loadFile(join(resourcesDir, 'splash.html')) + return window +} diff --git a/apps/desktop/src/updater.ts b/apps/desktop/src/updater.ts new file mode 100644 index 00000000..cd6ebd4a --- /dev/null +++ b/apps/desktop/src/updater.ts @@ -0,0 +1,266 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { app, type BrowserWindow } from 'electron' +import electronUpdater from 'electron-updater' + +const { autoUpdater } = electronUpdater +const UPDATE_SETTINGS_FILE = 'update-settings.json' +const INITIAL_CHECK_DELAY_MS = 10_000 +const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1_000 + +export interface UpdateSettings { + readonly autoUpdate: boolean +} + +export type UpdateState = { + status: 'disabled' | 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'error' + version?: string + percent?: number + message?: string + autoUpdate: boolean +} + +export type UpdateTelemetryTrack = ( + event: string, + properties?: Readonly>, +) => void + +const DEFAULT_SETTINGS: UpdateSettings = { autoUpdate: true } + +export function readUpdateSettings(dir: string): UpdateSettings { + try { + const parsed: unknown = JSON.parse(readFileSync(join(dir, UPDATE_SETTINGS_FILE), 'utf8')) + if (typeof parsed === 'object' && parsed !== null) { + const autoUpdate = (parsed as { autoUpdate?: unknown }).autoUpdate + if (typeof autoUpdate === 'boolean') return { autoUpdate } + } + } catch { + // Missing and corrupt settings use the default. + } + return DEFAULT_SETTINGS +} + +export function writeUpdateSettings(dir: string, settings: UpdateSettings): void { + writeFileSync(join(dir, UPDATE_SETTINGS_FILE), `${JSON.stringify(settings, null, 2)}\n`, 'utf8') +} + +let settings = DEFAULT_SETTINGS +let state: UpdateState = { + status: app.isPackaged ? 'idle' : 'disabled', + autoUpdate: settings.autoUpdate, +} +let getWindow: (() => BrowserWindow | undefined) | undefined +let initialCheckTimer: ReturnType | undefined +let checkInterval: ReturnType | undefined +let listenersWired = false +let initialized = false +let updateTelemetryTrack: UpdateTelemetryTrack = () => {} + +export function trackUpdateTransition( + previous: UpdateState, + next: UpdateState, + track: UpdateTelemetryTrack, +): void { + if (previous.status === next.status) return + switch (next.status) { + case 'checking': + track('desktop_update_check') + break + case 'available': + track('desktop_update_available', next.version === undefined ? {} : { version: next.version }) + break + case 'downloaded': + track('desktop_update_downloaded', next.version === undefined ? {} : { version: next.version }) + break + case 'error': + track('desktop_update_error', { + message: (next.message ?? 'unknown update error').replaceAll(/\s+/gu, ' ').slice(0, 200), + }) + break + default: + break + } +} + +function emitUpdateTelemetry(previous: UpdateState, next: UpdateState): void { + try { + trackUpdateTransition(previous, next, updateTelemetryTrack) + } catch { + // Telemetry must never delay update handling. + } +} + +function stateError(error: unknown): void { + updateState({ + status: 'error', + message: error instanceof Error ? error.message : String(error), + }) +} + +function updateState(next: Partial): void { + const previous = state + state = { ...state, ...next, autoUpdate: settings.autoUpdate } + emitUpdateTelemetry(previous, state) + const window = getWindow?.() + if (window === undefined || window.isDestroyed() || window.webContents.isDestroyed()) return + try { + window.webContents.send('pythinker:update:state', state) + } catch { + // The renderer can disappear between the destroyed check and send. + } +} + +function clearTimers(): void { + if (initialCheckTimer !== undefined) clearTimeout(initialCheckTimer) + if (checkInterval !== undefined) clearInterval(checkInterval) + initialCheckTimer = undefined + checkInterval = undefined +} + +function scheduleChecks(): void { + if (checkInterval !== undefined) return + checkInterval = setInterval(() => { + if (settings.autoUpdate) void runCheck() + }, CHECK_INTERVAL_MS) +} + +async function runCheck(): Promise { + updateState({ status: 'checking', message: undefined, version: undefined, percent: undefined }) + try { + await autoUpdater.checkForUpdates() + } catch (error) { + stateError(error) + } + return state +} + +function wireUpdaterEvents(): void { + if (listenersWired) return + try { + autoUpdater.on('checking-for-update', () => { + updateState({ status: 'checking', message: undefined }) + }) + autoUpdater.on('update-available', (info) => { + updateState({ status: 'available', version: info.version, message: undefined, percent: undefined }) + }) + autoUpdater.on('update-not-available', () => { + updateState({ status: 'idle', message: 'No updates available', version: undefined, percent: undefined }) + }) + autoUpdater.on('download-progress', (progress) => { + updateState({ status: 'downloading', percent: progress.percent }) + }) + autoUpdater.on('update-downloaded', (info) => { + updateState({ status: 'downloaded', version: info.version, percent: 100, message: undefined }) + }) + autoUpdater.on('error', stateError) + listenersWired = true + } catch (error) { + stateError(error) + } +} + +export function initUpdater( + windowGetter: () => BrowserWindow | undefined, + track: UpdateTelemetryTrack = () => {}, +): void { + if (initialized) { + getWindow = windowGetter + updateTelemetryTrack = track + updateState({}) + return + } + initialized = true + getWindow = windowGetter + updateTelemetryTrack = track + settings = readUpdateSettings(app.getPath('userData')) + state = { + status: app.isPackaged ? 'idle' : 'disabled', + autoUpdate: settings.autoUpdate, + } + updateState({}) + clearTimers() + + if (!app.isPackaged) return + + try { + autoUpdater.autoDownload = settings.autoUpdate + autoUpdater.autoInstallOnAppQuit = settings.autoUpdate + } catch (error) { + stateError(error) + return + } + wireUpdaterEvents() + app.once('will-quit', clearTimers) + + if (settings.autoUpdate) { + initialCheckTimer = setTimeout(() => { void runCheck() }, INITIAL_CHECK_DELAY_MS) + scheduleChecks() + } +} + +export function getUpdateState(): UpdateState { + return state +} + +export function setAutoUpdate(enabled: boolean): UpdateState { + const wasEnabled = settings.autoUpdate + const nextSettings = { autoUpdate: enabled } + writeUpdateSettings(app.getPath('userData'), nextSettings) + settings = nextSettings + updateState({}) + + if (!app.isPackaged) return state + + try { + autoUpdater.autoDownload = enabled + autoUpdater.autoInstallOnAppQuit = enabled + } catch (error) { + stateError(error) + return state + } + if (enabled) { + scheduleChecks() + if (!wasEnabled) void checkForUpdatesNow() + } else { + clearTimers() + } + return state +} + +export async function checkForUpdatesNow(): Promise { + if (!app.isPackaged) { + updateState({ status: 'disabled' }) + return state + } + + try { + autoUpdater.autoDownload = true + } catch (error) { + stateError(error) + return state + } + try { + return await runCheck() + } finally { + autoUpdater.autoDownload = settings.autoUpdate + } +} + +export function quitAndInstallNow(): UpdateState { + if (!app.isPackaged) { + updateState({ status: 'disabled' }) + return state + } + + try { + updateTelemetryTrack('desktop_update_install') + } catch { + // Telemetry must never delay update installation. + } + try { + autoUpdater.quitAndInstall() + } catch (error) { + stateError(error) + } + return state +} diff --git a/apps/desktop/src/window-lifecycle.ts b/apps/desktop/src/window-lifecycle.ts new file mode 100644 index 00000000..c2861ca0 --- /dev/null +++ b/apps/desktop/src/window-lifecycle.ts @@ -0,0 +1,94 @@ +/** Desktop window and application lifetime independent from Electron imports. */ + +/** Minimal close event accepted by the desktop lifecycle. */ +export interface WindowCloseEvent { + /** Keep the application alive while the window is hidden. */ + preventDefault(): void +} + +/** Window operations owned by the desktop lifecycle. */ +export interface DesktopWindow { + /** Whether the native window has been destroyed. */ + isDestroyed(): boolean + /** Whether the native window is visible. */ + isVisible(): boolean + /** Reveal the existing window. */ + show(): void + /** Give the existing window keyboard focus. */ + focus(): void + /** Hide without tearing down its renderer or Host connection. */ + hide(): void +} + +/** Dependencies supplied by the Electron main process. */ +export interface DesktopLifecycleOptions { + /** Return the current native window, when one exists. */ + readonly getWindow: () => DesktopWindow | undefined + /** Create and load a replacement native window. */ + readonly createWindow: () => Promise + /** Stop the desktop-owned Host and reach process quiescence. */ + readonly disposeHost: () => Promise + /** Retry Electron's ordinary quit after asynchronous teardown completes. */ + readonly quit: () => void + /** Report a teardown failure before the retry is released. */ + readonly reportError?: (error: unknown) => void +} + +/** Public controller for close, restore and explicit quit events. */ +export interface DesktopLifecycle { + /** True after an explicit application quit begins. */ + readonly isQuitting: boolean + /** Current quit operation, shared by every quit source. */ + readonly pendingQuit: Promise | undefined + /** Hide an ordinary close, or let it proceed during explicit quit. */ + onWindowClose(event: WindowCloseEvent): void + /** Show the existing window or create a replacement. */ + showWindow(): Promise + /** Dispose the Host once, then release Electron's quit sequence. */ + requestQuit(): Promise +} + +/** + * Create the desktop application lifecycle. + * @param options - Native window access, Host teardown and quit release. + * @returns A lifecycle whose Host outlives ordinary window closes. + */ +export function createDesktopLifecycle(options: DesktopLifecycleOptions): DesktopLifecycle { + let quitting = false + let pendingQuit: Promise | undefined + let creatingWindow: Promise | undefined + + const showWindow = async (): Promise => { + if (quitting) return + let window = options.getWindow() + if (window === undefined || window.isDestroyed()) { + creatingWindow ??= options.createWindow().finally(() => { creatingWindow = undefined }) + window = await creatingWindow + } + if (!window.isVisible()) window.show() + window.focus() + } + + const requestQuit = (): Promise => { + if (pendingQuit !== undefined) return pendingQuit + quitting = true + pendingQuit = options.disposeHost().catch((error: unknown) => { + options.reportError?.(error) + }).then(() => { + options.quit() + }) + return pendingQuit + } + + return { + get isQuitting() { return quitting }, + get pendingQuit() { return pendingQuit }, + onWindowClose(event) { + if (quitting) return + event.preventDefault() + options.getWindow()?.hide() + }, + showWindow, + requestQuit, + } +} diff --git a/apps/desktop/tests/host-supervisor.spec.ts b/apps/desktop/tests/host-supervisor.spec.ts new file mode 100644 index 00000000..049d10f2 --- /dev/null +++ b/apps/desktop/tests/host-supervisor.spec.ts @@ -0,0 +1,317 @@ +import { spawn } from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createHostSupervisor, + createReadinessParser, + type HostChild, +} from '../src/host-supervisor' + +vi.mock('node:child_process', { spy: true }) + +type HostExitListener = Parameters[0] +type HostExitSignal = Parameters[1] + +class FakeOutput { + private readonly listeners = new Set<(chunk: string) => void>() + + onData(listener: (chunk: string) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + emit(chunk: string): void { + for (const listener of this.listeners) listener(chunk) + } +} + +class FakeHostChild implements HostChild { + readonly pid = 123 + readonly stdout = new FakeOutput() + readonly stderr = new FakeOutput() + readonly signals: Array<'SIGTERM' | 'SIGKILL'> = [] + private readonly exitListeners = new Set() + private readonly errorListeners = new Set<(error: Error) => void>() + + onExit(listener: HostExitListener): () => void { + this.exitListeners.add(listener) + return () => { this.exitListeners.delete(listener) } + } + + onError(listener: (error: Error) => void): () => void { + this.errorListeners.add(listener) + return () => { this.errorListeners.delete(listener) } + } + + kill(signal: 'SIGTERM' | 'SIGKILL'): void { + this.signals.push(signal) + } + + emitExit(code: number | null = 0, signal: HostExitSignal = null): void { + for (const listener of this.exitListeners) listener(code, signal) + } + + emitError(error: Error): void { + for (const listener of this.errorListeners) listener(error) + } +} + +function observeSettlement(promise: Promise): ReturnType { + const settled = vi.fn() + void promise.then(settled, settled) + return settled +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('desktop Host readiness', () => { + it('extracts the canonical URL from arbitrarily chunked output and ignores unrelated URLs', () => { + const parser = createReadinessParser() + + expect(parser.push('Node warning: see https://nodejs.org/docs\n')).toBeUndefined() + expect(parser.push('Pythinker se')).toBeUndefined() + expect(parser.push('rver: http://127.0.')).toBeUndefined() + expect(parser.push('0.1:4173 (LAN: http://192.0.2.10:4173)')).toBeUndefined() + expect(parser.push('\nstartup complete\n')).toBe('http://127.0.0.1:4173') + expect(parser.finalize()).toBe('http://127.0.0.1:4173') + }) + + it('accepts a complete unterminated readiness line when the stream ends', () => { + const parser = createReadinessParser() + + expect(parser.push('diagnostic\nPythinker server: http://localhost:51234')).toBeUndefined() + expect(parser.finalize()).toBe('http://localhost:51234') + }) + + it.each([ + 'Pythinker server: https://127.0.0.1:4173', + 'Pythinker server: http://0.0.0.0:4173', + 'Pythinker server: http://127.0.0.1:0', + 'Pythinker server: http://127.0.0.1:65536', + 'Pythinker server: http://127.0.0.1:not-a-port', + ])('rejects an invalid readiness line: %s', (line) => { + const parser = createReadinessParser() + + expect(() => parser.push(`${line}\n`)).toThrow(/readiness/iu) + }) + + it('fails when the stream ends before a readiness line arrives', () => { + const parser = createReadinessParser() + + parser.push('ordinary startup output\n') + expect(() => parser.finalize()).toThrow(/readiness/iu) + }) + + it('rejects conflicting readiness URLs', () => { + const parser = createReadinessParser() + + expect(parser.push('Pythinker server: http://127.0.0.1:4173\n')).toBe('http://127.0.0.1:4173') + expect(() => parser.push('Pythinker server: http://127.0.0.1:4174\n')).toThrow(/conflicting readiness URLs/iu) + }) +}) + +describe('desktop Host supervisor', () => { + it('starts one child for concurrent callers and returns its stdout readiness URL', async () => { + const child = new FakeHostChild() + const spawnHost = vi.fn(() => child) + const supervisor = createHostSupervisor({ spawnHost }) + + const first = supervisor.start() + const second = supervisor.start() + expect(second).toBe(first) + expect(spawnHost).toHaveBeenCalledOnce() + + child.stdout.emit('Pythinker server: http://127.0.0.1:4567\n') + await expect(first).resolves.toBe('http://127.0.0.1:4567') + expect(child.signals).toEqual([]) + }) + + it('does not combine stderr and stdout fragments into a readiness line', async () => { + const child = new FakeHostChild() + const supervisor = createHostSupervisor({ spawnHost: () => child }) + const starting = supervisor.start() + const settled = observeSettlement(starting) + + child.stderr.emit('Pythinker se') + child.stdout.emit('rver: http://127.0.0.1:4567\n') + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + child.stdout.emit('Pythinker server: http://127.0.0.1:4567\n') + await expect(starting).resolves.toBe('http://127.0.0.1:4567') + }) + + it('reports output when the Host exits before readiness', async () => { + const child = new FakeHostChild() + const supervisor = createHostSupervisor({ spawnHost: () => child }) + const starting = supervisor.start() + + child.stderr.emit('configuration rejected\n') + child.emitExit(7) + + await expect(starting).rejects.toThrow(/exited before readiness \(code 7, signal null\).*configuration rejected/su) + }) + + it('contains a synchronous spawn failure as a rejected start', async () => { + const failure = new Error('spawn unavailable') + const supervisor = createHostSupervisor({ + spawnHost: () => { throw failure }, + }) + + await expect(supervisor.start()).rejects.toBe(failure) + }) + + it('forbids starting after shutdown', async () => { + const spawnHost = vi.fn(() => new FakeHostChild()) + const supervisor = createHostSupervisor({ spawnHost }) + + await expect(supervisor.shutdown()).resolves.toBeUndefined() + await expect(supervisor.start()).rejects.toThrow('desktop Host cannot start after shutdown') + expect(spawnHost).not.toHaveBeenCalled() + }) + + it('rejects startup when the child exits after an unterminated readiness fragment', async () => { + const child = new FakeHostChild() + const supervisor = createHostSupervisor({ spawnHost: () => child }) + const starting = supervisor.start() + + child.stdout.emit('Pythinker server: http://127.0.0.1:4567') + child.emitExit(0) + + await expect(starting).rejects.toThrow(/exited before readiness/iu) + }) + + it('times out startup once and terminates the unready child', async () => { + vi.useFakeTimers() + const child = new FakeHostChild() + const supervisor = createHostSupervisor({ + spawnHost: () => child, + readinessTimeoutMs: 25, + }) + const starting = supervisor.start() + const rejected = expect(starting).rejects.toThrow('desktop Host readiness timed out after 25ms') + + await vi.advanceTimersByTimeAsync(24) + expect(child.signals).toEqual([]) + await vi.advanceTimersByTimeAsync(1) + await rejected + expect(child.signals).toEqual(['SIGTERM']) + + await vi.advanceTimersByTimeAsync(100) + expect(child.signals).toEqual(['SIGTERM']) + }) + + it('reports a ready Host exit only when shutdown does not own it', async () => { + const child = new FakeHostChild() + const onUnexpectedExit = vi.fn() + const supervisor = createHostSupervisor({ + spawnHost: () => child, + onUnexpectedExit, + }) + const starting = supervisor.start() + await Promise.resolve() + child.stdout.emit('Pythinker server: http://127.0.0.1:4567\n') + await starting + + child.emitExit(9, null) + + expect(onUnexpectedExit).toHaveBeenCalledOnce() + expect(onUnexpectedExit).toHaveBeenCalledWith({ code: 9, signal: null }) + }) + + it('coalesces shutdown and waits for the ready child to exit after SIGTERM', async () => { + vi.useFakeTimers() + const child = new FakeHostChild() + const onUnexpectedExit = vi.fn() + const supervisor = createHostSupervisor({ + spawnHost: () => child, + shutdownTimeoutMs: 25, + onUnexpectedExit, + }) + const starting = supervisor.start() + child.stdout.emit('Pythinker server: http://127.0.0.1:4567\n') + await starting + + const first = supervisor.shutdown() + const second = supervisor.shutdown() + const settled = observeSettlement(first) + expect(second).toBe(first) + expect(child.signals).toEqual(['SIGTERM']) + expect(onUnexpectedExit).not.toHaveBeenCalled() + + child.emitExit(0) + await vi.advanceTimersByTimeAsync(0) + expect(settled).toHaveBeenCalledOnce() + await expect(first).resolves.toBeUndefined() + + await vi.advanceTimersByTimeAsync(25) + expect(child.signals).toEqual(['SIGTERM']) + }) + + it('escalates a stuck shutdown once and still waits for child exit', async () => { + vi.useFakeTimers() + const child = new FakeHostChild() + const supervisor = createHostSupervisor({ + spawnHost: () => child, + shutdownTimeoutMs: 25, + }) + const starting = supervisor.start() + child.stdout.emit('Pythinker server: http://127.0.0.1:4567\n') + await starting + + const closing = supervisor.shutdown() + const settled = observeSettlement(closing) + expect(child.signals).toEqual(['SIGTERM']) + await vi.advanceTimersByTimeAsync(24) + expect(child.signals).toEqual(['SIGTERM']) + await vi.advanceTimersByTimeAsync(1) + expect(child.signals).toEqual(['SIGTERM', 'SIGKILL']) + expect(settled).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(100) + expect(child.signals).toEqual(['SIGTERM', 'SIGKILL']) + child.emitExit(null, 'SIGKILL') + await vi.advanceTimersByTimeAsync(0) + expect(settled).toHaveBeenCalledOnce() + await expect(closing).resolves.toBeUndefined() + }) +}) + +describe('desktop Host process', () => { + it('opts the packaged Electron executable into its Node runtime', async () => { + const spawned = { + stdout: { on: vi.fn(), off: vi.fn() }, + stderr: { on: vi.fn(), off: vi.fn() }, + on: vi.fn(), + off: vi.fn(), + kill: vi.fn(), + } + vi.mocked(spawn).mockReturnValue(spawned as never) + + const { spawnPythinkerServer } = await import('../src/host-supervisor') + spawnPythinkerServer({ + nodeExecutable: '/Applications/Pythinker.app/Contents/MacOS/Pythinker', + cliEntry: '/Applications/Pythinker.app/Contents/Resources/host/node_modules/@pymodel/pythinker-code/dist/launcher.mjs', + cwd: '/Users/tester', + env: { PYTHINKER_DESKTOP: '1' }, + electronRunAsNode: true, + }) + + expect(spawn).toHaveBeenCalledWith( + '/Applications/Pythinker.app/Contents/MacOS/Pythinker', + [ + '/Applications/Pythinker.app/Contents/Resources/host/node_modules/@pymodel/pythinker-code/dist/launcher.mjs', + 'server', + 'run', + '--foreground', + '--port', + '0', + '--log-level', + 'error', + ], + expect.objectContaining({ env: { PYTHINKER_DESKTOP: '1', ELECTRON_RUN_AS_NODE: '1' } }), + ) + }) +}) diff --git a/apps/desktop/tests/packaging-config.spec.ts b/apps/desktop/tests/packaging-config.spec.ts new file mode 100644 index 00000000..73ffbaad --- /dev/null +++ b/apps/desktop/tests/packaging-config.spec.ts @@ -0,0 +1,87 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +interface DesktopPackage { + readonly scripts: Readonly> + readonly build: { + readonly afterPack: string + readonly appId: string + readonly electronDist?: string + readonly extraResources: readonly { + readonly from: string + readonly to: string + }[] + readonly mac: { + readonly hardenedRuntime: boolean + readonly icon: string + readonly notarize: boolean + } + readonly productName: string + readonly win: { readonly icon: string } + } +} + +interface RootPackage { + readonly scripts: Readonly> +} + +const desktopRoot = resolve(import.meta.dirname, '..') +const repositoryRoot = resolve(desktopRoot, '../..') +const desktopPackage = JSON.parse( + readFileSync(resolve(desktopRoot, 'package.json'), 'utf8'), +) as DesktopPackage +const rootPackage = JSON.parse( + readFileSync(resolve(repositoryRoot, 'package.json'), 'utf8'), +) as RootPackage + +describe('desktop packaging configuration', () => { + it('packages the application with expected metadata', () => { + expect(desktopPackage.build.appId).toBe('com.pythinker.desktop') + expect(desktopPackage.build.productName).toBe('Pythinker') + }) + + it('maps the staged Host node_modules directory as the copy root', () => { + expect(desktopPackage.build.extraResources).toEqual(expect.arrayContaining([ + { from: 'resources', to: 'desktop-resources' }, + { from: 'runtime-host/package.json', to: 'host/package.json' }, + { from: 'runtime-host/node_modules', to: 'host/node_modules' }, + ])) + expect(desktopPackage.build.afterPack).toBe('./scripts/verify-packaged-runtime.ts') + expect(existsSync(resolve(desktopRoot, 'resources/tray/trayIdle@2x.png'))).toBe(true) + expect(existsSync(resolve(desktopRoot, 'resources/splash/idle-00.png'))).toBe(true) + }) + + it('keeps the supplied image byte-for-byte and shares it across macOS and Windows', () => { + const icon = readFileSync(resolve(desktopRoot, 'build/icon.png')) + + expect(createHash('sha256').update(icon).digest('hex')) + .toBe('50294e23ec2763e0602dbf68bb0528d410ff6d3bb92b01e6a1d2dd683f342751') + expect(desktopPackage.build.mac.icon).toBe('build/icon.png') + expect(desktopPackage.build.win.icon).toBe('build/icon.png') + }) + + it('builds and stages the complete workspace before local packaging', () => { + for (const name of ['package', 'dist']) { + expect(desktopPackage.scripts[name]).toContain('pnpm --workspace-root run build') + expect(desktopPackage.scripts[name]).toContain('scripts/stage-runtime.ts') + } + expect(desktopPackage.scripts['package']).toContain('electron-builder --dir') + expect(desktopPackage.scripts['package']).not.toContain('release-preflight.ts') + }) + + it('makes the macOS DMG path signed, hardened, and notarized', () => { + const command = desktopPackage.scripts['dist:mac'] + + expect(command).toBe('node --import tsx scripts/release-mac.ts') + expect(desktopPackage.build.mac.hardenedRuntime).toBe(true) + expect(desktopPackage.build.mac.notarize).toBe(true) + }) + + it('exposes desktop commands at the repository root', () => { + expect(rootPackage.scripts['dev:desktop']).toBe('pnpm -C apps/desktop run dev') + expect(rootPackage.scripts['package:desktop']).toBe('pnpm -C apps/desktop run package') + expect(rootPackage.scripts['dist:mac:desktop']).toBe('pnpm -C apps/desktop run dist:mac') + }) +}) diff --git a/apps/desktop/tests/release-preflight.spec.ts b/apps/desktop/tests/release-preflight.spec.ts new file mode 100644 index 00000000..49ac276b --- /dev/null +++ b/apps/desktop/tests/release-preflight.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { adaptMacReleaseEnvironment, assertMacReleaseReady } from '../scripts/release-preflight' + +const DEVELOPER_ID_OUTPUT = ` + 1) 0123456789ABCDEF "Developer ID Application: Mengxin Yang (TEAM123456)" + 1 valid identities found +` + +function ready(overrides: Partial = {}) { + return assertMacReleaseReady({ + env: { APPLE_KEYCHAIN_PROFILE: 'pythinker-notary', ...overrides }, + platform: 'darwin', + listCodeSigningIdentities: () => DEVELOPER_ID_OUTPUT, + }) +} + +describe('macOS release preflight', () => { + it('accepts a valid Developer ID identity and Keychain notary profile', () => { + expect(ready()).toEqual({ + identity: 'Developer ID Application: Mengxin Yang (TEAM123456)', + notarization: 'keychain-profile', + signing: 'keychain', + }) + }) + + it.each([ + { + env: { + APPLE_API_KEY: '/private/AuthKey.p8', + APPLE_API_KEY_ID: 'KEY123', + APPLE_API_ISSUER: 'issuer-id', + APPLE_KEYCHAIN_PROFILE: undefined, + }, + source: 'api-key', + }, + { + env: { + APPLE_ID: 'developer@example.test', + APPLE_APP_SPECIFIC_PASSWORD: 'secret', + APPLE_TEAM_ID: 'TEAM123456', + APPLE_KEYCHAIN_PROFILE: undefined, + }, + source: 'apple-id', + }, + ])('accepts a complete $source credential set', ({ env, source }) => { + expect(ready(env).notarization).toBe(source) + }) + + it('rejects a missing Developer ID Application identity', () => { + expect(() => assertMacReleaseReady({ + env: { APPLE_KEYCHAIN_PROFILE: 'pythinker-notary' }, + platform: 'darwin', + listCodeSigningIdentities: () => '0 valid identities found', + })).toThrow('Developer ID Application') + }) + + it('rejects an explicitly selected development identity', () => { + expect(() => ready({ CSC_NAME: 'Apple Development: Developer (TEAM123456)' })) + .toThrow('CSC_NAME must select a Developer ID Application') + }) + + it('adapts the supplied PKCS#12 variables without writing a certificate file', () => { + const env = adaptMacReleaseEnvironment({ + APPLE_ID: 'developer@example.test', + APPLE_APP_SPECIFIC_PASSWORD: 'secret', + APPLE_TEAM_ID: 'TEAM123456', + CSC_KEY_PASSWORD: 'p12-secret', + MAC_CERT_P12_BASE64: Buffer.from([0x30, 0x03, 0x02, 0x01, 0x00]).toString('base64'), + MACOS_SIGN_IDENTITY: 'Developer ID Application: Mengxin Yang (TEAM123456)', + }) + expect(env['CSC_LINK']).toMatch(/^data:application\/x-pkcs12;base64,/) + expect(env['CSC_NAME']).toBe('Mengxin Yang (TEAM123456)') + expect(assertMacReleaseReady({ + env, + platform: 'darwin', + listCodeSigningIdentities: () => '0 valid identities found', + }).signing).toBe('p12') + }) + + it('normalizes a shell-escaped Developer ID identity from an env file', () => { + const env = adaptMacReleaseEnvironment({ + CSC_KEY_PASSWORD: 'p12-secret', + MAC_CERT_P12_BASE64: Buffer.from([0x30, 0x03, 0x02, 0x01, 0x00]).toString('base64'), + MACOS_SIGN_IDENTITY: String.raw`Developer\ ID\ Application:\ Mengxin\ Yang\ (TEAM123456)`, + }) + + expect(env['CSC_NAME']).toBe('Mengxin Yang (TEAM123456)') + }) + + it('rejects an incomplete or non-Developer-ID PKCS#12 signing group', () => { + expect(() => adaptMacReleaseEnvironment({ + MAC_CERT_P12_BASE64: 'MA==', + })).toThrow('MACOS_SIGN_IDENTITY') + expect(() => adaptMacReleaseEnvironment({ + CSC_KEY_PASSWORD: 'secret', + MAC_CERT_P12_BASE64: 'MA==', + MACOS_SIGN_IDENTITY: 'Apple Development: Developer (TEAM123456)', + })).toThrow('Developer ID Application') + }) + + it('rejects partial notarization credentials before electron-builder can skip notarization', () => { + expect(() => ready({ + APPLE_API_KEY: '/private/AuthKey.p8', + APPLE_KEYCHAIN_PROFILE: undefined, + })).toThrow('APPLE_API_KEY_ID, APPLE_API_ISSUER') + }) + + it('rejects release execution away from macOS', () => { + expect(() => assertMacReleaseReady({ + env: { APPLE_KEYCHAIN_PROFILE: 'pythinker-notary' }, + platform: 'win32', + listCodeSigningIdentities: () => DEVELOPER_ID_OUTPUT, + })).toThrow('must be built on macOS') + }) +}) diff --git a/apps/desktop/tests/tsconfig.json b/apps/desktop/tests/tsconfig.json new file mode 100644 index 00000000..b1e851cd --- /dev/null +++ b/apps/desktop/tests/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "composite": false, + "incremental": false, + "noEmit": true, + "rewriteRelativeImportExtensions": false, + "types": [ + "node", + "vitest" + ] + }, + "include": [ + "../scripts/**/*.ts", + "../src/**/*.ts", + "**/*.ts" + ] +} diff --git a/apps/desktop/tests/updater.spec.ts b/apps/desktop/tests/updater.spec.ts new file mode 100644 index 00000000..232c50ae --- /dev/null +++ b/apps/desktop/tests/updater.spec.ts @@ -0,0 +1,78 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => '', + }, +})) + +vi.mock('electron-updater', () => ({ + default: { autoUpdater: {} }, +})) + +import { + readUpdateSettings, + trackUpdateTransition, + writeUpdateSettings, + type UpdateState, +} from '../src/updater' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function temporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), 'pythinker-updater-')) + temporaryDirectories.push(directory) + return directory +} + +describe('update settings', () => { + it('defaults automatic updates to enabled when the file is missing', () => { + expect(readUpdateSettings(temporaryDirectory())).toEqual({ autoUpdate: true }) + }) + + it('defaults automatic updates to enabled when the file is corrupt', () => { + const directory = temporaryDirectory() + writeFileSync(join(directory, 'update-settings.json'), '{not-json', 'utf8') + + expect(readUpdateSettings(directory)).toEqual({ autoUpdate: true }) + }) + + it('round-trips the automatic-updates setting', () => { + const directory = temporaryDirectory() + writeUpdateSettings(directory, { autoUpdate: false }) + + expect(readUpdateSettings(directory)).toEqual({ autoUpdate: false }) + }) +}) + +describe('update telemetry transitions', () => { + it('emits the expected lifecycle event names', () => { + const events: string[] = [] + const track = (event: string): void => { + events.push(event) + } + const previous: UpdateState = { status: 'idle', autoUpdate: true } + + trackUpdateTransition(previous, { ...previous, status: 'checking' }, track) + trackUpdateTransition(previous, { ...previous, status: 'available', version: '0.2.0' }, track) + trackUpdateTransition(previous, { ...previous, status: 'downloaded', version: '0.2.0' }, track) + trackUpdateTransition(previous, { ...previous, status: 'error', message: 'download failed' }, track) + + expect(events).toEqual([ + 'desktop_update_check', + 'desktop_update_available', + 'desktop_update_downloaded', + 'desktop_update_error', + ]) + }) +}) diff --git a/apps/desktop/tests/verify-packaged-runtime.spec.ts b/apps/desktop/tests/verify-packaged-runtime.spec.ts new file mode 100644 index 00000000..ee624638 --- /dev/null +++ b/apps/desktop/tests/verify-packaged-runtime.spec.ts @@ -0,0 +1,41 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { afterPack } from '../scripts/verify-packaged-runtime' + +function context(appOutDir: string, electronPlatformName = 'darwin') { + return { + appOutDir, + electronPlatformName, + packager: { appInfo: { productFilename: 'Pythinker' } }, + } as Parameters[0] +} + +describe('packaged desktop runtime verification', () => { + it('accepts both packaged Host entrypoints', async () => { + const appOutDir = await mkdtemp(join(tmpdir(), 'pythinker-packaged-runtime-')) + try { + const resources = join(appOutDir, 'Pythinker.app', 'Contents', 'Resources', 'host', 'node_modules') + const cli = join(resources, '@pymodel', 'pythinker-code', 'dist', 'launcher.mjs') + const web = join(resources, '@pymodel', 'pythinker-code', 'dist-web', 'index.html') + await mkdir(join(cli, '..'), { recursive: true }) + await mkdir(join(web, '..'), { recursive: true }) + await writeFile(cli, '') + await writeFile(web, '') + + await expect(afterPack(context(appOutDir))).resolves.toBeUndefined() + } finally { + await rm(appOutDir, { recursive: true, force: true }) + } + }) + + it('rejects a shell whose Host dependency tree was filtered out', async () => { + const appOutDir = await mkdtemp(join(tmpdir(), 'pythinker-packaged-runtime-')) + try { + await expect(afterPack(context(appOutDir))).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(appOutDir, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/desktop/tests/window-lifecycle.spec.ts b/apps/desktop/tests/window-lifecycle.spec.ts new file mode 100644 index 00000000..daa2def0 --- /dev/null +++ b/apps/desktop/tests/window-lifecycle.spec.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createDesktopLifecycle, + type DesktopWindow, +} from '../src/window-lifecycle' + +interface FakeDesktopWindow extends DesktopWindow { + focus: ReturnType void>> + hide: ReturnType void>> + show: ReturnType void>> +} + +interface TestDeferred { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function testDeferred(): TestDeferred { + let resolve!: (value: T) => void + const promise = new Promise((accept) => { + resolve = accept + }) + return { promise, resolve } +} + +function fakeWindow(options: { destroyed?: boolean; visible?: boolean } = {}): FakeDesktopWindow { + let visible = options.visible ?? true + const show = vi.fn<() => void>(() => { visible = true }) + const hide = vi.fn<() => void>(() => { visible = false }) + return { + isDestroyed: () => options.destroyed ?? false, + isVisible: () => visible, + show, + focus: vi.fn<() => void>(), + hide, + } +} + +describe('desktop window lifecycle', () => { + it('hides an ordinary close without disposing the Host', () => { + const window = fakeWindow() + const preventDefault = vi.fn() + const disposeHost = vi.fn(() => Promise.resolve()) + const lifecycle = createDesktopLifecycle({ + getWindow: () => window, + createWindow: () => Promise.resolve(window), + disposeHost, + quit: vi.fn(), + }) + + lifecycle.onWindowClose({ preventDefault }) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(window.hide).toHaveBeenCalledOnce() + expect(disposeHost).not.toHaveBeenCalled() + expect(lifecycle.isQuitting).toBe(false) + }) + + it('restores and focuses the existing hidden window', async () => { + const window = fakeWindow({ visible: false }) + const createWindow = vi.fn(() => Promise.resolve(window)) + const lifecycle = createDesktopLifecycle({ + getWindow: () => window, + createWindow, + disposeHost: () => Promise.resolve(), + quit: vi.fn(), + }) + + await lifecycle.showWindow() + + expect(createWindow).not.toHaveBeenCalled() + expect(window.show).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalledOnce() + }) + + it('single-flights replacement creation for concurrent restore requests', async () => { + const replacement = fakeWindow({ visible: false }) + const created = testDeferred() + const createWindow = vi.fn(() => created.promise) + const lifecycle = createDesktopLifecycle({ + getWindow: () => undefined, + createWindow, + disposeHost: () => Promise.resolve(), + quit: vi.fn(), + }) + + const first = lifecycle.showWindow() + const second = lifecycle.showWindow() + expect(createWindow).toHaveBeenCalledOnce() + + created.resolve(replacement) + await Promise.all([first, second]) + expect(replacement.show).toHaveBeenCalledOnce() + expect(replacement.focus).toHaveBeenCalledTimes(2) + }) + + it('coalesces explicit quit, lets the window close, and releases quit after Host disposal', async () => { + const window = fakeWindow() + const disposal = testDeferred() + const disposeHost = vi.fn(() => disposal.promise) + const quit = vi.fn() + const lifecycle = createDesktopLifecycle({ + getWindow: () => window, + createWindow: () => Promise.resolve(window), + disposeHost, + quit, + }) + + const first = lifecycle.requestQuit() + const second = lifecycle.requestQuit() + expect(second).toBe(first) + expect(lifecycle.pendingQuit).toBe(first) + expect(lifecycle.isQuitting).toBe(true) + expect(disposeHost).toHaveBeenCalledOnce() + expect(quit).not.toHaveBeenCalled() + + const preventDefault = vi.fn() + lifecycle.onWindowClose({ preventDefault }) + expect(preventDefault).not.toHaveBeenCalled() + expect(window.hide).not.toHaveBeenCalled() + + await lifecycle.showWindow() + expect(window.focus).not.toHaveBeenCalled() + + disposal.resolve(undefined) + await first + expect(quit).toHaveBeenCalledOnce() + }) + + it('reports a Host disposal failure and still releases Electron quit', async () => { + const failure = new Error('Host disposal failed') + const reportError = vi.fn() + const quit = vi.fn() + const lifecycle = createDesktopLifecycle({ + getWindow: () => undefined, + createWindow: () => Promise.resolve(fakeWindow()), + disposeHost: () => Promise.reject(failure), + reportError, + quit, + }) + + await expect(lifecycle.requestQuit()).resolves.toBeUndefined() + expect(reportError).toHaveBeenCalledOnce() + expect(reportError).toHaveBeenCalledWith(failure) + expect(quit).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 00000000..d5a04ba0 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "types": [ + "node", + "electron" + ] + }, + "include": [ + "src" + ] +} diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts new file mode 100644 index 00000000..0bb3dc01 --- /dev/null +++ b/apps/desktop/tsdown.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'tsdown' + +/** Bundle the Electron main entry while preserving Electron as a runtime builtin. */ +export default defineConfig([ + { + entry: ['src/main.ts'], + outDir: 'dist', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + deps: { neverBundle: ['electron'] }, + }, + { + entry: ['src/preload.ts'], + outDir: 'dist', + format: ['cjs'], + platform: 'node', + target: 'es2024', + dts: false, + clean: false, + deps: { neverBundle: ['electron'] }, + }, +]) diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts new file mode 100644 index 00000000..80ffabbc --- /dev/null +++ b/apps/desktop/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.spec.ts'], + }, +}) diff --git a/apps/pythinker-code/src/launcher.ts b/apps/pythinker-code/src/launcher.ts index 592ec291..9877878f 100644 --- a/apps/pythinker-code/src/launcher.ts +++ b/apps/pythinker-code/src/launcher.ts @@ -3,21 +3,26 @@ import { spawn } from 'node:child_process'; const FFI_FLAG = '--experimental-ffi'; const FFI_WARNING_FLAG = '--disable-warning=ExperimentalWarning'; const FFI_CHILD_ENV = 'PYTHINKER_CODE_FFI_CHILD'; -const REQUIRED_RUNTIME = 'Node.js 26.4.0 or newer with experimental FFI support'; -const MINIMUM_NODE = [26, 4, 0] as const; +// Local on purpose: the FFI launcher test executes this file standalone, so it must stay import-free beyond node builtins. +const REQUIRED_RUNTIME = 'Node.js 20 or newer'; +const MINIMUM_NODE = [20, 0, 0] as const; +const FFI_NODE = [26, 4, 0] as const; const NATIVE_INSTALL_HINT = 'Alternatively, use the native installer (no Node.js required): https://code.pythinker.com'; /** - * Older Node (e.g. 24 LTS) has no `--experimental-ffi`, so the re-exec below - * would die with a cryptic `bad option` error. npm installs the package on any - * Node version (engines is only a warning for consumers), so guard here with - * an actionable message instead. + * Node versions before 26.4 do not support `--experimental-ffi`, so run the + * app directly instead of re-execing with an unsupported flag. npm installs + * the package on any Node version (engines is only a warning for consumers), + * so guard the actual runtime floor here with an actionable message. */ -function isRuntimeTooOld(): boolean { - const parts = process.versions.node.split('.').map(Number); +function isVersionBelow( + version: string, + minimum: readonly [number, number, number], +): boolean { + const parts = version.split('.').map(Number); const [major = 0, minor = 0, patch = 0] = parts; - const [reqMajor, reqMinor, reqPatch] = MINIMUM_NODE; + const [reqMajor, reqMinor, reqPatch] = minimum; if (major !== reqMajor) return major < reqMajor; if (minor !== reqMinor) return minor < reqMinor; return patch < reqPatch; @@ -29,8 +34,8 @@ function isFfiProcess(): boolean { } /** - * Windows-only fallback for platforms without `process.execve` (Node < 26.4 - * does not ship it on win32). Re-exec via spawn instead. + * Windows-only fallback for platforms without `process.execve`. Older Node + * releases do not ship it on win32. Re-exec via spawn instead. */ function launchWindowsFallback( nodeArguments: readonly string[], @@ -84,7 +89,7 @@ function launchWindowsFallback( } async function launch(): Promise { - if (isRuntimeTooOld()) { + if (isVersionBelow(process.versions.node, MINIMUM_NODE)) { process.stderr.write( `Pythinker Code requires ${REQUIRED_RUNTIME}; you are running Node.js ${process.versions.node}.\n` + `${NATIVE_INSTALL_HINT}\n`, @@ -93,6 +98,11 @@ async function launch(): Promise { return; } + if (isVersionBelow(process.versions.node, FFI_NODE)) { + await import(new URL('./main.mjs', import.meta.url).href); + return; + } + if (isFfiProcess()) { await import(new URL('./main.mjs', import.meta.url).href); return; diff --git a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts index 329e05b0..59af57fa 100644 --- a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts +++ b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts @@ -27,6 +27,8 @@ export type StatusBarStatus = Pick< | 'tokenSpeedEstimated' > & { readonly extras: readonly string[]; + /** The `extras` entry that carries the update notice, painted in `warning`. */ + readonly updateExtra?: string; readonly sessionKey: string; readonly statusLine: StatusLineConfig; }; @@ -61,7 +63,7 @@ export class StatusBarComponent implements Component { : undefined; let modesChip = status.statusLine.showModes ? renderModesChip(status) : undefined; const extraChips = status.extras.map((extra) => - chip(currentTheme.fg('textDim', extra)), + chip(currentTheme.fg(extra === status.updateExtra ? 'warning' : 'textDim', extra)), ); let cwdChip: string | undefined = chip( currentTheme.fg('textDim', shortenCwd(status.cwd, status.homeDir)), diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 2f46fdec..fbef6b42 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -141,6 +141,7 @@ import { foldFooterEvents, selectFooterViewModel, selectStatusBarExtras, + selectStatusItemParts, type FooterActivity, type FooterEvent, type FooterGoal, @@ -1400,6 +1401,11 @@ export class PythinkerTUI { this.state.appState.statusLine, ), ); + const statusParts = selectStatusItemParts( + this.state.footerState, + Date.now(), + this.state.appState.statusLine, + ); this.state.statusBar.update({ ...this.state.footerState.status, extras: selectStatusBarExtras( @@ -1407,6 +1413,7 @@ export class PythinkerTUI { Date.now(), this.state.appState.statusLine, ), + updateExtra: statusParts.update ?? undefined, sessionKey: this.state.appState.sessionTitle?.trim() || this.state.appState.sessionId || diff --git a/apps/pythinker-code/test/cli/ffi-launcher.test.ts b/apps/pythinker-code/test/cli/ffi-launcher.test.ts index 4c40144d..2ee3e8df 100644 --- a/apps/pythinker-code/test/cli/ffi-launcher.test.ts +++ b/apps/pythinker-code/test/cli/ffi-launcher.test.ts @@ -28,16 +28,22 @@ interface StartLauncherOptions { let fixtureDir: string; let launcherPath: string; +let nodeVersionPatchPath: string; function startLauncher( env?: NodeJS.ProcessEnv, options: StartLauncherOptions = {}, ): ChildProcessWithoutNullStreams { const ffiArguments = options.ffi ? [FFI_FLAG, FFI_WARNING_FLAG] : []; + const nodeVersionArguments = + env?.['PYTHINKER_TEST_NODE_VERSION'] === undefined + ? [] + : ['--import', nodeVersionPatchPath]; return spawn( process.execPath, [ ...ffiArguments, + ...nodeVersionArguments, '--import', tsxLoader, launcherPath, @@ -112,7 +118,22 @@ async function writeMain(source: string): Promise { beforeEach(async () => { fixtureDir = await mkdtemp(join(tmpdir(), 'pythinker-ffi-launcher-')); launcherPath = join(fixtureDir, 'launcher.ts'); + nodeVersionPatchPath = join(fixtureDir, 'patch-node-version.mjs'); await copyFile(launcherSource, launcherPath); + await writeFile( + nodeVersionPatchPath, + ` + const version = process.env.PYTHINKER_TEST_NODE_VERSION; + if (version !== undefined) { + Object.defineProperty(process.versions, 'node', { configurable: true, value: version }); + } + if (process.env.PYTHINKER_TEST_BLOCK_EXECVE === '1') { + process.execve = () => { + throw new Error('unexpected process.execve call'); + }; + } + `, + ); }); afterEach(async () => { @@ -120,6 +141,61 @@ afterEach(async () => { }); describe('FFI launcher', () => { + it('rejects Node versions below the runtime floor', async () => { + await writeMain("process.stdout.write('unexpected main import');"); + + const result = await collect( + startLauncher({ PYTHINKER_TEST_NODE_VERSION: '19.9.9' }), + ); + + expect(result.code).toBe(1); + expect(result.signal).toBeNull(); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain( + 'Pythinker Code requires Node.js 20 or newer; you are running Node.js 19.9.9.', + ); + }); + + it('imports the app directly without FFI on Node 24', async () => { + await writeMain(` + process.stdout.write(JSON.stringify({ + pid: process.pid, + ffi: process.execArgv.includes('${FFI_FLAG}'), + warningDisabled: process.execArgv.includes('${FFI_WARNING_FLAG}'), + marker: process.env.PYTHINKER_CODE_FFI_CHILD, + imported: import.meta.url.endsWith('/main.mjs'), + })); + `); + + const child = startLauncher( + { + PYTHINKER_TEST_NODE_VERSION: '24.18.0', + PYTHINKER_TEST_BLOCK_EXECVE: '1', + }, + { args: ['direct'] }, + ); + const originalPid = child.pid; + const result = await collect(child); + const details = JSON.parse(result.stdout) as { + pid: number; + ffi: boolean; + warningDisabled: boolean; + marker?: string; + imported: boolean; + }; + + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(''); + expect(details).toMatchObject({ + pid: originalPid, + ffi: false, + warningDisabled: false, + imported: true, + }); + expect(details.marker).toBeUndefined(); + }); + it('starts with FFI and preserves argv', async () => { await writeMain(` process.stdout.write(JSON.stringify({ diff --git a/apps/pythinker-code/test/tui/components/status-bar.test.ts b/apps/pythinker-code/test/tui/components/status-bar.test.ts index f358f376..3a6b462e 100644 --- a/apps/pythinker-code/test/tui/components/status-bar.test.ts +++ b/apps/pythinker-code/test/tui/components/status-bar.test.ts @@ -108,6 +108,31 @@ describe('StatusBarComponent', () => { } }); + it('paints the update extra in warning and leaves other extras dim', () => { + const previousLevel = chalk.level; + const previousPalette = currentTheme.palette; + chalk.level = 3; + currentTheme.setPalette(darkColors); + + try { + const component = new StatusBarComponent(); + component.update( + status({ + extras: ['6% · 55.6k/1M', '↑ v0.18.0 restart to apply'], + updateExtra: '↑ v0.18.0 restart to apply', + }), + ); + + const line = renderRow(component, 160); + + expect(line).toContain(chalk.hex(darkColors.warning)('↑ v0.18.0 restart to apply')); + expect(line).toContain(chalk.hex(darkColors.textDim)('6% · 55.6k/1M')); + } finally { + chalk.level = previousLevel; + currentTheme.setPalette(previousPalette); + } + }); + it('drops the gap, modes, and cwd in that order as width shrinks', () => { const component = new StatusBarComponent(); component.update(status()); diff --git a/apps/pythinker-web/public/brand/mascot-idle-strip.png b/apps/pythinker-web/public/brand/mascot-idle-strip.png new file mode 100644 index 00000000..d4f6ab79 Binary files /dev/null and b/apps/pythinker-web/public/brand/mascot-idle-strip.png differ diff --git a/apps/pythinker-web/public/brand/mascot-states.png b/apps/pythinker-web/public/brand/mascot-states.png new file mode 100644 index 00000000..6692567e Binary files /dev/null and b/apps/pythinker-web/public/brand/mascot-states.png differ diff --git a/apps/pythinker-web/src/App.vue b/apps/pythinker-web/src/App.vue index 8e274684..03f7214e 100644 --- a/apps/pythinker-web/src/App.vue +++ b/apps/pythinker-web/src/App.vue @@ -162,6 +162,7 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener('keydown', onGlobalKeydown, true); stopSpinner(); + clearTimeout(sidebarSwapTimer); }); // Escape closes whichever transient right-side detail panel is open. @@ -195,10 +196,12 @@ const SIDEBAR_COLLAPSED_KEY = 'pythinker-web.sidebar-collapsed'; const SIDEBAR_DEFAULT = 270; const SIDEBAR_MIN = 170; const SIDEBAR_MAX = 420; -const SIDEBAR_COLLAPSED_WIDTH = 36; +const SIDEBAR_COLLAPSED_WIDTH = 90; const sessionColWidth = ref(SIDEBAR_DEFAULT); const sidebarCollapsed = ref(false); +const railVisible = ref(false); +let sidebarSwapTimer: ReturnType | undefined; const sideWidth = computed(() => sidebarCollapsed.value ? SIDEBAR_COLLAPSED_WIDTH : sessionColWidth.value, ); @@ -209,6 +212,7 @@ function loadSidebarCollapsed(): void { } catch { sidebarCollapsed.value = false; } + railVisible.value = sidebarCollapsed.value; } function saveSidebarCollapsed(): void { @@ -219,9 +223,23 @@ function saveSidebarCollapsed(): void { } } -function toggleSidebarCollapse(): void { - sidebarCollapsed.value = !sidebarCollapsed.value; +function setSidebarCollapsed(collapsed: boolean): void { + sidebarCollapsed.value = collapsed; saveSidebarCollapsed(); + clearTimeout(sidebarSwapTimer); + sidebarSwapTimer = setTimeout(() => { + railVisible.value = sidebarCollapsed.value; + }, 150); +} + +function toggleSidebarCollapse(): void { + setSidebarCollapsed(!sidebarCollapsed.value); +} + +async function expandAndSearch(): Promise { + setSidebarCollapsed(false); + await nextTick(); + void sidebarRef.value?.openSearch(); } // --------------------------------------------------------------------------- @@ -565,6 +583,7 @@ watch(client.activeSessionId, () => { }); // Reference to ConversationPane so we can imperatively switch tabs +const sidebarRef = ref | null>(null); const conversationPaneRef = ref | null>(null); // Shift-multi-selected workspace ids; when >1 are selected the main pane @@ -872,13 +891,17 @@ function openPr(url: string): void {