forked from imbytecat/fullstack-starter
feat(desktop): 拆分 sidecar 管理并接入健康检查路由
This commit is contained in:
@@ -1,142 +1,57 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
import { join } from 'node:path'
|
||||
import { app, BrowserWindow, dialog, shell } from 'electron'
|
||||
import killProcessTree from 'tree-kill'
|
||||
import { createSidecarRuntime } from './sidecar'
|
||||
|
||||
const DEV_SERVER_URL = 'http://localhost:3000'
|
||||
const SAFE_EXTERNAL_PROTOCOLS = new Set(['https:', 'http:', 'mailto:'])
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let serverProcess: ReturnType<typeof spawn> | null = null
|
||||
let windowCreationPromise: Promise<void> | null = null
|
||||
let isQuitting = false
|
||||
let serverReady = false
|
||||
|
||||
const shouldAbortWindowLoad = (): boolean =>
|
||||
isQuitting || !mainWindow || mainWindow.isDestroyed()
|
||||
|
||||
const showErrorAndQuit = (title: string, detail: string) => {
|
||||
if (isQuitting) return
|
||||
if (isQuitting) {
|
||||
return
|
||||
}
|
||||
|
||||
dialog.showErrorBox(title, detail)
|
||||
app.quit()
|
||||
}
|
||||
|
||||
const getAvailablePort = (): Promise<number> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.listen(0, () => {
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === 'string') {
|
||||
server.close()
|
||||
reject(new Error('Failed to resolve port'))
|
||||
return
|
||||
}
|
||||
server.close(() => resolve(addr.port))
|
||||
})
|
||||
server.on('error', reject)
|
||||
})
|
||||
const sidecar = createSidecarRuntime({
|
||||
devServerUrl: DEV_SERVER_URL,
|
||||
isPackaged: app.isPackaged,
|
||||
resourcesPath: process.resourcesPath,
|
||||
isQuitting: () => isQuitting,
|
||||
onUnexpectedStop: (detail) => {
|
||||
showErrorAndQuit('Service Stopped', detail)
|
||||
},
|
||||
})
|
||||
|
||||
const isServerReady = async (url: string): Promise<boolean> => {
|
||||
const toErrorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
const canOpenExternally = (url: string): boolean => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
return response.ok
|
||||
const parsed = new URL(url)
|
||||
return SAFE_EXTERNAL_PROTOCOLS.has(parsed.protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const waitForServer = async (
|
||||
url: string,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<boolean> => {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs && !isQuitting) {
|
||||
if (await isServerReady(url)) return true
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const stopServerProcess = () => {
|
||||
if (!serverProcess) {
|
||||
return
|
||||
}
|
||||
|
||||
const runningServer = serverProcess
|
||||
serverProcess = null
|
||||
|
||||
if (!runningServer.pid || runningServer.exitCode !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
killProcessTree(runningServer.pid, (error?: Error) => {
|
||||
if (error) {
|
||||
console.error('Failed to stop server process:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const spawnServer = (port: number): string => {
|
||||
const binaryName = process.platform === 'win32' ? 'server.exe' : 'server'
|
||||
const binaryPath = join(process.resourcesPath, binaryName)
|
||||
|
||||
serverProcess = spawn(binaryPath, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
HOST: '127.0.0.1',
|
||||
},
|
||||
stdio: 'ignore',
|
||||
})
|
||||
serverProcess.unref()
|
||||
|
||||
serverProcess.on('error', (err) => {
|
||||
console.error('Failed to start server:', err)
|
||||
showErrorAndQuit(
|
||||
'Startup Failed',
|
||||
'A required component failed to start. Please reinstall the app.',
|
||||
)
|
||||
})
|
||||
|
||||
serverProcess.on('exit', (code) => {
|
||||
serverProcess = null
|
||||
if (!isQuitting && serverReady) {
|
||||
showErrorAndQuit(
|
||||
'Service Stopped',
|
||||
app.isPackaged
|
||||
? 'The background service stopped unexpectedly. Please restart the app.'
|
||||
: `Server process exited unexpectedly (code ${code}). Check the server logs for details.`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return `http://127.0.0.1:${port}`
|
||||
}
|
||||
|
||||
const resolveServerUrl = async (): Promise<string | null> => {
|
||||
if (!app.isPackaged) {
|
||||
return DEV_SERVER_URL
|
||||
}
|
||||
|
||||
let port: number
|
||||
try {
|
||||
port = await getAvailablePort()
|
||||
} catch {
|
||||
showErrorAndQuit(
|
||||
'Startup Failed',
|
||||
"The app couldn't allocate a local port. Please close other apps and try again.",
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (isQuitting) {
|
||||
return null
|
||||
}
|
||||
|
||||
return spawnServer(port)
|
||||
}
|
||||
|
||||
const createWindow = async () => {
|
||||
mainWindow = new BrowserWindow({
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrl = await sidecar.resolveUrl()
|
||||
if (isQuitting) {
|
||||
return
|
||||
}
|
||||
|
||||
const windowRef = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
@@ -145,89 +60,94 @@ const createWindow = async () => {
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
mainWindow = windowRef
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
windowRef.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (!canOpenExternally(url)) {
|
||||
if (!app.isPackaged) {
|
||||
console.warn(`Blocked external URL: ${url}`)
|
||||
}
|
||||
|
||||
return { action: 'deny' }
|
||||
}
|
||||
|
||||
void shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
windowRef.on('closed', () => {
|
||||
if (mainWindow === windowRef) {
|
||||
mainWindow = null
|
||||
}
|
||||
})
|
||||
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
mainWindow.show()
|
||||
try {
|
||||
await windowRef.loadURL(targetUrl)
|
||||
} catch (error) {
|
||||
if (mainWindow === windowRef) {
|
||||
mainWindow = null
|
||||
}
|
||||
|
||||
const serverUrl = await resolveServerUrl()
|
||||
if (!serverUrl || shouldAbortWindowLoad()) {
|
||||
stopServerProcess()
|
||||
return
|
||||
if (!windowRef.isDestroyed()) {
|
||||
windowRef.destroy()
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!app.isPackaged) console.log(`Waiting for server at ${serverUrl}...`)
|
||||
const ready = await waitForServer(serverUrl)
|
||||
if (shouldAbortWindowLoad()) {
|
||||
stopServerProcess()
|
||||
return
|
||||
if (!windowRef.isDestroyed()) {
|
||||
windowRef.show()
|
||||
}
|
||||
}
|
||||
|
||||
const ensureWindow = async () => {
|
||||
if (windowCreationPromise) {
|
||||
return windowCreationPromise
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
showErrorAndQuit(
|
||||
'Startup Failed',
|
||||
app.isPackaged
|
||||
? 'The app is taking too long to start. Please try again.'
|
||||
: 'Dev server not responding. Run `bun dev` in apps/server first.',
|
||||
)
|
||||
return
|
||||
}
|
||||
windowCreationPromise = createWindow().finally(() => {
|
||||
windowCreationPromise = null
|
||||
})
|
||||
|
||||
serverReady = true
|
||||
mainWindow.loadURL(serverUrl)
|
||||
return windowCreationPromise
|
||||
}
|
||||
|
||||
const beginQuit = () => {
|
||||
isQuitting = true
|
||||
stopServerProcess()
|
||||
sidecar.stop()
|
||||
}
|
||||
|
||||
const handleWindowCreationError = (error: unknown, context: string) => {
|
||||
console.error(`${context}:`, error)
|
||||
showErrorAndQuit(
|
||||
"App Couldn't Start",
|
||||
app.isPackaged
|
||||
? 'A required component failed to start. Please reinstall the app.'
|
||||
: `${context}: ${toErrorMessage(error)}`,
|
||||
)
|
||||
}
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(createWindow)
|
||||
.catch((e) => {
|
||||
console.error('Failed to create window:', e)
|
||||
showErrorAndQuit(
|
||||
"App Couldn't Start",
|
||||
app.isPackaged
|
||||
? "We couldn't open the application window. Please restart your computer and try again."
|
||||
: `Failed to create window: ${e instanceof Error ? e.message : String(e)}`,
|
||||
)
|
||||
.then(() => ensureWindow())
|
||||
.catch((error) => {
|
||||
handleWindowCreationError(error, 'Failed to create window')
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
beginQuit()
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (!isQuitting && BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow().catch((e) => {
|
||||
console.error('Failed to re-create window:', e)
|
||||
showErrorAndQuit(
|
||||
"App Couldn't Start",
|
||||
app.isPackaged
|
||||
? "We couldn't open the application window. Please restart the app."
|
||||
: `Failed to re-create window: ${e instanceof Error ? e.message : String(e)}`,
|
||||
)
|
||||
})
|
||||
if (isQuitting || BrowserWindow.getAllWindows().length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureWindow().catch((error) => {
|
||||
handleWindowCreationError(error, 'Failed to re-create window')
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
beginQuit()
|
||||
})
|
||||
app.on('before-quit', beginQuit)
|
||||
|
||||
Reference in New Issue
Block a user