Skip to content

Commit b13ff20

Browse files
committed
feat(desktop): auto-update, update settings UI, and telemetry
Add electron-updater with a GitHub Releases feed: automatic updates on by default with a persisted setting, a sandboxed preload bridge, and a Desktop app section in Settings with the toggle, manual check, and restart-to-update action. Wire desktop lifecycle and update-lifecycle telemetry through the existing telemetry package. Align desktop dev-dependency versions with the workspace and refresh the flake pnpmDeps hash for the lockfile change.
1 parent 5cbfd00 commit b13ff20

12 files changed

Lines changed: 706 additions & 90 deletions

File tree

apps/desktop/package.json

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,30 @@
1515
},
1616
"license": "MIT",
1717
"devDependencies": {
18-
"@types/node": "^22.20.0",
18+
"@pymodel/pythinker-telemetry": "workspace:*",
19+
"@types/node": "^26.1.2",
1920
"electron": "43.4.0",
2021
"electron-builder": "26.15.3",
21-
"tsdown": "^0.22.2",
22+
"electron-updater": "^6.8.9",
23+
"tsdown": "0.22.3",
2224
"typescript": "6.0.3",
23-
"vitest": "^4.1.8"
25+
"vitest": "4.1.9"
2426
},
2527
"build": {
2628
"appId": "com.pythinker.desktop",
2729
"productName": "Pythinker",
30+
"publish": [
31+
{
32+
"provider": "github",
33+
"owner": "PyModel",
34+
"repo": "pythinker-code"
35+
}
36+
],
2837
"afterPack": "./scripts/verify-packaged-runtime.ts",
2938
"asar": true,
3039
"files": [
3140
"dist/main.js",
41+
"dist/preload.cjs",
3242
"package.json"
3343
],
3444
"extraResources": [

apps/desktop/src/main.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
/** Electron application shell for the loopback Pythinker Web Host. */
22

3-
import { existsSync } from 'node:fs'
3+
import { randomUUID } from 'node:crypto'
4+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
45
import { join, resolve } from 'node:path'
56
import {
67
app,
78
BrowserWindow,
89
dialog,
10+
ipcMain,
911
Menu,
1012
nativeImage,
1113
session,
@@ -14,15 +16,30 @@ import {
1416
type Event,
1517
type MenuItemConstructorOptions,
1618
} from 'electron'
19+
import {
20+
flushTelemetrySync,
21+
initializeTelemetry,
22+
installCrashHandlers,
23+
setCrashPhase,
24+
track,
25+
} from '@pymodel/pythinker-telemetry'
1726
import { createHostSupervisor, spawnPythinkerServer, type HostSupervisor } from './host-supervisor'
1827
import { createSplashWindow } from './splash'
28+
import {
29+
checkForUpdatesNow,
30+
getUpdateState,
31+
initUpdater,
32+
quitAndInstallNow,
33+
setAutoUpdate,
34+
} from './updater'
1935
import { createDesktopLifecycle, type DesktopLifecycle } from './window-lifecycle'
2036

2137
const APP_NAME = 'Pythinker'
2238
const WINDOW_WIDTH = 1440
2339
const WINDOW_HEIGHT = 920
2440
const DESKTOP_DIR = resolve(import.meta.dirname, '..')
2541
const REPOSITORY_ROOT = resolve(DESKTOP_DIR, '../..')
42+
const TELEMETRY_APP_NAME = 'pythinker-desktop'
2643

2744
interface TrayImages {
2845
readonly idle: Electron.NativeImage
@@ -37,6 +54,41 @@ let hostOrigin: string | undefined
3754
let bootQuitPromise: Promise<void> | undefined
3855
let stopTrayAnimation: (() => void) | undefined
3956
let quitReleased = false
57+
let quitTelemetrySent = false
58+
59+
function desktopDeviceId(homeDir: string): string {
60+
const path = join(homeDir, 'device_id')
61+
try {
62+
const existing = readFileSync(path, 'utf8').trim()
63+
if (existing !== '') return existing
64+
} catch {
65+
// A missing or unreadable id gets a fresh in-memory value.
66+
}
67+
const id = randomUUID()
68+
try {
69+
writeFileSync(path, id, { encoding: 'utf8', mode: 0o600 })
70+
} catch {
71+
// Telemetry can still use the in-memory id.
72+
}
73+
return id
74+
}
75+
76+
function initializeDesktopTelemetry(): void {
77+
const homeDir = app.getPath('userData')
78+
initializeTelemetry({
79+
homeDir,
80+
deviceId: desktopDeviceId(homeDir),
81+
appName: TELEMETRY_APP_NAME,
82+
version: app.getVersion(),
83+
uiMode: 'desktop',
84+
})
85+
installCrashHandlers()
86+
track('desktop_app_start', {
87+
platform: process.platform,
88+
arch: process.arch,
89+
packaged: app.isPackaged,
90+
})
91+
}
4092

4193
/** Resolve artifacts from the checkout in development and resourcesPath when packaged. */
4294
function hostPaths(): { nodeExecutable: string; cliEntry: string; cwd: string; electronRunAsNode: boolean } {
@@ -155,11 +207,13 @@ async function createMainWindow(): Promise<BrowserWindow> {
155207
webPreferences: {
156208
contextIsolation: true,
157209
nodeIntegration: false,
210+
preload: join(app.getAppPath(), 'dist', 'preload.cjs'),
158211
sandbox: true,
159212
webSecurity: true,
160213
},
161214
})
162215
mainWindow = window
216+
initUpdater(() => mainWindow, track)
163217
window.on('close', (event) => { lifecycle?.onWindowClose(event) })
164218
window.on('closed', () => {
165219
if (mainWindow === window) mainWindow = undefined
@@ -180,6 +234,14 @@ async function createMainWindow(): Promise<BrowserWindow> {
180234
return window
181235
}
182236

237+
ipcMain.handle('pythinker:update:get', () => getUpdateState())
238+
ipcMain.handle('pythinker:update:set-auto', (_event, enabled: unknown) => {
239+
if (typeof enabled !== 'boolean') throw new TypeError('automatic updates must be a boolean')
240+
return setAutoUpdate(enabled)
241+
})
242+
ipcMain.handle('pythinker:update:check', () => checkForUpdatesNow())
243+
ipcMain.handle('pythinker:update:install', () => quitAndInstallNow())
244+
183245
function createTray(images: TrayImages): void {
184246
tray = new Tray(images.idle)
185247
tray.setToolTip(APP_NAME)
@@ -214,6 +276,7 @@ function requestAppQuit(): Promise<void> {
214276

215277
async function boot(): Promise<void> {
216278
if (bootQuitPromise !== undefined) return
279+
initializeDesktopTelemetry()
217280
const paths = hostPaths()
218281
assertHostArtifacts(paths)
219282
const splash = createSplashWindow(desktopResources('splash'))
@@ -239,7 +302,13 @@ async function boot(): Promise<void> {
239302
void requestAppQuit()
240303
},
241304
})
242-
hostOrigin = await host.start()
305+
try {
306+
hostOrigin = await host.start()
307+
track('desktop_server_ready')
308+
} catch (error) {
309+
track('desktop_server_failed')
310+
throw error
311+
}
243312
stopTrayAnimation?.()
244313
stopTrayAnimation = undefined
245314
hardenSession()
@@ -251,6 +320,7 @@ async function boot(): Promise<void> {
251320
reportError: (error) => { console.error('desktop shutdown failed:', error) },
252321
})
253322
await lifecycle.showWindow()
323+
setCrashPhase('runtime')
254324
destroySplash()
255325
} catch (error) {
256326
stopTrayAnimation?.()
@@ -269,6 +339,12 @@ if (!app.requestSingleInstanceLock()) {
269339
// Tray and Host own application lifetime on every platform.
270340
})
271341
app.on('before-quit', (event: Event) => {
342+
if (!quitTelemetrySent) {
343+
quitTelemetrySent = true
344+
setCrashPhase('shutdown')
345+
track('desktop_app_quit')
346+
flushTelemetrySync()
347+
}
272348
if (quitReleased) return
273349
event.preventDefault()
274350
void requestAppQuit()

apps/desktop/src/preload.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { contextBridge, ipcRenderer } from 'electron'
2+
3+
contextBridge.exposeInMainWorld('pythinkerDesktop', {
4+
platform: process.platform,
5+
getUpdateState: () => ipcRenderer.invoke('pythinker:update:get'),
6+
setAutoUpdate: (enabled: boolean) => ipcRenderer.invoke('pythinker:update:set-auto', enabled),
7+
checkForUpdates: () => ipcRenderer.invoke('pythinker:update:check'),
8+
quitAndInstall: () => ipcRenderer.invoke('pythinker:update:install'),
9+
onUpdateState: (cb: (state: unknown) => void) => {
10+
const listener = (_event: unknown, state: unknown) => cb(state)
11+
ipcRenderer.on('pythinker:update:state', listener)
12+
return () => ipcRenderer.removeListener('pythinker:update:state', listener)
13+
},
14+
})

0 commit comments

Comments
 (0)