Skip to content

Commit 5a76a71

Browse files
committed
feat(exe): 数据存 exe 旁的 Windsurf_data + 首次运行自动开面板
打包 exe 下原来 DATA_DIR/.env 落在只读快照 C:\snapshot\...(pkg 虚拟路径)→ "Cannot mkdir in a snapshot",状态存不下、还去启 Linux 专用 LS。 - config.js:检测 process.pkg,packaged 时以 exe 所在目录(process.execPath) 为基准:.env 从 exe 旁读;DATA_DIR 未设时默认 <exe目录>/Windsurf_data (accounts/stats/logs 全落这,和快照隔离)。packaged 默认补 DEVIN_CONNECT=1 (跳过 LS)+ HOST=127.0.0.1(不暴露局域网)。 - config.js:mkdir 前记 isFirstRun(Windsurf_data 尚不存在=首次部署)。 - index.js:仅首次运行自动 `cmd /c start` 打开 dashboard;之后不再开; WINDSURFAPI_NO_OPEN=1 可关(CI smoke 已设)。 - 非 packaged(源码/Docker)路径完全不变(process.pkg 未定义)。 - README 补 Windsurf_data + 首次自动开面板说明。
1 parent c40ae89 commit 5a76a71

4 files changed

Lines changed: 68 additions & 6 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ jobs:
151151
run: |
152152
$env:DEVIN_CONNECT = '1'; $env:HOST = '127.0.0.1'; $env:PORT = '3999'
153153
$env:API_KEY = 'ci-smoke'; $env:DATA_DIR = "$env:RUNNER_TEMP\wa-smoke"
154+
$env:WINDSURFAPI_NO_OPEN = '1' # don't try to launch a browser on the headless runner
154155
$out = "$env:RUNNER_TEMP\exe-out.log"; $err = "$env:RUNNER_TEMP\exe-err.log"
155156
$p = Start-Process -FilePath dist-windows\windsurfapi.exe -PassThru -WindowStyle Hidden -RedirectStandardOutput $out -RedirectStandardError $err
156157
$ok = $false

deploy/windows/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
## 两种跑法
66

