Compare commits

..

No commits in common. "35ce3c39af7dd92cfa60b347851f2a8249573c41" and "bb2b8148c79e4e8d97b38329f540f9a4ce2c7607" have entirely different histories.

2 changed files with 268 additions and 150 deletions

View File

@ -6,9 +6,32 @@ import desktopBridge, {
isElectrobunDesktop isElectrobunDesktop
} from '../../../electrobun-bridge.js' } from '../../../electrobun-bridge.js'
const electron = window.require ? window.require('electron') : null
const ipcRenderer = electron ? electron.ipcRenderer : null
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export function isElectron() { export function isElectron() {
return isElectrobunBridgeReady() || isElectrobunDesktop() if (isElectrobunDesktop()) {
return true
}
if (
typeof window !== 'undefined' &&
window.process &&
window.process.type === 'renderer'
) {
return true
}
if (
typeof navigator === 'object' &&
typeof navigator.userAgent === 'string' &&
navigator.userAgent.indexOf('Electron') >= 0
) {
return true
}
return false
} }
const ElectronContext = createContext() const ElectronContext = createContext()
@ -33,6 +56,7 @@ const ElectronProvider = ({ children }) => {
const [isMaximized, setIsMaximized] = useState(false) const [isMaximized, setIsMaximized] = useState(false)
const [isFullScreen, setIsFullScreen] = useState(false) const [isFullScreen, setIsFullScreen] = useState(false)
const [electronAvailable] = useState(isElectron()) const [electronAvailable] = useState(isElectron())
const useElectrobun = isElectrobunBridgeReady() || isElectrobunDesktop()
const navigate = useNavigate() const navigate = useNavigate()
const lastNavigationAtRef = useRef(0) const lastNavigationAtRef = useRef(0)
@ -61,34 +85,74 @@ const ElectronProvider = ({ children }) => {
}, []) }, [])
const openExternalUrl = (url) => { const openExternalUrl = (url) => {
if (!electronAvailable) return false if (useElectrobun) {
void desktopBridge.openExternalUrl(url).catch((error) => { void desktopBridge.openExternalUrl(url).catch((error) => {
console.warn('[ElectronContext] Failed to open external url:', error) console.warn('[ElectronContext] Failed to open external url:', error)
}) })
return true return true
}
if (electronAvailable && ipcRenderer) {
ipcRenderer.invoke('open-external-url', url)
return true
}
return false
} }
const openInternalUrl = (url) => { const openInternalUrl = (url) => {
if (!electronAvailable) return false if (useElectrobun) {
void desktopBridge.openInternalUrl(url).catch((error) => { void desktopBridge.openInternalUrl(url).catch((error) => {
console.warn('[ElectronContext] Failed to open internal url:', error) console.warn('[ElectronContext] Failed to open internal url:', error)
}) })
return true return true
}
if (electronAvailable && ipcRenderer) {
ipcRenderer.invoke('open-internal-url', url)
return true
}
return false
} }
useEffect(() => { useEffect(() => {
if (!electronAvailable) return if (!electronAvailable) return
document.body.classList.add('electron-body') if (useElectrobun) {
return () => { desktopBridge.getOsInfo().then((info) => {
document.body.classList.remove('electron-body') if (info?.platform) {
setPlatform(info.platform)
if (info.platform === 'darwin') {
document.documentElement.classList.add('macos-vibrancy')
}
}
})
desktopBridge.getWindowState().then(applyWindowState)
const unsubWindowState = desktopBridge.onMessage(
'windowState',
applyWindowState
)
const unsubNavigate = desktopBridge.onMessage('navigate', (url) => {
if (url.toLowerCase() == '/favicon.ico') {
return
}
console.log('[ElectronContext] Navigating to:', url)
navigate(url)
})
const unsubNavigationGesture = desktopBridge.onMessage(
'navigationGesture',
navigateHistory
)
return () => {
unsubWindowState()
unsubNavigate()
unsubNavigationGesture()
}
} }
}, [electronAvailable])
useEffect(() => { if (!ipcRenderer) return
if (!electronAvailable) return
desktopBridge.getOsInfo().then((info) => { ipcRenderer.invoke('os-info').then((info) => {
if (info?.platform) { if (info?.platform) {
setPlatform(info.platform) setPlatform(info.platform)
if (info.platform === 'darwin') { if (info.platform === 'darwin') {
@ -97,30 +161,39 @@ const ElectronProvider = ({ children }) => {
} }
}) })
desktopBridge.getWindowState().then(applyWindowState) ipcRenderer.invoke('window-state').then(applyWindowState)
const unsubWindowState = desktopBridge.onMessage( const windowStateHandler = (_event, state) => {
'windowState', applyWindowState(state)
applyWindowState }
) ipcRenderer.on('window-state', windowStateHandler)
const unsubNavigate = desktopBridge.onMessage('navigate', (url) => {
const navigateHandler = (_event, url) => {
if (url.toLowerCase() == '/favicon.ico') { if (url.toLowerCase() == '/favicon.ico') {
return return
} }
console.log('[ElectronContext] Navigating to:', url) console.log('[ElectronContext] Navigating to:', url)
navigate(url) navigate(url)
}) }
const unsubNavigationGesture = desktopBridge.onMessage( ipcRenderer.on('navigate', navigateHandler)
'navigationGesture',
navigateHistory const navigationGestureHandler = (_event, direction) => {
) navigateHistory(direction)
}
ipcRenderer.on('navigation-gesture', navigationGestureHandler)
return () => { return () => {
unsubWindowState() ipcRenderer.removeListener('navigate', navigateHandler)
unsubNavigate() ipcRenderer.removeListener('navigation-gesture', navigationGestureHandler)
unsubNavigationGesture() ipcRenderer.removeListener('window-state', windowStateHandler)
} }
}, [applyWindowState, electronAvailable, navigate, navigateHistory]) }, [
applyWindowState,
electronAvailable,
navigate,
navigateHistory,
useElectrobun
])
useEffect(() => { useEffect(() => {
if (!electronAvailable || platform !== 'darwin') return if (!electronAvailable || platform !== 'darwin') return
@ -154,74 +227,103 @@ const ElectronProvider = ({ children }) => {
}, [electronAvailable, navigateHistory, platform]) }, [electronAvailable, navigateHistory, platform])
const handleWindowControl = (action) => { const handleWindowControl = (action) => {
if (!electronAvailable) return if (useElectrobun) {
void desktopBridge.windowControl(action) void desktopBridge.windowControl(action)
return
}
if (electronAvailable && ipcRenderer) {
ipcRenderer.send('window-control', action)
}
} }
const getAuthSession = async () => { const getAuthSession = async () => {
if (!electronAvailable) return null if (!electronAvailable) return null
return await desktopBridge.getAuthSession() if (useElectrobun) return await desktopBridge.getAuthSession()
if (!ipcRenderer) return null
return await ipcRenderer.invoke('auth-session-get')
} }
const setAuthSession = async (session) => { const setAuthSession = async (session) => {
if (!electronAvailable) return false if (!electronAvailable) return false
const result = await desktopBridge.setAuthSession(session) if (useElectrobun) {
return result?.ok ?? false const result = await desktopBridge.setAuthSession(session)
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('auth-session-set', session)
} }
const clearAuthSession = async () => { const clearAuthSession = async () => {
if (!electronAvailable) return false if (!electronAvailable) return false
const result = await desktopBridge.clearAuthSession() if (useElectrobun) {
return result?.ok ?? false const result = await desktopBridge.clearAuthSession()
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('auth-session-clear')
} }
const getAppSettings = useCallback(async () => { const getAppSettings = useCallback(async () => {
if (!electronAvailable) return {} if (!electronAvailable) return {}
return await desktopBridge.getAppSettings() if (useElectrobun) return await desktopBridge.getAppSettings()
}, [electronAvailable]) if (!ipcRenderer) return {}
return await ipcRenderer.invoke('app-settings-get')
}, [electronAvailable, useElectrobun])
const setAppSettings = useCallback( const setAppSettings = useCallback(
async (settings) => { async (settings) => {
if (!electronAvailable) return false if (!electronAvailable) return false
const result = await desktopBridge.setAppSettings(settings) if (useElectrobun) {
return result?.ok ?? false const result = await desktopBridge.setAppSettings(settings)
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('app-settings-set', settings)
}, },
[electronAvailable] [electronAvailable, useElectrobun]
) )
const startAppUpdate = useCallback( const startAppUpdate = useCallback(
async (update) => { async (update) => {
if (!electronAvailable) return false if (!electronAvailable) return false
const result = await desktopBridge.startAppUpdate(update) if (useElectrobun) {
return result?.ok ?? false const result = await desktopBridge.startAppUpdate(update)
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('app-update-start', update)
}, },
[electronAvailable] [electronAvailable, useElectrobun]
) )
const checkAppUpdateResult = useCallback(async () => { const checkAppUpdateResult = useCallback(async () => {
if (!electronAvailable) return null if (!electronAvailable || !useElectrobun) return null
return await desktopBridge.checkAppUpdateResult() return await desktopBridge.checkAppUpdateResult()
}, [electronAvailable]) }, [electronAvailable, useElectrobun])
const checkDuplicateInstallations = useCallback(async () => { const checkDuplicateInstallations = useCallback(async () => {
if (!electronAvailable) return null if (!electronAvailable || !useElectrobun) return null
return await desktopBridge.checkDuplicateInstallations() return await desktopBridge.checkDuplicateInstallations()
}, [electronAvailable]) }, [electronAvailable, useElectrobun])
const removeDuplicateInstallations = useCallback(async () => { const removeDuplicateInstallations = useCallback(async () => {
if (!electronAvailable) return false if (!electronAvailable || !useElectrobun) return false
const result = await desktopBridge.removeDuplicateInstallations() const result = await desktopBridge.removeDuplicateInstallations()
return result?.ok ?? false return result?.ok ?? false
}, [electronAvailable]) }, [electronAvailable, useElectrobun])
const onDuplicateInstallationsRemoved = useCallback( const onDuplicateInstallationsRemoved = useCallback(
(handler) => { (handler) => {
if (!electronAvailable || typeof handler !== 'function') { if (
!electronAvailable ||
!useElectrobun ||
typeof handler !== 'function'
) {
return () => {} return () => {}
} }
return desktopBridge.onMessage('duplicateInstallationsRemoved', handler) return desktopBridge.onMessage('duplicateInstallationsRemoved', handler)
}, },
[electronAvailable] [electronAvailable, useElectrobun]
) )
const onAppUpdateProgress = useCallback( const onAppUpdateProgress = useCallback(
@ -229,9 +331,24 @@ const ElectronProvider = ({ children }) => {
if (!electronAvailable || typeof handler !== 'function') { if (!electronAvailable || typeof handler !== 'function') {
return () => {} return () => {}
} }
return desktopBridge.onMessage('appUpdateProgress', handler)
if (useElectrobun) {
return desktopBridge.onMessage('appUpdateProgress', handler)
}
if (!ipcRenderer) return () => {}
const progressHandler = (_event, progress) => {
handler(progress)
}
ipcRenderer.on('app-update-progress', progressHandler)
return () => {
ipcRenderer.removeListener('app-update-progress', progressHandler)
}
}, },
[electronAvailable] [electronAvailable, useElectrobun]
) )
const onCheckForUpdatesRequest = useCallback( const onCheckForUpdatesRequest = useCallback(
@ -239,9 +356,24 @@ const ElectronProvider = ({ children }) => {
if (!electronAvailable || typeof handler !== 'function') { if (!electronAvailable || typeof handler !== 'function') {
return () => {} return () => {}
} }
return desktopBridge.onMessage('checkForUpdates', handler)
if (useElectrobun) {
return desktopBridge.onMessage('checkForUpdates', handler)
}
if (!ipcRenderer) return () => {}
const checkHandler = () => {
handler()
}
ipcRenderer.on('check-for-updates', checkHandler)
return () => {
ipcRenderer.removeListener('check-for-updates', checkHandler)
}
}, },
[electronAvailable] [electronAvailable, useElectrobun]
) )
const getToken = async () => { const getToken = async () => {
@ -257,8 +389,12 @@ const ElectronProvider = ({ children }) => {
const resizeSpotlightWindow = async (height) => { const resizeSpotlightWindow = async (height) => {
if (!electronAvailable) return false if (!electronAvailable) return false
try { try {
const result = await desktopBridge.resizeSpotlightWindow(height) if (useElectrobun) {
return result?.ok ?? false const result = await desktopBridge.resizeSpotlightWindow(height)
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('spotlight-window-resize', height)
} catch (error) { } catch (error) {
console.warn( console.warn(
'[ElectronContext] Failed to resize spotlight window:', '[ElectronContext] Failed to resize spotlight window:',
@ -271,22 +407,31 @@ const ElectronProvider = ({ children }) => {
const setSidebarViewMenu = useCallback( const setSidebarViewMenu = useCallback(
async (sections) => { async (sections) => {
if (!electronAvailable) return false if (!electronAvailable) return false
const result = await desktopBridge.setSidebarViewMenu(sections) if (useElectrobun) {
return result?.ok ?? false const result = await desktopBridge.setSidebarViewMenu(sections)
return result?.ok ?? false
}
if (!ipcRenderer) return false
return await ipcRenderer.invoke('set-sidebar-view-menu', sections)
}, },
[electronAvailable] [electronAvailable, useElectrobun]
) )
const getElectronVersion = useCallback(async () => { const getElectronVersion = useCallback(async () => {
if (!electronAvailable) return null if (!electronAvailable) return null
return await desktopBridge.getAppVersion() if (useElectrobun) return await desktopBridge.getAppVersion()
}, [electronAvailable]) if (!ipcRenderer) return null
return await ipcRenderer.invoke('electron-version')
}, [electronAvailable, useElectrobun])
const getAppEngine = useCallback(async () => { const getAppEngine = useCallback(async () => {
if (!electronAvailable) return 'native' if (!electronAvailable) return 'native'
const engine = await desktopBridge.getAppEngine() if (useElectrobun) {
return engine === 'chromium' ? 'chromium' : 'native' const engine = await desktopBridge.getAppEngine()
}, [electronAvailable]) return engine === 'chromium' ? 'chromium' : 'native'
}
return 'native'
}, [electronAvailable, useElectrobun])
return ( return (
<ElectronContext.Provider <ElectronContext.Provider

View File

@ -126,14 +126,13 @@ export const prepareInstallerPath = async (installerPath) => {
const startWindowsInstallerProgressWatch = ( const startWindowsInstallerProgressWatch = (
logPath, logPath,
sendProgress, sendProgress,
{ onInstallSuccessful, onInstallFailed, onStalled, stallTimeoutMs } onInstallSuccessful
) => { ) => {
let installerOutput = '' let installerOutput = ''
let offset = 0 let offset = 0
let lastPercent = null let lastPercent = null
let lastMessage = null let lastMessage = null
let outcomeNotified = false let installSuccessNotified = false
let lastActivityAt = Date.now()
const poll = async () => { const poll = async () => {
try { try {
@ -146,7 +145,6 @@ const startWindowsInstallerProgressWatch = (
await handle.read(buffer, 0, buffer.length, offset) await handle.read(buffer, 0, buffer.length, offset)
offset = stat.size offset = stat.size
installerOutput += buffer.toString('utf8') installerOutput += buffer.toString('utf8')
lastActivityAt = Date.now()
const { percent, message } = const { percent, message } =
parseWindowsInstallerProgress(installerOutput) parseWindowsInstallerProgress(installerOutput)
@ -162,14 +160,13 @@ const startWindowsInstallerProgressWatch = (
}) })
} }
if (!outcomeNotified) { if (
if (isWindowsInstallFailed(installerOutput)) { !installSuccessNotified &&
outcomeNotified = true isWindowsInstallSuccessful(installerOutput) &&
onInstallFailed?.(installerOutput) !isWindowsInstallFailed(installerOutput)
} else if (isWindowsInstallSuccessful(installerOutput)) { ) {
outcomeNotified = true installSuccessNotified = true
onInstallSuccessful?.(installerOutput) onInstallSuccessful?.(installerOutput)
}
} }
} finally { } finally {
await handle.close() await handle.close()
@ -182,18 +179,6 @@ const startWindowsInstallerProgressWatch = (
} }
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
// The installer runs detached (outside our job object), so a dead
// installer only shows up as the log going quiet before any outcome.
if (
!outcomeNotified &&
stallTimeoutMs > 0 &&
Date.now() - lastActivityAt > stallTimeoutMs
) {
outcomeNotified = true
onStalled?.(installerOutput)
return
}
poll().catch((error) => { poll().catch((error) => {
console.error('[app-update] installer log poll error:', error) console.error('[app-update] installer log poll error:', error)
}) })
@ -264,22 +249,8 @@ export const launchWindowsInstaller = async (
const stopProgressWatch = startWindowsInstallerProgressWatch( const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath, logPath,
sendProgress, sendProgress,
{ (output) => {
stallTimeoutMs: 3 * 60 * 1000, settleSuccess(output)
onInstallSuccessful: (output) => {
settleSuccess(output)
},
onInstallFailed: (output) => {
settleFailure(getInstallErrorMessage(null, output))
},
onStalled: (output) => {
settleFailure(
getInstallErrorMessage(
new Error('The update installer stopped responding.'),
output
)
)
}
} }
) )
@ -287,63 +258,65 @@ export const launchWindowsInstaller = async (
return await stopProgressWatch() return await stopProgressWatch()
} }
// Silent NSIS install. // Silent NSIS install in a detached child process (not a batch file).
// /S = silent (https://nsis.sourceforge.io/Reference/SilentInstall) // /S = silent (https://nsis.sourceforge.io/Reference/SilentInstall)
// /UPDATE = in-app update (stage into Farm Control.new; swap after exit) // /UPDATE = in-app update (stage into Farm Control.new; swap after exit)
// /RESTARTFC = installer waits for this process to exit, swaps folders, relaunches // /RESTARTFC = installer waits for this process to exit, swaps folders, relaunches
// /PARENTPID = exact process to wait for (CEF may outlive the launcher) // /PARENTPID = exact process to wait for (CEF may outlive the launcher)
// /LOG= = progress log (installer:% / PHASE / STATUS / COPY_TOTAL / COPY_FILE) // /LOG= + FARMCONTROL_INSTALL_LOG = progress log
const installerCommandLine = [ // (installer:% / PHASE / STATUS / COPY_TOTAL / COPY_FILE)
`"${resolvedPath}"`, const installerArgs = [
'/S', '/S',
'/UPDATE', '/UPDATE',
'/RESTARTFC', '/RESTARTFC',
`/PARENTPID=${process.pid}`, `/PARENTPID=${process.pid}`,
`/LOG="${logPath}"` `/LOG=${logPath}`
].join(' ') ]
// This process runs inside a Windows job object (CEF/launcher) that kills const installerProcess = spawn(resolvedPath, installerArgs, {
// every child process when the app exits — a directly spawned installer detached: true,
// (even with detached: true) inherits the job and dies mid folder-swap the env: {
// moment the app quits. Creating the process via WMI (Win32_Process.Create) ...process.env,
// parents it to the WMI provider host, outside our job, so it survives. FARMCONTROL_INSTALL_LOG: logPath
const quoteForPowershell = (value) => `'${value.replace(/'/g, "''")}'` },
stdio: 'ignore',
windowsHide: true
})
const spawnerProcess = spawn( installerProcess.unref()
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-WindowStyle',
'Hidden',
'-Command',
`$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${quoteForPowershell(installerCommandLine)} }; exit $result.ReturnValue`
],
{
stdio: 'ignore',
windowsHide: true
}
)
spawnerProcess.on('error', async (error) => { installerProcess.on('error', async (error) => {
const output = await refreshInstallerOutput() const output = await refreshInstallerOutput()
console.error('[app-update] installer launch error:', error) console.error('[app-update] installer error:', error)
settleFailure(getInstallErrorMessage(error, output)) settleFailure(getInstallErrorMessage(error, output))
}) })
spawnerProcess.on('exit', async (code) => { installerProcess.on('exit', async (code) => {
// PowerShell exits as soon as the installer is created; from here the
// log watcher owns success/failure. Only a non-zero code (WMI create
// failed) means the installer never started.
if (code === 0 || settled) return
const output = await refreshInstallerOutput() const output = await refreshInstallerOutput()
settleFailure(
getInstallErrorMessage( await fs.unlink(logPath).catch(() => {})
new Error(`Failed to launch the update installer (code ${code}).`),
output if (settled) return
if (code !== 0) {
settleFailure(
getInstallErrorMessage(
new Error(`Installer exited with code ${code}.`),
output
)
) )
) return
}
if (
isWindowsInstallFailed(output) ||
!isWindowsInstallSuccessful(output)
) {
settleFailure(getInstallErrorMessage(null, output))
return
}
settleSuccess(output)
}) })
}) })
} }