99 shell ,
1010 systemPreferences ,
1111} from 'electron' ;
12- import { randomBytes } from 'node:crypto' ;
12+ import { randomBytes , randomUUID } from 'node:crypto' ;
1313import { execFile } from 'node:child_process' ;
1414import { join } from 'node:path' ;
1515import { mkdtempSync } from 'node:fs' ;
@@ -34,6 +34,7 @@ import { createDocumentSession } from './main/services/document-session.mjs';
3434import { createRecents } from './main/services/recents.mjs' ;
3535import { createReaderPreferences } from './main/services/preferences.mjs' ;
3636import { createAppIcon } from './main/services/app-icon.mjs' ;
37+ import { createCompanionServer } from './main/services/companion-server.mjs' ;
3738import {
3839 configureEPUBSupport ,
3940 readSystemPDF ,
@@ -141,9 +142,11 @@ else {
141142 let preferences ;
142143 let recents ;
143144 let annotations ;
145+ let quickLinks ;
144146 let documentSession ;
145147 let appUpdates ;
146148 let backendOptions ;
149+ let companion ;
147150 let documentsReady = false ;
148151 let quitting = false ;
149152 let backendFailureHandled = false ;
@@ -213,6 +216,157 @@ else {
213216 operationMetrics : developerOperationMetrics ,
214217 loadServiceCredentials : ( ) => serviceCredentialStore ?. load ?. ( ) || { } ,
215218 } ) ;
219+ const desktopSessionID = ( ) => `desktop-${ randomUUID ( ) } ` ;
220+ const desktopClaimsInFlight = new Map ( ) ;
221+ const recentIDForPath = ( path ) => {
222+ const entries = recents ?. list ?. ( ) ;
223+ return typeof path === 'string' && Array . isArray ( entries )
224+ ? entries . find ( ( entry ) => recents . path ( entry . id ) === path ) ?. id
225+ : undefined ;
226+ } ;
227+ const notifyCompanionLock = ( target , error ) => {
228+ if ( ! target || target . isDestroyed ?. ( ) ) return ;
229+ const device =
230+ typeof error ?. deviceName === 'string' && error . deviceName . trim ( )
231+ ? error . deviceName . trim ( )
232+ : 'another device' ;
233+ try {
234+ target . webContents . send (
235+ 'reader:action' ,
236+ 'companion-lock' ,
237+ `This document is open on ${ device } . Close it there before opening it here.` ,
238+ ) ;
239+ } catch { }
240+ } ;
241+ const companionLockError = ( message , deviceName ) => {
242+ const error = Error ( message || 'This document is open on another device.' ) ;
243+ error . code = 'DOCUMENT_LOCKED' ;
244+ if ( deviceName ) error . deviceName = deviceName ;
245+ return error ;
246+ } ;
247+ const claimCompanionID = async ( id , target , notificationTarget = target ) => {
248+ if ( ! id || ! companion ?. claimDesktopDocument ) return { available : false } ;
249+ const state = registry . stateFor ( target ) ;
250+ if ( state ?. companionDocumentId === id && state . companionSessionID )
251+ return { claimed : true , resumed : true , sessionID : state . companionSessionID } ;
252+ for ( const [ other , otherState ] of windows ) {
253+ if (
254+ other !== target &&
255+ ! other . isDestroyed ?. ( ) &&
256+ otherState ?. companionDocumentId === id &&
257+ otherState . companionSessionID
258+ ) {
259+ const error = companionLockError (
260+ 'This document is already open in another desktop window.' ,
261+ 'This Mac' ,
262+ ) ;
263+ notifyCompanionLock ( notificationTarget , error ) ;
264+ throw error ;
265+ }
266+ }
267+ const existing = desktopClaimsInFlight . get ( id ) ;
268+ if ( existing ) {
269+ if ( existing . target === target ) return existing . promise ;
270+ const error = companionLockError (
271+ 'This document is already opening in another desktop window.' ,
272+ 'This Mac' ,
273+ ) ;
274+ notifyCompanionLock ( notificationTarget , error ) ;
275+ throw error ;
276+ }
277+ const sessionID = desktopSessionID ( ) ;
278+ const promise = ( async ( ) => {
279+ try {
280+ const result = await companion . claimDesktopDocument ( id , sessionID , 'This Mac' ) ;
281+ if ( result ?. available === false ) return result ;
282+ const current = registry . stateFor ( target ) ;
283+ if ( current ) {
284+ current . companionDocumentId = id ;
285+ current . companionSessionID = sessionID ;
286+ }
287+ return result ;
288+ } catch ( error ) {
289+ error . companionLock = true ;
290+ notifyCompanionLock ( notificationTarget , error ) ;
291+ throw error ;
292+ }
293+ } ) ( ) ;
294+ desktopClaimsInFlight . set ( id , { target, promise } ) ;
295+ try {
296+ return await promise ;
297+ } finally {
298+ if ( desktopClaimsInFlight . get ( id ) ?. promise === promise ) desktopClaimsInFlight . delete ( id ) ;
299+ }
300+ } ;
301+ const claimCompanionPath = async ( path , target , notificationTarget = target ) => {
302+ let id = recentIDForPath ( path ) ;
303+ let prepared ;
304+ if ( ! id && companion ?. claimDesktopDocument && recents ?. prepare ) {
305+ prepared = recents . prepare ( path ) ;
306+ id = prepared . id ;
307+ }
308+ if ( ! id ) return { available : false } ;
309+ try {
310+ if ( prepared ?. created ) await recents . persistPrepared ?. ( id ) ;
311+ const result = await claimCompanionID ( id , target , notificationTarget ) ;
312+ if ( result ?. available === false && prepared ?. created )
313+ await recents . discardPreparedAndPersist ?. ( id ) ;
314+ return result ;
315+ } catch ( error ) {
316+ if ( prepared ?. created ) await recents . discardPreparedAndPersist ?. ( id ) . catch ( ( ) => { } ) ;
317+ throw error ;
318+ }
319+ } ;
320+ const companionOwnership = ( target ) => {
321+ const state = registry . stateFor ( target ) ;
322+ return state ?. companionDocumentId && state ?. companionSessionID
323+ ? { id : state . companionDocumentId , sessionID : state . companionSessionID }
324+ : undefined ;
325+ } ;
326+ const releaseCompanionDocument = async ( target , expected ) => {
327+ const state = registry . stateFor ( target ) ;
328+ const ownership = companionOwnership ( target ) ;
329+ if ( expected && ( ownership ?. id !== expected . id || ownership ?. sessionID !== expected . sessionID ) )
330+ return { available : false , stale : true } ;
331+ const id = ownership ?. id ;
332+ const sessionID = ownership ?. sessionID ;
333+ if ( ! id || ! sessionID || ! companion ?. releaseDesktopDocument ) return { available : false } ;
334+ const result = await companion . releaseDesktopDocument ( id , sessionID ) ;
335+ if (
336+ result ?. available !== false &&
337+ state ?. companionDocumentId === id &&
338+ state . companionSessionID === sessionID
339+ ) {
340+ delete state . companionDocumentId ;
341+ delete state . companionSessionID ;
342+ }
343+ return result ;
344+ } ;
345+ const desktopClaims = ( ) => {
346+ const claims = new Map ( ) ;
347+ for ( const [ target , state ] of windows ) {
348+ if ( state ?. settingsOwner || target . isDestroyed ?. ( ) ) continue ;
349+ const queuedPath = state . documents ?. find ( ( document ) => typeof document === 'string' ) ;
350+ const active =
351+ state . companionDocumentId ||
352+ state . documentIdentity ||
353+ state . performance ?. hasDocument ||
354+ queuedPath ;
355+ if ( ! active ) continue ;
356+ const path = state . unkeyedAnnotationSource ?. path || queuedPath ;
357+ const id = state . companionDocumentId || recentIDForPath ( path ) ;
358+ if ( id ) {
359+ const sessionID =
360+ state . companionDocumentId === id && state . companionSessionID
361+ ? state . companionSessionID
362+ : desktopSessionID ( ) ;
363+ state . companionDocumentId ||= id ;
364+ state . companionSessionID ||= sessionID ;
365+ claims . set ( id , { id, sessionID, deviceName : 'This Mac' } ) ;
366+ }
367+ }
368+ return [ ...claims . values ( ) ] ;
369+ } ;
216370 let menuController ;
217371 const windowController = createWindowController ( {
218372 app,
@@ -240,6 +394,11 @@ else {
240394 getRebuildMenu : ( ) => menuController ?. rebuild || ( ( ) => { } ) ,
241395 getUpdateMenu : ( ) => menuController ?. updateMenu || ( ( ) => { } ) ,
242396 updateAppearance,
397+ claimCompanionDocument : claimCompanionPath ,
398+ releaseCompanionDocument,
399+ companionOwnership,
400+ flushCompanionPersistence : ( ) =>
401+ Promise . all ( [ annotations ?. flush ?. ( ) , quickLinks ?. flush ?. ( ) , recents ?. flush ?. ( ) ] ) ,
243402 } ) ;
244403 const deliverPendingFiles = async ( ) => {
245404 while ( pendingFiles . length ) await windowController . openExternalDocument ( pendingFiles . shift ( ) ) ;
@@ -375,6 +534,10 @@ else {
375534 smoke,
376535 diagnostics : ! ! smoke ,
377536 credentials : { getKey : credentialStore . getKey , status : credentialStore . status } ,
537+ getCompanionTranslationSettings : ( ) => ( {
538+ preferences : preferences . load ( ) ,
539+ credentials : serviceCredentialStore . load ( ) ,
540+ } ) ,
378541 chatGPTSubscription,
379542 onCrash : handleBackendFailure ,
380543 ...( [
@@ -402,6 +565,7 @@ else {
402565 state . backend . setCredentials ( credentialStore . getKey ( ) , status ) ,
403566 ) ,
404567 ) ;
568+ await companion ?. setCredentials ( credentialStore . getKey ( ) , status ) ;
405569 return status ;
406570 } ,
407571 clear : async ( ) => {
@@ -411,10 +575,36 @@ else {
411575 state . backend . setCredentials ( credentialStore . getKey ( ) , status ) ,
412576 ) ,
413577 ) ;
578+ await companion ?. setCredentials ( credentialStore . getKey ( ) , status ) ;
414579 return status ;
415580 } ,
416581 } ;
417- const quickLinks = createQuickLinkStore ( join ( app . getPath ( 'userData' ) , 'quick-links' ) ) ;
582+ if ( ! smoke && ! ciLaunchCheck ) {
583+ companion = await createCompanionServer ( {
584+ path : join ( app . getPath ( 'userData' ) , 'companion.json' ) ,
585+ startBackend : startBackendService ,
586+ backendOptions : ( ) => backendOptions ,
587+ documents : {
588+ recentsPath : join ( app . getPath ( 'userData' ) , 'recent-documents.json' ) ,
589+ annotationsRoot : join ( app . getPath ( 'userData' ) , 'annotations' ) ,
590+ quickLinksRoot : join ( app . getPath ( 'userData' ) , 'quick-links' ) ,
591+ statePath : join ( app . getPath ( 'userData' ) , 'companion-documents.json' ) ,
592+ } ,
593+ dialog,
594+ discoveryEnabled : preferences . load ( ) . companionDiscoveryEnabled ,
595+ getDesktopClaims : desktopClaims ,
596+ label : ( text ) =>
597+ menuLabel (
598+ text ,
599+ resolveUILanguage (
600+ preferences . load ( ) . uiLanguage || 'en' ,
601+ app . getPreferredSystemLanguages ( ) [ 0 ] || app . getLocale ( ) ,
602+ ) ,
603+ ) ,
604+ } ) ;
605+ void companion . launch ( ) ;
606+ }
607+ quickLinks = createQuickLinkStore ( join ( app . getPath ( 'userData' ) , 'quick-links' ) ) ;
418608
419609 menuController = createApplicationMenu ( {
420610 app,
@@ -429,6 +619,7 @@ else {
429619 tabs : windowController . tabs ,
430620 newTab : windowController . newTab ,
431621 handleBackendFailure,
622+ shareKernels : companion ? ( target ) => companion . showDialog ( target ) : undefined ,
432623 hideNativeMenuBar : windowController . hideNativeMenuBar ,
433624 resolveUILanguage,
434625 menuLabel,
@@ -453,6 +644,9 @@ else {
453644 token,
454645 smoke,
455646 rebuildMenu : menuController . rebuild ,
647+ claimCompanionDocument : claimCompanionID ,
648+ notifyCompanionLock,
649+ releaseCompanionDocument,
456650 } ) ;
457651 registerAnnotationIPC ( {
458652 handle : handleMeasuredIPC ,
@@ -509,6 +703,10 @@ else {
509703 registry,
510704 releaseLink,
511705 } ) ;
706+ handleMeasuredIPC ( 'companion:connections' , ( event ) => {
707+ registry . trustedWindow ( event ) ;
708+ return companion ?. connections ( ) || [ ] ;
709+ } ) ;
512710 registerPreferencesIPC ( {
513711 handle : handleMeasuredIPC ,
514712 trustedWindow : registry . trustedWindow ,
@@ -517,6 +715,7 @@ else {
517715 appUpdates,
518716 backendOptions,
519717 token,
718+ setCompanionDiscoveryEnabled : ( enabled ) => companion ?. setDiscoveryEnabled ( enabled ) ,
520719 setWindowVibrancy : windowController . setWindowVibrancy ,
521720 updateAppearance,
522721 rebuildMenu : menuController . rebuild ,
@@ -551,6 +750,13 @@ else {
551750 validateSystemPDF,
552751 documentIdentity : windowController . documentIdentity ,
553752 focusDocumentWindow : windowController . focusDocumentWindow ,
753+ claimCompanionDocument : claimCompanionID ,
754+ claimCompanionPath : claimCompanionPath ,
755+ notifyCompanionLock,
756+ releaseCompanionDocument,
757+ companionOwnership,
758+ flushCompanionPersistence : ( ) =>
759+ Promise . all ( [ annotations ?. flush ?. ( ) , quickLinks ?. flush ?. ( ) , recents ?. flush ?. ( ) ] ) ,
554760 } ) ;
555761 registerWindowIPC ( {
556762 handle : handleMeasuredIPC ,
@@ -678,22 +884,42 @@ else {
678884 event . preventDefault ( ) ;
679885 quitting = true ;
680886 windowController . setRuntime ( { quitting : true } ) ;
681- void Promise . allSettled (
682- [ ...windows . keys ( ) ] . map ( windowController . saveWindowReadingView ) ,
683- ) . finally ( ( ) =>
684- Promise . allSettled (
685- [ ...windows . values ( ) ]
686- . map ( ( state ) => state . backend . close ( ) )
687- . concat ( [
688- ...closingBackends ,
689- subscriptionClosing ,
690- preferences ?. flush ( ) ,
691- serviceCredentialStore ?. flush ( ) ,
692- recents ?. flush ( ) ,
693- documentSession ?. saveOnQuit ( ) ,
694- annotations ?. flush ( ) ,
695- ] ) ,
696- ) . finally ( ( ) => app . quit ( ) ) ,
697- ) ;
887+ const quittingWindows = [ ...windows . keys ( ) ] ;
888+ void Promise . all (
889+ quittingWindows . map ( ( target ) => windowController . saveWindowReadingView ( target ) ) ,
890+ )
891+ . then ( ( ) => Promise . all ( [ documentSession ?. saveOnQuit ( ) , annotations ?. flush ( ) ] ) )
892+ . then ( ( ) => Promise . all ( [ quickLinks ?. flush ?. ( ) , recents ?. flush ?. ( ) ] ) )
893+ . then ( ( ) => Promise . all ( quittingWindows . map ( ( target ) => releaseCompanionDocument ( target ) ) ) )
894+ . then ( ( ) =>
895+ Promise . all (
896+ [ ...windows . values ( ) ]
897+ . map ( ( state ) => state . backend . close ( ) )
898+ . concat ( [
899+ ...closingBackends ,
900+ companion ?. close ( ) ,
901+ subscriptionClosing ,
902+ preferences ?. flush ( ) ,
903+ serviceCredentialStore ?. flush ( ) ,
904+ ] ) ,
905+ ) ,
906+ )
907+ . then ( ( ) => app . quit ( ) )
908+ . catch ( ( error ) => {
909+ quitting = false ;
910+ windowController . setRuntime ( { quitting : false } ) ;
911+ for ( const target of quittingWindows ) {
912+ if ( ! target || target . isDestroyed ?. ( ) ) continue ;
913+ try {
914+ target . show ( ) ;
915+ target . focus ( ) ;
916+ target . webContents . send (
917+ 'reader:action' ,
918+ 'companion-save-failed' ,
919+ error ?. message || 'Could not save this document. It remains open here; try again.' ,
920+ ) ;
921+ } catch { }
922+ }
923+ } ) ;
698924 } ) ;
699925}
0 commit comments