77
**A. 单 exe(开箱即用,无需装 Node)** —— 类似 KiroStudio 的 `kirostudio.exe`
8-
[GitHub Releases](https://github.com/dwgx/WindsurfAPI/releases) 下载 `windsurfapi.exe`(每个 `v*` tag 由 CI 的 windows runner 用 pkg 打包,已 smoke 验证能起 + 出面板),放进任意空文件夹,双击即跑:
9-
- 首次运行读同目录 `.env`(没有则用内置默认:`PORT=3003` / `HOST=127.0.0.1` / `DEVIN_CONNECT=1`,但**没有 API_KEY/密码**,建议先照下面 B 的 `start.bat` 生成一份 `.env` 或手写)。
10-
- 面板 `http://127.0.0.1:3003/dashboard`,dashboard 静态资源已打进 exe(pkg 快照 FS),无需额外文件。
11-
- 关窗口 = 停止。换新版 = 下载新 exe 覆盖重跑,`.env`/数据不动。
8+
[GitHub Releases](https://github.com/dwgx/WindsurfAPI/releases) 下载 `windsurfapi.exe`(每个 `v*` tag 由 CI 的 windows runner 打包,已 smoke 验证能起 + 出面板),放进任意空文件夹,双击即跑:
9+
- exe 内置默认:`DEVIN_CONNECT=1`(纯 HTTP 路,不需要 Linux 专用的 language server)、`HOST=127.0.0.1`(仅本机,不暴露局域网)、`PORT=3003`
10+
- **所有状态存到 exe 同目录的 `Windsurf_data/` 文件夹**(accounts.json / stats / logs),与程序内部的只读快照隔离。想换位置可在同目录 `.env` 里设 `DATA_DIR=`
11+
- **首次运行会自动打开** `http://127.0.0.1:3003/dashboard`(之后启动不再自动开;`WINDSURFAPI_NO_OPEN=1` 可关掉)。
12+
- ⚠️ 内置默认**没有 API_KEY / 面板密码**。要加认证,在 exe 同目录放个 `.env``API_KEY=xxx``DASHBOARD_PASSWORD=xxx`(或用下面 B 的 `start.bat` 生成一份再拷过来)。
13+
- 关窗口 = 停止。换新版 = 下载新 exe 覆盖,`Windsurf_data/``.env` 不动。
1214

1315
**B. 源码 + 脚本(需装 Node 20+,零 npm 依赖)** —— 开发/自更新首选,`git pull` 即拉最新。
1416
双击 `start.bat`(见下),它生成 `.env` + 打印密钥 + 前台监督循环。

src/config.js

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,19 @@ import { fileURLToPath } from 'url';
55
const __dirname = dirname(fileURLToPath(import.meta.url));
66
const ROOT = resolve(__dirname, '..');
77

8+
// When packaged as a single .exe (pkg), __dirname/ROOT point INSIDE the
9+
// read-only snapshot (C:\snapshot\...), so .env can't be read from there and
10+
// DATA_DIR would try to mkdir in the snapshot (fails). The real writable
11+
// location is the folder the .exe sits in. Use that as the base for .env and
12+
// the default data dir so a double-clicked exe keeps its state in a `data/`
13+
// folder next to itself — cleanly isolated from the bundled Linux snapshot.
14+
const IS_PACKAGED = !!process.pkg;
15+
const EXE_DIR = IS_PACKAGED ? dirname(process.execPath) : ROOT;
16+
817
// Load .env file manually (zero dependencies)
918
function loadEnv() {
10-
const envPath = resolve(ROOT, '.env');
19+
// Packaged: read the .env sitting next to the .exe, not the snapshot copy.
20+
const envPath = resolve(EXE_DIR, '.env');
1121
if (!existsSync(envPath)) return;
1222
const content = readFileSync(envPath, 'utf-8');
1323
for (const line of content.split('\n')) {
@@ -34,14 +44,32 @@ if (process.env.WINDSURFAPI_SKIP_DOTENV !== '1') {
3444
loadEnv();
3545
}
3646

47+
// Packaged-exe sane defaults (only when the user hasn't set them). The Windows
48+
// exe has no bundled Language Server (Linux-only) and is meant for local
49+
// single-user use, so: DEVIN_CONNECT=1 (pure-HTTP path, skip the LS entirely),
50+
// HOST=127.0.0.1 (loopback — don't expose an unauthenticated gateway to the
51+
// LAN on first run). Both stay overridable via .env / env.
52+
if (IS_PACKAGED) {
53+
if (!process.env.DEVIN_CONNECT && !process.env.DEVIN_ONLY) process.env.DEVIN_CONNECT = '1';
54+
if (!process.env.HOST && !process.env.BIND_HOST) process.env.HOST = '127.0.0.1';
55+
}
56+
3757
// `sharedDataDir` is the cluster-shared root: a single accounts.json lives
3858
// here so add-account writes from any replica are visible to every replica
3959
// after restart. `dataDir` is replica-local under REPLICA_ISOLATE=1 and is
4060
// safe to use for telemetry that does not need cross-replica visibility.
4161
// See issue #67 — when the two were collapsed into one path, every
4262
// docker-compose upgrade orphaned the user's accounts.json under a stale
4363
// `replica-${HOSTNAME}` subdir.
44-
const sharedDataDir = process.env.DATA_DIR ? resolve(ROOT, process.env.DATA_DIR) : ROOT;
64+
// Base that relative DATA_DIR / default data dir resolve against. Packaged:
65+
// the .exe's folder (writable) — NEVER the snapshot ROOT. Default (DATA_DIR
66+
// unset) for a packaged exe is `<exe-dir>/Windsurf_data` so a double-click
67+
// drops all state (accounts/stats/logs) into one tidy folder beside the
68+
// program, isolated from the bundled Linux snapshot.
69+
const DATA_BASE = IS_PACKAGED ? EXE_DIR : ROOT;
70+
const sharedDataDir = process.env.DATA_DIR
71+
? resolve(DATA_BASE, process.env.DATA_DIR)
72+
: (IS_PACKAGED ? join(EXE_DIR, 'Windsurf_data') : ROOT);
4573
const dataDir = (() => {
4674
let base = sharedDataDir;
4775
if (process.env.REPLICA_ISOLATE === '1' && process.env.HOSTNAME) {
@@ -50,6 +78,11 @@ const dataDir = (() => {
5078
return base;
5179
})();
5280

81+
// First-run detection (packaged only): capture whether the data folder exists
82+
// BEFORE we mkdir it, so index.js can decide to auto-open the dashboard only on
83+
// the very first launch (no Windsurf_data folder yet = fresh deploy).
84+
const isFirstRun = IS_PACKAGED && !existsSync(sharedDataDir);
85+
5386
try {
5487
mkdirSync(sharedDataDir, { recursive: true });
5588
mkdirSync(dataDir, { recursive: true });
@@ -106,6 +139,13 @@ export const config = {
106139

107140
// Proxy testing
108141
allowPrivateProxyHosts: process.env.ALLOW_PRIVATE_PROXY_HOSTS === '1',
142+
143+
// True when running as a packaged single-exe (pkg). Used to enable the
144+
// double-click desktop UX: data folder beside the exe, auto-open dashboard.
145+
isPackaged: IS_PACKAGED,
146+
// True on the very first packaged launch (Windsurf_data didn't exist yet).
147+
// index.js opens the dashboard in the browser only on this first deploy.
148+
isFirstRun,
109149
};
110150

111151
const levels = { debug: 0, info: 1, warn: 2, error: 3 };

src/index.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,25 @@ async function main() {
177177

178178
const server = startServer();
179179

180+
// Packaged single-exe desktop UX: on the FIRST deploy (no Windsurf_data folder
181+
// existed yet), pop the dashboard in the default browser so the initial
182+
// double-click lands on a usable setup page. Subsequent launches don't reopen
183+
// it. Best effort — a headless/CI run (WINDSURFAPI_NO_OPEN=1) or a missing
184+
// opener just skips it. Delayed slightly so the "Server on ..." line prints.
185+
if (config.isFirstRun && process.env.WINDSURFAPI_NO_OPEN !== '1') {
186+
const url = `http://127.0.0.1:${config.port}/dashboard`;
187+
setTimeout(() => {
188+
try {
189+
// Windows: `cmd /c start "" <url>` opens the default browser without a
190+
// shell window lingering. execFile avoids spawning a persistent shell.
191+
import('child_process').then(({ execFile }) => {
192+
execFile('cmd', ['/c', 'start', '', url], { windowsHide: true }, () => {});
193+
}).catch(() => {});
194+
log.info(`First run — opening dashboard in your browser: ${url}`);
195+
} catch { /* opener unavailable — user can open the URL manually */ }
196+
}, 800);
197+
}
198+
180199
let shuttingDown = false;
181200
const shutdown = (signal) => {
182201
if (shuttingDown) return;

0 commit comments

Comments
 (0)