Skip to content

feat(desktop): add HAPI desktop launcher#769

Open
junxin367 wants to merge 6 commits into
tiann:mainfrom
junxin367:codex/hapi-desktop-launcher
Open

feat(desktop): add HAPI desktop launcher#769
junxin367 wants to merge 6 commits into
tiann:mainfrom
junxin367:codex/hapi-desktop-launcher

Conversation

@junxin367
Copy link
Copy Markdown
Contributor

变更概述

  • 新增 desktop/ Electron 桌面启动器,一键管理 HAPI Hub 和 Runner。
  • 支持托盘、首页/设置页、Hub 日志、目录配置、Relay、中文/英文界面。
  • 打包 Windows portable / win-unpacked 和 macOS app,并合并 desktop release 产物到 .github/workflows/release.yml
  • 优化 Windows portable 启动:首次无缓存显示准备提示;缓存存在时不显示提示窗口,并直接快速打开主窗口。

原因

当前用户需要通过命令行分别启动 running 和 hub,hub 还需要常驻命令行窗口。桌面启动器提供单入口操作,降低启动和配置成本,同时保留日志可见性。

影响范围

  • 新增 desktop workspace 和 Electron Builder 配置。
  • package.json 增加 desktop 相关脚本。
  • Release workflow 新增 Windows/macOS desktop 产物。
  • 新增需求上下文、ADR 和设计说明文档。

效果图

首页

HAPI Desktop 首页

设置页

HAPI Desktop 设置页

验证结果

  • bun run typecheck:desktop
  • bun run build:desktop:win
  • workflow YAML 解析通过:cli,desktop-windows,desktop-macos,release
  • git diff --cached --check
  • Windows portable 启动实测:
    • 无缓存:提示窗口约 0.246s 出现,随后生成缓存。
    • 有缓存:提示窗口未出现,主窗口约 0.057s 打开。

风险/回滚

  • 风险:macOS app 当前依赖 GitHub 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 是否符合发布预期。

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Desktop release bundles the npm optional CLI instead of the freshly built release CLI — bun run build:desktop:win / build:desktop:mac call prepare:cli, and prepare-bundled-cli.ts only copies @twsxtd/hapi-* from node_modules. The cli job 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:save allows service settings to change while services are running — the renderer disables controls, but the main-process IPC handler still writes any LauncherConfig. A stale settings draft, tray-triggered start, or any renderer-side call can change hubPort, workspaceRoots, or relayEnabled during 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. Evidence desktop/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

Comment thread .github/workflows/release.yml Outdated
with:
bun-version: 1.3.14
- run: bun install
- run: bun run build:desktop:win
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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-metadata

Apply the same pattern for macOS with the matching hapi-darwin-*.tar.gz before running electron-builder.

Comment thread desktop/src/main/ipc.ts Outdated

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))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)
})

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Hub relay access tokens are forwarded into the desktop console — appendProcessOutput sends 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 full token= URL in the new in-app log pane, contrary to the desktop spec's no-token-log requirement. Evidence desktop/src/main/processManager.ts:238, related existing Hub output at hub/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 ignores stopped/stopping, so the tray's “停止 HAPI” action during starting can run cleanup while start() is still awaiting runner start/Hub readiness. After stop() sets stopped, the original start() can still spawn Hub or set running, leaving services alive after the user stopped them. Evidence desktop/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: bun is 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

Comment thread desktop/src/main/processManager.ts Outdated
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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') {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
}

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Hub stop fallback never escalates — ChildProcess.killed becomes true once kill('SIGTERM') is successfully sent, not when the process has exited, so this timeout path resolves after 5s without sending SIGKILL to a hung Hub. The launcher can report stopped while the old Hub still owns the port, breaking the next start/exit cleanup. Evidence desktop/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

