2026-09-27 11:24:36 +02:00
// webapps.js — install a site as an app ("PWA install"), the way Chrome and
// Edge offer it. Electron ships Chromium's renderer without the browser-side
// web-app machinery, so `beforeinstallprompt` never fires in an Electron app
// and every site's own "Install our app" chip stays hidden. This module fills
// that gap on the browser side:
//
// · probeTab() reads a page's <link rel="manifest">, checks it describes
// an installable app (a name plus a standalone-style display
// mode, start_url on the page's own origin) and records the
// descriptor on the tab. main.js then shows the address-bar
// chip and fires a synthetic `beforeinstallprompt` so the
// site's own chip appears and works too.
// · install() asks the user (native dialog), stores the app under
// <userData>/webapps/, turns the manifest icon into an .ico
// and writes Start Menu / desktop shortcuts that launch
// Theseus with `--app=<start_url>`.
// · open() the chromeless app window: same session, same BCNR
// resolution, same add-on bridges as a tab, own taskbar
// identity so it can be pinned like any app.
// · launch() what `--app=` resolves to at startup or on a second
// instance.
//
// Windows-first: shortcuts and taskbar identity are Windows APIs; on other
// platforms the app still installs and opens, only without shortcuts.
const { app , BrowserWindow , dialog , nativeImage , shell , session , Menu , clipboard , nativeTheme } = require ( "electron" ) ;
const path = require ( "path" ) ;
const fs = require ( "fs" ) ;
const crypto = require ( "crypto" ) ;
// Display modes that mean "this wants to be an app, not a page".
const APP _DISPLAYS = new Set ( [ "standalone" , "fullscreen" , "minimal-ui" , "window-controls-overlay" , "tabbed" ] ) ;
// Runs inside the page: find the manifest link and fetch it the way the
// page itself would (same-origin cookies unless the link opts into CORS
// credentials). Returns { href, text } or null.
const PROBE _SRC = ` (async () => { try {
const l = document . querySelector ( 'link[rel~="manifest"]' ) ; if ( ! l || ! l . href ) return null ;
const r = await fetch ( l . href , { credentials : l . crossOrigin === "use-credentials" ? "include" : "same-origin" } ) ;
if ( ! r . ok ) return null ; return { href : l . href , text : await r . text ( ) } ;
} catch ( e ) { return null ; } } ) ( ) ` ;
// Runs inside the page after a successful probe: the synthetic
// beforeinstallprompt. prompt() asks Theseus through a DOM event that the
// session preload (bcnr-preload.js) relays over IPC; the answer comes back
// as another DOM event, and userChoice resolves with Chrome's shape.
const PROMPT _SRC = ` (() => { try {
if ( window . _ _theseusInstallPrompt ) return ; window . _ _theseusInstallPrompt = 1 ;
const e = new Event ( "beforeinstallprompt" , { cancelable : true } ) ;
e . platforms = [ "windows" ] ;
let done ; e . userChoice = new Promise ( ( r ) => { done = r ; } ) ;
document . addEventListener ( "theseus:webapp-accepted" , ( ) => done ( { outcome : "accepted" , platform : "windows" } ) , { once : true } ) ;
document . addEventListener ( "theseus:webapp-dismissed" , ( ) => done ( { outcome : "dismissed" , platform : "windows" } ) , { once : true } ) ;
e . prompt = ( ) => { document . dispatchEvent ( new Event ( "theseus:webapp-prompt" ) ) ; return e . userChoice ; } ;
window . dispatchEvent ( e ) ;
} catch ( err ) { } } ) ( ) ` ;
const INSTALLED _SRC = ` try { window.dispatchEvent(new Event("appinstalled")); } catch (e) {} ` ;
let deps = { } ; // supplied by main.js — see init()
let apps = [ ] ; // installed apps (persisted)
let dir = null , file = null ;
const windows = new Map ( ) ; // key -> BrowserWindow
let promptOpen = false ;
function init ( d ) {
deps = d ;
dir = path . join ( app . getPath ( "userData" ) , "webapps" ) ;
file = path . join ( dir , "apps.json" ) ;
try { apps = JSON . parse ( fs . readFileSync ( file , "utf8" ) ) ; } catch { apps = [ ] ; }
if ( ! Array . isArray ( apps ) ) apps = [ ] ;
}
function save ( ) {
try { fs . mkdirSync ( dir , { recursive : true } ) ; fs . writeFileSync ( file , JSON . stringify ( apps , null , 2 ) ) ; } catch ( e ) { console . warn ( "[webapps] save failed:" , e ? . message ) ; }
}
const list = ( ) => apps . slice ( ) ;
const find = ( key ) => apps . find ( ( a ) => a . key === key ) || null ;
const str = ( v ) => ( typeof v === "string" ? v . trim ( ) : "" ) ;
// Pages on BNS names load as bns://host/…; the app is stored under its
// https form (what the user sees, what the shortcut carries). targetUrlFor
// turns it back into bns:// at launch when the resolver says so.
const norm = ( u ) => String ( u || "" ) . replace ( /^bns:\/\//i , "https://" ) ;
function originOf ( u ) { try { return new URL ( norm ( u ) ) . origin ; } catch { return "" ; } }
const keyFor = ( id ) => crypto . createHash ( "sha1" ) . update ( id ) . digest ( "hex" ) . slice ( 0 , 16 ) ;
const aumidFor = ( key ) => ` st.silentmode.theseus.app. ${ key } ` ;
// ---- manifest → descriptor ----------------------------------------------
function parseSizes ( s ) {
let best = 0 ;
for ( const part of String ( s || "" ) . split ( /\s+/ ) ) {
if ( part === "any" ) return Infinity ;
const m = /^(\d+)x(\d+)$/i . exec ( part ) ; if ( m ) best = Math . max ( best , Math . min ( + m [ 1 ] , + m [ 2 ] ) ) ;
}
return best ;
}
function pickIcon ( icons , base ) {
if ( ! Array . isArray ( icons ) ) return null ;
let best = null ;
for ( const ic of icons ) {
if ( ! ic || typeof ic !== "object" || ! str ( ic . src ) ) continue ;
const type = str ( ic . type ) . toLowerCase ( ) ;
const svg = type === "image/svg+xml" || /\.svg(\?|$)/i . test ( ic . src ) ;
if ( svg ) continue ; // nativeImage can't rasterise SVG; the favicon fallback covers it
if ( type && ! /^image\/(png|jpeg|jpg|webp|x-icon|vnd\.microsoft\.icon)$/ . test ( type ) ) continue ;
const purpose = str ( ic . purpose ) . toLowerCase ( ) . split ( /\s+/ ) . filter ( Boolean ) ;
const any = purpose . length === 0 || purpose . includes ( "any" ) ;
const size = parseSizes ( ic . sizes ) ;
let href ; try { href = new URL ( ic . src , base ) . href ; } catch { continue ; }
// Rank: "any"-purpose over maskable-only, then the largest size up to
// 512 (bigger is only downscaled), then anything larger.
const rank = ( any ? 1e6 : 0 ) + ( size === Infinity ? 512 : size <= 512 ? size : 512 - ( size - 512 ) / 1e4 ) ;
if ( ! best || rank > best . rank ) best = { href , size , rank , any } ;
}
return best ;
}
// Turn a page URL + fetched manifest into an app descriptor, or null when the
// manifest doesn't describe an installable app.
function describe ( pageUrl , manifestHref , text ) {
let m ; try { m = JSON . parse ( text ) ; } catch { return null ; }
if ( ! m || typeof m !== "object" || Array . isArray ( m ) ) return null ;
const name = str ( m . name ) || str ( m . short _name ) ;
if ( ! name ) return null ;
const display = APP _DISPLAYS . has ( str ( m . display ) ) ||
( Array . isArray ( m . display _override ) && m . display _override . some ( ( d ) => APP _DISPLAYS . has ( str ( d ) ) ) ) ;
if ( ! display ) return null ;
let startRaw ; try { startRaw = new URL ( str ( m . start _url ) || "." , manifestHref ) . href ; } catch { return null ; }
const startUrl = norm ( startRaw ) . replace ( /#.*$/ , "" ) ;
const origin = originOf ( pageUrl ) ;
if ( ! origin || originOf ( startUrl ) !== origin ) return null ;
let scope ;
try { scope = norm ( new URL ( str ( m . scope ) || "." , startRaw ) . href ) . replace ( /[?#].*$/ , "" ) ; } catch { scope = "" ; }
if ( ! scope || originOf ( scope ) !== origin || ! startUrl . startsWith ( scope ) ) scope = startUrl . replace ( /[?#].*$/ , "" ) . replace ( /[^/]*$/ , "" ) ;
let id ; try { id = new URL ( str ( m . id ) || startUrl , startUrl ) . href ; } catch { id = startUrl ; }
id = norm ( id ) ;
const icon = pickIcon ( m . icons , manifestHref ) ;
return {
key : keyFor ( id ) , id , name : name . slice ( 0 , 80 ) , shortName : ( str ( m . short _name ) || name ) . slice ( 0 , 40 ) ,
startUrl , scope , origin , host : ( ( ) => { try { return new URL ( origin ) . host ; } catch { return "" ; } } ) ( ) ,
manifestUrl : manifestHref ,
iconUrl : icon ? icon . href : null , iconSize : icon ? icon . size : 0 ,
themeColor : /^#[0-9a-f]{6}$/i . test ( str ( m . theme _color ) ) ? str ( m . theme _color ) : null ,
backgroundColor : /^#[0-9a-f]{6}$/i . test ( str ( m . background _color ) ) ? str ( m . background _color ) : null ,
} ;
}
function probeable ( url ) { return /^(https?|bns):\/\//i . test ( String ( url || "" ) ) ; }
// Read the active document's manifest and record the app descriptor (or
// null) on the tab. Returns the descriptor. Fires the synthetic
// beforeinstallprompt when the app isn't installed yet.
async function probeTab ( tab ) {
const wc = tab ? . view ? . webContents ;
if ( ! wc || wc . isDestroyed ( ) ) return null ;
const pageUrl = wc . getURL ( ) ;
if ( ! probeable ( pageUrl ) ) { tab . webapp = null ; return null ; }
let res = null ;
try { res = await wc . executeJavaScript ( PROBE _SRC , true ) ; } catch { }
if ( wc . isDestroyed ( ) || wc . getURL ( ) !== pageUrl ) return tab . webapp || null ; // navigated away meanwhile
const desc = res && res . href && typeof res . text === "string" ? describe ( pageUrl , res . href , res . text ) : null ;
tab . webapp = desc ;
if ( desc && ! find ( desc . key ) ) { try { await wc . executeJavaScript ( PROMPT _SRC , true ) ; } catch { } }
return desc ;
}
// Chrome-shaped summary for the toolbar.
function chipState ( tab ) {
const d = tab ? . webapp ; if ( ! d ) return null ;
return { key : d . key , name : d . name , installed : ! ! find ( d . key ) } ;
}
// ---- icon ------------------------------------------------------------------
// A .ico that simply wraps one PNG (valid since Vista; what most modern .ico
// files are). Windows scales it for every shell size.
function pngToIco ( png ) {
const w = png . readUInt32BE ( 16 ) , h = png . readUInt32BE ( 20 ) ;
const hdr = Buffer . alloc ( 6 ) ; hdr . writeUInt16LE ( 0 , 0 ) ; hdr . writeUInt16LE ( 1 , 2 ) ; hdr . writeUInt16LE ( 1 , 4 ) ;
const ent = Buffer . alloc ( 16 ) ;
ent [ 0 ] = w >= 256 ? 0 : w ; ent [ 1 ] = h >= 256 ? 0 : h ; ent [ 2 ] = 0 ; ent [ 3 ] = 0 ;
ent . writeUInt16LE ( 1 , 4 ) ; ent . writeUInt16LE ( 32 , 6 ) ; ent . writeUInt32LE ( png . length , 8 ) ; ent . writeUInt32LE ( 22 , 12 ) ;
return Buffer . concat ( [ hdr , ent , png ] ) ;
}
async function fetchBytes ( u ) {
const r = await session . defaultSession . fetch ( u , { cache : "force-cache" } ) ;
if ( ! r . ok ) throw new Error ( "HTTP " + r . status ) ;
return Buffer . from ( await r . arrayBuffer ( ) ) ;
}
function bundledIcon ( ) {
return app . isPackaged ? path . join ( process . resourcesPath , "icon.ico" ) : path . join ( _ _dirname , "build" , "icon.ico" ) ;
}
// Manifest icon → PNG (≤ 256 px, square) + ICO on disk. Falls back to the
// page favicon, then to the Theseus icon, so an install never fails on art.
async function writeIcon ( desc , faviconUrl , appDir ) {
const candidates = [ desc . iconUrl , faviconUrl ] . filter ( Boolean ) ;
for ( const u of candidates ) {
try {
const raw = await fetchBytes ( u ) ;
if ( raw . length > 4 && raw . readUInt16LE ( 0 ) === 0 && raw . readUInt16LE ( 2 ) === 1 ) { // already an .ico
fs . writeFileSync ( path . join ( appDir , "icon.ico" ) , raw ) ;
const img = nativeImage . createFromPath ( path . join ( appDir , "icon.ico" ) ) ;
if ( ! img . isEmpty ( ) ) fs . writeFileSync ( path . join ( appDir , "icon.png" ) , img . toPNG ( ) ) ;
return true ;
}
let img = nativeImage . createFromBuffer ( raw ) ;
if ( img . isEmpty ( ) ) continue ;
const { width , height } = img . getSize ( ) ;
if ( width > 256 || height > 256 || width !== height ) {
const s = Math . min ( 256 , Math . max ( width , height ) ) ;
img = img . resize ( { width : s , height : s , quality : "best" } ) ;
}
const png = img . toPNG ( ) ;
fs . writeFileSync ( path . join ( appDir , "icon.png" ) , png ) ;
fs . writeFileSync ( path . join ( appDir , "icon.ico" ) , pngToIco ( png ) ) ;
return true ;
} catch { }
}
try { fs . copyFileSync ( bundledIcon ( ) , path . join ( appDir , "icon.ico" ) ) ; } catch { }
return false ;
}
const icoPath = ( entry ) => path . join ( dir , entry . key , "icon.ico" ) ;
const pngPath = ( entry ) => path . join ( dir , entry . key , "icon.png" ) ;
function windowIcon ( entry ) {
for ( const p of [ pngPath ( entry ) , icoPath ( entry ) ] ) { try { const i = nativeImage . createFromPath ( p ) ; if ( ! i . isEmpty ( ) ) return i ; } catch { } }
return bundledIcon ( ) ;
}
// ---- shortcuts ---------------------------------------------------------------
function launchSpec ( entry ) {
const target = process . execPath ;
// Dev runs are `electron.exe <appdir>`; packaged builds are just the exe.
const args = ( app . isPackaged ? "" : ` " ${ app . getAppPath ( ) } " ` ) + ` --app= ${ entry . startUrl } ` ;
return { target , args } ;
}
function shortcutName ( entry ) {
const base = entry . name . replace ( /[\\/:*?"<>|]+/g , " " ) . replace ( /\s+/g , " " ) . trim ( ) . slice ( 0 , 60 ) || entry . host ;
// Two different apps with the same name: the second gets its host appended.
const clash = apps . some ( ( a ) => a . key !== entry . key && a . shortcutBase === base ) ;
return clash ? ` ${ base } ( ${ entry . host } ) ` : base ;
}
function writeShortcuts ( entry , desktop ) {
const paths = [ ] ;
if ( process . platform !== "win32" ) return paths ;
const { target , args } = launchSpec ( entry ) ;
const name = shortcutName ( entry ) ;
entry . shortcutBase = name ;
const dirs = [ path . join ( app . getPath ( "appData" ) , "Microsoft" , "Windows" , "Start Menu" , "Programs" ) ] ;
if ( desktop ) { try { dirs . push ( app . getPath ( "desktop" ) ) ; } catch { } }
for ( const d of dirs ) {
const lnk = path . join ( d , ` ${ name } .lnk ` ) ;
try {
fs . mkdirSync ( d , { recursive : true } ) ;
const ok = shell . writeShortcutLink ( lnk , "create" , {
target , args , cwd : path . dirname ( target ) , icon : icoPath ( entry ) , iconIndex : 0 ,
appUserModelId : aumidFor ( entry . key ) , description : ` ${ entry . name } — runs in Theseus Navigator ` ,
} ) ;
if ( ok ) paths . push ( lnk ) ;
} catch ( e ) { console . warn ( "[webapps] shortcut failed:" , lnk , e ? . message ) ; }
}
return paths ;
}
function removeShortcuts ( entry ) {
for ( const p of entry . shortcuts || [ ] ) { try { fs . unlinkSync ( p ) ; } catch { } }
}
// ---- install / uninstall --------------------------------------------------------
// Asks the user, then installs. `desc` comes from probeTab; `ctx` names the
// tab's webContents (for the appinstalled event) and favicon. Resolves
// { ok, entry } or { ok:false, error } ("cancelled" when the user said no).
async function install ( desc , ctx = { } ) {
if ( ! desc || ! desc . key ) return { ok : false , error : "not installable" } ;
const have = find ( desc . key ) ;
if ( have ) { open ( have ) ; return { ok : true , entry : have , alreadyInstalled : true } ; }
if ( promptOpen ) return { ok : false , error : "another install prompt is open" } ;
promptOpen = true ;
try {
const parent = deps . parentWindow ? deps . parentWindow ( ) : undefined ;
let icon ; // best effort preview in the dialog
try { const raw = await fetchBytes ( desc . iconUrl || "" ) ; const i = nativeImage . createFromBuffer ( raw ) ; if ( ! i . isEmpty ( ) ) icon = i . resize ( { width : 64 , height : 64 } ) ; } catch { }
const { response , checkboxChecked } = await dialog . showMessageBox ( parent , {
type : "question" , title : "Install app" , icon ,
message : ` Install ${ desc . name } ? ` ,
detail : ` ${ desc . host } \n \n It opens in its own window and gets a Start Menu entry. It keeps running inside Theseus, with your names, add-ons and settings. ` ,
buttons : [ "Install" , "Cancel" ] , defaultId : 0 , cancelId : 1 , noLink : true ,
checkboxLabel : process . platform === "win32" ? "Also add a desktop shortcut" : undefined ,
checkboxChecked : process . platform === "win32" ,
} ) ;
if ( response !== 0 ) return { ok : false , error : "cancelled" } ;
const entry = {
key : desc . key , id : desc . id , name : desc . name , shortName : desc . shortName ,
startUrl : desc . startUrl , scope : desc . scope , origin : desc . origin , host : desc . host ,
manifestUrl : desc . manifestUrl , themeColor : desc . themeColor , backgroundColor : desc . backgroundColor ,
installedAt : new Date ( ) . toISOString ( ) , shortcuts : [ ] , bounds : null ,
} ;
const appDir = path . join ( dir , entry . key ) ;
fs . mkdirSync ( appDir , { recursive : true } ) ;
entry . hasIcon = await writeIcon ( desc , ctx . favicon || null , appDir ) ;
entry . shortcuts = writeShortcuts ( entry , ! ! checkboxChecked ) ;
apps = apps . filter ( ( a ) => a . key !== entry . key ) ; apps . push ( entry ) ; save ( ) ;
try { const wc = ctx . wc ; if ( wc && ! wc . isDestroyed ( ) ) wc . executeJavaScript ( INSTALLED _SRC , true ) . catch ( ( ) => { } ) ; } catch { }
deps . changed && deps . changed ( ) ;
open ( entry ) ;
return { ok : true , entry } ;
} catch ( e ) {
console . warn ( "[webapps] install failed:" , e ? . message ) ;
return { ok : false , error : e ? . message || "install failed" } ;
} finally { promptOpen = false ; }
}
async function uninstall ( key , ask = true ) {
const entry = find ( key ) ; if ( ! entry ) return false ;
if ( ask ) {
const parent = deps . parentWindow ? deps . parentWindow ( ) : undefined ;
const { response } = await dialog . showMessageBox ( parent , {
type : "question" , title : "Remove app" , message : ` Remove ${ entry . name } from Theseus? ` ,
detail : "Its window and shortcuts go away. The site itself and your data on it are untouched." ,
buttons : [ "Remove" , "Cancel" ] , defaultId : 0 , cancelId : 1 , noLink : true ,
} ) ;
if ( response !== 0 ) return false ;
}
const w = windows . get ( key ) ; if ( w && ! w . isDestroyed ( ) ) { try { w . close ( ) ; } catch { } }
removeShortcuts ( entry ) ;
try { fs . rmSync ( path . join ( dir , entry . key ) , { recursive : true , force : true } ) ; } catch { }
apps = apps . filter ( ( a ) => a . key !== key ) ; save ( ) ;
deps . changed && deps . changed ( ) ;
return true ;
}
// ---- the app window ----------------------------------------------------------
function open ( entry , urlOverride ) {
if ( ! entry ) return null ;
const had = windows . get ( entry . key ) ;
if ( had && ! had . isDestroyed ( ) ) {
if ( urlOverride ) had . webContents . loadURL ( urlOverride ) . catch ( ( ) => { } ) ;
if ( had . isMinimized ( ) ) had . restore ( ) ;
had . focus ( ) ;
return had ;
}
const dark = nativeTheme . shouldUseDarkColors ;
const b = entry . bounds && typeof entry . bounds === "object" ? entry . bounds : { } ;
const w = new BrowserWindow ( {
width : b . width || 1100 , height : b . height || 760 ,
... ( Number . isFinite ( b . x ) && Number . isFinite ( b . y ) ? { x : b . x , y : b . y } : { } ) ,
title : entry . name , show : false ,
backgroundColor : entry . backgroundColor || ( dark ? "#0b0e14" : "#ffffff" ) ,
icon : windowIcon ( entry ) ,
webPreferences : { contextIsolation : true , nodeIntegration : false , sandbox : true } ,
} ) ;
windows . set ( entry . key , w ) ;
w . setMenuBarVisibility ( false ) ;
if ( process . platform === "win32" ) {
// Own taskbar identity: pinning the button pins the app, not Theseus,
// and relaunching from the pin comes back through --app=.
const { target , args } = launchSpec ( entry ) ;
try { w . setAppDetails ( { appId : aumidFor ( entry . key ) , appIconPath : icoPath ( entry ) , appIconIndex : 0 , relaunchCommand : ` " ${ target } " ${ args } ` , relaunchDisplayName : entry . name } ) ; } catch { }
}
const wc = w . webContents ;
deps . prepareContents && deps . prepareContents ( wc ) ;
// Page title in the title bar, suffixed with the app name unless the page
// already carries it (most do).
wc . on ( "page-title-updated" , ( e , title ) => {
e . preventDefault ( ) ;
const t = String ( title || "" ) . trim ( ) ;
const has = t && ( t . toLowerCase ( ) . includes ( entry . shortName . toLowerCase ( ) ) || t . toLowerCase ( ) . includes ( entry . name . toLowerCase ( ) ) ) ;
try { w . setTitle ( ! t ? entry . name : has ? t : ` ${ t } — ${ entry . shortName } ` ) ; } catch { }
} ) ;
// Cross-host navigations inside the window stay BCNR-first, like tabs.
wc . on ( "will-navigate" , ( e , u ) => {
try {
const p = new URL ( u ) ;
if ( p . protocol !== "http:" && p . protocol !== "https:" ) return ;
if ( ! deps . isBnsHost || ! deps . isBnsHost ( p . hostname ) ) return ;
let cur = "" ; try { cur = new URL ( wc . getURL ( ) ) . hostname ; } catch { }
if ( cur === p . hostname ) return ;
e . preventDefault ( ) ;
deps . targetUrlFor ( u ) . then ( ( t ) => { if ( t ) wc . loadURL ( t ) . catch ( ( ) => { } ) ; } ) ;
} catch { }
} ) ;
// Popups go to Theseus tabs — one place for tabs, and an app window has
// no tab strip to hold them.
wc . setWindowOpenHandler ( ( { url } ) => {
if ( url && url !== "about:blank" ) deps . openInTab ( url ) ;
return { action : "deny" } ;
} ) ;
// No toolbar, so the keyboard carries navigation: Alt+←/→, F5, Ctrl+R.
wc . on ( "before-input-event" , ( e , input ) => {
if ( input . type !== "keyDown" ) return ;
const nav = wc . navigationHistory ;
if ( input . alt && input . key === "ArrowLeft" && nav . canGoBack ( ) ) { nav . goBack ( ) ; e . preventDefault ( ) ; }
else if ( input . alt && input . key === "ArrowRight" && nav . canGoForward ( ) ) { nav . goForward ( ) ; e . preventDefault ( ) ; }
else if ( input . key === "F5" || ( input . control && ( input . key === "r" || input . key === "R" ) ) ) { input . shift || ( input . control && input . key === "F5" ) ? wc . reloadIgnoringCache ( ) : wc . reload ( ) ; e . preventDefault ( ) ; }
else if ( input . key === "F12" || ( input . control && input . shift && ( input . key === "I" || input . key === "i" ) ) ) { wc . isDevToolsOpened ( ) ? wc . closeDevTools ( ) : wc . openDevTools ( { mode : "bottom" } ) ; e . preventDefault ( ) ; }
} ) ;
wc . on ( "context-menu" , ( _e , p ) => {
const items = [ ] ;
if ( p . linkURL ) {
items . push (
{ label : "Open link in Theseus" , click : ( ) => deps . openInTab ( p . linkURL ) } ,
{ label : "Copy link address" , click : ( ) => clipboard . writeText ( p . linkURL ) } ,
{ type : "separator" } ,
) ;
}
if ( p . isEditable ) items . push ( { role : "cut" } , { role : "copy" } , { role : "paste" } , { type : "separator" } ) ;
else if ( p . selectionText ) items . push ( { role : "copy" } , { type : "separator" } ) ;
const nav = wc . navigationHistory ;
items . push (
{ label : "Back" , enabled : nav . canGoBack ( ) , click : ( ) => nav . goBack ( ) } ,
{ label : "Forward" , enabled : nav . canGoForward ( ) , click : ( ) => nav . goForward ( ) } ,
{ label : "Reload" , click : ( ) => wc . reload ( ) } ,
{ type : "separator" } ,
{ label : "Open this page in Theseus" , click : ( ) => deps . openInTab ( wc . getURL ( ) ) } ,
{ label : "Copy page address" , click : ( ) => clipboard . writeText ( norm ( wc . getURL ( ) ) ) } ,
{ type : "separator" } ,
{ label : ` Remove ${ entry . shortName } from Theseus… ` , click : ( ) => uninstall ( entry . key , true ) } ,
) ;
Menu . buildFromTemplate ( items ) . popup ( { window : w } ) ;
} ) ;
const remember = ( ) => { try { if ( ! w . isMinimized ( ) ) { entry . bounds = w . getNormalBounds ( ) ; save ( ) ; } } catch { } } ;
w . on ( "resize" , remember ) ; w . on ( "move" , remember ) ;
w . on ( "closed" , ( ) => { if ( windows . get ( entry . key ) === w ) windows . delete ( entry . key ) ; } ) ;
w . once ( "ready-to-show" , ( ) => { try { w . show ( ) ; } catch { } } ) ;
Promise . resolve ( deps . targetUrlFor ? deps . targetUrlFor ( urlOverride || entry . startUrl ) : ( urlOverride || entry . startUrl ) )
. then ( ( t ) => wc . loadURL ( t || urlOverride || entry . startUrl ) )
. catch ( ( ) => { } ) ;
return w ;
}
const openWindows = ( ) => [ ... windows . values ( ) ] . filter ( ( w ) => ! w . isDestroyed ( ) ) ;
// ---- --app= launches --------------------------------------------------------------
function appUrlFromArgv ( argv ) {
for ( const a of argv || [ ] ) {
const m = /^--app=(.+)$/ . exec ( String ( a ) ) ;
if ( m && /^https?:\/\//i . test ( m [ 1 ] ) ) return m [ 1 ] ;
}
return null ;
}
// Open the installed app that owns `url` (start_url match first, then any
// app whose scope contains it). A URL no app owns is returned as `null` so
// the caller can fall back to a normal tab.
function launch ( url ) {
const u = norm ( url ) ;
const entry = apps . find ( ( a ) => a . startUrl === u ) || apps . find ( ( a ) => u . startsWith ( a . scope ) ) ;
if ( ! entry ) return null ;
return open ( entry , entry . startUrl === u ? undefined : u ) ;
}
module . exports = { init , list , find , probeTab , chipState , install , uninstall , open , openWindows , launch , appUrlFromArgv , describe } ;