mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
fix(desktop): isolate backend port from the web console
This commit is contained in:
@@ -4,16 +4,23 @@ import path from 'path'
|
||||
import os from 'os'
|
||||
import fs from 'fs'
|
||||
import http from 'http'
|
||||
import net from 'net'
|
||||
|
||||
// Writable data dir for the packaged app (config.json, run.log, user data).
|
||||
// Lives in the user's home so it survives app updates and avoids writing into
|
||||
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
|
||||
const COW_DATA_DIR = path.join(os.homedir(), '.cow')
|
||||
|
||||
// Preferred port for the desktop backend. Deliberately not 9899 (the web
|
||||
// console's default) so a source-run `python app.py` never collides with the
|
||||
// packaged app. It's only a *preference*: if it's taken we bind a free port
|
||||
// chosen by the OS, so we never hard-depend on any single port being free.
|
||||
const PREFERRED_PORT = 9876
|
||||
|
||||
export class PythonBackend extends EventEmitter {
|
||||
private process: ChildProcess | null = null
|
||||
private backendPath: string
|
||||
private port: number = 9899
|
||||
private port: number = PREFERRED_PORT
|
||||
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
|
||||
|
||||
constructor(backendPath: string) {
|
||||
@@ -66,23 +73,69 @@ export class PythonBackend extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve config.json from the given data dir to read the web port. The
|
||||
* Read an explicit `web_port` from config.json, if the user pinned one. The
|
||||
* packaged build keeps config in COW_DATA_DIR (~/.cow); dev reads it from the
|
||||
* repo path. Returns the default port when no config (or web_port) is found.
|
||||
* repo path. Returns null when unset, so the caller can auto-pick a free port
|
||||
* instead of fighting over a fixed one.
|
||||
*/
|
||||
private readPort(dataDir: string): number {
|
||||
private readConfiguredPort(dataDir: string): number | null {
|
||||
try {
|
||||
const configPath = path.join(dataDir, 'config.json')
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
if (config.web_port) {
|
||||
return config.web_port
|
||||
const p = Number(config.web_port)
|
||||
if (Number.isInteger(p) && p > 0 && p < 65536) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
// ignore — fall through to auto-selection
|
||||
}
|
||||
return 9899
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a usable port without hard-depending on any specific one:
|
||||
* 1. A user-pinned web_port is honored as-is (their explicit choice).
|
||||
* 2. Otherwise try PREFERRED_PORT; if free, use it.
|
||||
* 3. If taken, let the OS hand us a guaranteed-free ephemeral port.
|
||||
* This never throws on a busy port — it just moves on to a free one.
|
||||
*/
|
||||
private async resolvePort(dataDir: string): Promise<number> {
|
||||
const pinned = this.readConfiguredPort(dataDir)
|
||||
if (pinned !== null) {
|
||||
return pinned
|
||||
}
|
||||
if (await this.isPortFree(PREFERRED_PORT)) {
|
||||
return PREFERRED_PORT
|
||||
}
|
||||
return this.findFreePort()
|
||||
}
|
||||
|
||||
/** True if we can bind 127.0.0.1:port right now (i.e. it's free). */
|
||||
private isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const tester = net
|
||||
.createServer()
|
||||
.once('error', () => resolve(false))
|
||||
.once('listening', () => {
|
||||
tester.close(() => resolve(true))
|
||||
})
|
||||
.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
/** Ask the OS for a free ephemeral port (listen on 0, read the assigned one). */
|
||||
private findFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer()
|
||||
srv.once('error', reject)
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address()
|
||||
const port = typeof addr === 'object' && addr ? addr.port : PREFERRED_PORT
|
||||
srv.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -97,14 +150,17 @@ export class PythonBackend extends EventEmitter {
|
||||
const bundled = this.findBundledBackend()
|
||||
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
|
||||
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
|
||||
this.port = this.readPort(dataDir)
|
||||
|
||||
const alreadyRunning = await this.probeHealth()
|
||||
if (alreadyRunning) {
|
||||
this.status = 'ready'
|
||||
this.emit('log', `Backend already running on port ${this.port}`)
|
||||
this.emit('ready', this.port)
|
||||
return
|
||||
// Always launch our OWN backend (re-entrancy is guarded above by the
|
||||
// status check, so we never double-spawn for this instance). We don't reuse
|
||||
// whatever happens to be on the port: that's how the app previously
|
||||
// attached to a source-run web console and read the wrong config. Pick a
|
||||
// usable port instead — honoring a user-pinned web_port, else PREFERRED_PORT
|
||||
// when free, else an OS-assigned free port. This never depends on a probe.
|
||||
try {
|
||||
this.port = await this.resolvePort(dataDir)
|
||||
} catch {
|
||||
this.port = PREFERRED_PORT
|
||||
}
|
||||
|
||||
let command: string
|
||||
@@ -150,6 +206,9 @@ export class PythonBackend extends EventEmitter {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
COW_DESKTOP: '1',
|
||||
// The shell owns the port: tell the backend to bind exactly here so the
|
||||
// two sides can never disagree (and we avoid the 9899 web-console clash).
|
||||
COW_WEB_PORT: String(this.port),
|
||||
...(bundled ? { COW_DATA_DIR } : {}),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -185,16 +244,6 @@ export class PythonBackend extends EventEmitter {
|
||||
await this.waitForReady()
|
||||
}
|
||||
|
||||
private probeHealth(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
||||
resolve(res.statusCode === 200)
|
||||
})
|
||||
req.on('error', () => resolve(false))
|
||||
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
|
||||
})
|
||||
}
|
||||
|
||||
private waitForReady(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// Wall-clock deadline rather than an attempt counter: if the machine
|
||||
|
||||
@@ -25,7 +25,7 @@ interface ApiResult {
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl = 'http://127.0.0.1:9899'
|
||||
private baseUrl = 'http://127.0.0.1:9876'
|
||||
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url
|
||||
|
||||
@@ -9,7 +9,7 @@ interface BackendState {
|
||||
export function useBackend() {
|
||||
const [state, setState] = useState<BackendState>({
|
||||
status: 'connecting',
|
||||
port: 9899,
|
||||
port: 9876,
|
||||
})
|
||||
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useBackend() {
|
||||
const readyRef = useRef(false)
|
||||
// Holds the latest resolved port so the visibility handler (registered once)
|
||||
// always probes the correct port without re-running the effect.
|
||||
const portRef = useRef(9899)
|
||||
const portRef = useRef(9876)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -75,7 +75,7 @@ export function useBackend() {
|
||||
|
||||
if (api) {
|
||||
api.getBackendPort().then((port) => {
|
||||
const p = port || 9899
|
||||
const p = port || 9876
|
||||
portRef.current = p
|
||||
setState((prev) => ({ ...prev, port: p }))
|
||||
startPolling(p)
|
||||
@@ -98,7 +98,7 @@ export function useBackend() {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
startPolling(9899)
|
||||
startPolling(9876)
|
||||
}
|
||||
|
||||
// When the window comes back to the foreground, re-probe immediately so a
|
||||
|
||||
Reference in New Issue
Block a user