Comment thread desktop/src/main/processManager.ts Outdated
proc.once('exit', finish)
proc.kill('SIGTERM')
setTimeout(() => {
if (!proc.killed) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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()

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] HAPI_HOME users can never pass desktop startup readiness — buildEnv() inherits the launcher's environment for Hub/Runner, so when HAPI_HOME is set the CLI stores/uses its machineId under that directory. readMachineId() still hard-codes ~/.hapi/settings.json, so waitForReady() polls the wrong machine id or no id at all and the desktop launcher times out then cleans up a service that actually started. Evidence desktop/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 bun binary, so bun run typecheck:desktop could not be executed here)

HAPI Bot

Comment thread desktop/src/main/processManager.ts Outdated

async function readMachineId(): Promise<string | null> {
try {
const raw = await readFile(join(homedir(), '.hapi', 'settings.json'), 'utf8')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
    }
}

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_HOME finding 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 at 0.1.0 and the release workflow does not sync it from the tag before package:win:prebundled.

Testing

  • Not run (automation; bun is not installed in this runner, so bun run typecheck:desktop could not be executed here)

HAPI Bot

FileOpen $1 "$INSTDIR\.portable-version" r
FileRead $1 $2
FileClose $1
StrCmp $2 "${VERSION}" launchApp doExtract
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:prebundled

Alternatively include ${GITHUB_SHA}/build metadata in the portable cache key so each release invalidates the extracted directory.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...HEAD passed
  • Not run: bun run typecheck:desktop / package tests(本 runner 未安装 bun,且无 node_modules

HAPI Bot

@ZyphrZero
Copy link
Copy Markdown

😫 No Electron!

@junxin367
Copy link
Copy Markdown
Contributor Author

😫 No Electron!

其实我有相关用rust+Tauri的,但是在想要不要引入的问题。后面还是没有引入😂

@ZyphrZero
Copy link
Copy Markdown

ZyphrZero commented Jun 2, 2026

😫 No Electron!

其实我有相关用rust+Tauri的,但是在想要不要引入的问题。后面还是没有引入😂

这类Launcher启动器更适合作为独立项目,主项目作为服务不太适合引入更多的外部依赖,目前的问题是做自托管部署有点麻烦,可以考虑做一个TUI

@junxin367
Copy link
Copy Markdown
Contributor Author

😫 No Electron!

其实我有相关用rust+Tauri的,但是在想要不要引入的问题。后面还是没有引入😂

感觉不适合引入,这类Launcher启动器更适合作为独立项目,主项目作纯服务,目前的问题是做自托管部署有点麻烦,可以考虑做一个TUI

是适合单独一个项目的,不过这个在项目里面是单独的一个文件夹去做划分的,不会互相依赖。看看 @tiann 要不要收了。

@tiann
Copy link
Copy Markdown
Owner

tiann commented Jun 2, 2026

当前状态下 web 与 app 交互很少,tauri 可能更合适。另外,menu bar 的形态可能会更好,如果做 Desktop App,很有可能到最后把主页那一堆都顺手塞进去了,这可能不是个好主意。

@ZyphrZero
Copy link
Copy Markdown

当前状态下 web 与 app 交互很少,tauri 可能更合适。另外,menu bar 的形态可能会更好,如果做 Desktop App,很有可能到最后把主页那一堆都顺手塞进去了,这可能不是个好主意。

我在L站写了一个分享HAPI + CF Tunnel 远程 Vibe 部署经验分享,部署下来感觉缺少一个更好用的配置工具,我的想法是做一个类似Hermes那种的TUI控制终端,在这方面我已经有一些自己写的Rust TUI的例子,而且支持鼠标交互,作者感觉有必要加吗?后面可以提交一些PR @tiann

@junxin367
Copy link
Copy Markdown
Contributor Author

感觉可以由作者开发一个,或者其他人也可以提交。
解决2个问题,一个是启动后不需要一直占用一个pwsh命令行,一个是增加自己部署穿透的功能,其他的就是体验优化了

@junxin367
Copy link
Copy Markdown
Contributor Author

还是rust+Tauri的效果好,打包后才5m大小。不过作者不需要desktop,那我就单独把文件上传上来把
hapi-desktop.zip
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants