fix: honor WEBUI_HOST for desktop backend binding (#1897)

* fix desktop WebUI host binding

* fix desktop dotenv host parsing
This commit is contained in:
zhulinsen
2026-07-03 21:01:22 +08:00
committed by GitHub
parent a52c8502d7
commit afbab3e717
4 changed files with 398 additions and 12 deletions

View File

@@ -32,6 +32,8 @@ const LATEST_RELEASE_API_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${G
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
const DESKTOP_UPDATE_BACKUP_DIR = '.dsa-desktop-update-backup';
const DESKTOP_UPDATE_BACKUP_MANIFEST_FILE = 'runtime-state.json';
const DESKTOP_BACKEND_DEFAULT_HOST = '127.0.0.1';
const PUBLIC_BIND_HOSTS = Object.freeze(new Set(['0.0.0.0', '::', '[::]', '*']));
const MAC_DESKTOP_CLI_PATH_ENTRIES = Object.freeze([
'/opt/homebrew/bin',
'/usr/local/bin',
@@ -691,8 +693,166 @@ function extendMacDesktopBackendPath(rawPath) {
return entries.join(path.delimiter);
}
function buildBackendEnvironment({ envFile, dbPath, logDir, port = null, sourceEnv = process.env }) {
function normalizeBackendHost(value, fallback = '') {
const normalized = String(value || '').trim();
return normalized || fallback;
}
function hasOwnValue(object, key) {
return Object.prototype.hasOwnProperty.call(object || {}, key);
}
function parseQuotedEnvValue(value, quote) {
let result = '';
for (let index = 1; index < value.length; index += 1) {
const char = value[index];
if (char === quote) {
if (quote === '"') {
return result.replace(/\\([nrt"\\$])/g, (_match, escaped) => {
if (escaped === 'n') {
return '\n';
}
if (escaped === 'r') {
return '\r';
}
if (escaped === 't') {
return '\t';
}
return escaped;
});
}
return result.replace(/\\'/g, "'").replace(/\\\\/g, '\\');
}
result += char;
}
return value.trim();
}
function parseEnvScalarValue(rawValue) {
const value = String(rawValue || '').trimStart();
if (!value) {
return '';
}
const quote = value[0];
if (quote === '"' || quote === "'") {
return parseQuotedEnvValue(value, quote);
}
for (let index = 0; index < value.length; index += 1) {
if (value[index] === '#' && (index === 0 || /\s/.test(value[index - 1]))) {
return value.slice(0, index).trim();
}
}
return value.trim();
}
function expandEnvReferences(value, values = {}, sourceEnv = process.env) {
return String(value || '').replace(
/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}/g,
(_match, name, defaultValue) => {
if (hasOwnValue(sourceEnv, name)) {
return String(sourceEnv[name]);
}
if (hasOwnValue(values, name)) {
return String(values[name]);
}
return defaultValue === undefined ? '' : defaultValue;
}
);
}
function readEnvFileValues(envFile, sourceEnv = process.env) {
if (!envFile || !fs.existsSync(envFile)) {
return {};
}
let content = '';
try {
content = fs.readFileSync(envFile, 'utf-8');
} catch (_error) {
return {};
}
const values = {};
for (const line of content.split(/\r?\n/)) {
const match = line.match(/^\uFEFF?\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (!match) {
continue;
}
values[match[1]] = expandEnvReferences(
parseEnvScalarValue(match[2]),
values,
sourceEnv
);
}
return values;
}
function readEnvFileValue(envFile, key, sourceEnv = process.env) {
const values = readEnvFileValues(envFile, sourceEnv);
return hasOwnValue(values, key) ? values[key] : null;
}
function resolveBackendBindHost({
envFile,
sourceEnv = process.env,
fallback = DESKTOP_BACKEND_DEFAULT_HOST,
} = {}) {
const sourceHost = normalizeBackendHost(sourceEnv.WEBUI_HOST);
if (sourceHost) {
return sourceHost;
}
const envFileHost = normalizeBackendHost(readEnvFileValue(envFile, 'WEBUI_HOST', sourceEnv));
return envFileHost || fallback;
}
function resolveDesktopConnectHost(bindHost) {
const host = normalizeBackendHost(bindHost, DESKTOP_BACKEND_DEFAULT_HOST);
if (PUBLIC_BIND_HOSTS.has(host.toLowerCase())) {
return DESKTOP_BACKEND_DEFAULT_HOST;
}
return host;
}
function formatUrlHost(host) {
const normalized = normalizeBackendHost(host, DESKTOP_BACKEND_DEFAULT_HOST);
if (normalized.startsWith('[') && normalized.endsWith(']')) {
return normalized;
}
return normalized.includes(':') ? `[${normalized}]` : normalized;
}
function buildBackendUrl(host, port, pathname = '/') {
const url = new URL(`http://${formatUrlHost(host)}:${port}/`);
url.pathname = pathname;
return url.toString();
}
function buildBackendArgs({ host, port }) {
return [
'--serve-only',
'--host',
normalizeBackendHost(host, DESKTOP_BACKEND_DEFAULT_HOST),
'--port',
String(port),
];
}
function buildBackendEnvironment({
envFile,
dbPath,
logDir,
port = null,
host = null,
sourceEnv = process.env,
}) {
const selectedPort = Number(port);
const selectedHost = normalizeBackendHost(host) || resolveBackendBindHost({ envFile, sourceEnv });
const env = {
...sourceEnv,
DSA_DESKTOP_MODE: 'true',
@@ -701,6 +861,7 @@ function buildBackendEnvironment({ envFile, dbPath, logDir, port = null, sourceE
LOG_DIR: logDir,
PYTHONUTF8: '1',
PYTHONIOENCODING: 'utf-8',
WEBUI_HOST: selectedHost,
WEBUI_ENABLED: 'false',
BOT_ENABLED: 'false',
DINGTALK_STREAM_ENABLED: 'false',
@@ -802,7 +963,8 @@ function ensureEnvFile(envPath) {
fs.writeFileSync(envPath, '# Configure your API keys and stock list here.\n', 'utf-8');
}
function findAvailablePort(startPort = 8000, endPort = 8100) {
function findAvailablePort(startPort = 8000, endPort = 8100, host = DESKTOP_BACKEND_DEFAULT_HOST) {
const bindHost = normalizeBackendHost(host, DESKTOP_BACKEND_DEFAULT_HOST);
return new Promise((resolve, reject) => {
const tryPort = (port) => {
if (port > endPort) {
@@ -817,7 +979,7 @@ function findAvailablePort(startPort = 8000, endPort = 8100) {
server.once('listening', () => {
server.close(() => resolve(port));
});
server.listen(port, '127.0.0.1');
server.listen(port, bindHost);
};
tryPort(startPort);
@@ -982,14 +1144,15 @@ function waitForHealth(
});
}
function startBackend({ port, envFile, dbPath, logDir }) {
function startBackend({ port, envFile, dbPath, logDir, host = null }) {
const backendPath = resolveBackendPath();
backendStartError = null;
const launchStartedAt = Date.now();
const bindHost = normalizeBackendHost(host) || resolveBackendBindHost({ envFile });
const env = buildBackendEnvironment({ envFile, dbPath, logDir, port });
const env = buildBackendEnvironment({ envFile, dbPath, logDir, port, host: bindHost });
const args = ['--serve-only', '--host', '127.0.0.1', '--port', String(port)];
const args = buildBackendArgs({ host: bindHost, port });
let launchMode = '';
let launchCommand = '';
let launchCwd = '';
@@ -1147,8 +1310,8 @@ function resolveDesktopVersion() {
return String(app.getVersion() || '').trim();
}
function buildMainPageUrl(port, timestamp = Date.now()) {
const url = new URL(`http://127.0.0.1:${port}/`);
function buildMainPageUrl(port, timestamp = Date.now(), host = DESKTOP_BACKEND_DEFAULT_HOST) {
const url = new URL(buildBackendUrl(host, port, '/'));
url.searchParams.set('desktop_version', resolveDesktopVersion() || 'unknown');
url.searchParams.set('cache_bust', String(timestamp));
return url.toString();
@@ -1652,8 +1815,12 @@ async function createWindow() {
ensureEnvFile(envPath);
logStartup(`Env file ready: ${envPath}`);
const backendBindHost = resolveBackendBindHost({ envFile: envPath });
const backendConnectHost = resolveDesktopConnectHost(backendBindHost);
logStartup(`Backend bind host=${backendBindHost}; desktop connect host=${backendConnectHost}`);
const portFindStartedAt = Date.now();
const port = await findAvailablePort(8000, 8100);
const port = await findAvailablePort(8000, 8100, backendBindHost);
logStartup(`Using port ${port} (selected in ${Date.now() - portFindStartedAt}ms)`);
logStartup(`App directory=${appDir}`);
@@ -1661,7 +1828,7 @@ async function createWindow() {
const logDir = path.join(appDir, 'logs');
try {
const launchInfo = startBackend({ port, envFile: envPath, dbPath, logDir });
const launchInfo = startBackend({ port, envFile: envPath, dbPath, logDir, host: backendBindHost });
logStartup(`Backend launch mode=${launchInfo.mode}`);
logStartup(`Backend launch command=${launchInfo.command}`);
logStartup(`Backend launch cwd=${launchInfo.cwd}`);
@@ -1673,7 +1840,7 @@ async function createWindow() {
return;
}
const healthUrl = `http://127.0.0.1:${port}/api/health`;
const healthUrl = buildBackendUrl(backendConnectHost, port, '/api/health');
let lastHealthProgressLogAt = 0;
const healthProgressLogIntervalMs = 2000;
@@ -1738,7 +1905,7 @@ async function createWindow() {
);
logStartup(`Backend ready in ${healthInfo.elapsedMs}ms (${healthInfo.attempts} probes)`);
const mainPageStartedAt = Date.now();
const mainPageUrl = buildMainPageUrl(port);
const mainPageUrl = buildMainPageUrl(port, Date.now(), backendConnectHost);
await mainWindow.loadURL(mainPageUrl);
logStartup(`Main page loadURL resolved in ${Date.now() - mainPageStartedAt}ms url=${mainPageUrl}`);
logStartup(`Main UI loaded in ${Date.now() - startupStartedAt}ms`);
@@ -1782,20 +1949,27 @@ module.exports = {
UPDATE_STATUS,
buildUpdateState,
backupPackagedRuntimeState,
buildBackendArgs,
checkForDesktopUpdates,
compareVersions,
evaluateReleaseUpdate,
buildBackendUrl,
buildBackendEnvironment,
extendMacDesktopBackendPath,
extractReleaseMetadata,
fetchLatestReleaseJson,
findAvailablePort,
buildMainPageUrl,
migrateMacPackagedRuntimeState,
normalizeVersionString,
parseSemver,
readEnvFileValue,
resolveAppDir,
resolveBackendBindHost,
resolveDesktopConnectHost,
restorePackagedRuntimeStateFromBackup,
sanitizeReleaseUrl,
startBackend,
stopBackend,
__getBackendProcessForTest() {
return backendProcess;

View File

@@ -136,6 +136,27 @@ test('buildMainPageUrl includes desktop version and cache buster', (t) => {
);
});
test('buildMainPageUrl uses a connect host when provided', (t) => {
const mainModule = loadMainModule(t, {
app: {
getVersion: () => '3.17.1',
},
});
assert.equal(
mainModule.buildMainPageUrl(8123, 1234567890, '192.168.1.9'),
'http://192.168.1.9:8123/?desktop_version=3.17.1&cache_bust=1234567890'
);
});
test('resolveDesktopConnectHost keeps desktop navigation local for public binds', (t) => {
const mainModule = loadMainModule(t);
assert.equal(mainModule.resolveDesktopConnectHost('0.0.0.0'), '127.0.0.1');
assert.equal(mainModule.resolveDesktopConnectHost('::'), '127.0.0.1');
assert.equal(mainModule.resolveDesktopConnectHost('192.168.1.9'), '192.168.1.9');
});
test('buildBackendEnvironment extends macOS GUI PATH with Homebrew CLI directories', (t) => {
const mainModule = loadMainModule(t, { platform: 'darwin' });
@@ -160,6 +181,7 @@ test('buildBackendEnvironment extends macOS GUI PATH with Homebrew CLI directori
assert.equal(env.ENV_FILE, '/tmp/dsa/.env');
assert.equal(env.DATABASE_PATH, '/tmp/dsa/data.db');
assert.equal(env.LOG_DIR, '/tmp/dsa/logs');
assert.equal(env.WEBUI_HOST, '127.0.0.1');
});
test('buildBackendEnvironment keeps non-macOS PATH unchanged', (t) => {
@@ -192,6 +214,187 @@ test('buildBackendEnvironment pins WEBUI_PORT to the Electron-selected backend p
});
assert.equal(env.WEBUI_PORT, '8000');
assert.equal(env.WEBUI_HOST, '127.0.0.1');
});
test('resolveBackendBindHost reads WEBUI_HOST from env file', (t) => {
const mainModule = loadMainModule(t, { platform: 'win32' });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsa-desktop-host-'));
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
const envPath = path.join(tmpDir, '.env');
fs.writeFileSync(envPath, 'WEBUI_HOST=0.0.0.0 # allow LAN\nWEBUI_PORT=8000\n', 'utf-8');
assert.equal(mainModule.readEnvFileValue(envPath, 'WEBUI_HOST'), '0.0.0.0');
assert.equal(
mainModule.resolveBackendBindHost({ envFile: envPath, sourceEnv: {} }),
'0.0.0.0'
);
});
test('resolveBackendBindHost expands WEBUI_HOST dotenv references', (t) => {
const mainModule = loadMainModule(t, { platform: 'win32' });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsa-desktop-host-'));
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
const envPath = path.join(tmpDir, '.env');
fs.writeFileSync(envPath, 'BIND_HOST=0.0.0.0\nWEBUI_HOST=${BIND_HOST}\n', 'utf-8');
assert.equal(mainModule.readEnvFileValue(envPath, 'WEBUI_HOST', {}), '0.0.0.0');
assert.equal(
mainModule.resolveBackendBindHost({ envFile: envPath, sourceEnv: {} }),
'0.0.0.0'
);
});
test('resolveBackendBindHost handles quoted WEBUI_HOST with inline comment', (t) => {
const mainModule = loadMainModule(t, { platform: 'win32' });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsa-desktop-host-'));
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
const envPath = path.join(tmpDir, '.env');
fs.writeFileSync(envPath, 'WEBUI_HOST="0.0.0.0" # allow LAN\n', 'utf-8');
assert.equal(mainModule.readEnvFileValue(envPath, 'WEBUI_HOST', {}), '0.0.0.0');
assert.equal(
mainModule.resolveBackendBindHost({ envFile: envPath, sourceEnv: {} }),
'0.0.0.0'
);
});
test('resolveBackendBindHost supports dotenv default expansion', (t) => {
const mainModule = loadMainModule(t, { platform: 'win32' });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsa-desktop-host-'));
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
const envPath = path.join(tmpDir, '.env');
fs.writeFileSync(envPath, 'WEBUI_HOST=${MISSING_HOST:-127.0.0.1}\n', 'utf-8');
assert.equal(mainModule.readEnvFileValue(envPath, 'WEBUI_HOST', {}), '127.0.0.1');
assert.equal(
mainModule.resolveBackendBindHost({ envFile: envPath, sourceEnv: {} }),
'127.0.0.1'
);
});
test('resolveBackendBindHost keeps process WEBUI_HOST override ahead of env file', (t) => {
const mainModule = loadMainModule(t, { platform: 'win32' });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsa-desktop-host-'));
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
const envPath = path.join(tmpDir, '.env');
fs.writeFileSync(envPath, 'WEBUI_HOST=0.0.0.0\n', 'utf-8');
assert.equal(
mainModule.resolveBackendBindHost({
envFile: envPath,
sourceEnv: { WEBUI_HOST: '192.168.1.9' },
}),
'192.168.1.9'
);
});
test('buildBackendEnvironment injects env file WEBUI_HOST into backend process', (t) => {
const mainModule = loadMainModule(t, { platform: 'win32' });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsa-desktop-host-'));
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
const envPath = path.join(tmpDir, '.env');
fs.writeFileSync(envPath, 'BIND_HOST=0.0.0.0\nWEBUI_HOST=${BIND_HOST}\n', 'utf-8');
const env = mainModule.buildBackendEnvironment({
envFile: envPath,
dbPath: 'C:\\Users\\user\\AppData\\Roaming\\Daily Stock Analysis\\data\\stock_analysis.db',
logDir: 'C:\\Users\\user\\AppData\\Roaming\\Daily Stock Analysis\\logs',
port: 8000,
sourceEnv: {
PATH: 'C:\\Windows\\System32',
},
});
assert.equal(env.WEBUI_HOST, '0.0.0.0');
assert.equal(env.WEBUI_PORT, '8000');
});
test('buildBackendArgs passes resolved host to main.py', (t) => {
const mainModule = loadMainModule(t, { platform: 'win32' });
assert.deepEqual(mainModule.buildBackendArgs({ host: '0.0.0.0', port: 8123 }), [
'--serve-only',
'--host',
'0.0.0.0',
'--port',
'8123',
]);
});
test('findAvailablePort listens on requested bind host', async (t) => {
let listenedHost = '';
const fakeNet = {
createServer: () => {
const server = new EventEmitter();
server.listen = (_port, host) => {
listenedHost = host;
process.nextTick(() => server.emit('listening'));
};
server.close = (callback) => {
if (callback) {
callback();
}
};
return server;
},
};
const mainModule = loadMainModule(t, { platform: 'win32', net: fakeNet });
const port = await mainModule.findAvailablePort(8123, 8123, '0.0.0.0');
assert.equal(port, 8123);
assert.equal(listenedHost, '0.0.0.0');
});
test('startBackend passes WEBUI_HOST from env file to backend args and env', (t) => {
const previousWebuiHost = process.env.WEBUI_HOST;
delete process.env.WEBUI_HOST;
t.after(() => {
if (previousWebuiHost === undefined) {
delete process.env.WEBUI_HOST;
} else {
process.env.WEBUI_HOST = previousWebuiHost;
}
});
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsa-desktop-host-'));
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
const envPath = path.join(tmpDir, '.env');
fs.writeFileSync(envPath, 'BIND_HOST=0.0.0.0\nWEBUI_HOST=${BIND_HOST}\n', 'utf-8');
const spawned = [];
const fakeBackendProcess = new EventEmitter();
fakeBackendProcess.stdout = new EventEmitter();
fakeBackendProcess.stderr = new EventEmitter();
fakeBackendProcess.exitCode = null;
fakeBackendProcess.signalCode = null;
fakeBackendProcess.kill = () => true;
const mainModule = loadMainModule(t, {
platform: 'win32',
childProcess: {
spawn: (command, args, options) => {
spawned.push({ command, args, options });
return fakeBackendProcess;
},
},
});
t.after(() => mainModule.__setBackendProcessForTest(null));
mainModule.startBackend({
port: 8123,
envFile: envPath,
dbPath: path.join(tmpDir, 'stock_analysis.db'),
logDir: path.join(tmpDir, 'logs'),
});
assert.equal(spawned.length, 1);
assert.deepEqual(spawned[0].args.slice(-5), [
'--serve-only',
'--host',
'0.0.0.0',
'--port',
'8123',
]);
assert.equal(spawned[0].options.env.WEBUI_HOST, '0.0.0.0');
});
test('extendMacDesktopBackendPath preserves existing order and avoids duplicates', (t) => {

View File

@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
> For user-friendly release highlights, see the [GitHub Releases](https://github.com/ZhuLinsen/daily_stock_analysis/releases) page.
## [Unreleased]
- [修复] 修复 Windows 桌面端启动后端时固定传入 `--host 127.0.0.1` 导致 `.env``WEBUI_HOST=0.0.0.0` 不生效、局域网无法访问 WebUI 的问题;桌面端仍默认使用 `127.0.0.1`,仅在显式配置 `WEBUI_HOST` 后按配置绑定,并继续使用本机地址完成健康检查和窗口加载。
- [新功能] 钉钉群机器人通知支持 — 支持通过 `DINGTALK_WEBHOOK_URL``DINGTALK_SECRET` 配置钉钉推送,并支持长文本自动切片以适配 20KB 限制。
- [文档] 记录 Agent `/chat/stream` progress event 契约,说明新增 `stage_start``stage_done``pipeline_timeout``pipeline_budget_skipped` 的字段语义、Web 兼容边界、验证方式、回滚方式;其中 `pipeline_budget_skipped` 表示剩余预算不足、未启动下一阶段即跳过的语义;本变更不触及 provider/model/Base URL 或运行时配置迁移语义。

View File

@@ -267,6 +267,14 @@ win-unpacked/
- 开发态 `npm run dev` 与打包态 `npm run build` / 安装包都会复用同一条版本注入链路,不再在 `preload.js` 里维护独立硬编码版本号
- `README.md` 继续保留安装和运行入口说明;这类桌面端运行时细节统一落在本专题文档维护,避免入门文档膨胀
### 局域网访问 Windows 桌面端 WebUI
- 桌面端默认仍按 `WEBUI_HOST=127.0.0.1` 只允许本机访问,避免安装后无意暴露后端服务
- 如需让同一局域网内其他设备访问,在桌面端 `.env``系统设置 -> WebUI 监听地址` 中设置 `WEBUI_HOST=0.0.0.0`,保存后重启桌面端
- 桌面端会自动选择 `8000-8100` 中可用端口并传给后端;常见情况下仍是 `8000`,若端口被占用,可在 `logs/desktop.log` 查看 `Using port ...``Backend launch command=...`
- Windows 防火墙或服务器安全组仍需放行实际监听端口;对外暴露前建议同时启用 `ADMIN_AUTH_ENABLED`
- 即使后端绑定 `0.0.0.0`,桌面窗口自身仍会使用本机可访问地址完成健康检查和页面加载
### 桌面端更新提醒
- 应用在主界面加载完成后会后台检查 GitHub Releases 的最新正式版,并与当前 `app.getVersion()` 做语义化版本比较