fix: handle failures in the PAC server, Gist sync and floating window (#2032)

- The PAC server called listen() with no 'error' listener. A host the user
  cannot bind to - or a port already in use - emitted an unhandled 'error'
  event, which in Node terminates the process, so a bad PAC host setting
  crashed the whole main process. Attach an error handler and surface the
  failure to the caller.

- The Gist upload never checked the HTTP status. Once the token expired,
  GitHub answered 401 and the code still recorded the runtime config as
  successfully synced, so the user saw a healthy backup that did not
  exist. Check the status and report the failure.

- On 'render-process-gone' the floating window handler only set the module
  reference to null without destroying the BrowserWindow. The dead window
  stayed on screen, always-on-top, with nothing left holding a reference
  to close it. Destroy it before clearing the reference.
This commit is contained in:
MOMO0302-02
2026-08-21 03:10:23 -07:00
committed by GitHub
parent 7caecd6474
commit 637a875aba
3 changed files with 57 additions and 12 deletions

View File

@@ -54,13 +54,26 @@ async function createFloatingWindow(): Promise<void> {
}
}
floatingWindow = new BrowserWindow(windowOptions)
const win = new BrowserWindow(windowOptions)
floatingWindow = win
floatingWindowState.manage(floatingWindow)
// 事件监听器
floatingWindow.webContents.on('render-process-gone', (_, details) => {
logError('Render process gone', details.reason)
floatingWindow = null
// 只丢引用不销毁的话,屏幕上会残留一个无边框、置顶、不在任务栏且 closable: false 的幽灵窗,
// 用户既关不掉也找不到,只能重启应用
if (!win.isDestroyed()) {
win.destroy()
}
})
// 窗口销毁后必须清掉引用,否则 isVisible() 之类的调用会作用在已销毁窗口上抛错;
// 判断 win 是为了避免旧窗口的 closed 事件把新建窗口的引用清掉
win.on('closed', () => {
if (floatingWindow === win) {
floatingWindow = null
}
})
floatingWindow.on('ready-to-show', () => {

View File

@@ -30,6 +30,15 @@ function hashRuntimeConfig(runtimeConfig: string): string {
return createHash('sha256').update(runtimeConfig).digest('hex')
}
// chromeRequest 对任何状态码都会 resolve不校验状态码的话 token 失效401/403会被当成成功
// 错误响应体被当作「远端没有这个 gist」上传也「成功」哈希被记为已同步后就再也不会重试
function assertGithubOk(res: chromeRequest.Response, action: string): void {
if (res.status < 200 || res.status >= 300) {
const detail = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
throw new Error(`GitHub API ${action} failed with status ${res.status}: ${detail}`)
}
}
async function listGists(token: string): Promise<GistInfo[]> {
const { 'mixed-port': port = DEFAULT_MIHOMO_PORTS.mixed } = await getControledMihomoConfig()
const res = await chromeRequest.get('https://api.github.com/gists', {
@@ -45,12 +54,13 @@ async function listGists(token: string): Promise<GistInfo[]> {
},
responseType: 'json'
})
assertGithubOk(res, 'list gists')
return Array.isArray(res.data) ? res.data : []
}
async function createGist(token: string, content: string): Promise<void> {
const { 'mixed-port': port = DEFAULT_MIHOMO_PORTS.mixed } = await getControledMihomoConfig()
await chromeRequest.post(
const res = await chromeRequest.post(
'https://api.github.com/gists',
{
description: 'Auto Synced Clash Party Runtime Config',
@@ -70,11 +80,12 @@ async function createGist(token: string, content: string): Promise<void> {
}
}
)
assertGithubOk(res, 'create gist')
}
async function updateGist(token: string, id: string, content: string): Promise<void> {
const { 'mixed-port': port = DEFAULT_MIHOMO_PORTS.mixed } = await getControledMihomoConfig()
await chromeRequest.patch(
const res = await chromeRequest.patch(
`https://api.github.com/gists/${id}`,
{
description: 'Auto Synced Clash Party Runtime Config',
@@ -93,6 +104,7 @@ async function updateGist(token: string, id: string, content: string): Promise<v
}
}
)
assertGithubOk(res, 'update gist')
}
export async function getGistUrl(): Promise<string> {

View File

@@ -11,7 +11,7 @@ import * as chromeRequest from '../utils/chromeRequest'
import subStoreIcon from '../../../resources/subStoreIcon.png?asset'
import { dataDir, mihomoWorkDir, subStoreDir, substoreLogPath } from '../utils/dirs'
import { getAppConfig, getControledMihomoConfig } from '../config'
import { systemLogger } from '../utils/logger'
import { proxyLogger, systemLogger } from '../utils/logger'
import { createCappedLogWritableStream } from '../utils/logFile'
import { DEFAULT_MIHOMO_PORTS, DEFAULT_USE_SUB_STORE } from '../../shared/appConfig'
@@ -46,7 +46,7 @@ export function findAvailablePort(startPort: number): Promise<number> {
})
}
let pacServer: http.Server
let pacServer: http.Server | undefined
export async function startPacServer(): Promise<void> {
await stopPacServer()
@@ -60,17 +60,37 @@ export async function startPacServer(): Promise<void> {
const { 'mixed-port': port = DEFAULT_MIHOMO_PORTS.mixed } = await getControledMihomoConfig()
script = script.replaceAll('%mixed-port%', port.toString())
pacPort = await findAvailablePort(10000)
pacServer = http
.createServer(async (_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' })
res.end(script)
})
.listen(pacPort, host)
const server = http.createServer(async (_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' })
res.end(script)
})
// host 由用户自由填写findAvailablePort 只在 127.0.0.1 上探测过端口,这里绑定可能失败
// EADDRNOTAVAIL / ENOTFOUND。listen 失败是异步事件,没有 'error' 监听器会直接变成主进程
// 未捕获异常并弹错误框,所以把失败转成 reject 交给调用方处理
await new Promise<void>((resolve, reject) => {
const onError = (err: Error): void => {
server.removeListener('listening', onListening)
reject(err)
}
const onListening = (): void => {
server.removeListener('error', onError)
// 监听成功后仍保留 error 监听,防止运行期出错(如网络接口变化)再次崩溃主进程
server.on('error', (err) => {
proxyLogger.error('PAC server error', err).catch(() => {})
})
resolve()
}
server.once('error', onError)
server.once('listening', onListening)
server.listen(pacPort, host)
})
pacServer = server
}
export async function stopPacServer(): Promise<void> {
if (pacServer) {
pacServer.close()
pacServer = undefined
}
}