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'
45import { join , resolve } from 'node:path'
56import {
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'
1726import { createHostSupervisor , spawnPythinkerServer , type HostSupervisor } from './host-supervisor'
1827import { createSplashWindow } from './splash'
28+ import {
29+ checkForUpdatesNow ,
30+ getUpdateState ,
31+ initUpdater ,
32+ quitAndInstallNow ,
33+ setAutoUpdate ,
34+ } from './updater'
1935import { createDesktopLifecycle , type DesktopLifecycle } from './window-lifecycle'
2036
2137const APP_NAME = 'Pythinker'
2238const WINDOW_WIDTH = 1440
2339const WINDOW_HEIGHT = 920
2440const DESKTOP_DIR = resolve ( import . meta. dirname , '..' )
2541const REPOSITORY_ROOT = resolve ( DESKTOP_DIR , '../..' )
42+ const TELEMETRY_APP_NAME = 'pythinker-desktop'
2643
2744interface TrayImages {
2845 readonly idle : Electron . NativeImage
@@ -37,6 +54,41 @@ let hostOrigin: string | undefined
3754let bootQuitPromise : Promise < void > | undefined
3855let stopTrayAnimation : ( ( ) => void ) | undefined
3956let 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. */
4294function 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+
183245function createTray ( images : TrayImages ) : void {
184246 tray = new Tray ( images . idle )
185247 tray . setToolTip ( APP_NAME )
@@ -214,6 +276,7 @@ function requestAppQuit(): Promise<void> {
214276
215277async 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 ( )
0 commit comments