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:
@@ -1157,7 +1157,10 @@ class WebChannel(ChatChannel):
|
|||||||
def startup(self):
|
def startup(self):
|
||||||
configured_host = conf().get("web_host", "")
|
configured_host = conf().get("web_host", "")
|
||||||
host = configured_host or ("0.0.0.0" if _is_password_enabled() else "127.0.0.1")
|
host = configured_host or ("0.0.0.0" if _is_password_enabled() else "127.0.0.1")
|
||||||
port = conf().get("web_port", 9899)
|
# The desktop app passes its chosen port via COW_WEB_PORT so its backend
|
||||||
|
# never collides with a source-run web console (default 9899). This makes
|
||||||
|
# the port a single source of truth owned by the Electron shell.
|
||||||
|
port = int(os.environ.get("COW_WEB_PORT") or conf().get("web_port", 9899))
|
||||||
is_public_bind = host in ("0.0.0.0", "::")
|
is_public_bind = host in ("0.0.0.0", "::")
|
||||||
|
|
||||||
self._cleanup_stale_voice_recordings()
|
self._cleanup_stale_voice_recordings()
|
||||||
|
|||||||
@@ -4,16 +4,23 @@ import path from 'path'
|
|||||||
import os from 'os'
|
import os from 'os'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import http from 'http'
|
import http from 'http'
|
||||||
|
import net from 'net'
|
||||||
|
|
||||||
// Writable data dir for the packaged app (config.json, run.log, user data).
|
// 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
|
// 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.
|
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
|
||||||
const COW_DATA_DIR = path.join(os.homedir(), '.cow')
|
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 {
|
export class PythonBackend extends EventEmitter {
|
||||||
private process: ChildProcess | null = null
|
private process: ChildProcess | null = null
|
||||||
private backendPath: string
|
private backendPath: string
|
||||||
private port: number = 9899
|
private port: number = PREFERRED_PORT
|
||||||
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
|
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
|
||||||
|
|
||||||
constructor(backendPath: string) {
|
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
|
* 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 {
|
try {
|
||||||
const configPath = path.join(dataDir, 'config.json')
|
const configPath = path.join(dataDir, 'config.json')
|
||||||
if (fs.existsSync(configPath)) {
|
if (fs.existsSync(configPath)) {
|
||||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||||
if (config.web_port) {
|
const p = Number(config.web_port)
|
||||||
return config.web_port
|
if (Number.isInteger(p) && p > 0 && p < 65536) {
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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> {
|
async start(): Promise<void> {
|
||||||
@@ -97,14 +150,17 @@ export class PythonBackend extends EventEmitter {
|
|||||||
const bundled = this.findBundledBackend()
|
const bundled = this.findBundledBackend()
|
||||||
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
|
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
|
||||||
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
|
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
|
||||||
this.port = this.readPort(dataDir)
|
|
||||||
|
|
||||||
const alreadyRunning = await this.probeHealth()
|
// Always launch our OWN backend (re-entrancy is guarded above by the
|
||||||
if (alreadyRunning) {
|
// status check, so we never double-spawn for this instance). We don't reuse
|
||||||
this.status = 'ready'
|
// whatever happens to be on the port: that's how the app previously
|
||||||
this.emit('log', `Backend already running on port ${this.port}`)
|
// attached to a source-run web console and read the wrong config. Pick a
|
||||||
this.emit('ready', this.port)
|
// usable port instead — honoring a user-pinned web_port, else PREFERRED_PORT
|
||||||
return
|
// 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
|
let command: string
|
||||||
@@ -150,6 +206,9 @@ export class PythonBackend extends EventEmitter {
|
|||||||
...process.env,
|
...process.env,
|
||||||
PYTHONUNBUFFERED: '1',
|
PYTHONUNBUFFERED: '1',
|
||||||
COW_DESKTOP: '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 } : {}),
|
...(bundled ? { COW_DATA_DIR } : {}),
|
||||||
},
|
},
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
@@ -185,16 +244,6 @@ export class PythonBackend extends EventEmitter {
|
|||||||
await this.waitForReady()
|
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> {
|
private waitForReady(): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// Wall-clock deadline rather than an attempt counter: if the machine
|
// Wall-clock deadline rather than an attempt counter: if the machine
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface ApiResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private baseUrl = 'http://127.0.0.1:9899'
|
private baseUrl = 'http://127.0.0.1:9876'
|
||||||
|
|
||||||
setBaseUrl(url: string) {
|
setBaseUrl(url: string) {
|
||||||
this.baseUrl = url
|
this.baseUrl = url
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface BackendState {
|
|||||||
export function useBackend() {
|
export function useBackend() {
|
||||||
const [state, setState] = useState<BackendState>({
|
const [state, setState] = useState<BackendState>({
|
||||||
status: 'connecting',
|
status: 'connecting',
|
||||||
port: 9899,
|
port: 9876,
|
||||||
})
|
})
|
||||||
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export function useBackend() {
|
|||||||
const readyRef = useRef(false)
|
const readyRef = useRef(false)
|
||||||
// Holds the latest resolved port so the visibility handler (registered once)
|
// Holds the latest resolved port so the visibility handler (registered once)
|
||||||
// always probes the correct port without re-running the effect.
|
// always probes the correct port without re-running the effect.
|
||||||
const portRef = useRef(9899)
|
const portRef = useRef(9876)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -75,7 +75,7 @@ export function useBackend() {
|
|||||||
|
|
||||||
if (api) {
|
if (api) {
|
||||||
api.getBackendPort().then((port) => {
|
api.getBackendPort().then((port) => {
|
||||||
const p = port || 9899
|
const p = port || 9876
|
||||||
portRef.current = p
|
portRef.current = p
|
||||||
setState((prev) => ({ ...prev, port: p }))
|
setState((prev) => ({ ...prev, port: p }))
|
||||||
startPolling(p)
|
startPolling(p)
|
||||||
@@ -98,7 +98,7 @@ export function useBackend() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
startPolling(9899)
|
startPolling(9876)
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the window comes back to the foreground, re-probe immediately so a
|
// When the window comes back to the foreground, re-probe immediately so a
|
||||||
|
|||||||
Reference in New Issue
Block a user