Skip to content

fix(http-api): apply toggle in the backend when no renderer is alive - #1039

Closed
zbeosf wants to merge 1 commit into
oldj:masterfrom
zbeosf:fix/hide-at-launch-http-api
Closed

zbeosf wants to merge 1 commit into
oldj:masterfrom
zbeosf:fix/hide-at-launch-http-api

Conversation

@zbeosf

@zbeosf zbeosf commented Aug 26, 2026 •

Copy link
Copy Markdown

Summary

Fixes #1038 — with Hide at launch on, GET /api/toggle answers ok but does nothing.

api_toggle does not change state itself: it broadcasts toggle_item and relies on the main window's onToggleItem to run the apply pipeline. A Tauri event with no listener is dropped, and there are two supported configurations with no main window — hide_at_launch skips window creation at setup, and lightweight_mode destroys the window on close. In both, the broadcast reaches nobody and the endpoint still reports success.

The module doc of http_api.rs already names both the assumption and the way out:

the v5 storage plan noted that doing the apply directly inside the HTTP handler would work even when no renderer is alive, but in our build the main window is created at startup and only ever hidden, so the broadcast always reaches a live listener.

That premise does not hold under hide_at_launch. This PR takes the documented alternative, but only for the case the premise misses.

What changed

  • http_api.rs — api_toggle broadcasts as before when a main window exists, and falls back to applying in the handler when there is none. The common path is untouched, so choice_mode semantics stay centralised in the renderer for every UI-driven toggle.
  • commands.rs — the apply pipeline (privileged write, apply history, system_hosts_updated, tray title refresh, cmd_after_hosts_apply) is extracted from apply_hosts_selection into apply_aggregated_content, so both entry points produce identical side effects. apply_hosts_selection keeps its behaviour and just calls it.
  • storage/manifest.rs — set_on_state_of_item, a port of the renderer's setOnStateOfItem (src/common/hostsFn.ts): single-choice exclusion at top level and inside folders, folder_mode overriding the global choice_mode, the optional folder cascade, and the parent-reconcile walk.

The backend path goes through the same apply_aggregated_content pipeline a UI-driven apply uses — a bare write would silently skip history and the post-apply command. Beyond that it takes the same precautions the renderer relies on:

  • Refuses to apply when no write mode is set. The renderer opens the mode picker instead of applying; with no UI to fall back on, the backend returns write mode not set. rather than taking apply_to_system_hosts's overwrite default and wiping hand-written entries for anyone still carrying the empty Electron-era value.
  • Re-reads the tree under store_lock after the write, then re-applies the flip. The privileged write can sit on an auth prompt for minutes, and the refresh scanner may legitimately rewrite manifest.json meanwhile — saving the pre-apply snapshot would clobber it. Same shape as the remote-refresh path in refresh.rs.
  • Serialises backend applies so concurrent requests cannot stack OS auth prompts, and runs the blocking write on spawn_blocking so it never pins a worker of the runtime shared by the HTTP server and every async command.
  • Refreshes the tray title after the save — tray::refresh_title reads manifest.json from disk, so the call inside the pipeline sees the pre-toggle tree; without a second call the menubar trails one toggle behind, and with no window open it is the only place the active profile is visible. Also emits tray_list_updated so a lazily-built tray mini window does not keep showing stale state.
  • Distinguishes failures: cancelled., write mode not set., applied but not persisted., apply failed. — instead of collapsing a dismissed prompt, a policy denial and a full disk into one opaque reply.

Nothing is created, shown, or hidden differently — hide_at_launch still skips window creation exactly as before, and the structural test asserting that still passes.

Tests

  • 15 new unit tests for set_on_state_of_item: multi/single choice, folder cascade, single-choice folders, parent reconcile, nested folders, unknown ids, folder_mode absent/0 inheriting the global choice_mode, single-choice cascading into the folders it switches off, and cascade skipping non-folder nodes.
  • cargo test → 182 passed, 0 failed (including the existing window_config canaries, which assert that hide_at_launch must not create a window — untouched by this PR).
  • npm run test:all (typecheck + vitest 84 + cargo + playwright 44) passes.

Manually verified on macOS with hide_at_launch = true, http_api_on = true, no window ever opened:

action result
toggle a top-level profile switches, /etc/hosts written
toggle a folder (multi_chose_folder_switch_all) folder + all children on
toggle one child off child off, parent auto-off, sibling untouched
choice_mode = 1, toggle another profile previous selection cleared
apply history / cmd_after_hosts_apply recorded and run, same as a UI apply
tray title with show_title_on_tray reflects the profile just switched on
write_mode = "" refused, /etc/hosts untouched

Notes

This also restores the bundled Alfred workflow, whose action is exactly curl 'http://127.0.0.1:50761/api/toggle?id={query}'.


