feat(desktop): add HAPI desktop launcher#769
Conversation
There was a problem hiding this comment.
Findings
- [Major] Desktop release bundles the npm optional CLI instead of the freshly built release CLI —
bun run build:desktop:win/build:desktop:maccallprepare:cli, andprepare-bundled-cli.tsonly copies@twsxtd/hapi-*fromnode_modules. Theclijob builds the current tag's CLI artifacts, but the desktop jobs do not consume them, so desktop releases can ship an older/unpublished npm optional binary instead of the same commit being released. Evidence.github/workflows/release.yml:53,desktop/scripts/prepare-bundled-cli.ts:50.
Suggested fix:desktop-windows: needs: cli steps: - uses: actions/download-artifact@v4 with: name: cli-release-artifacts path: cli-artifacts - shell: pwsh run: | New-Item -ItemType Directory -Force desktop/resources/hapi-cli/win | Out-Null Expand-Archive cli-artifacts/hapi-win32-x64.zip -DestinationPath desktop/resources/hapi-cli/win -Force - run: cd desktop && bun run build && bun run patch:portable-template && electron-builder --win portable && bun run patch:win-metadata
- [Major]
config:saveallows service settings to change while services are running — the renderer disables controls, but the main-process IPC handler still writes anyLauncherConfig. A stale settings draft, tray-triggered start, or any renderer-side call can changehubPort,workspaceRoots, orrelayEnabledduring runtime, which violates the stopped-only setting rule and can make Open Web/stop/status use config that does not match the running Hub/Runner. Evidencedesktop/src/main/ipc.ts:8.
Suggested fix:ipcMain.handle('config:save', async (_event, config: LauncherConfig) => { const status = processManager.getState().status if (status !== 'stopped' && status !== 'error') { return await configStore.update((current) => ({ ...current, locale: config.locale })) } return await configStore.write(config) })
Questions
- None.
Summary
- Review mode: initial
- Two issues found: desktop release artifact provenance and main-process enforcement for stopped-only service settings.
Testing
- Not run (automation). Suggested coverage: a desktop packaging check that verifies the bundled CLI version/commit source, plus an IPC/config test that rejects service-field changes while runtime status is
running.
HAPI Bot
| with: | ||
| bun-version: 1.3.14 | ||
| - run: bun install | ||
| - run: bun run build:desktop:win |
There was a problem hiding this comment.
[MAJOR] This desktop job builds the app from bun install only, and prepare:cli later copies @twsxtd/hapi-* from node_modules instead of the CLI artifacts produced by the cli job for this tag. That can make the desktop release ship a stale/unpublished optional npm binary rather than the same commit being released.
Suggested fix:
desktop-windows:
needs: cli
steps:
- uses: actions/download-artifact@v4
with:
name: cli-release-artifacts
path: cli-artifacts
- shell: pwsh
run: |
New-Item -ItemType Directory -Force desktop/resources/hapi-cli/win | Out-Null
Expand-Archive cli-artifacts/hapi-win32-x64.zip -DestinationPath desktop/resources/hapi-cli/win -Force
- run: cd desktop && bun run build && bun run patch:portable-template && electron-builder --win portable && bun run patch:win-metadataApply the same pattern for macOS with the matching hapi-darwin-*.tar.gz before running electron-builder.
|
|
||
| export function registerIpc(configStore: ConfigStore, processManager: ProcessManager, getWindow: () => BrowserWindow | null): void { | ||
| ipcMain.handle('config:get', async () => await configStore.read()) | ||
| ipcMain.handle('config:save', async (_event, config: LauncherConfig) => await configStore.write(config)) |
There was a problem hiding this comment.
[MAJOR] The stopped-only service setting rule is only enforced by disabled renderer controls; this IPC handler still writes any LauncherConfig while services are running. A stale settings draft, tray-triggered start, or renderer-side call can change hubPort, workspaceRoots, or relayEnabled during runtime, leaving the saved config out of sync with the running Hub/Runner.
Suggested fix:
ipcMain.handle('config:save', async (_event, config: LauncherConfig) => {
const status = processManager.getState().status
if (status !== 'stopped' && status !== 'error') {
return await configStore.update((current) => ({
...current,
locale: config.locale
}))
}
return await configStore.write(config)
})There was a problem hiding this comment.
Findings
- [Major] Hub relay access tokens are forwarded into the desktop console —
appendProcessOutputsends every Hub stdout/stderr line directly to the renderer, while the Hub prints a tokenized public access URL during relay startup. Because desktop defaults Relay on, starting HAPI can expose a fulltoken=URL in the new in-app log pane, contrary to the desktop spec's no-token-log requirement. Evidencedesktop/src/main/processManager.ts:238, related existing Hub output athub/src/startHub.ts:284.
Suggested fix:private appendProcessOutput(source: 'hub' | 'runner', chunk: Buffer): void { const lines = chunk.toString('utf8').split(/\r?\n/).filter((line) => line.length > 0) for (const line of lines) { this.appendLog(source, redactSensitiveLogLine(line)) } } function redactSensitiveLogLine(line: string): string { return line .replace(/([?&]token=)[^&\s]+/gi, '$1[REDACTED]') .replace(/(Token:\s*)\S+/i, '$1[REDACTED]') }
- [Major] Tray-triggered lifecycle actions can race start and stop —
stop()only ignoresstopped/stopping, so the tray's “停止 HAPI” action duringstartingcan run cleanup whilestart()is still awaitingrunner start/Hub readiness. Afterstop()setsstopped, the originalstart()can still spawn Hub or setrunning, leaving services alive after the user stopped them. Evidencedesktop/src/main/processManager.ts:102,desktop/src/main/tray.ts:20.
Suggested fix:private lifecycleQueue: Promise<void> = Promise.resolve() async start(): Promise<void> { await this.enqueueLifecycle(async () => { if (this.state.status === 'starting' || this.state.status === 'running' || this.state.status === 'stopping') return await this.startInternal() }) } async stop(): Promise<void> { await this.enqueueLifecycle(async () => { if (this.state.status === 'stopped' || this.state.status === 'stopping') return await this.stopInternal() }) } private async enqueueLifecycle(task: () => Promise<void>): Promise<void> { const run = this.lifecycleQueue.then(task, task) this.lifecycleQueue = run.catch(() => {}) await run }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Prior bot findings appear addressed. Two remaining issues found in the latest full diff: sensitive Hub access output is bridged into the desktop log UI, and start/stop lifecycle calls can race through the tray.
Testing
- Not run:
bunis not available in this runner (/bin/bash: bun: command not found). Suggested coverage: desktop log redaction/suppression for relay output, plus a ProcessManager lifecycle test for stop-while-starting.
HAPI Bot
| private appendProcessOutput(source: 'hub' | 'runner', chunk: Buffer): void { | ||
| const lines = chunk.toString('utf8').split(/\r?\n/).filter((line) => line.length > 0) | ||
| for (const line of lines) { | ||
| this.appendLog(source, line) |
There was a problem hiding this comment.
[MAJOR] This forwards raw Hub stdout/stderr into the desktop console. With Relay enabled, the Hub prints a full public access URL containing token= (hub/src/startHub.ts:284), so the new desktop log pane can expose the CLI API token. The desktop spec explicitly says logs must not print a full tokenized URL.
Suggested fix:
private appendProcessOutput(source: 'hub' | 'runner', chunk: Buffer): void {
const lines = chunk.toString('utf8').split(/\r?\n/).filter((line) => line.length > 0)
for (const line of lines) {
this.appendLog(source, redactSensitiveLogLine(line))
}
}
function redactSensitiveLogLine(line: string): string {
return line
.replace(/([?&]token=)[^&\s]+/gi, '$1[REDACTED]')
.replace(/(Token:\s*)\S+/i, '$1[REDACTED]')
}| } | ||
|
|
||
| async stop(): Promise<void> { | ||
| if (this.state.status === 'stopped' || this.state.status === 'stopping') { |
There was a problem hiding this comment.
[MAJOR] stop() can run while start() is still in progress because it only ignores stopped/stopping. The tray exposes that path by treating starting as “停止 HAPI” (desktop/src/main/tray.ts:20). That lets cleanup run while the original start() continues and can later spawn Hub or set the state back to running after the user stopped it.
Suggested fix:
private lifecycleQueue: Promise<void> = Promise.resolve()
async start(): Promise<void> {
await this.enqueueLifecycle(async () => {
if (this.state.status === 'starting' || this.state.status === 'running' || this.state.status === 'stopping') return
await this.startInternal()
})
}
async stop(): Promise<void> {
await this.enqueueLifecycle(async () => {
if (this.state.status === 'stopped' || this.state.status === 'stopping') return
await this.stopInternal()
})
}
private async enqueueLifecycle(task: () => Promise<void>): Promise<void> {
const run = this.lifecycleQueue.then(task, task)
this.lifecycleQueue = run.catch(() => {})
await run
}There was a problem hiding this comment.
Findings
- [Major] Hub stop fallback never escalates —
ChildProcess.killedbecomes true oncekill('SIGTERM')is successfully sent, not when the process has exited, so this timeout path resolves after 5s without sendingSIGKILLto a hung Hub. The launcher can reportstoppedwhile the old Hub still owns the port, breaking the next start/exit cleanup. Evidencedesktop/src/main/processManager.ts:222.
Suggested fix:let exited = false proc.once('exit', () => { exited = true finish() }) proc.kill('SIGTERM') setTimeout(() => { if (!exited) { proc.kill('SIGKILL') } finish() }, 5000).unref()
Summary
- Review mode: follow-up after new commits
- 上一轮的日志 token 脱敏和 start/stop 并发问题已在当前 diff 中处理;本轮只发现上面的 Hub 停止兜底问题。
Testing
- Not run (automation)
HAPI Bot
| proc.once('exit', finish) | ||
| proc.kill('SIGTERM') | ||
| setTimeout(() => { | ||
| if (!proc.killed) { |
There was a problem hiding this comment.
[MAJOR] ChildProcess.killed becomes true once kill('SIGTERM') is successfully sent, not when the process has exited. This timeout therefore resolves after 5s without sending SIGKILL to a hung Hub, and the launcher can report stopped while the old Hub still owns the port.
Suggested fix:
let exited = false
proc.once('exit', () => {
exited = true
finish()
})
proc.kill('SIGTERM')
setTimeout(() => {
if (!exited) {
proc.kill('SIGKILL')
}
finish()
}, 5000).unref()There was a problem hiding this comment.
Findings
- [Major]
HAPI_HOMEusers can never pass desktop startup readiness —buildEnv()inherits the launcher's environment for Hub/Runner, so whenHAPI_HOMEis set the CLI stores/uses itsmachineIdunder that directory.readMachineId()still hard-codes~/.hapi/settings.json, sowaitForReady()polls the wrong machine id or no id at all and the desktop launcher times out then cleans up a service that actually started. Evidencedesktop/src/main/processManager.ts:351.
Suggested fix:function getHapiHome(): string { return process.env.HAPI_HOME?.replace(/^~/, homedir()) ?? join(homedir(), '.hapi') } async function readMachineId(): Promise<string | null> { try { const raw = await readFile(join(getHapiHome(), 'settings.json'), 'utf8') const parsed = JSON.parse(raw) as { machineId?: unknown } return typeof parsed.machineId === 'string' ? parsed.machineId : null } catch { return null } }
Summary
- Review mode: follow-up after new commits
- 上一轮指出的 Hub 停止兜底问题已修复;本轮仍有一个
HAPI_HOME场景下的启动校验阻断问题。
Testing
- Not run (automation; this environment has no
bunbinary, sobun run typecheck:desktopcould not be executed here)
HAPI Bot
|
|
||
| async function readMachineId(): Promise<string | null> { | ||
| try { | ||
| const raw = await readFile(join(homedir(), '.hapi', 'settings.json'), 'utf8') |
There was a problem hiding this comment.
[MAJOR] buildEnv() inherits process.env for the spawned Hub/Runner, so if the user starts the desktop launcher with HAPI_HOME set, the CLI runner stores and reports its machineId from that alternate HAPI home. This helper still reads only ~/.hapi/settings.json, which makes waitForReady() poll the wrong machine id or no id and then time out/cleanup even though the services started.
Suggested fix:
function getHapiHome(): string {
return process.env.HAPI_HOME?.replace(/^~/, homedir()) ?? join(homedir(), '.hapi')
}
async function readMachineId(): Promise<string | null> {
try {
const raw = await readFile(join(getHapiHome(), 'settings.json'), 'utf8')
const parsed = JSON.parse(raw) as { machineId?: unknown }
return typeof parsed.machineId === 'string' ? parsed.machineId : null
} catch {
return null
}
}There was a problem hiding this comment.
Findings
- [Major] Windows portable cache can keep launching a stale extracted app. Evidence
desktop/build/portable.nsi:68,desktop/package.json:5,.github/workflows/release.yml:67.
Summary
- Review mode: follow-up after new commits
- Prior HAPI Bot
HAPI_HOMEfinding is resolved in the current head. One release/runtime issue remains: the Windows portable cache invalidates on${VERSION}, but the desktop package version is fixed at0.1.0and the release workflow does not sync it from the tag beforepackage:win:prebundled.
Testing
- Not run (automation;
bunis not installed in this runner, sobun run typecheck:desktopcould not be executed here)
HAPI Bot
| FileOpen $1 "$INSTDIR\.portable-version" r | ||
| FileRead $1 $2 | ||
| FileClose $1 | ||
| StrCmp $2 "${VERSION}" launchApp doExtract |
There was a problem hiding this comment.
[MAJOR] The portable launcher skips extraction whenever $INSTDIR\.portable-version equals ${VERSION}. Because ${VERSION} comes from desktop/package.json and this PR introduces it as a fixed 0.1.0, while the release workflow runs package:win:prebundled without syncing that version from the tag, every future Windows portable release with the same desktop package version will reuse the first cached %TEMP%\hapi-desktop-portable app. Users who downloaded an earlier portable build can keep launching the stale bundled Electron app/CLI even after replacing the portable .exe.
Suggested fix:
- name: Sync desktop package version
shell: pwsh
run: |
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
$pkg = Get-Content -LiteralPath "desktop/package.json" -Raw | ConvertFrom-Json
$pkg.version = $version
$pkg | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath "desktop/package.json"
- run: cd desktop && bun run package:win:prebundledAlternatively include ${GITHUB_SHA}/build metadata in the portable cache key so each release invalidates the extracted directory.
There was a problem hiding this comment.
Findings
- 未发现符合报告阈值的问题。
Summary
- Review mode: follow-up after new commits
- 上次 HAPI Bot 关于 Windows portable 缓存版本的反馈已在当前 head 中解决。已静态审查 desktop launcher、release workflow、进程启动/停止、token 注入、IPC/preload 边界;未发现需要阻塞的新增问题。
- 剩余风险:desktop workspace 是新增面,当前没有自动化测试覆盖;跨平台打包和运行时生命周期仍主要依赖 Windows/macOS 构建实测。
Testing
git diff --check 39fba5292c525814b7f4dbae2ce18254dddb2480...HEADpassed- Not run:
bun run typecheck:desktop/ package tests(本 runner 未安装bun,且无node_modules)
HAPI Bot
|
😫 No Electron! |
其实我有相关用rust+Tauri的,但是在想要不要引入的问题。后面还是没有引入😂 |
这类Launcher启动器更适合作为独立项目,主项目作为服务不太适合引入更多的外部依赖,目前的问题是做自托管部署有点麻烦,可以考虑做一个TUI |
是适合单独一个项目的,不过这个在项目里面是单独的一个文件夹去做划分的,不会互相依赖。看看 @tiann 要不要收了。 |
|
当前状态下 web 与 app 交互很少,tauri 可能更合适。另外,menu bar 的形态可能会更好,如果做 Desktop App,很有可能到最后把主页那一堆都顺手塞进去了,这可能不是个好主意。 |
我在L站写了一个分享HAPI + CF Tunnel 远程 Vibe 部署经验分享,部署下来感觉缺少一个更好用的配置工具,我的想法是做一个类似Hermes那种的TUI控制终端,在这方面我已经有一些自己写的Rust TUI的例子,而且支持鼠标交互,作者感觉有必要加吗?后面可以提交一些PR @tiann |
|
感觉可以由作者开发一个,或者其他人也可以提交。 |
|
还是rust+Tauri的效果好,打包后才5m大小。不过作者不需要desktop,那我就单独把文件上传上来把 |

变更概述
desktop/Electron 桌面启动器,一键管理 HAPI Hub 和 Runner。.github/workflows/release.yml。原因
当前用户需要通过命令行分别启动 running 和 hub,hub 还需要常驻命令行窗口。桌面启动器提供单入口操作,降低启动和配置成本,同时保留日志可见性。
影响范围
package.json增加 desktop 相关脚本。效果图
首页
设置页
验证结果
bun run typecheck:desktopbun run build:desktop:wincli,desktop-windows,desktop-macos,releasegit diff --cached --check0.246s出现,随后生成缓存。0.057s打开。风险/回滚
macos-latest构建验证,本地 Windows 环境无法实际运行 macOS 包。desktop/workspace、根脚本和 release workflow 的 desktop jobs,不影响现有 CLI/Hub/Web 主流程。Reviewer notes
重点看:
desktop/src/main/processManager.ts的 Hub/Runner 启停顺序、token 来源和日志转发。desktop/build/portable.nsi的 portable 缓存逻辑,确保无缓存提示、有缓存静默。.github/workflows/release.yml的 desktop release artifacts 是否符合发布预期。