中文说明

修复 #1038 —— 开启「启动时隐藏窗口」后,/api/toggle 返回 ok 却没有任何效果。

api_toggle 本身不修改状态,它只广播 toggle_item,依赖主窗口的 onToggleItem 完成切换与应用。没有监听者的 Tauri 事件会被丢弃,而「启动时隐藏」会完全跳过主窗口的创建,于是广播无人接收,接口却仍然报告成功。

http_api.rs 的模块注释中已经同时写出了这个前提和出路:在 handler 里直接应用「即使没有 renderer 存活也能工作」。本 PR 采用的正是这个方案,但只用在前提不成立的那种情况。

改动内容

  • http_api.rs —— 主窗口存在时照旧广播(UI 触发的切换仍然走 renderer,choice_mode 语义保持集中在一处);只有在没有主窗口时才在 handler 内直接应用。
  • commands.rs —— 把应用流程(提权写入、应用历史、system_hosts_updated 广播、托盘标题刷新、cmd_after_hosts_apply)从 apply_hosts_selection 中抽取为 apply_aggregated_content,使两个入口产生完全一致的副作用;apply_hosts_selection 行为不变,只是改为调用它。
  • storage/manifest.rs —— 新增 set_on_state_of_item,把 renderer 的 setOnStateOfItem(src/common/hostsFn.ts)移植到 Rust:顶层与文件夹内的单选互斥、folder_mode 覆盖全局 choice_mode、可选的文件夹级联,以及父节点状态的回溯同步。

后端路径走的是与 UI 触发完全相同的 apply_aggregated_content 流程(只做裸写入会静默跳过历史记录和应用后命令),并且额外做了渲染进程本来就依赖的几项保护:

  • 未设置写入模式时拒绝应用:渲染进程会弹出模式选择框;后端没有 UI 可退回,因此返回 write mode not set.,而不是套用 overwrite 默认值把用户手写的 hosts 条目清掉。
  • 写入完成后在 store_lock 下重新读取并重新应用:提权写入可能在认证框上停留数分钟,其间远程刷新可能合法地改写了 manifest.json,直接保存旧快照会覆盖它。做法与 refresh.rs 的远程刷新路径一致。
  • 串行化后端应用,避免并发请求叠加认证框;用 spawn_blocking 执行阻塞写入,避免占用 HTTP 服务与所有 async 命令共用的运行时线程。
  • 保存之后再刷新一次托盘标题:tray::refresh_title 从磁盘读取 manifest,流程内那次调用看到的还是切换前的树;不补这一次的话菜单栏会永远慢一步,而在没有窗口时那是用户唯一能看到当前方案的地方。同时发出 tray_list_updated,避免已创建的托盘小窗显示过期状态。
  • 区分失败原因:cancelled. / write mode not set. / applied but not persisted. / apply failed.。

窗口的创建、显示、隐藏行为没有任何改变 —— 「启动时隐藏」依然跳过窗口创建,对应的结构性测试也依然通过。

测试

新增 15 个 set_on_state_of_item 单元测试(多选/单选、文件夹级联、单选文件夹、父节点回溯、嵌套文件夹、未知 id,以及 folder_mode 缺省/为 0 时继承全局 choice_mode 等)。cargo test → 182 passed, 0 failed;npm run test:all(typecheck + vitest 84 + cargo + playwright 44)全部通过。

在 macOS 上以 hide_at_launch = true、http_api_on = true、且从未打开过窗口的状态下手动验证:切换顶层方案、切换文件夹(子项全开)、关闭其中一个子项(父节点自动关闭、兄弟节点不受影响)、choice_mode = 1 时切换另一个方案(原选中项被清除),/etc/hosts 均正确写入。

附注

这同时也修复了自带的 Alfred workflow —— 它的动作正是 curl 'http://127.0.0.1:50761/api/toggle?id={query}'。

@zbeosf
zbeosf force-pushed the fix/hide-at-launch-http-api branch from 67e81e1 to 504bf3b Compare August 26, 2026 04:57
@zbeosf
zbeosf force-pushed the fix/hide-at-launch-http-api branch from 504bf3b to 8a9007e Compare August 26, 2026 05:20
oldj added a commit that referenced this pull request Sep 3, 2026
fix(http-api): apply toggle in the backend when no renderer is alive
@oldj

oldj commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Thanks for the thorough fix and tests! Merged into develop as ec298cf (with a trivial doc/use conflict resolved against the /api/refresh changes already on develop). It will ship with the next release. Closing this PR since it targets master.

感谢修复与详尽的测试!已合入 develop(ec298cfe),会随下个版本发布。因 PR 目标分支是 master,这里手动关闭。

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.

HTTP API /api/toggle silently does nothing when Hide at launch is enabled

2 participants