From 3d738f6bd87a40454414620adeac5ff5fd2e4ada Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 13 Aug 2026 14:04:08 +0800 Subject: [PATCH] feat(backup): multi-protocol remote backup - folder/WebDAV/S3/SFTP push and restore-latest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置→备份页正式开放,新增「远程备份」组:口令保护归档(密文出境)可推送 到本地/网络目录、WebDAV、S3 兼容存储(SigV4,路径式,AWS/MinIO/R2/B2)或 SFTP(复用已保存 SSH 主机与认证)。归档时间戳命名、每端保留最近 10 份, 「恢复最新备份」取回最新一份after口令确认落盘。WebDAV/S3 密钥入 Windows 凭据管理器;打包/Argon2/网络全在后台线程(NebulaBackupRemote 事件模型与 WebDAV 同步一致)。OAuth 三家(GitHub/Drive/OneDrive)暂缓。SigV4 附 AWS 官方文档签名值的 KAT 守护。 同车提交并行会话的收尾:托盘 attention(tray.rs 与 main/window_context 接 线)、GPUI 侧栏设置按钮、sync 同步集补 resume_ai/tray 键。工作区整体通过 722 项测试后提交。 --- CHANGELOG.md | 16 +- Cargo.lock | 1 + nebula_app/Cargo.toml | 4 + nebula_app/src/backup_remote.rs | 1153 ++++++++++++++++++++++++ nebula_app/src/display/mod.rs | 299 +++++- nebula_app/src/display/settings.rs | 478 +++++++++- nebula_app/src/encrypted_backup.rs | 4 +- nebula_app/src/event.rs | 56 ++ nebula_app/src/event/types.rs | 13 + nebula_app/src/gpui_shell/workspace.rs | 12 + nebula_app/src/input/chrome.rs | 44 +- nebula_app/src/input/keyboard.rs | 35 + nebula_app/src/input/mod.rs | 2 + nebula_app/src/input/mouse.rs | 9 +- nebula_app/src/main.rs | 10 + nebula_app/src/sync.rs | 2 + nebula_app/src/tray.rs | 590 ++++++++++++ nebula_app/src/window_context.rs | 62 ++ 18 files changed, 2767 insertions(+), 23 deletions(-) create mode 100644 nebula_app/src/backup_remote.rs create mode 100644 nebula_app/src/tray.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4036a..74ffab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Every release entry is provided in English and Simplified Chinese. #### Added +- **Remote backups over multiple protocols** — the Settings → Backup page is now available and gains a "Remote backup" group that pushes the existing password-protected archive (Argon2id + AES-256-GCM; servers only ever see ciphertext) to a destination of choice: a local/network folder (NAS UNC paths), WebDAV, S3-compatible storage (AWS/MinIO/R2/B2 via SigV4, path-style), or SFTP reusing saved SSH hosts and their authentication. Archives are timestamped (`nebula-backup-YYYYMMDD-HHMMSS.nbk`), the newest 10 are retained per destination, and "Restore latest" fetches and applies the most recent one after passphrase entry. WebDAV/S3 secrets live in the Windows Credential Manager (env-var fallback elsewhere), non-secret settings persist in `nebula_backup.txt`, and all packing, key derivation, and network I/O run on a background thread. Provider surface modeled on Netcatty's cloud-sync adapters (its OAuth providers — GitHub/Google Drive/OneDrive — are out of scope for now). - **Two completion styles with a switch** — Settings → Terminal → Completion adds a "Completion style" choice between the existing inline ghost text and a new popup candidate list. The popup gathers history, frecency directories, PATH commands, and filesystem matches (up to 8, with source tags), navigates with Up/Down, accepts with the configured accept key (Tab/Right), and dismisses with Esc without re-opening until the line changes. A command-palette action toggles the style, and the choice persists as `completion_style`. - **A real color picker for the custom background** — the background-color popup now leads with a saturation/value plane and a hue bar; dragging picks continuously with live terminal preview and persists on release. The preset swatches and the hex field remain, and all three inputs stay in sync (including hue retention across gray/black/white picks). - **Clipboard screenshot paste, locally and over SSH** — pasting with an image-only clipboard (e.g. right after Win+Shift+S) now converts the bitmap to PNG and pastes a file path instead of nothing. Local panes write a temp file; SSH panes upload via the existing SFTP stack to `/tmp/nebula-paste-.png` in the background and then type the remote path into the pane — so image-accepting CLIs like codex and claude receive a usable path on both sides (interaction modeled on Netcatty). @@ -17,13 +18,18 @@ Every release entry is provided in English and Simplified Chinese. - **Configurable terminal cell width** — Settings → Appearance offers Compact and Relaxed cell-width modes. Changing the mode immediately rebuilds font metrics, the terminal grid, pane layout, and PTY dimensions, and the choice persists across launches. - **Ordered font fallback chains** — `font_family` accepts comma-separated families such as `JetBrains Mono, LXGW WenKai, Cascadia Code`. The first family remains the primary face, later families fill missing glyphs in order for regular, bold, italic, and bold-italic text, and the embedded Maple font remains the final fallback. System and imported/private fonts now use the same family lookup path for validation, preview, and rendering. (#33) - **Middle-click to close tabs** — middle-clicking a tab or its close control follows the existing close flow, including confirmation when required. +- **Audible terminal bell** — BEL (`\a`) now plays the system notification sound in addition to the visual bell, so an AI CLI finishing a turn is audible even from another tab. Throttled so a bell-happy program cannot machine-gun it; disable with `bell.audible = false`. (#37) +- **AI conversations resume across restarts** — panes that had a claude/codex conversation open when Nebula closed (or crashed) now type the exact resume command (`claude --resume ` / `codex resume `) into the restored shell automatically. Session identity comes from the CLIs' own hook payloads; a claude detected without an id falls back to `claude --continue` in the restored directory. Injection only targets plain restored shells (never SSH/profile seeds), ids are validated before anything reaches the terminal, and Settings → Advanced offers the "resume AI conversations on restore" switch (`resume_ai`, on by default). +- **System tray icon with agent attention** — a resident tray icon flips to an amber-dot state whenever any AI CLI stops and waits for input, even with every window buried or minimized. Right-click lists all agent panes with their state (running / waiting) and jumps straight to the source pane through the same focus path as toast clicks; left-click goes to the most urgent pane. The tray mirrors the sidebar badges (one source of truth) and can be turned off in Settings → Advanced (`tray`, on by default). #### Fixed - **Explorer context-menu launches join the resident instance** — "Open in Nebula" used to be treated as explicit intent and always started an independent window, so tabs detached in the resident process looked lost. A directory launch without `-e` now attaches the existing instance first (restoring its tabs) and opens the directory as a new tab in that window, brought to the foreground; without a resident instance it still starts standalone. The runtime API's `tab.new` gained an optional `cwd` parameter to carry this. - **Imported terminals become selectable in the default-shell dropdown** — the dropdown's hit test counted only detected shells while the list also renders imported quick-launch profiles, leaving the trailing rows visible but unclickable. Both sides now share one count. - **Git panel no longer blanks on repositories owned by another user** — `git status`/`diff` in the drawer run with a per-invocation `safe.directory` exemption, fixing the silently empty Git view on `\\wsl$\…` roots and elevated-owner checkouts where the same commands work in the user's own shell. Failures now leave a debug-log trace instead of a silent blank. -- **Visible Windows windows recover from stuck render gates** — startup-time occlusion misreports and missing frame callbacks can no longer leave an already visible window accepting input without repainting. Occlusion is ignored unless the window is actually minimized, and the existing 1 Hz window heartbeat idempotently releases stale `occluded` and `has_frame` gates. (#21) +- **Visible Windows windows recover from stuck render gates** — startup-time occlusion misreports and missing frame callbacks can no longer leave an already visible window accepting input without repainting. Occlusion is ignored unless the window is actually minimized, the existing 1 Hz window heartbeat idempotently releases stale `occluded` and `has_frame` gates, and the watchdog is now armed at window creation so even a freeze before the first frame self-heals within a second. (#21, #32) +- **Drive-root context menu launches work** — right-clicking the background of `D:\` (any drive root) and choosing "Open in Nebula" failed with "invalid working directory": Explorer expands `%V` to `D:\`, whose trailing backslash escapes the closing quote on the command line. The mangled path is now repaired, and the error's log hint uses PowerShell syntax (`$env:NEBULA_LOG`) so copy-paste resolves. (#36) +- **Multi-line paste stops interrupting codex** — the multi-line paste confirmation now only fires when newlines are actually headed to a shell that would execute them line by line. Applications in bracketed-paste mode (codex, vim, modern PSReadLine) receive the paste as one atomic chunk, so the warning no longer blocks them. The dark-theme "Enter" keycap on the confirm dialog's accent button also derives from the button's own ink instead of the near-black panel color. (#35) #### Improved @@ -33,6 +39,7 @@ Every release entry is provided in English and Simplified Chinese. #### 新增 +- **多协议远程备份** — “设置 → 备份”页面正式开放,并新增「远程备份」组:把既有的口令保护归档(Argon2id + AES-256-GCM,服务器只见密文)推送到所选目的地——本地/网络目录(含 NAS 的 UNC 路径)、WebDAV、S3 兼容存储(SigV4 签名、路径式 URL,AWS/MinIO/R2/B2 通吃)、或复用已保存 SSH 主机及其认证的 SFTP。归档按时间戳命名(`nebula-backup-YYYYMMDD-HHMMSS.nbk`),每个远端保留最近 10 份;「恢复最新备份」取回最新一份、输口令后落盘。WebDAV/S3 的密钥存 Windows 凭据管理器(其他平台走环境变量),非密文配置持久化在 `nebula_backup.txt`;打包、密钥派生与网络全部在后台线程完成。协议面参考 Netcatty 的云同步适配器(其 GitHub/Google Drive/OneDrive 三个 OAuth 后端暂不纳入)。 - **两种补全样式可切换** — “设置 → 终端 → 补全”新增「补全样式」:在既有的行内灰字与新的弹窗候选列表之间选择。弹窗汇总历史命令、常用目录、PATH 命令与文件系统匹配(至多 8 项,带来源标签),↑/↓ 选行、按补全接受键(Tab/→)接受、Esc 关闭且同一行不再重弹;命令面板提供切换动作,选择以 `completion_style` 持久化。 - **自定义背景色改用真调色盘** — 背景色浮层顶部新增饱和度/明度取色面与色相条,按住拖动即连续取色、终端实时预览、松手落盘。预设色板与 16 进制输入保留,三种输入互相同步(灰/黑/白取色时保留既有色相)。 - **剪贴板截图粘贴(本地与 SSH)** — 剪贴板只有位图没有文本时(如 Win+Shift+S 之后),粘贴会把位图转成 PNG 并粘出文件路径:本地 pane 写入临时文件;SSH pane 经既有 SFTP 栈后台上传到远端 `/tmp/nebula-paste-<时间戳>.png` 再把远端路径敲进会话——codex/claude 这类接受图片路径的 CLI 在两侧都能直接用(交互参考 Netcatty)。 @@ -40,13 +47,18 @@ Every release entry is provided in English and Simplified Chinese. - **可配置终端单元格宽度** — “设置 → 外观”新增“紧凑”和“宽松”两种单元格宽度模式。切换后会立即重算字体度量、终端网格、分屏布局和 PTY 尺寸,并在下次启动时保留选择。 - **有序字体 fallback 链** — `font_family` 支持逗号分隔的字体族,例如 `JetBrains Mono, 霞鹜文楷, Cascadia Code`。第一个字体族仍是主字体,后续字体族按顺序为常规、粗体、斜体和粗斜体补齐缺失字形,内置 Maple 字体始终作为最后兜底。系统字体与导入/私有字体现在统一通过同一族名查找路径完成校验、预览和渲染。(#33) - **中键关闭标签页** — 在标签页或其关闭控件上单击鼠标中键,会进入既有的关闭流程;需要确认时仍会正常弹出确认。 +- **终端铃声** — BEL(`\a`)现在在视觉铃声之外播放系统提示音:AI CLI 在别的标签页里完成回合也听得见。内置节流,刷铃声的程序不会连成机关枪;`bell.audible = false` 可关闭。(#37) +- **AI 对话跨重启接续** — 关闭(或崩溃)时某个 pane 里还开着 claude/codex 对话的,冷恢复后会自动把 resume 命令(`claude --resume ` / `codex resume `)敲进恢复出来的 shell。会话身份来自 CLI 自己的 hook 载荷;识别到 claude 但没有 id 时退化为在恢复目录里 `claude --continue`。注入只针对恢复出的裸 shell(绝不注入 SSH/Profile 首格),id 上屏前先做字符集校验;“设置 → 高级”提供「恢复时自动接续 AI 对话」开关(`resume_ai`,默认开)。 +- **托盘图标 agent 提醒** — 常驻系统托盘图标:任一 AI CLI 停下来等输入时翻转为橙点 attention 态,窗口全被压住或最小化也看得见。右键列出所有 agent pane 及状态(运行中/等待输入),点击经 toast 同一条聚焦路径直达来源 pane;左键直达最需要人的那个。托盘与侧栏徽章同一事实源;“设置 → 高级”可关(`tray`,默认开)。 #### 修复 - **资源管理器右键打开并入驻留实例** — 「在 Nebula 中打开」此前被当作显式意图而总是启动独立窗口,驻留进程里 detached 的标签看起来就“丢了”。带目录、无 `-e` 命令的启动现在会先 ATTACH 既有实例(找回原有标签),再在该窗口新开定目录标签并置前;没有驻留实例时仍独立启动。runtime API 的 `tab.new` 为此新增可选 `cwd` 参数。 - **导入的终端在默认 Shell 下拉中可以选中** — 下拉的命中测试此前只统计检测到的 shell,而列表还渲染了导入的快速启动配置,末尾几行看得见点不中。绘制与命中现在共用同一计数。 - **Git 面板在他人所有的仓库上不再空白** — 抽屉里的 `git status`/`diff` 以单次调用范围的 `safe.directory` 豁免运行,修复 `\\wsl$\…` 根目录与提升权限检出下“自己 shell 里 git status 正常、面板却空白”的问题;失败现在会留下调试日志而不是无声空白。 -- **Windows 可见窗口可从卡死的渲染门控中恢复** — 启动期的遮挡误报或帧回调丢失不再让可见窗口陷入“能接收输入但不重绘”。窗口没有真正最小化时会忽略遮挡误报,既有的 1 Hz 窗口心跳也会以幂等方式释放卡住的 `occluded` 与 `has_frame` 门控。(#21) +- **Windows 可见窗口可从卡死的渲染门控中恢复** — 启动期的遮挡误报或帧回调丢失不再让可见窗口陷入“能接收输入但不重绘”。窗口没有真正最小化时会忽略遮挡误报,既有的 1 Hz 窗口心跳也会以幂等方式释放卡住的 `occluded` 与 `has_frame` 门控;看门狗现在在建窗时即刻武装,首帧之前的冻结也能在一秒内自愈。(#21、#32) +- **盘符根目录右键启动可用** — 在 `D:\` 这类盘符根目录背景右键「在 Nebula 中打开」此前报「无效的工作目录」:资源管理器把 `%V` 展开成 `D:\`,末尾反斜杠在命令行上转义了收尾引号。被吃掉的路径现在会被修复;错误提示里的日志变量改用 PowerShell 语法(`$env:NEBULA_LOG`),复制即可用。(#36) +- **多行粘贴不再打断 codex** — 多行粘贴确认现在只在换行真的会被 shell 逐行执行时弹出。处于 bracketed paste 模式的应用(codex、vim、新版 PSReadLine)会把粘贴当作一个整体接收,警告不再拦路。确认框里深色主题下 accent 主按钮上的「Enter」键帽也改从按钮自身墨色派生,不再是一块近黑色。(#35) #### 改进 diff --git a/Cargo.lock b/Cargo.lock index 7db8f0b..60c4628 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4578,6 +4578,7 @@ dependencies = [ "gpui", "gpui-component", "gpui-component-assets", + "hmac 0.13.0", "home", "html-escape", "image", diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 844a190..eb77503 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -70,6 +70,9 @@ clap = { version = "4.2.7", features = ["derive", "env"] } copypasta = { version = "0.10.1", default-features = false } crossfont = "0.9.0" glutin = { version = "0.32.2", default-features = false, features = ["egl", "wgl"] } +# 远程备份的 S3 SigV4 签名(backup_remote)。0.13 配 sha2 0.11 的 digest +# 世代;lock 里已有该版本,零新增下载。 +hmac = "0.13" html-escape = "0.2.14" home = "0.5.5" libc = "0.2" @@ -172,6 +175,7 @@ windows-sys = { version = "0.59", features = [ "Win32_UI_Shell_PropertiesSystem", "Win32_System_Variant", "Win32_System_Threading", + "Win32_System_LibraryLoader", "Win32_System_SystemInformation", "Win32_System_Registry", "Win32_System_Console", diff --git a/nebula_app/src/backup_remote.rs b/nebula_app/src/backup_remote.rs new file mode 100644 index 0000000..6be2265 --- /dev/null +++ b/nebula_app/src/backup_remote.rs @@ -0,0 +1,1153 @@ +//! 多协议远程备份:把 [`crate::encrypted_backup`] 的加密归档(NEBUBAK1, +//! Argon2id + AES-256-GCM,服务器只见密文)推到远端,或取回最新一份恢复。 +//! +//! 协议后端(对照 Netcatty 的 cloudSync 适配器面裁剪): +//! - 本地/网络目录(含 NAS 的 UNC 路径) +//! - WebDAV(PUT/GET/PROPFIND/DELETE,Basic 认证,同 `sync.rs` 的栈) +//! - S3 兼容(SigV4 签名,路径式 URL——AWS/MinIO/R2/B2/OSS 通吃) +//! - SFTP(复用既有 SSH 会话栈与已保存主机的认证,Netcatty 没有的一路) +//! +//! Netcatty 还有 GitHub/Google Drive/OneDrive 三个 OAuth 后端;桌面终端里 +//! 没有浏览器授权回环,先不做。归档按 UTC 时间戳命名(字典序即时间序), +//! 每个远端保留最近 [`KEEP_ARCHIVES`] 份,多出的在推送成功后尽力清理。 +//! 网络/密钥派生都在调用方的后台线程阻塞完成(与 `sync.rs` 同一模型)。 + +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use log::warn; + +/// 远程备份配置文件(位于 `nebula_data_dir()`),独立于 `nebula_sync.txt`: +/// 备份管道自身的配置不该被备份恢复覆盖到不可用。 +const CONFIG_FILE: &str = "nebula_backup.txt"; + +/// 归档文件名:`nebula-backup-YYYYMMDD-HHMMSS.nbk`(UTC)。前后缀是列表 +/// 过滤的安全边界——清理绝不会碰远端目录里不带这两段的文件。 +const ARCHIVE_PREFIX: &str = "nebula-backup-"; +const ARCHIVE_SUFFIX: &str = ".nbk"; + +/// 每个远端保留的归档份数。 +const KEEP_ARCHIVES: usize = 10; + +/// Windows 凭据管理器条目(与 `sync.rs` 的通用 DPAPI 存取同一后端)。 +#[cfg(windows)] +const WEBDAV_PASSWORD_TARGET: &str = "Nebula Backup WebDAV Password"; +#[cfg(windows)] +const S3_SECRET_TARGET: &str = "Nebula Backup S3 Secret Key"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BackupProtocol { + #[default] + Off, + Folder, + WebDav, + S3, + Sftp, +} + +impl BackupProtocol { + pub fn from_settings(value: &str) -> Option { + match value.trim() { + "off" => Some(Self::Off), + "folder" => Some(Self::Folder), + "webdav" => Some(Self::WebDav), + "s3" => Some(Self::S3), + "sftp" => Some(Self::Sftp), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::Off => "off", + Self::Folder => "folder", + Self::WebDav => "webdav", + Self::S3 => "s3", + Self::Sftp => "sftp", + } + } +} + +/// `nebula_backup.txt` 的解析结果。每个协议的字段独立保存:来回切换协议 +/// 不丢已填的配置。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BackupRemoteConfig { + pub protocol: BackupProtocol, + pub folder_path: String, + pub webdav_url: String, + pub webdav_username: String, + pub s3_endpoint: String, + pub s3_region: String, + /// 原样保存的「桶/前缀」串(如 `my-bucket/nebula`),首个 `/` 前是桶。 + pub s3_bucket: String, + pub s3_access_key: String, + pub sftp_destination: String, + pub sftp_path: String, + /// 手写豁免开关(UI 故意不给入口):自建内网 WebDAV/S3 允许 http。 + pub allow_http: bool, +} + +pub fn config_path() -> PathBuf { + crate::display::nebula_data_dir().join(CONFIG_FILE) +} + +/// 设置页输入槽位数(协议决定语义)。密文槽(WebDAV 密码 / S3 Secret) +/// 不进配置文件,由凭据管理器承接。 +pub fn field_count(protocol: BackupProtocol) -> usize { + match protocol { + BackupProtocol::Off => 0, + BackupProtocol::Folder => 1, + BackupProtocol::WebDav => 3, + BackupProtocol::S3 => 5, + BackupProtocol::Sftp => 2, + } +} + +/// 密文槽位下标(有的协议没有:目录不需要凭据,SFTP 复用 SSH 主机认证)。 +pub fn secret_field(protocol: BackupProtocol) -> Option { + match protocol { + BackupProtocol::WebDav => Some(2), + BackupProtocol::S3 => Some(4), + _ => None, + } +} + +impl BackupRemoteConfig { + pub fn load() -> Self { + Self::parse(&std::fs::read_to_string(config_path()).unwrap_or_default()) + } + + fn parse(text: &str) -> Self { + let mut cfg = Self::default(); + for line in text.lines() { + let Some((key, value)) = line.split_once('=') else { continue }; + let value = value.trim(); + match key.trim() { + "protocol" => { + if let Some(protocol) = BackupProtocol::from_settings(value) { + cfg.protocol = protocol; + } + }, + "folder_path" => cfg.folder_path = value.to_owned(), + "webdav_url" => cfg.webdav_url = value.to_owned(), + "webdav_username" => cfg.webdav_username = value.to_owned(), + "s3_endpoint" => cfg.s3_endpoint = value.to_owned(), + "s3_region" => cfg.s3_region = value.to_owned(), + "s3_bucket" => cfg.s3_bucket = value.to_owned(), + "s3_access_key" => cfg.s3_access_key = value.to_owned(), + "sftp_destination" => cfg.sftp_destination = value.to_owned(), + "sftp_path" => cfg.sftp_path = value.to_owned(), + "allow_http" => cfg.allow_http = matches!(value, "1" | "true" | "on"), + _ => {}, + } + } + cfg + } + + /// 按当前协议读第 `index` 个非密文槽位的值(密文槽返回 None——它从 + /// 不驻留配置,占位文案由凭据存在性决定)。 + pub fn slot(&self, index: usize) -> Option<&str> { + match (self.protocol, index) { + (BackupProtocol::Folder, 0) => Some(&self.folder_path), + (BackupProtocol::WebDav, 0) => Some(&self.webdav_url), + (BackupProtocol::WebDav, 1) => Some(&self.webdav_username), + (BackupProtocol::S3, 0) => Some(&self.s3_endpoint), + (BackupProtocol::S3, 1) => Some(&self.s3_region), + (BackupProtocol::S3, 2) => Some(&self.s3_bucket), + (BackupProtocol::S3, 3) => Some(&self.s3_access_key), + (BackupProtocol::Sftp, 0) => Some(&self.sftp_destination), + (BackupProtocol::Sftp, 1) => Some(&self.sftp_path), + _ => None, + } + } + + /// [`Self::slot`] 的写侧;写不进(密文槽/越界)返回 false。 + pub fn set_slot(&mut self, index: usize, value: String) -> bool { + let target = match (self.protocol, index) { + (BackupProtocol::Folder, 0) => &mut self.folder_path, + (BackupProtocol::WebDav, 0) => &mut self.webdav_url, + (BackupProtocol::WebDav, 1) => &mut self.webdav_username, + (BackupProtocol::S3, 0) => &mut self.s3_endpoint, + (BackupProtocol::S3, 1) => &mut self.s3_region, + (BackupProtocol::S3, 2) => &mut self.s3_bucket, + (BackupProtocol::S3, 3) => &mut self.s3_access_key, + (BackupProtocol::Sftp, 0) => &mut self.sftp_destination, + (BackupProtocol::Sftp, 1) => &mut self.sftp_path, + _ => return false, + }; + *target = value; + true + } + + pub fn save(&self) -> Result<(), String> { + let text = format!( + "protocol={}\nfolder_path={}\nwebdav_url={}\nwebdav_username={}\ns3_endpoint={}\ns3_region={}\ns3_bucket={}\ns3_access_key={}\nsftp_destination={}\nsftp_path={}\nallow_http={}\n", + self.protocol.settings_value(), + self.folder_path.trim(), + self.webdav_url.trim(), + self.webdav_username.trim(), + self.s3_endpoint.trim(), + self.s3_region.trim(), + self.s3_bucket.trim(), + self.s3_access_key.trim(), + self.sftp_destination.trim(), + self.sftp_path.trim(), + self.allow_http as u8, + ); + std::fs::write(config_path(), text).map_err(|err| format!("写入备份配置失败:{err}")) + } +} + +// ---- 凭据(Windows 凭据管理器;其余平台用环境变量) ---- + +pub fn has_webdav_password() -> bool { + webdav_password().is_some() +} + +pub fn has_s3_secret() -> bool { + s3_secret().is_some() +} + +#[cfg(windows)] +pub fn store_webdav_password(username: &str, secret: &str) -> Result<(), String> { + crate::ssh_credentials::windows_store::save_secret( + WEBDAV_PASSWORD_TARGET, + username, + secret.trim().as_bytes(), + ) + .map_err(|err| format!("保存到凭据管理器失败:{err}")) +} + +#[cfg(windows)] +pub fn store_s3_secret(access_key: &str, secret: &str) -> Result<(), String> { + crate::ssh_credentials::windows_store::save_secret( + S3_SECRET_TARGET, + access_key, + secret.trim().as_bytes(), + ) + .map_err(|err| format!("保存到凭据管理器失败:{err}")) +} + +#[cfg(not(windows))] +pub fn store_webdav_password(_username: &str, _secret: &str) -> Result<(), String> { + Err("此平台请设环境变量 NEBULA_BACKUP_WEBDAV_PASSWORD".to_owned()) +} + +#[cfg(not(windows))] +pub fn store_s3_secret(_access_key: &str, _secret: &str) -> Result<(), String> { + Err("此平台请设环境变量 NEBULA_BACKUP_S3_SECRET".to_owned()) +} + +fn webdav_password() -> Option { + if let Ok(password) = std::env::var("NEBULA_BACKUP_WEBDAV_PASSWORD") { + if !password.trim().is_empty() { + return Some(password.trim().to_owned()); + } + } + #[cfg(windows)] + if let Ok(Some(secret)) = + crate::ssh_credentials::windows_store::load_secret(WEBDAV_PASSWORD_TARGET) + { + return String::from_utf8(secret).ok(); + } + None +} + +fn s3_secret() -> Option { + if let Ok(secret) = std::env::var("NEBULA_BACKUP_S3_SECRET") { + if !secret.trim().is_empty() { + return Some(secret.trim().to_owned()); + } + } + #[cfg(windows)] + if let Ok(Some(secret)) = crate::ssh_credentials::windows_store::load_secret(S3_SECRET_TARGET) { + return String::from_utf8(secret).ok(); + } + None +} + +/// 当前协议的密文字段是否已经有可用凭据(设置页占位文案用)。 +pub fn protocol_secret_set(protocol: BackupProtocol) -> bool { + match protocol { + BackupProtocol::WebDav => has_webdav_password(), + BackupProtocol::S3 => has_s3_secret(), + _ => true, + } +} + +// ---- 归档命名 ---- + +fn now_unix() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +/// Unix 秒 → UTC (年, 月, 日, 时, 分, 秒)。Howard Hinnant 的 civil_from_days, +/// 不引日期库——备份名只要可排序、可读即可。 +fn utc_parts(secs: u64) -> (i64, u32, u32, u32, u32, u32) { + let days = (secs / 86_400) as i64; + let rem = secs % 86_400; + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + ( + year, + month as u32, + day as u32, + (rem / 3_600) as u32, + (rem % 3_600 / 60) as u32, + (rem % 60) as u32, + ) +} + +fn archive_name(secs: u64) -> String { + let (year, month, day, hour, minute, second) = utc_parts(secs); + format!("{ARCHIVE_PREFIX}{year:04}{month:02}{day:02}-{hour:02}{minute:02}{second:02}{ARCHIVE_SUFFIX}") +} + +/// 只认我们自己的归档名(前后缀精确匹配)。列表、恢复与清理共用这一道 +/// 过滤,远端目录里的其他文件对备份管道不可见。 +fn is_archive_name(name: &str) -> bool { + name.starts_with(ARCHIVE_PREFIX) + && name.ends_with(ARCHIVE_SUFFIX) + && name.len() > ARCHIVE_PREFIX.len() + ARCHIVE_SUFFIX.len() +} + +// ---- 协议后端抽象 ---- + +/// 一个远端目的地的最小动词集。列表只返回归档名(时间戳命名让字典序 = +/// 时间序,新旧判断不依赖各协议五花八门的 mtime 语义)。 +trait Backend { + fn put(&self, name: &str, bytes: &[u8]) -> Result<(), String>; + fn get(&self, name: &str) -> Result, String>; + fn list(&self) -> Result, String>; + fn delete(&self, name: &str) -> Result<(), String>; + /// 状态行里的目的地描述(不含凭据)。 + fn describe(&self) -> String; +} + +/// 校验配置完整性并组装后端。缺什么直接说清楚缺什么,绝不半配置上路。 +fn backend(cfg: &BackupRemoteConfig) -> Result, String> { + match cfg.protocol { + BackupProtocol::Off => Err("未启用远程备份:先在设置 → 备份里选择协议".to_owned()), + BackupProtocol::Folder => { + let path = cfg.folder_path.trim(); + if path.is_empty() { + return Err("未配置备份目录路径".to_owned()); + } + Ok(Box::new(FolderBackend { root: PathBuf::from(path) })) + }, + BackupProtocol::WebDav => { + let url = cfg.webdav_url.trim().trim_end_matches('/').to_owned(); + if url.is_empty() { + return Err("未配置 WebDAV 目录 URL".to_owned()); + } + if !url.starts_with("https://") && !cfg.allow_http { + return Err( + "已拒绝:WebDAV URL 不是 HTTPS(自建内网服务可在 nebula_backup.txt 写 allow_http=1 豁免)" + .to_owned(), + ); + } + let username = cfg.webdav_username.trim().to_owned(); + if username.is_empty() { + return Err("未配置 WebDAV 用户名".to_owned()); + } + let password = webdav_password().ok_or("缺少 WebDAV 密码:在设置里输入或设 NEBULA_BACKUP_WEBDAV_PASSWORD")?; + Ok(Box::new(WebDavBackend { url, username, password })) + }, + BackupProtocol::S3 => { + let endpoint = cfg.s3_endpoint.trim().trim_end_matches('/').to_owned(); + if endpoint.is_empty() { + return Err("未配置 S3 Endpoint".to_owned()); + } + if !endpoint.starts_with("https://") && !cfg.allow_http { + return Err( + "已拒绝:S3 Endpoint 不是 HTTPS(自建内网服务可在 nebula_backup.txt 写 allow_http=1 豁免)" + .to_owned(), + ); + } + let region = cfg.s3_region.trim().to_owned(); + if region.is_empty() { + return Err("未配置 S3 区域(MinIO/R2 可写 us-east-1 / auto)".to_owned()); + } + let raw = cfg.s3_bucket.trim().trim_matches('/'); + let (bucket, prefix) = match raw.split_once('/') { + Some((bucket, prefix)) => (bucket.to_owned(), prefix.trim_matches('/').to_owned()), + None => (raw.to_owned(), String::new()), + }; + if bucket.is_empty() { + return Err("未配置 S3 存储桶".to_owned()); + } + let access_key = cfg.s3_access_key.trim().to_owned(); + if access_key.is_empty() { + return Err("未配置 S3 Access Key".to_owned()); + } + let secret_key = + s3_secret().ok_or("缺少 S3 Secret Key:在设置里输入或设 NEBULA_BACKUP_S3_SECRET")?; + Ok(Box::new(S3Backend { endpoint, region, bucket, prefix, access_key, secret_key })) + }, + BackupProtocol::Sftp => { + let destination = cfg.sftp_destination.trim().to_owned(); + if destination.is_empty() { + return Err("未配置 SFTP 目标(user@host[:port],认证复用 SSH 主机配置)".to_owned()); + } + let mut path = cfg.sftp_path.trim().trim_end_matches('/').to_owned(); + if path.is_empty() { + path = ".".to_owned(); + } + Ok(Box::new(SftpBackend { destination, path })) + }, + } +} + +/// 点击「备份到远程 / 从远程恢复」时的先行校验:配置齐全性与凭据存在性, +/// 不碰网络。错误文案与真正执行时一致,用户在输口令前就能看到缺什么。 +pub fn validate() -> Result<(), String> { + backend(&BackupRemoteConfig::load()).map(|_| ()) +} + +// ---- 入口:推送 / 取回 ---- + +/// 上传一份新归档并尽力清理超出保留数的旧份。阻塞,跑在后台线程。 +pub fn push(packet: &[u8]) -> Result { + let cfg = BackupRemoteConfig::load(); + let backend = backend(&cfg)?; + let name = archive_name(now_unix()); + backend.put(&name, packet)?; + let mut message = + format!("已备份到{}({name},{} KB)", backend.describe(), packet.len().div_ceil(1024)); + // 清理是尽力而为:上传已经成功,旧份删不掉只降级为日志加一句提示。 + match prune(backend.as_ref()) { + Ok(kept) if kept > 1 => message.push_str(&format!(",远端保留 {kept} 份")), + Ok(_) => {}, + Err(err) => { + warn!("backup prune: {err}"); + message.push_str(";旧份清理失败(详见日志)"); + }, + } + Ok(message) +} + +/// 取回远端最新一份归档:`(归档名, 密文字节)`。阻塞,跑在后台线程。 +pub fn pull_latest() -> Result<(String, Vec), String> { + let cfg = BackupRemoteConfig::load(); + let backend = backend(&cfg)?; + let mut names: Vec = + backend.list()?.into_iter().filter(|name| is_archive_name(name)).collect(); + names.sort(); + let Some(latest) = names.pop() else { + return Err(format!("{}上没有 Nebula 备份归档", backend.describe())); + }; + let bytes = backend.get(&latest)?; + Ok((latest, bytes)) +} + +/// 删除超出 [`KEEP_ARCHIVES`] 的最旧归档,返回清理后的份数。 +fn prune(backend: &dyn Backend) -> Result { + let mut names: Vec = + backend.list()?.into_iter().filter(|name| is_archive_name(name)).collect(); + names.sort(); + let excess = names.len().saturating_sub(KEEP_ARCHIVES); + for name in &names[..excess] { + backend.delete(name)?; + } + Ok(names.len() - excess) +} + +// ---- 后端:本地/网络目录 ---- + +struct FolderBackend { + root: PathBuf, +} + +impl Backend for FolderBackend { + fn put(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + std::fs::create_dir_all(&self.root).map_err(|err| format!("创建备份目录失败:{err}"))?; + crate::atomic_file::write(&self.root.join(name), bytes) + .map_err(|err| format!("写入备份失败:{err}")) + } + + fn get(&self, name: &str) -> Result, String> { + std::fs::read(self.root.join(name)).map_err(|err| format!("读取备份失败:{err}")) + } + + fn list(&self) -> Result, String> { + let entries = match std::fs::read_dir(&self.root) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(format!("读取备份目录失败:{err}")), + }; + let mut names = Vec::new(); + for entry in entries { + let entry = entry.map_err(|err| format!("读取备份目录失败:{err}"))?; + if entry.file_type().map(|kind| kind.is_file()).unwrap_or(false) { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + } + Ok(names) + } + + fn delete(&self, name: &str) -> Result<(), String> { + std::fs::remove_file(self.root.join(name)).map_err(|err| format!("删除旧备份失败:{err}")) + } + + fn describe(&self) -> String { + format!("目录 {}", self.root.display()) + } +} + +// ---- 后端:WebDAV ---- + +struct WebDavBackend { + /// 目录 URL,已去尾部斜杠。 + url: String, + username: String, + password: String, +} + +fn http_agent() -> ureq::Agent { + ureq::config::Config::builder() + .timeout_global(Some(Duration::from_secs(60))) + .http_status_as_error(false) + .build() + .new_agent() +} + +impl WebDavBackend { + fn auth(&self) -> String { + use base64::Engine as _; + let raw = format!("{}:{}", self.username, self.password); + format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(raw)) + } + + fn file_url(&self, name: &str) -> String { + format!("{}/{name}", self.url) + } + + fn put_once(&self, name: &str, bytes: &[u8]) -> Result { + let response = http_agent() + .put(&self.file_url(name)) + .header("Authorization", &self.auth()) + .send(bytes) + .map_err(|err| format!("上传失败:{err}"))?; + Ok(response.status().as_u16()) + } + + /// 目录不存在时的一次性补救。MKCOL 只建最后一级——多级路径请先在 + /// 服务端建好(和 Netcatty 的 ensureDirectory 语义一致)。 + fn mkcol(&self) -> Result<(), String> { + let request = ureq::http::Request::builder() + .method("MKCOL") + .uri(&self.url) + .header("Authorization", self.auth()) + .body(Vec::new()) + .map_err(|err| format!("构造 MKCOL 请求失败:{err}"))?; + let response = + http_agent().run(request).map_err(|err| format!("创建远端目录失败:{err}"))?; + match response.status().as_u16() { + // 405 = 目录已存在(Method Not Allowed on existing collection)。 + 200 | 201 | 405 => Ok(()), + 401 | 403 => Err("认证失败:检查 WebDAV 用户名/密码".to_owned()), + status => Err(format!("创建远端目录失败:HTTP {status}")), + } + } +} + +impl Backend for WebDavBackend { + fn put(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + let status = self.put_once(name, bytes)?; + let status = match status { + // 目标目录不存在时多数服务器回 404/409;建目录后重试一次。 + 404 | 409 => { + self.mkcol()?; + self.put_once(name, bytes)? + }, + other => other, + }; + match status { + 200 | 201 | 204 => Ok(()), + 401 | 403 => Err("认证失败:检查 WebDAV 用户名/密码".to_owned()), + other => Err(format!("上传失败:HTTP {other}")), + } + } + + fn get(&self, name: &str) -> Result, String> { + let mut response = http_agent() + .get(&self.file_url(name)) + .header("Authorization", &self.auth()) + .call() + .map_err(|err| format!("下载失败:{err}"))?; + match response.status().as_u16() { + 200 => response.body_mut().read_to_vec().map_err(|err| format!("读取远端失败:{err}")), + 401 | 403 => Err("认证失败:检查 WebDAV 用户名/密码".to_owned()), + status => Err(format!("下载失败:HTTP {status}")), + } + } + + fn list(&self) -> Result, String> { + let request = ureq::http::Request::builder() + .method("PROPFIND") + .uri(format!("{}/", self.url)) + .header("Authorization", self.auth()) + .header("Depth", "1") + .header("Content-Type", "application/xml") + .body(r#""#.to_owned()) + .map_err(|err| format!("构造列表请求失败:{err}"))?; + let mut response = + http_agent().run(request).map_err(|err| format!("列出远端失败:{err}"))?; + match response.status().as_u16() { + 207 | 200 => {}, + // 目录还不存在 = 还没有任何备份。 + 404 => return Ok(Vec::new()), + 401 | 403 => return Err("认证失败:检查 WebDAV 用户名/密码".to_owned()), + status => return Err(format!("列出远端失败:HTTP {status}")), + } + let body = + response.body_mut().read_to_vec().map_err(|err| format!("读取列表失败:{err}"))?; + Ok(propfind_archive_names(&String::from_utf8_lossy(&body))) + } + + fn delete(&self, name: &str) -> Result<(), String> { + let request = ureq::http::Request::builder() + .method("DELETE") + .uri(&self.file_url(name)) + .header("Authorization", self.auth()) + .body(Vec::new()) + .map_err(|err| format!("构造删除请求失败:{err}"))?; + let response = + http_agent().run(request).map_err(|err| format!("删除旧备份失败:{err}"))?; + match response.status().as_u16() { + 200 | 204 | 404 => Ok(()), + status => Err(format!("删除旧备份失败:HTTP {status}")), + } + } + + fn describe(&self) -> String { + format!("WebDAV {}", self.url) + } +} + +/// 从 PROPFIND 的多状态 XML 里挑出我们的归档名。不引 XML 解析器:只认 +/// `…href>… Vec { + let mut names = Vec::new(); + let mut rest = xml; + while let Some(open) = rest.find("href>") { + rest = &rest[open + "href>".len()..]; + let Some(close) = rest.find('<') else { break }; + let href = rest[..close].trim().trim_end_matches('/'); + let name = href.rsplit('/').next().unwrap_or_default(); + if is_archive_name(name) && !names.iter().any(|seen| seen == name) { + names.push(name.to_owned()); + } + rest = &rest[close..]; + } + names +} + +// ---- 后端:S3 兼容(SigV4) ---- + +struct S3Backend { + /// `https://host[:port]`,已去尾部斜杠。 + endpoint: String, + region: String, + bucket: String, + /// 可为空;非空时不带首尾斜杠。 + prefix: String, + access_key: String, + secret_key: String, +} + +impl S3Backend { + fn host(&self) -> String { + let after_scheme = + self.endpoint.split_once("://").map(|(_, rest)| rest).unwrap_or(&self.endpoint); + let host = after_scheme.split('/').next().unwrap_or(after_scheme); + // 默认端口按惯例省略;显式写默认端口会让签名的 host 与 ureq 实际 + // 发送的 Host 头不一致,SignatureDoesNotMatch 且极难排查。 + let default_port = if self.endpoint.starts_with("http://") { ":80" } else { ":443" }; + host.strip_suffix(default_port).unwrap_or(host).to_owned() + } + + fn key(&self, name: &str) -> String { + if self.prefix.is_empty() { name.to_owned() } else { format!("{}/{name}", self.prefix) } + } + + /// 路径式 object 路径:`/bucket/key`(对 MinIO/R2/自建最不挑剔)。 + fn object_path(&self, name: &str) -> String { + format!("/{}/{}", self.bucket, self.key(name)) + } + + fn request( + &self, + method: &str, + path: &str, + query: &[(&str, &str)], + body: &[u8], + ) -> Result<(u16, Vec), String> { + let amz_date = sigv4_timestamp(now_unix()); + let payload_hash = sha256_hex(body); + let authorization = sigv4_authorization( + method, + &self.host(), + path, + query, + &payload_hash, + &amz_date, + &self.region, + &self.access_key, + &self.secret_key, + ); + let canonical_query = sigv4_canonical_query(query); + let url = if canonical_query.is_empty() { + format!("{}{}", self.endpoint, sigv4_encode_path(path)) + } else { + format!("{}{}?{canonical_query}", self.endpoint, sigv4_encode_path(path)) + }; + let request = ureq::http::Request::builder() + .method(method) + .uri(&url) + .header("Authorization", authorization) + .header("x-amz-date", &amz_date) + .header("x-amz-content-sha256", &payload_hash) + .body(body.to_vec()) + .map_err(|err| format!("构造 S3 请求失败:{err}"))?; + let mut response = http_agent().run(request).map_err(|err| format!("连接失败:{err}"))?; + let status = response.status().as_u16(); + let bytes = + response.body_mut().read_to_vec().map_err(|err| format!("读取响应失败:{err}"))?; + Ok((status, bytes)) + } + + fn explain(status: u16, body: &[u8]) -> String { + // S3 的错误正文是小段 XML;只挑 给人看,避免整包倾倒。 + let text = String::from_utf8_lossy(body); + let code = text + .split_once("") + .and_then(|(_, rest)| rest.split_once("")) + .map(|(code, _)| code.trim().to_owned()); + match code { + Some(code) if !code.is_empty() => format!("HTTP {status}({code})"), + _ => format!("HTTP {status}"), + } + } +} + +impl Backend for S3Backend { + fn put(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + let (status, body) = self.request("PUT", &self.object_path(name), &[], bytes)?; + match status { + 200 => Ok(()), + 403 => Err("认证失败:检查 S3 Access Key / Secret Key / 区域".to_owned()), + _ => Err(format!("上传失败:{}", Self::explain(status, &body))), + } + } + + fn get(&self, name: &str) -> Result, String> { + let (status, body) = self.request("GET", &self.object_path(name), &[], &[])?; + match status { + 200 => Ok(body), + 403 => Err("认证失败:检查 S3 Access Key / Secret Key / 区域".to_owned()), + _ => Err(format!("下载失败:{}", Self::explain(status, &body))), + } + } + + fn list(&self) -> Result, String> { + let prefix = self.key(ARCHIVE_PREFIX); + let path = format!("/{}/", self.bucket); + let (status, body) = + self.request("GET", &path, &[("list-type", "2"), ("prefix", &prefix)], &[])?; + match status { + 200 => {}, + 403 => return Err("认证失败:检查 S3 Access Key / Secret Key / 区域".to_owned()), + _ => return Err(format!("列出远端失败:{}", Self::explain(status, &body))), + } + let text = String::from_utf8_lossy(&body); + let mut names = Vec::new(); + let mut rest: &str = &text; + while let Some(open) = rest.find("") { + rest = &rest[open + "".len()..]; + let Some(close) = rest.find("") else { break }; + let key = &rest[..close]; + let name = key.rsplit('/').next().unwrap_or_default(); + if is_archive_name(name) { + names.push(name.to_owned()); + } + rest = &rest[close..]; + } + Ok(names) + } + + fn delete(&self, name: &str) -> Result<(), String> { + let (status, body) = self.request("DELETE", &self.object_path(name), &[], &[])?; + match status { + 200 | 204 => Ok(()), + _ => Err(format!("删除旧备份失败:{}", Self::explain(status, &body))), + } + } + + fn describe(&self) -> String { + format!("S3 {}/{}", self.host(), self.bucket) + } +} + +// ---- SigV4 签名(S3 专用最小实现) ---- + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::Digest as _; + let digest = sha2::Sha256::digest(bytes); + digest.iter().fold(String::with_capacity(64), |mut out, byte| { + out.push_str(&format!("{byte:02x}")); + out + }) +} + +fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { + use hmac::{Hmac, KeyInit as _, Mac as _}; + let mut mac = Hmac::::new_from_slice(key).expect("HMAC 接受任意长度密钥"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +fn sigv4_timestamp(secs: u64) -> String { + let (year, month, day, hour, minute, second) = utc_parts(secs); + format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z") +} + +/// AWS 的 uri-encode:未保留字符原样,其余 %XX 大写;`encode_slash=false` +/// 时保留路径分隔符(object key 用)。 +fn sigv4_uri_encode(text: &str, encode_slash: bool) -> String { + let mut out = String::with_capacity(text.len()); + for byte in text.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(byte as char) + }, + b'/' if !encode_slash => out.push('/'), + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +fn sigv4_encode_path(path: &str) -> String { + sigv4_uri_encode(path, false) +} + +fn sigv4_canonical_query(query: &[(&str, &str)]) -> String { + let mut pairs: Vec<(String, String)> = query + .iter() + .map(|(key, value)| (sigv4_uri_encode(key, true), sigv4_uri_encode(value, true))) + .collect(); + pairs.sort(); + pairs + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("&") +} + +/// 组装 Authorization 头。签名头固定为 host + x-amz-content-sha256 + +/// x-amz-date 三件套——正好是我们每个请求发出的全部自定义头。 +#[allow(clippy::too_many_arguments)] +fn sigv4_authorization( + method: &str, + host: &str, + path: &str, + query: &[(&str, &str)], + payload_hash: &str, + amz_date: &str, + region: &str, + access_key: &str, + secret_key: &str, +) -> String { + let date = &amz_date[..8]; + let canonical_request = format!( + "{method}\n{}\n{}\nhost:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n\nhost;x-amz-content-sha256;x-amz-date\n{payload_hash}", + sigv4_encode_path(path), + sigv4_canonical_query(query), + ); + let scope = format!("{date}/{region}/s3/aws4_request"); + let string_to_sign = + format!("AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", sha256_hex(canonical_request.as_bytes())); + let key = hmac_sha256(format!("AWS4{secret_key}").as_bytes(), date.as_bytes()); + let key = hmac_sha256(&key, region.as_bytes()); + let key = hmac_sha256(&key, b"s3"); + let key = hmac_sha256(&key, b"aws4_request"); + let signature = hmac_sha256(&key, string_to_sign.as_bytes()) + .iter() + .fold(String::with_capacity(64), |mut out, byte| { + out.push_str(&format!("{byte:02x}")); + out + }); + format!( + "AWS4-HMAC-SHA256 Credential={access_key}/{scope}, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}" + ) +} + +// ---- 后端:SFTP(复用 SSH 会话栈) ---- + +struct SftpBackend { + destination: String, + path: String, +} + +impl SftpBackend { + fn remote_file(&self, name: &str) -> String { + format!("{}/{name}", self.path) + } + + /// 在共享 SSH runtime 上阻塞执行一段 SFTP 作业。调用方已在后台 OS + /// 线程,block_on 不会碰 UI 线程。 + fn run(&self, job: F) -> Result + where + F: FnOnce(russh_sftp::client::SftpSession) -> Fut, + Fut: std::future::Future>, + { + let runtime = + crate::ssh_session::runtime().map_err(|err| format!("SSH 运行时不可用:{err}"))?; + let destination = self.destination.clone(); + runtime.block_on(async move { + let sftp = crate::ssh_session::open_sftp(&destination) + .await + .map_err(|err| format!("SFTP 连接失败:{err}"))?; + job(sftp).await + }) + } +} + +impl Backend for SftpBackend { + fn put(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + use russh_sftp::protocol::OpenFlags; + use tokio::io::AsyncWriteExt as _; + let path = self.path.clone(); + let remote = self.remote_file(name); + self.run(|sftp| async move { + // 目录已存在时 create_dir 失败是常态,忽略;真正的问题会在 + // 打开文件时以更准确的错误暴露。 + let _ = sftp.create_dir(path).await; + let mut file = sftp + .open_with_flags(remote, OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE) + .await + .map_err(|err| format!("打开远端文件失败:{err}"))?; + file.write_all(&bytes).await.map_err(|err| format!("写入失败:{err}"))?; + file.shutdown().await.map_err(|err| format!("写入收尾失败:{err}"))?; + Ok(()) + }) + } + + fn get(&self, name: &str) -> Result, String> { + use tokio::io::AsyncReadExt as _; + let remote = self.remote_file(name); + self.run(|sftp| async move { + let mut file = + sftp.open(remote).await.map_err(|err| format!("打开远端文件失败:{err}"))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).await.map_err(|err| format!("读取失败:{err}"))?; + Ok(bytes) + }) + } + + fn list(&self) -> Result, String> { + let path = self.path.clone(); + self.run(|sftp| async move { + let entries = match sftp.read_dir(path).await { + Ok(entries) => entries, + // 目录还不存在 = 还没有任何备份。 + Err(_) => return Ok(Vec::new()), + }; + Ok(entries.into_iter().map(|entry| entry.file_name()).collect()) + }) + } + + fn delete(&self, name: &str) -> Result<(), String> { + let remote = self.remote_file(name); + self.run(|sftp| async move { + sftp.remove_file(remote).await.map_err(|err| format!("删除旧备份失败:{err}")) + }) + } + + fn describe(&self) -> String { + format!("SFTP {}:{}", self.destination, self.path) + } +} + +pub fn warn_result(result: &Result) { + if let Err(err) = result { + warn!("backup remote: {err}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn archive_names_are_utc_stamped_and_sort_chronologically() { + // 2026-08-13 05:19:00 UTC。 + assert_eq!(archive_name(1_786_598_340), "nebula-backup-20260813-051900.nbk"); + // Unix 纪元与闰日。 + assert_eq!(archive_name(0), "nebula-backup-19700101-000000.nbk"); + assert_eq!(archive_name(1_582_934_400), "nebula-backup-20200229-000000.nbk"); + let older = archive_name(1_700_000_000); + let newer = archive_name(1_800_000_000); + assert!(newer > older, "时间戳命名必须让字典序等于时间序"); + } + + #[test] + fn archive_filter_rejects_foreign_files() { + assert!(is_archive_name("nebula-backup-20260813-051900.nbk")); + assert!(!is_archive_name("nebula-backup-.nbk")); + assert!(!is_archive_name("photo.png")); + assert!(!is_archive_name("nebula-backup-20260813-051900.nbk.tmp")); + assert!(!is_archive_name("other-backup-20260813.nbk")); + } + + #[test] + fn config_round_trips_every_protocol_field() { + let cfg = BackupRemoteConfig { + protocol: BackupProtocol::S3, + folder_path: r"\\nas\share\nebula".into(), + webdav_url: "https://dav.example.com/nebula".into(), + webdav_username: "user".into(), + s3_endpoint: "https://s3.us-east-1.amazonaws.com".into(), + s3_region: "us-east-1".into(), + s3_bucket: "bucket/prefix".into(), + s3_access_key: "AKIDEXAMPLE".into(), + sftp_destination: "dev@10.0.0.8:2222".into(), + sftp_path: "/home/dev/backups".into(), + allow_http: true, + }; + let text = format!( + "protocol={}\nfolder_path={}\nwebdav_url={}\nwebdav_username={}\ns3_endpoint={}\ns3_region={}\ns3_bucket={}\ns3_access_key={}\nsftp_destination={}\nsftp_path={}\nallow_http=1\n", + cfg.protocol.settings_value(), + cfg.folder_path, + cfg.webdav_url, + cfg.webdav_username, + cfg.s3_endpoint, + cfg.s3_region, + cfg.s3_bucket, + cfg.s3_access_key, + cfg.sftp_destination, + cfg.sftp_path, + ); + assert_eq!(BackupRemoteConfig::parse(&text), cfg); + // 未知键与坏行安静跳过。 + assert_eq!(BackupRemoteConfig::parse("garbage\nunknown=1\n"), BackupRemoteConfig::default()); + } + + #[test] + fn sigv4_matches_aws_documented_lifecycle_example() { + // AWS 官方示例(s3 REST API 签名文档 GET Bucket Lifecycle): + // 空载荷、仅 host/x-amz-content-sha256/x-amz-date 三个签名头。 + let authorization = sigv4_authorization( + "GET", + "examplebucket.s3.amazonaws.com", + "/", + &[("lifecycle", "")], + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "20130524T000000Z", + "us-east-1", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ); + assert_eq!( + authorization, + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543" + ); + } + + #[test] + fn sigv4_encoding_follows_aws_rules() { + assert_eq!(sigv4_uri_encode("a b/c~d", false), "a%20b/c~d"); + assert_eq!(sigv4_uri_encode("a b/c~d", true), "a%20b%2Fc~d"); + // 查询串键排序 + 空值保留等号。 + assert_eq!( + sigv4_canonical_query(&[("prefix", "nebula-backup-"), ("list-type", "2")]), + "list-type=2&prefix=nebula-backup-" + ); + assert_eq!(sigv4_canonical_query(&[("lifecycle", "")]), "lifecycle="); + assert_eq!(sigv4_timestamp(1_369_353_600), "20130524T000000Z"); + } + + #[test] + fn propfind_scan_extracts_only_our_archives() { + let xml = r#" + + /dav/nebula/ + /dav/nebula/nebula-backup-20260813-051900.nbk + /dav/nebula/nebula-backup-20260812-231000.nbk + /dav/nebula/notes.txt + /dav/nebula/nebula-backup-20260812-231000.nbk +"#; + assert_eq!( + propfind_archive_names(xml), + vec![ + "nebula-backup-20260813-051900.nbk".to_owned(), + "nebula-backup-20260812-231000.nbk".to_owned(), + ] + ); + } + + #[test] + fn s3_host_strips_default_port_and_splits_bucket_prefix() { + let backend = S3Backend { + endpoint: "https://minio.lan:9000".into(), + region: "us-east-1".into(), + bucket: "nebula".into(), + prefix: "backups".into(), + access_key: "ak".into(), + secret_key: "sk".into(), + }; + assert_eq!(backend.host(), "minio.lan:9000"); + assert_eq!(backend.object_path("a.nbk"), "/nebula/backups/a.nbk"); + let default_port = S3Backend { endpoint: "https://s3.example.com:443".into(), ..backend }; + assert_eq!(default_port.host(), "s3.example.com"); + } + + /// 内存后端专测 prune 的排序与保留语义。 + struct MemoryBackend(std::cell::RefCell>); + impl Backend for MemoryBackend { + fn put(&self, name: &str, _bytes: &[u8]) -> Result<(), String> { + self.0.borrow_mut().push(name.to_owned()); + Ok(()) + } + fn get(&self, _name: &str) -> Result, String> { + Ok(Vec::new()) + } + fn list(&self) -> Result, String> { + Ok(self.0.borrow().clone()) + } + fn delete(&self, name: &str) -> Result<(), String> { + self.0.borrow_mut().retain(|kept| kept != name); + Ok(()) + } + fn describe(&self) -> String { + "memory".to_owned() + } + } + + #[test] + fn prune_keeps_newest_archives_and_ignores_foreign_files() { + let names: Vec = (0..13) + .map(|hour| format!("nebula-backup-202608{:02}-000000.nbk", hour + 1)) + .chain(["keep-me.txt".to_owned()]) + .collect(); + let backend = MemoryBackend(std::cell::RefCell::new(names)); + assert_eq!(prune(&backend).unwrap(), KEEP_ARCHIVES); + let survivors = backend.0.borrow(); + // 13 份归档删到 10 份(删最旧的 01/02/03 号),外来文件不动。 + assert_eq!(survivors.len(), KEEP_ARCHIVES + 1); + assert!(survivors.contains(&"keep-me.txt".to_owned())); + assert!(!survivors.iter().any(|name| name.contains("20260801"))); + assert!(!survivors.iter().any(|name| name.contains("20260803"))); + assert!(survivors.iter().any(|name| name.contains("20260804"))); + assert!(survivors.iter().any(|name| name.contains("20260813"))); + } +} diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 887baa7..5ed80f3 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -128,6 +128,19 @@ pub(crate) use ui::theme::write_nebula_prompt_theme; enum BackupOperation { Export(std::path::PathBuf), Restore(std::path::PathBuf), + /// 远程备份/恢复(协议与目的地在 `nebula_backup.txt`)。口令确认后由 + /// 事件层在后台线程执行——网络绝不进 UI 线程。 + RemotePush, + RemotePull, +} + +/// 口令确认后待执行的远程备份动作,`complete_backup_operation` 返回给 +/// 输入层去分发事件(display 自己够不到 event proxy)。 +#[derive(Debug, Clone)] +pub(crate) struct RemoteBackupRequest { + pub upload: bool, + pub passphrase: String, + pub selection: crate::encrypted_backup::BackupSelection, } /// Shared caret blink phase for the chrome text editors (rename / filter / @@ -1029,6 +1042,14 @@ pub(crate) fn restore_session_enabled() -> bool { .unwrap_or(true) } +/// 托盘图标开关(设置·高级,默认开)。与 [`restore_session_enabled`] 同一 +/// 处境:托盘在建窗之前初始化,只能走原始设置读取。 +pub(crate) fn tray_enabled() -> bool { + nebula_settings_value("tray") + .map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off")) + .unwrap_or(true) +} + #[cfg(windows)] fn nebula_pathexts() -> Vec { std::env::var("PATHEXT") @@ -1681,6 +1702,9 @@ pub struct Display { /// 冷恢复时自动接续各 pane 的 AI 对话(claude/codex resume,T1-2)。 /// 消费方在 `window_context::resume_agent_sessions`。 pub nebula_resume_ai: bool, + /// 系统托盘常驻图标 + agent attention 状态(T1-3)。消费方在 + /// `crate::tray`;这里只是设置页的开关状态。 + pub nebula_tray: bool, /// Runtime window opacity controlled from Nebula settings. pub nebula_window_opacity: f32, /// Which settings combobox (floating option list) is expanded, if any. @@ -1791,6 +1815,19 @@ pub struct Display { nebula_provider_codex_confirm: Option, nebula_backup_selection: crate::encrypted_backup::BackupSelection, pub(crate) nebula_backup_status: Option<(String, bool)>, + /// 最近一次备份状态来自远程动作(true)还是本地导出/恢复(false)—— + /// 状态行画在触发它的那组控件旁边。 + nebula_backup_status_remote: bool, + /// 远程备份协议(`nebula_backup.txt` 的缓存,设置页打开时装载)。 + nebula_backup_protocol: crate::backup_remote::BackupProtocol, + /// 远程备份的 5 个输入槽草稿(语义随协议变化;密文槽只是「待保存」 + /// 缓冲——提交即入凭据管理器并清空,明文从不驻留)。 + nebula_backup_remote_inputs: [String; 5], + pub(crate) nebula_backup_remote_focus: Option, + /// 当前协议的密文凭据是否已在凭据管理器(占位文案用,不回读明文)。 + nebula_backup_remote_secret_set: bool, + /// 远程备份/恢复动作进行中(后台线程),按钮变灰防重复分发。 + nebula_backup_busy: bool, /// 聚焦的 SSH 代理输入框(0=代理地址 1=绕过列表;正文直接编辑 /// `nebula_ssh_proxy_url` / `nebula_ssh_proxy_no_proxy`,失焦提交落盘)。 pub(crate) nebula_ssh_proxy_focus: Option, @@ -2430,6 +2467,7 @@ impl Display { nebula_keep_session: settings_init.keep_session, nebula_restore_session: settings_init.restore_session, nebula_resume_ai: settings_init.resume_ai, + nebula_tray: settings_init.tray, nebula_window_opacity: settings_init.opacity, nebula_background: if settings_init.follow_system_theme { Some(nebula_theme.palette().term_bg) @@ -2461,6 +2499,12 @@ impl Display { nebula_provider_codex_confirm: None, nebula_backup_selection: crate::encrypted_backup::BackupSelection::default(), nebula_backup_status: None, + nebula_backup_status_remote: false, + nebula_backup_protocol: Default::default(), + nebula_backup_remote_inputs: Default::default(), + nebula_backup_remote_focus: None, + nebula_backup_remote_secret_set: false, + nebula_backup_busy: false, nebula_ssh_proxy_focus: None, nebula_ssh_proxy_cursor: Default::default(), nebula_ssh_proxy_backup: Default::default(), @@ -3608,6 +3652,7 @@ impl Display { keep_session: self.nebula_keep_session, restore_session: self.nebula_restore_session, resume_ai: self.nebula_resume_ai, + tray: self.nebula_tray, opacity: self.nebula_window_opacity, dragging_opacity: self.nebula_settings_opacity_drag.map(|(target, _, _)| target), cursor_shape: self.nebula_cursor_shape, @@ -3682,6 +3727,12 @@ impl Display { ssh_proxy_overrides: Vec::new(), backup_selection: self.nebula_backup_selection, backup_status: self.nebula_backup_status.clone(), + backup_status_remote: self.nebula_backup_status_remote, + backup_protocol: self.nebula_backup_protocol, + backup_remote_inputs: self.nebula_backup_remote_inputs.clone(), + backup_remote_focus: self.nebula_backup_remote_focus, + backup_remote_secret_set: self.nebula_backup_remote_secret_set, + backup_busy: self.nebula_backup_busy, } } @@ -3707,6 +3758,189 @@ impl Display { self.pending_update.dirty = true; } + // ---- 设置→备份→远程备份 ---- + + /// 当前远程备份协议(命中测试要按它裁剪可见输入行)。 + pub fn backup_protocol(&self) -> crate::backup_remote::BackupProtocol { + self.nebula_backup_protocol + } + + /// 打开设置时装载远程备份状态:协议与非密文字段来自 + /// `nebula_backup.txt`,密文只查存在性(明文不进 UI 状态)。 + pub fn load_backup_remote_state(&mut self) { + let cfg = crate::backup_remote::BackupRemoteConfig::load(); + self.nebula_backup_protocol = cfg.protocol; + self.nebula_backup_remote_inputs = Default::default(); + for (index, input) in self.nebula_backup_remote_inputs.iter_mut().enumerate() { + if let Some(value) = cfg.slot(index) { + *input = value.to_owned(); + } + } + self.nebula_backup_remote_secret_set = + crate::backup_remote::protocol_secret_set(cfg.protocol); + self.nebula_backup_remote_focus = None; + } + + /// 设置页下拉选择远程备份协议:持久化并按新协议重装输入槽。 + pub fn set_backup_protocol_option(&mut self, index: usize) { + let Some(protocol) = settings::BACKUP_PROTOCOL_OPTIONS.get(index).copied() else { return }; + self.commit_backup_remote_field(); + let mut cfg = crate::backup_remote::BackupRemoteConfig::load(); + cfg.protocol = protocol; + if let Err(err) = cfg.save() { + self.nebula_backup_status = Some((err, true)); + self.nebula_backup_status_remote = true; + } + self.load_backup_remote_state(); + self.close_settings_dropdown(); + self.pending_update.dirty = true; + } + + /// 聚焦某个远程备份输入框;先提交上一个(点击切换即失焦保存)。 + pub fn focus_backup_remote_field(&mut self, index: usize) { + if self.nebula_backup_remote_focus == Some(index) { + return; + } + self.commit_backup_remote_field(); + let count = crate::backup_remote::field_count(self.nebula_backup_protocol); + if count == 0 { + return; + } + self.nebula_backup_remote_focus = Some(index.min(count - 1)); + self.pending_update.dirty = true; + } + + pub fn backup_remote_field_push(&mut self, ch: char) { + let Some(index) = self.nebula_backup_remote_focus else { return }; + if ch.is_control() { + return; + } + // 密文槽允许内部空格(trim 在保存侧);其余槽拒绝空白——URL、 + // 路径、区域名里出现空格只会是误粘贴。 + let secret = + crate::backup_remote::secret_field(self.nebula_backup_protocol) == Some(index); + if ch.is_whitespace() && !secret { + return; + } + if self.nebula_backup_remote_inputs[index].chars().count() < 512 { + self.nebula_backup_remote_inputs[index].push(ch); + self.pending_update.dirty = true; + } + } + + pub fn backup_remote_field_paste(&mut self, text: &str) { + for ch in text.chars() { + self.backup_remote_field_push(ch); + } + } + + pub fn backup_remote_field_backspace(&mut self) { + let Some(index) = self.nebula_backup_remote_focus else { return }; + if self.nebula_backup_remote_inputs[index].pop().is_some() { + self.pending_update.dirty = true; + } + } + + /// 失焦提交:普通槽写 `nebula_backup.txt`;密文槽若有输入则存入凭据 + /// 管理器并清空缓冲。 + pub fn commit_backup_remote_field(&mut self) { + let Some(index) = self.nebula_backup_remote_focus.take() else { return }; + self.pending_update.dirty = true; + let protocol = self.nebula_backup_protocol; + if crate::backup_remote::secret_field(protocol) == Some(index) { + let secret = std::mem::take(&mut self.nebula_backup_remote_inputs[index]); + if secret.trim().is_empty() { + return; + } + let result = match protocol { + crate::backup_remote::BackupProtocol::WebDav => { + crate::backup_remote::store_webdav_password( + self.nebula_backup_remote_inputs[1].trim(), + &secret, + ) + }, + crate::backup_remote::BackupProtocol::S3 => { + crate::backup_remote::store_s3_secret( + self.nebula_backup_remote_inputs[3].trim(), + &secret, + ) + }, + _ => return, + }; + match result { + Ok(()) => { + self.nebula_backup_remote_secret_set = true; + }, + Err(err) => { + self.nebula_backup_status = Some((err, true)); + self.nebula_backup_status_remote = true; + }, + } + return; + } + let mut cfg = crate::backup_remote::BackupRemoteConfig::load(); + cfg.protocol = protocol; + if cfg.set_slot(index, self.nebula_backup_remote_inputs[index].trim().to_owned()) { + if let Err(err) = cfg.save() { + self.nebula_backup_status = Some((err, true)); + self.nebula_backup_status_remote = true; + } + } + } + + /// Esc:丢弃当前草稿并失焦(还原为文件值;密文槽清空)。 + pub fn cancel_backup_remote_field(&mut self) { + let Some(index) = self.nebula_backup_remote_focus.take() else { return }; + let cfg = crate::backup_remote::BackupRemoteConfig::load(); + self.nebula_backup_remote_inputs[index] = + cfg.slot(index).map(str::to_owned).unwrap_or_default(); + self.pending_update.dirty = true; + } + + /// 「备份到远程 / 从远程恢复」按钮:先行校验配置,通过则弹口令确认。 + /// 真正的网络动作等口令提交后由事件层在后台线程执行。 + pub fn start_backup_remote(&mut self, upload: bool) { + if self.nebula_backup_busy { + return; + } + self.commit_backup_remote_field(); + self.nebula_backup_status_remote = true; + if upload && self.nebula_backup_selection.is_empty() { + self.nebula_backup_status = Some(( + self.ui_language() + .pick("至少选择一项备份内容", "Select at least one backup item") + .to_owned(), + true, + )); + self.window.request_redraw(); + return; + } + if let Err(err) = crate::backup_remote::validate() { + self.nebula_backup_status = Some((err, true)); + self.window.request_redraw(); + return; + } + self.nebula_backup_operation = Some(if upload { + BackupOperation::RemotePush + } else { + BackupOperation::RemotePull + }); + self.nebula_backup_passphrase.clear(); + self.nebula_backup_passphrase_select_all.clear(); + self.nebula_backup_status = None; + self.nebula_confirm = Some(NebulaConfirm::BackupPassphrase { restoring: !upload }); + self.window.request_redraw(); + } + + /// 后台远程备份线程收尾(`NebulaBackupRemoteDone`)。 + pub fn backup_remote_done(&mut self, message: &str, error: bool) { + self.nebula_backup_busy = false; + self.nebula_backup_status = Some((message.to_owned(), error)); + self.nebula_backup_status_remote = true; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + pub fn start_backup_export(&mut self) { if self.nebula_backup_selection.is_empty() { self.nebula_backup_status = Some(( @@ -3715,6 +3949,7 @@ impl Display { .to_owned(), true, )); + self.nebula_backup_status_remote = false; self.window.request_redraw(); return; } @@ -3775,8 +4010,10 @@ impl Display { self.window.request_redraw(); } - pub fn complete_backup_operation(&mut self) { - let Some(operation) = self.nebula_backup_operation.clone() else { return }; + /// 口令确认。本地导出/恢复同步完成(小文件 + Argon2 一次派生);远程 + /// 动作返回请求,由调用方发事件到后台线程——网络不进 UI 线程。 + pub fn complete_backup_operation(&mut self) -> Option { + let Some(operation) = self.nebula_backup_operation.clone() else { return None }; let passphrase = self.nebula_backup_passphrase.clone(); let result = match operation { BackupOperation::Export(path) => { @@ -3789,7 +4026,47 @@ impl Display { BackupOperation::Restore(path) => std::fs::read(&path) .map_err(|error| error.to_string()) .and_then(|packet| crate::encrypted_backup::restore(&packet, &passphrase)), + BackupOperation::RemotePush | BackupOperation::RemotePull => { + self.nebula_backup_status_remote = true; + if passphrase.chars().count() < 8 { + self.nebula_backup_status = Some(( + self.ui_language() + .pick( + "备份操作失败: 口令至少 8 个字符", + "Backup operation failed: passphrase must be at least 8 characters", + ) + .to_owned(), + true, + )); + self.nebula_backup_passphrase.clear(); + self.nebula_backup_passphrase_select_all.clear(); + self.window.request_redraw(); + return None; + } + let upload = matches!(operation, BackupOperation::RemotePush); + self.nebula_backup_busy = true; + self.nebula_backup_status = Some(( + self.ui_language() + .pick( + if upload { "备份上传中…" } else { "正在取回远端备份…" }, + if upload { "Uploading backup…" } else { "Fetching remote backup…" }, + ) + .to_owned(), + false, + )); + self.nebula_confirm = None; + self.nebula_backup_operation = None; + self.nebula_backup_passphrase.clear(); + self.nebula_backup_passphrase_select_all.clear(); + self.window.request_redraw(); + return Some(RemoteBackupRequest { + upload, + passphrase, + selection: self.nebula_backup_selection, + }); + }, }; + self.nebula_backup_status_remote = false; match result { Ok(()) => { let restoring = matches!( @@ -3831,6 +4108,7 @@ impl Display { }, } self.window.request_redraw(); + None } pub fn cancel_backup_operation(&mut self) { @@ -3860,6 +4138,7 @@ impl Display { self.cancel_backup_operation(); } self.commit_sync_field(); + self.commit_backup_remote_field(); self.nebula_settings_dropdown = None; self.nebula_settings_hover = SettingsHit::None; self.nebula_settings_pressed = SettingsHit::None; @@ -3867,6 +4146,7 @@ impl Display { self.nebula_settings_text_drag = None; } else { self.load_sync_state(); + self.load_backup_remote_state(); // Each explicit visit starts at a predictable page origin. self.nebula_settings_scroll = 0.0; self.nebula_settings_text_drag = None; @@ -7057,9 +7337,19 @@ impl Display { provider.is_some_and(|provider| provider.codex_goals), provider.is_some_and(|provider| provider.codex_remote_compaction), self.nebula_resume_ai, + self.nebula_tray, ] } + /// 高级:「常驻托盘图标」开关。翻转即生效:托盘线程收到 enable/disable + /// 后立刻挂上或摘掉通知区图标。 + pub fn toggle_tray(&mut self) { + self.nebula_tray = !self.nebula_tray; + crate::tray::set_enabled(self.nebula_tray); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + /// 高级·会话:「恢复时接续 AI 对话」开关。 pub fn toggle_resume_ai(&mut self) { self.nebula_resume_ai = !self.nebula_resume_ai; @@ -7511,6 +7801,7 @@ impl Display { keep_session: self.nebula_keep_session, restore_session: self.nebula_restore_session, resume_ai: self.nebula_resume_ai, + tray: self.nebula_tray, opacity: self.nebula_window_opacity, background: self.nebula_background, background_image: self.nebula_background_image.clone(), @@ -7610,6 +7901,10 @@ impl Display { self.nebula_keep_session = settings.keep_session; self.nebula_restore_session = settings.restore_session; self.nebula_resume_ai = settings.resume_ai; + if self.nebula_tray != settings.tray { + self.nebula_tray = settings.tray; + crate::tray::set_enabled(settings.tray); + } self.nebula_panel_resize = settings.panel_resize; // 手改文件把宽度调了的话,和拖拽一样要触发一次 reflow。 let panel_dims_changed = (self.nebula_sidebar_w - settings.sidebar_w).abs() > 0.5 diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index 2d7f3c9..b3783a8 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -41,10 +41,10 @@ use super::{ /// 完成前不向用户暴露入口;集中守门可避免绘制、命中和滚动高度各自遗漏。 const SHOW_WEBDAV_SYNC_SETTINGS: bool = false; -/// 备份页的后端已经可用,但页面先不放进侧栏,等恢复流程的可视化预览和 -/// 冲突策略交互接入后再重新开放。枚举、命中和导出逻辑继续保留,避免隐藏 -/// 入口演变成删除功能。 -const SHOW_BACKUP_SETTINGS: bool = false; +/// 备份页对用户开放(2026-08-13):本地加密导出/恢复此前已完整,本次加上 +/// 多协议远程备份(目录/WebDAV/S3/SFTP,见 `crate::backup_remote`)后闭环 +/// 成立。恢复的可视化预览仍是后续增强,不再作为门禁。 +const SHOW_BACKUP_SETTINGS: bool = true; /// Sidebar sections of the settings panel. Deliberately small: only sections /// with real functionality behind them are listed. @@ -127,6 +127,8 @@ pub enum SettingsDropdown { CompletionStyle, CursorShape, TabReveal, + /// 备份:远程备份协议(关闭/目录/WebDAV/S3/SFTP)。 + BackupProtocol, /// 代理:SSH 连接代理模式(关闭/系统/自定义)。 SshProxyMode, /// 网络→指定代理→手动填写:地址协议(SOCKS5/HTTP)。 @@ -189,6 +191,16 @@ pub(super) const COMPLETION_STYLE_OPTIONS: [CompletionStyle; 2] = pub(super) const TAB_REVEAL_OPTIONS: [TabRevealMotion; 2] = [TabRevealMotion::Slide, TabRevealMotion::Instant]; +/// 远程备份协议下拉的行序。`display` 的 `set_backup_protocol_option` +/// 按同一数组下标持久化,两侧永不错位。 +pub(crate) const BACKUP_PROTOCOL_OPTIONS: [crate::backup_remote::BackupProtocol; 5] = [ + crate::backup_remote::BackupProtocol::Off, + crate::backup_remote::BackupProtocol::Folder, + crate::backup_remote::BackupProtocol::WebDav, + crate::backup_remote::BackupProtocol::S3, + crate::backup_remote::BackupProtocol::Sftp, +]; + /// 下拉行序即为这里的顺序;连接时的真正决策在 `crate::ssh_proxy`。 pub(super) const SSH_PROXY_MODE_OPTIONS: [crate::ssh_proxy::ProxyMode; 3] = [ crate::ssh_proxy::ProxyMode::Off, @@ -499,6 +511,91 @@ fn completion_style_label(style: CompletionStyle, language: UiLanguage) -> &'sta } } +fn backup_protocol_label( + protocol: crate::backup_remote::BackupProtocol, + language: UiLanguage, +) -> &'static str { + use crate::backup_remote::BackupProtocol; + match protocol { + BackupProtocol::Off => language.pick("关闭", "Off"), + BackupProtocol::Folder => language.pick("本地 / 网络目录", "Local / network folder"), + BackupProtocol::WebDav => "WebDAV", + BackupProtocol::S3 => language.pick("S3 兼容存储", "S3-compatible storage"), + BackupProtocol::Sftp => language.pick("SFTP(SSH 主机)", "SFTP (SSH host)"), + } +} + +/// 远程备份输入行的标签(槽位语义随协议变化)。 +fn backup_remote_field_label( + protocol: crate::backup_remote::BackupProtocol, + index: usize, + language: UiLanguage, +) -> &'static str { + use crate::backup_remote::BackupProtocol; + match (protocol, index) { + (BackupProtocol::Folder, 0) => language.pick("备份目录", "Backup folder"), + (BackupProtocol::WebDav, 0) => language.pick("目录 URL", "Directory URL"), + (BackupProtocol::WebDav, 1) => language.pick("用户名", "Username"), + (BackupProtocol::WebDav, 2) => language.pick("WebDAV 密码", "WebDAV password"), + (BackupProtocol::S3, 0) => "Endpoint", + (BackupProtocol::S3, 1) => language.pick("区域", "Region"), + (BackupProtocol::S3, 2) => language.pick("存储桶 / 前缀", "Bucket / prefix"), + (BackupProtocol::S3, 3) => "Access Key", + (BackupProtocol::S3, 4) => "Secret Key", + (BackupProtocol::Sftp, 0) => language.pick("SSH 目标", "SSH destination"), + (BackupProtocol::Sftp, 1) => language.pick("远端目录", "Remote directory"), + _ => "", + } +} + +/// 远程备份输入框的空值占位示例。 +fn backup_remote_field_placeholder( + protocol: crate::backup_remote::BackupProtocol, + index: usize, + language: UiLanguage, +) -> &'static str { + use crate::backup_remote::BackupProtocol; + match (protocol, index) { + (BackupProtocol::Folder, 0) => r"D:\Backups\Nebula 或 \\nas\share\nebula", + (BackupProtocol::WebDav, 0) => "https://dav.example.com/nebula/", + (BackupProtocol::WebDav, 1) => language.pick("WebDAV 用户名", "WebDAV username"), + (BackupProtocol::S3, 0) => "https://s3.us-east-1.amazonaws.com", + (BackupProtocol::S3, 1) => "us-east-1", + (BackupProtocol::S3, 2) => "my-bucket/nebula", + (BackupProtocol::S3, 3) => "AKIA…", + (BackupProtocol::Sftp, 0) => "user@host[:port]", + (BackupProtocol::Sftp, 1) => "/home/user/backups", + _ => language.pick("未设置", "Not set"), + } +} + +/// 远程备份输入框的展示内容:`(文本, 是否占位, 列数)`。密文槽显示为掩码 +/// 点;超宽截尾部(编辑总发生在末尾)。列数供 caret 定位。 +fn backup_remote_input_display( + view: &SettingsView, + index: usize, + max_cols: usize, +) -> (String, bool, usize) { + let language = view.language; + let protocol = view.backup_protocol; + let secret = crate::backup_remote::secret_field(protocol) == Some(index); + let raw = &view.backup_remote_inputs[index]; + if raw.is_empty() { + let text = if secret && view.backup_remote_secret_set { + language.pick("已保存(输入以更换)", "Saved (type to replace)").to_owned() + } else { + backup_remote_field_placeholder(protocol, index, language).to_owned() + }; + return (text, true, 0); + } + if secret { + let dots = raw.chars().count().min(24); + return ("●".repeat(dots), false, dots); + } + let (text, cols) = text_tail(raw, max_cols); + (text, false, cols) +} + fn language_label(preference: LanguagePreference, language: UiLanguage) -> &'static str { match preference { LanguagePreference::System => language.pick("跟随系统", "Follow system"), @@ -617,6 +714,8 @@ pub enum SettingsHit { RestoreSessionToggle, /// 高级: 冷恢复时自动接续各 pane 里的 AI 对话(claude/codex resume)。 ResumeAiToggle, + /// 高级: 常驻系统托盘图标(agent 等待输入时变 attention 态)。 + TrayToggle, /// 高级→同步: 输入框(0=url 1=用户名 2=WebDAV 密码 3=E2E 口令)。 SyncInput(usize), SyncAutoPullToggle, @@ -652,12 +751,19 @@ pub enum SettingsHit { BackupSelection(usize), BackupExport, BackupRestore, + /// 备份→远程备份: 协议下拉触发行 + 展开的选项行。 + BackupProtocolCycle, + BackupProtocolOption(usize), + /// 备份→远程备份: 输入框(槽位语义随协议变化)。 + BackupRemoteField(usize), + BackupRemotePush, + BackupRemotePull, } /// Stable animation slots for the settings switches. Rendering and the shared /// motion clock both use this mapping, so one switch can never borrow another /// switch's thumb position while the pointer moves between rows. -pub(super) const SETTINGS_TOGGLE_COUNT: usize = 16; +pub(super) const SETTINGS_TOGGLE_COUNT: usize = 17; pub(super) fn settings_toggle_slot(hit: SettingsHit) -> Option { Some(match hit { @@ -678,6 +784,7 @@ pub(super) fn settings_toggle_slot(hit: SettingsHit) -> Option { SettingsHit::ProviderCodexRemoteToggle => 14, // 追加在尾部:槽位是稳定映射,重排会让开关借走彼此的动画状态。 SettingsHit::ResumeAiToggle => 15, + SettingsHit::TrayToggle => 16, _ => return None, }) } @@ -726,6 +833,8 @@ pub(super) struct NebulaRuntimeSettings { pub(super) restore_session: bool, /// 高级·会话:冷恢复时自动接续各 pane 的 AI 对话(claude/codex resume)。 pub(super) resume_ai: bool, + /// 高级:常驻系统托盘图标。 + pub(super) tray: bool, /// 窗口背景模糊。Windows 11 上是 Mica(见 /// `display::window::apply_windows_backdrop`),macOS / Wayland 上走 /// winit 自己的实现。默认开:纯 alpha 会让背景的高频细节直接透上来压在 @@ -815,6 +924,9 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { // 默认开:恢复布局却丢下正聊到一半的对话,等于只恢复了一半现场。 // 关掉它仍恢复标签/分屏/目录,只是不再敲 resume。 resume_ai: true, + // 默认开:托盘是 agent 等待提醒的常驻出口(任务栏闪烁会被忽略、 + // toast 会过期);不想要常驻图标的人在设置里关。 + tray: true, // 2026-07-31 用户裁定:默认开。纯 alpha 下背景的高频细节压着文字, // 那正是"透明度调低就看不清"的物理来源;模糊把它拍成低频色块。想 // 真透出后面窗口内容的人可以关掉。 @@ -920,6 +1032,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { Some(("keep_session", v)) => settings.keep_session = parse_bool(v, false), Some(("restore_session", v)) => settings.restore_session = parse_bool(v, true), Some(("resume_ai", v)) => settings.resume_ai = parse_bool(v, true), + Some(("tray", v)) => settings.tray = parse_bool(v, true), Some(("panel_resize", v)) => settings.panel_resize = parse_bool(v, false), Some(("sidebar_w", v)) => { if let Ok(w) = v.trim().parse::() { @@ -1134,7 +1247,7 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { path, format!( "language={}\ntheme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\ncompletion_style={completion_style}\nshell={shell}\nstartup_directory={startup_directory}\nfont_family={}\nfont_size={font_size}\ncursor_shape={}\ncursor_blink={}\ncopy_on_select={}\ncjk_bold_regular={} -tab_reveal={}\ndensity={}\nnew_tab_position={}\ncell_width_mode={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nresume_ai={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npanel_resize={}\nsidebar_w={:.0}\ndrawer_w={:.0}\nhosts_band={:.0}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nssh_proxy_mode={ssh_proxy_mode}\nssh_proxy_url={ssh_proxy_url}\nssh_proxy_no_proxy={ssh_proxy_no_proxy}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}", +tab_reveal={}\ndensity={}\nnew_tab_position={}\ncell_width_mode={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nresume_ai={}\ntray={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npanel_resize={}\nsidebar_w={:.0}\ndrawer_w={:.0}\nhosts_band={:.0}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nssh_proxy_mode={ssh_proxy_mode}\nssh_proxy_url={ssh_proxy_url}\nssh_proxy_no_proxy={ssh_proxy_no_proxy}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}", settings.language.as_str(), settings.follow_system_theme as u8, settings.ghost as u8, @@ -1152,6 +1265,7 @@ tab_reveal={}\ndensity={}\nnew_tab_position={}\ncell_width_mode={}\nfetch={}\npo settings.keep_session as u8, settings.restore_session as u8, settings.resume_ai as u8, + settings.tray as u8, settings.blur as u8, settings.opacity, settings.background_image_opacity, @@ -1278,6 +1392,8 @@ struct SettingsGeometry { restore_session: (f32, f32, f32, f32), /// 高级·会话:冷恢复自动接续 AI 对话。 resume_ai: (f32, f32, f32, f32), + /// 高级:常驻系统托盘图标。 + tray: (f32, f32, f32, f32), /// 网络页:主模式仍是下拉框;指定代理分支按 HTML 原型排成扫描标题、 /// 一张连续列表卡、选中项展开、绕过列表与每主机覆盖。 ssh_proxy_mode: (f32, f32, f32, f32), @@ -1302,6 +1418,10 @@ struct SettingsGeometry { backup_groups: [(f32, f32, f32, f32); 4], backup_rows: [(f32, f32, f32, f32); 9], backup_actions: (f32, f32, f32, f32), + /// 远程备份组:协议下拉行、5 个输入槽行(按协议裁剪可见数)、动作行。 + backup_remote_protocol: (f32, f32, f32, f32), + backup_remote_fields: [(f32, f32, f32, f32); 5], + backup_remote_actions: (f32, f32, f32, f32), backup_h: f32, } @@ -1311,7 +1431,7 @@ fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 { } fn advanced_content_end(advanced_y0: f32, sync_y0: f32, row_h: f32) -> f32 { - if SHOW_WEBDAV_SYNC_SETTINGS { sync_y0 + 7.0 * row_h } else { advanced_y0 + 3.0 * row_h } + if SHOW_WEBDAV_SYNC_SETTINGS { sync_y0 + 7.0 * row_h } else { advanced_y0 + 4.0 * row_h } } /// Max scroll offset for `section` at the current window size. The input @@ -1627,7 +1747,8 @@ fn settings_geometry( let keep_session = (row_x, at(advanced_y0), row_w, row_h); let restore_session = (row_x, at(advanced_y0 + ROW_H), row_w, row_h); let resume_ai = (row_x, at(advanced_y0 + ROW_H * 2.0), row_w, row_h); - let sync_y0 = advanced_y0 + ROW_H * 3.0 + GROUP_ADVANCE; + let tray = (row_x, at(advanced_y0 + ROW_H * 3.0), row_w, row_h); + let sync_y0 = advanced_y0 + ROW_H * 4.0 + GROUP_ADVANCE; let sync_row = |i: f32| (row_x, at(sync_y0 + i * ROW_H), row_w, row_h); let sync_rows = [sync_row(0.0), sync_row(1.0), sync_row(2.0), sync_row(3.0)]; let sync_auto_pull = sync_row(4.0); @@ -1714,7 +1835,25 @@ fn settings_geometry( backup_row(812.0), // fonts ]; let backup_actions = backup_segment; - let backup_h = s(864.0 + 40.0 - 72.0); + // 远程备份组接在清单卡之后(清单行位一个不动):协议下拉 + 至多 5 个 + // 输入行 + 动作行。行位按字段最多的协议(S3=5)预留;字段更少的协议 + // 隐藏尾部行,动作行经 `backup_remote_actions_rect` 上移贴住可见行。 + let backup_remote_y0 = 944.0; + let backup_remote_protocol = (row_x, at(backup_remote_y0), row_w, row_h); + let backup_remote_field = |index: usize| { + (row_x, at(backup_remote_y0 + (1.0 + index as f32) * ROW_H), row_w, row_h) + }; + let backup_remote_fields = [ + backup_remote_field(0), + backup_remote_field(1), + backup_remote_field(2), + backup_remote_field(3), + backup_remote_field(4), + ]; + let backup_remote_actions_y = backup_remote_y0 + 6.0 * ROW_H + 12.0; + let backup_remote_actions = + (row_x, at(backup_remote_actions_y), s(300.0).min(row_w), s(38.0)); + let backup_h = s(backup_remote_actions_y + 38.0 + 64.0 - 72.0); SettingsGeometry { gear, @@ -1824,6 +1963,7 @@ fn settings_geometry( keep_session, restore_session, resume_ai, + tray, ssh_proxy_mode, ssh_proxy_scan_head, ssh_proxy_scan_button, @@ -1844,6 +1984,9 @@ fn settings_geometry( backup_groups, backup_rows, backup_actions, + backup_remote_protocol, + backup_remote_fields, + backup_remote_actions, backup_h, } } @@ -2165,6 +2308,9 @@ fn dropdown_anchor( (Section::Profiles, SettingsDropdown::CompletionStyle) => { Some((anchor(geometry.completion_style), COMPLETION_STYLE_OPTIONS.len())) }, + (Section::Backup, SettingsDropdown::BackupProtocol) => { + Some((anchor(geometry.backup_remote_protocol), BACKUP_PROTOCOL_OPTIONS.len())) + }, (Section::Appearance, SettingsDropdown::CellWidthMode) => { Some((anchor(geometry.cell_width_mode), CELL_WIDTH_MODE_OPTIONS.len())) }, @@ -2413,6 +2559,7 @@ pub fn settings_hit( proxy: ProxyPaneState, keymap_pane: KeymapPaneState, provider_count: usize, + backup_protocol: crate::backup_remote::BackupProtocol, ) -> SettingsHit { let mut geometry = settings_geometry( size_info, @@ -2498,6 +2645,7 @@ pub fn settings_hit( SettingsDropdown::Language => SettingsHit::Language(LANGUAGE_OPTIONS[index]), SettingsDropdown::Accept => SettingsHit::AcceptOption(index), SettingsDropdown::CompletionStyle => SettingsHit::CompletionStyleOption(index), + SettingsDropdown::BackupProtocol => SettingsHit::BackupProtocolOption(index), SettingsDropdown::TabReveal => SettingsHit::TabRevealOption(index), SettingsDropdown::Density => SettingsHit::DensityOption(index), SettingsDropdown::NewTabPosition => SettingsHit::NewTabPositionOption(index), @@ -2851,6 +2999,9 @@ pub fn settings_hit( if contains_rect(widgets::toggle_rect(geometry.resume_ai, scale_factor), x, y) { return SettingsHit::ResumeAiToggle; } + if contains_rect(widgets::toggle_rect(geometry.tray, scale_factor), x, y) { + return SettingsHit::TrayToggle; + } if SHOW_WEBDAV_SYNC_SETTINGS { for (index, rect) in geometry.sync_rows.iter().enumerate() { // 命中整行都算输入框:行左侧是它的 label,点标签聚焦 @@ -2888,6 +3039,34 @@ pub fn settings_hit( if contains_rect(restore, x, y) { return SettingsHit::BackupRestore; } + if contains_rect( + widgets::combobox_rect(geometry.backup_remote_protocol, scale_factor), + x, + y, + ) { + return SettingsHit::BackupProtocolCycle; + } + let field_count = crate::backup_remote::field_count(backup_protocol); + for (index, rect) in + geometry.backup_remote_fields.iter().take(field_count).enumerate() + { + // 命中整行都算输入框:行左侧是它的 label,点标签聚焦 + // 输入是 Windows 设置页的惯例(与同步行一致)。 + if contains_rect(*rect, x, y) { + return SettingsHit::BackupRemoteField(index); + } + } + if backup_protocol != crate::backup_remote::BackupProtocol::Off { + let actions = + backup_remote_actions_rect(&geometry, scale_factor, field_count); + let [push, pull] = sync_button_rects(actions, scale_factor); + if contains_rect(push, x, y) { + return SettingsHit::BackupRemotePush; + } + if contains_rect(pull, x, y) { + return SettingsHit::BackupRemotePull; + } + } }, } } @@ -2976,6 +3155,8 @@ pub(super) struct SettingsView { pub(super) restore_session: bool, /// 高级·会话:冷恢复自动接续 AI 对话。 pub(super) resume_ai: bool, + /// 高级:常驻系统托盘图标。 + pub(super) tray: bool, pub(super) blur: bool, pub(super) opacity: f32, /// Which opacity slider is mid-drag, for thumb-dot grow feedback. @@ -3057,6 +3238,14 @@ pub(super) struct SettingsView { pub(super) ssh_proxy_overrides: Vec<(String, String, usize)>, pub(super) backup_selection: BackupSelection, pub(super) backup_status: Option<(String, bool)>, + /// 状态行画在触发动作的控件旁:true = 远程组下方,false = 清单下方。 + pub(super) backup_status_remote: bool, + /// 远程备份:协议、5 个输入槽草稿、聚焦槽位、密文凭据存在性、动作忙。 + pub(super) backup_protocol: crate::backup_remote::BackupProtocol, + pub(super) backup_remote_inputs: [String; 5], + pub(super) backup_remote_focus: Option, + pub(super) backup_remote_secret_set: bool, + pub(super) backup_busy: bool, } /// 同步行右侧的输入框矩形(quad/text/hit 三处共用)。行左侧留给标签。 @@ -3315,6 +3504,20 @@ fn backup_segment_rects( ] } +/// 远程备份动作行紧跟当前协议的最后一个可见输入行(字段数随协议变化, +/// 行位跟着上移)。hit / quad / text 三个 pass 共用,按钮与点击区不漂移。 +/// 行距从几何自身推导(ROW_H 随密度变化,是 `settings_geometry` 的局部)。 +fn backup_remote_actions_rect( + geometry: &SettingsGeometry, + scale: f32, + field_count: usize, +) -> (f32, f32, f32, f32) { + let (bx, _, bw, bh) = geometry.backup_remote_actions; + let pitch = geometry.backup_remote_fields[1].1 - geometry.backup_remote_fields[0].1; + let y = geometry.backup_remote_fields[0].1 + field_count as f32 * pitch + 12.0 * scale; + (bx, y, bw, bh) +} + fn backup_item_selected(selection: BackupSelection, index: usize) -> bool { match index { 0 => selection.appearance, @@ -3403,6 +3606,9 @@ fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> O SettingsDropdown::CompletionStyle => { COMPLETION_STYLE_OPTIONS.iter().position(|style| *style == view.completion_style) }, + SettingsDropdown::BackupProtocol => { + BACKUP_PROTOCOL_OPTIONS.iter().position(|protocol| *protocol == view.backup_protocol) + }, SettingsDropdown::TabReveal => { TAB_REVEAL_OPTIONS.iter().position(|motion| *motion == view.tab_reveal) }, @@ -3443,6 +3649,7 @@ fn dropdown_hover_index(hover: SettingsHit, dropdown: SettingsDropdown) -> Optio (SettingsDropdown::CompletionStyle, SettingsHit::CompletionStyleOption(index)) => { Some(index) }, + (SettingsDropdown::BackupProtocol, SettingsHit::BackupProtocolOption(index)) => Some(index), (SettingsDropdown::TabReveal, SettingsHit::TabRevealOption(index)) => Some(index), (SettingsDropdown::Density, SettingsHit::DensityOption(index)) => Some(index), (SettingsDropdown::NewTabPosition, SettingsHit::NewTabPositionOption(index)) => Some(index), @@ -4633,7 +4840,7 @@ pub(super) fn push_quads( } }, NebulaSettingsSection::Advanced => { - group_frame(quads, geometry.keep_session, 3); + group_frame(quads, geometry.keep_session, 4); row_hover(quads, geometry.keep_session, view.hover == SettingsHit::KeepSessionToggle); toggle( quads, @@ -4668,6 +4875,16 @@ pub(super) fn push_quads( view.hover == SettingsHit::ResumeAiToggle, view.pressed == SettingsHit::ResumeAiToggle, ); + row_hover(quads, geometry.tray, view.hover == SettingsHit::TrayToggle); + toggle( + quads, + &mut staged, + geometry.tray, + SettingsHit::TrayToggle, + view.tray, + view.hover == SettingsHit::TrayToggle, + view.pressed == SettingsHit::TrayToggle, + ); if SHOW_WEBDAV_SYNC_SETTINGS { // ---- 同步(WebDAV,spec 003):4 输入行 + 自动拉取开关 ---- @@ -4878,6 +5095,91 @@ pub(super) fn push_quads( clip(quads, UiQuad::solid(cb.0, cb.1, cb.2, cb.3, chip_radius, sk.panel)); } } + + // ---- 远程备份:协议下拉 + 按协议裁剪的输入行 + 动作行 ---- + { + let field_count = crate::backup_remote::field_count(view.backup_protocol); + let group_rows = 1 + field_count; + group_frame(quads, geometry.backup_remote_protocol, group_rows); + row_hover( + quads, + geometry.backup_remote_protocol, + view.hover == SettingsHit::BackupProtocolCycle, + ); + combobox( + quads, + &mut staged, + geometry.backup_remote_protocol, + view.hover == SettingsHit::BackupProtocolCycle, + view.dropdown == Some(SettingsDropdown::BackupProtocol), + ); + let cell_w = size.cell_width(); + for (index, row) in + geometry.backup_remote_fields.iter().take(field_count).enumerate() + { + row_hover(quads, *row, view.hover == SettingsHit::BackupRemoteField(index)); + let rect = sync_input_rect(*row, scale); + let (ix, iy, iw, ih) = rect; + let focused = view.backup_remote_focus == Some(index); + let border = if focused { + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255) + } else { + Rgba::new(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b, 90) + }; + let mut stroke = Vec::new(); + surface::push_stroke(&mut stroke, rect, control_radius, scale, border); + for quad in stroke { + clip(quads, quad); + } + clip(quads, UiQuad::solid(ix, iy, iw, ih, control_radius, sk.surface)); + if focused && super::caret_blink_on() { + let max_cols = (((iw - s(24.0)) / cell_w) as usize).max(1); + let (_, placeholder, cols) = + backup_remote_input_display(view, index, max_cols); + let cols = if placeholder { 0 } else { cols }; + let caret_h = ih - s(10.0); + clip( + quads, + UiQuad::solid( + (ix + s(12.0) + cols as f32 * cell_w).min(ix + iw - s(6.0)), + iy + (ih - caret_h) / 2.0, + (1.5 * scale).max(1.0), + caret_h, + 0.0, + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), + ), + ); + } + } + // 动作行:两个独立按钮(协议关闭时不画,忙时变灰不吃 hover)。 + if view.backup_protocol != crate::backup_remote::BackupProtocol::Off { + let actions = backup_remote_actions_rect(&geometry, scale, field_count); + let [push_rect, pull_rect] = sync_button_rects(actions, scale); + for (rect, hit) in [ + (push_rect, SettingsHit::BackupRemotePush), + (pull_rect, SettingsHit::BackupRemotePull), + ] { + let (bx, by, bw, bh) = rect; + let hot = view.hover == hit && !view.backup_busy; + let mut stroke = Vec::new(); + surface::push_stroke(&mut stroke, rect, control_radius, scale, sk.hairline); + for quad in stroke { + clip(quads, quad); + } + clip( + quads, + UiQuad::solid( + bx, + by, + bw, + bh, + control_radius, + if hot { sk.hover } else { sk.panel }, + ), + ); + } + } + } }, } @@ -5358,6 +5660,9 @@ pub(super) fn draw_popup_text( SettingsDropdown::CompletionStyle => { completion_style_label(COMPLETION_STYLE_OPTIONS[index], language).to_owned() }, + SettingsDropdown::BackupProtocol => { + backup_protocol_label(BACKUP_PROTOCOL_OPTIONS[index], language).to_owned() + }, SettingsDropdown::TabReveal => { tab_reveal_label(TAB_REVEAL_OPTIONS[index], language).to_owned() }, @@ -7347,6 +7652,26 @@ pub(super) fn draw_text( ); } } + { + let (_, ry, _, rh) = geometry.tray; + if visible(ry, rh) { + // 关掉立即摘图标;agent 提醒仍有 toast 和任务栏闪烁兜底。 + row_label( + r, + gc, + size, + scale, + &sk, + geometry.tray, + language.pick( + "常驻系统托盘图标(agent 等待输入时变色提醒)", + "System tray icon (turns amber when an agent needs you)", + ), + "", + sk.ink, + ); + } + } if SHOW_WEBDAV_SYNC_SETTINGS { // ---- 同步(WebDAV)---- @@ -7617,9 +7942,100 @@ pub(super) fn draw_text( ); } } + // ---- 远程备份 ---- + let field_count = crate::backup_remote::field_count(view.backup_protocol); + { + let (rx, ry, _, rh) = geometry.backup_remote_protocol; + let remote_title_y = ry - s(30.0); + if visible(remote_title_y, title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + rx, + remote_title_y, + language.pick("远程备份", "Remote backup"), + ); + } + if visible(ry, rh) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.backup_remote_protocol, + language.pick("协议", "Protocol"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.backup_remote_protocol, + backup_protocol_label(view.backup_protocol, language), + sk.ink, + ); + } + for (index, row) in + geometry.backup_remote_fields.iter().take(field_count).enumerate() + { + if !visible(row.1, row.3) { + continue; + } + row_label( + r, + gc, + size, + scale, + &sk, + *row, + backup_remote_field_label(view.backup_protocol, index, language), + "", + sk.ink, + ); + let (ix, iy, iw, ih) = sync_input_rect(*row, scale); + let max_cols = (((iw - s(24.0)) / cell_w) as usize).max(1); + let (text, placeholder, _) = backup_remote_input_display(view, index, max_cols); + let ink = if placeholder { sk.ink_dim } else { sk.ink }; + r.draw_chrome_text(size, ix + s(12.0), iy + (ih - cell_h) / 2.0, ink, &text, gc); + } + if view.backup_protocol != crate::backup_remote::BackupProtocol::Off { + let actions = backup_remote_actions_rect(&geometry, scale, field_count); + if visible(actions.1, actions.3) { + let [push_rect, pull_rect] = sync_button_rects(actions, scale); + let captions = [ + (push_rect, language.pick("备份到远程", "Back up now")), + (pull_rect, language.pick("恢复最新备份", "Restore latest")), + ]; + for ((bx, by, bw, bh), caption) in captions { + let cols: usize = + caption.chars().map(|c| c.width().unwrap_or(1).max(1)).sum(); + let ink = if view.backup_busy { sk.ink_dim } else { sk.ink }; + r.draw_chrome_text( + size, + bx + (bw - cols as f32 * cell_w) / 2.0, + by + (bh - cell_h) / 2.0, + ink, + caption, + gc, + ); + } + } + } + } + // 状态行画在触发动作的那组控件下方(本地导出/恢复 → 清单卡尾; + // 远程动作 → 远程按钮行下)。 if let Some((status, error)) = &view.backup_status { - let last = geometry.backup_rows[8]; - let status_y = last.1 + last.3 + s(8.0); + let status_y = if view.backup_status_remote { + let actions = backup_remote_actions_rect(&geometry, scale, field_count); + actions.1 + actions.3 + s(8.0) + } else { + let last = geometry.backup_rows[8]; + last.1 + last.3 + s(8.0) + }; if visible(status_y, cell_h) { r.draw_chrome_text( size, @@ -7734,13 +8150,40 @@ mod tests { fn hidden_webdav_group_does_not_extend_advanced_content() { assert!(!SHOW_WEBDAV_SYNC_SETTINGS); // SSH 已迁到独立页面;隐藏同步组不能继续把 Advanced 撑高。会话组 - // 现为 3 行:保留会话 / 恢复会话 / 恢复时接续 AI 对话(resume_ai)。 - assert_eq!(advanced_content_end(146.0, 308.0, 44.0), 278.0); + // 现为 4 行:保留会话 / 恢复会话 / 恢复时接续 AI 对话(resume_ai) + // / 常驻托盘图标(tray)。 + assert_eq!(advanced_content_end(146.0, 308.0, 44.0), 322.0); } #[test] - fn backup_settings_entry_stays_gated_until_restore_workflow_is_complete() { - assert!(!SHOW_BACKUP_SETTINGS); + fn backup_settings_entry_is_exposed_with_remote_destinations() { + // 2026-08-13:远程备份(目录/WebDAV/S3/SFTP)接入后备份页开放。 + assert!(SHOW_BACKUP_SETTINGS); + } + + #[test] + fn backup_remote_actions_track_visible_field_count() { + let size = SizeInfo::new(1600.0, 1000.0, 8.0, 16.0, 0.0, 0.0, false); + let area = (0.0, 40.0, 1600.0, 960.0); + let geometry = settings_geometry( + &size, + 1.0, + area, + 0.0, + 0, + 0, + Density::Standard, + ProxyPaneState::default(), + KeymapPaneState::default(), + ); + // 满配(S3 = 5 字段)时动作行与几何里的静态槽位重合。 + let full = super::backup_remote_actions_rect(&geometry, 1.0, 5); + assert!((full.1 - geometry.backup_remote_actions.1).abs() < 0.5); + // 字段更少的协议动作行上移,且始终排在最后一个可见行之后。 + let folder = super::backup_remote_actions_rect(&geometry, 1.0, 1); + assert!(folder.1 < full.1); + let first_field = geometry.backup_remote_fields[0]; + assert!(folder.1 >= first_field.1 + first_field.3); } #[test] @@ -7868,6 +8311,7 @@ mod tests { ProxyPaneState::default(), KeymapPaneState::default(), provider_count, + Default::default(), ); assert_eq!(hit, SettingsHit::ProviderRow(13)); } @@ -7989,6 +8433,7 @@ mod tests { proxy, KeymapPaneState::default(), 6, + Default::default(), ) }; // 命中矩形与绘制矩形同源:用 hit-test 内部同款控件几何函数取靶。 @@ -8067,6 +8512,7 @@ mod tests { proxy, KeymapPaneState::default(), 6, + Default::default(), ) }; let (protocol, address) = ssh_proxy_manual_controls(geometry.ssh_proxy_expand, 1.0); diff --git a/nebula_app/src/encrypted_backup.rs b/nebula_app/src/encrypted_backup.rs index b73228b..5b30e5e 100644 --- a/nebula_app/src/encrypted_backup.rs +++ b/nebula_app/src/encrypted_backup.rs @@ -33,8 +33,10 @@ pub(crate) enum BackupCategory { Fonts, } +// `pub`(而非 pub(crate)):作为 `EventType::NebulaBackupRemote` 的字段随 +// 公有枚举可达;bin crate 里只为消除 private-interfaces 告警。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct BackupSelection { +pub struct BackupSelection { pub appearance: bool, pub config: bool, pub ssh: bool, diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index d0f47d2..da8ec93 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -911,6 +911,39 @@ impl ApplicationHandler for Processor { window_context.handle_sync_done(&message, error, history_changed); } }, + // 远程备份(设置→备份):打包、Argon2 派生与网络都在后台 OS + // 线程阻塞完成,主循环只发起与收尾——与 WebDAV 同步同一模型。 + (EventType::NebulaBackupRemote { upload, passphrase, selection }, _) => { + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + let result = if upload { + crate::encrypted_backup::collect(selection) + .and_then(|archive| { + crate::encrypted_backup::seal(&archive, &passphrase) + }) + .and_then(|packet| crate::backup_remote::push(&packet)) + } else { + crate::backup_remote::pull_latest().and_then(|(name, packet)| { + crate::encrypted_backup::restore(&packet, &passphrase) + .map(|()| format!("已从远端恢复 {name},重启后应用全部设置")) + }) + }; + crate::backup_remote::warn_result(&result); + let (message, error) = match result { + Ok(message) => (message, false), + Err(err) => (err, true), + }; + let _ = proxy.send_event(crate::event::Event::new( + EventType::NebulaBackupRemoteDone { message, error }, + None, + )); + }); + }, + (EventType::NebulaBackupRemoteDone { message, error }, _) => { + for window_context in self.windows.values_mut() { + window_context.handle_backup_remote_done(&message, error); + } + }, (EventType::LocalProxyScan, Some(window_id)) => { let proxy = self.proxy.clone(); let window_id = *window_id; @@ -1247,6 +1280,15 @@ impl ApplicationHandler for Processor { window_context.dirty = true; window_context.display.window.request_redraw(); } + // 托盘 agent 清单同样搭 1 Hz 时钟:跨窗口聚合,tray::update + // 内容不变时自去抖。多窗口各自的 tick 都会走到这里,最先到 + // 的那个完成本秒的发布,其余是廉价 no-op。 + let agents = self + .windows + .values() + .flat_map(|window_context| window_context.tray_agents()) + .collect(); + crate::tray::update(agents); }, (EventType::NebulaResizeSettled, Some(window_id)) => { if let Some(window_context) = self.windows.get_mut(window_id) { @@ -1809,6 +1851,18 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon }); } + fn nebula_backup_remote(&self, request: crate::display::RemoteBackupRequest) { + let _ = self.event_proxy.send_event(Event { + window_id: Some(self.display.window.id()), + tab_id: None, + payload: EventType::NebulaBackupRemote { + upload: request.upload, + passphrase: request.passphrase, + selection: request.selection, + }, + }); + } + fn nebula_local_proxy_scan(&mut self) { if !self.display.take_local_proxy_scan_request() { return; @@ -2989,6 +3043,8 @@ impl input::Processor> { | EventType::AiFixReady { .. } | EventType::NebulaSync { .. } | EventType::NebulaSyncDone { .. } + | EventType::NebulaBackupRemote { .. } + | EventType::NebulaBackupRemoteDone { .. } | EventType::LocalProxyScan | EventType::LocalProxyScanDone(_) | EventType::FocusWindow { .. } => (), diff --git a/nebula_app/src/event/types.rs b/nebula_app/src/event/types.rs index dc6e7b4..83afaa1 100644 --- a/nebula_app/src/event/types.rs +++ b/nebula_app/src/event/types.rs @@ -78,6 +78,19 @@ pub enum EventType { error: bool, history_changed: bool, }, + /// 远程备份请求(设置→备份,口令确认后)。打包/加密/网络都在后台 + /// 线程;口令只在事件载荷里过一手,线程用完即弃。 + NebulaBackupRemote { + upload: bool, + passphrase: String, + selection: crate::encrypted_backup::BackupSelection, + }, + /// 后台远程备份线程完成(消息文本已含成功/失败语义,恢复成功的 + /// 提示里带「重启后应用」)。 + NebulaBackupRemoteDone { + message: String, + error: bool, + }, /// 设置→网络的本机代理握手扫描。请求在后台线程运行,结果回到目标窗口。 LocalProxyScan, LocalProxyScanDone(Vec), diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index d39e9ea..662a1b9 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -260,6 +260,18 @@ impl NebulaWorkspace { ), ) .child(v_flex().flex_1().gap_1().children(items)) + .child( + h_flex().px_1().child( + Button::new("sidebar-settings") + .icon(IconName::Settings) + .ghost() + .small() + .tooltip("设置 (Ctrl+,)") + .on_click(cx.listener(|this, _, window, cx| { + this.open_settings(window, cx); + })), + ), + ) } } diff --git a/nebula_app/src/input/chrome.rs b/nebula_app/src/input/chrome.rs index b51f566..83b0932 100644 --- a/nebula_app/src/input/chrome.rs +++ b/nebula_app/src/input/chrome.rs @@ -91,7 +91,11 @@ impl> Processor { self.ctx.open_path(&directory); }, NebulaConfirm::BackupPassphrase { .. } => { - self.ctx.display().complete_backup_operation(); + // 本地导出/恢复同步完成;远程动作返回请求,转成事件在后台 + // 线程执行(打包、Argon2 与网络都不进 UI 线程)。 + if let Some(request) = self.ctx.display().complete_backup_operation() { + self.ctx.nebula_backup_remote(request); + } }, } } @@ -815,6 +819,7 @@ impl> Processor { self.ctx.display().ssh_proxy_pane_state(), self.ctx.display().keymap_pane_state(), self.ctx.display().provider_count(), + self.ctx.display().backup_protocol(), ); // Keep the primary-button target for the settings renderer's // HTML-like toggle active state until the matching release. @@ -841,6 +846,9 @@ impl> Processor { if !matches!(settings_hit, crate::display::SettingsHit::SyncInput(_)) { self.ctx.display().commit_sync_field(); } + if !matches!(settings_hit, crate::display::SettingsHit::BackupRemoteField(_)) { + self.ctx.display().commit_backup_remote_field(); + } if !matches!(settings_hit, crate::display::SettingsHit::ProviderField(_)) { self.ctx.display().commit_provider_field(); } @@ -884,6 +892,33 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::BackupProtocolCycle => { + self.ctx + .display() + .toggle_settings_dropdown(crate::display::SettingsDropdown::BackupProtocol); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::BackupProtocolOption(index) => { + self.ctx.display().set_backup_protocol_option(index); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::BackupRemoteField(index) => { + self.ctx.display().focus_backup_remote_field(index); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::BackupRemotePush => { + self.ctx.display().start_backup_remote(true); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::BackupRemotePull => { + self.ctx.display().start_backup_remote(false); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::BackupRestore => { self.ctx.display().start_backup_restore(); self.ctx.mark_dirty(); @@ -927,6 +962,11 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::TrayToggle => { + self.ctx.display().toggle_tray(); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::AcceptCycle => { self.ctx .display() @@ -1662,6 +1702,8 @@ fn settings_dropdown_keeps_open(hit: crate::display::SettingsHit) -> bool { | Hit::AcceptOption(_) | Hit::CompletionStyleCycle | Hit::CompletionStyleOption(_) + | Hit::BackupProtocolCycle + | Hit::BackupProtocolOption(_) | Hit::TabRevealDropdown | Hit::TabRevealOption(_) | Hit::DensityDropdown diff --git a/nebula_app/src/input/keyboard.rs b/nebula_app/src/input/keyboard.rs index 0af36aa..205209a 100644 --- a/nebula_app/src/input/keyboard.rs +++ b/nebula_app/src/input/keyboard.rs @@ -161,6 +161,41 @@ impl> Processor { return; } + // 设置→备份→远程备份的输入框独占键盘:语义与同步输入框一致 + // (Enter/Tab 提交、Esc 还原、Ctrl+V 粘贴、其余字符追加)。 + if self.ctx.display().settings_open() + && self.ctx.display().nebula_backup_remote_focus.is_some() + { + match &key.logical_key { + Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Tab) => { + self.ctx.display().commit_backup_remote_field(); + }, + Key::Named(NamedKey::Escape) => { + self.ctx.display().cancel_backup_remote_field(); + }, + Key::Named(NamedKey::Backspace) => { + self.ctx.display().backup_remote_field_backspace(); + }, + Key::Named(NamedKey::Space) => { + self.ctx.display().backup_remote_field_push(' '); + }, + Key::Character(text) => { + if mods.control_key() && text.eq_ignore_ascii_case("v") { + let paste = self.ctx.clipboard_mut().load(ClipboardType::Clipboard); + self.ctx.display().backup_remote_field_paste(&paste); + } else if !mods.control_key() { + for ch in text.chars() { + self.ctx.display().backup_remote_field_push(ch); + } + } + }, + _ => {}, + } + self.ctx.display().update_settings_ime_cursor(); + self.ctx.mark_dirty(); + return; + } + // 设置→供应商输入框:普通元数据可编辑,API Key 只在提交瞬间 // 写入系统凭据管理器,随后立即清空草稿,避免明文残留在 UI 状态。 if self.ctx.display().settings_open() && self.ctx.display().nebula_provider_focus.is_some() diff --git a/nebula_app/src/input/mod.rs b/nebula_app/src/input/mod.rs index 52253be..8d50551 100644 --- a/nebula_app/src/input/mod.rs +++ b/nebula_app/src/input/mod.rs @@ -136,6 +136,8 @@ pub trait ActionContext { fn refresh_terminal_profiles(&mut self) {} /// Kick a WebDAV sync (spec 003). true = push, false = pull. fn nebula_sync(&self, _push: bool) {} + /// 口令确认后的远程备份/恢复:发事件到后台线程执行。 + fn nebula_backup_remote(&self, _request: crate::display::RemoteBackupRequest) {} fn nebula_local_proxy_scan(&mut self) {} fn nebula_proxy_test(&mut self) {} diff --git a/nebula_app/src/input/mouse.rs b/nebula_app/src/input/mouse.rs index 18814f5..43b2592 100644 --- a/nebula_app/src/input/mouse.rs +++ b/nebula_app/src/input/mouse.rs @@ -355,6 +355,7 @@ impl> Processor { self.ctx.display().ssh_proxy_pane_state(), self.ctx.display().keymap_pane_state(), self.ctx.display().provider_count(), + self.ctx.display().backup_protocol(), ); let chrome_hover = if crate::display::in_chrome_bar(&window_size, scale, x as f32, y as f32) { @@ -448,6 +449,7 @@ impl> Processor { | crate::display::SettingsHit::KeepSessionToggle | crate::display::SettingsHit::RestoreSessionToggle | crate::display::SettingsHit::ResumeAiToggle + | crate::display::SettingsHit::TrayToggle | crate::display::SettingsHit::BackgroundColor | crate::display::SettingsHit::BackgroundSvPlane | crate::display::SettingsHit::BackgroundHueBar @@ -501,11 +503,16 @@ impl> Processor { }, crate::display::SettingsHit::BackupSelection(_) | crate::display::SettingsHit::BackupExport - | crate::display::SettingsHit::BackupRestore => { + | crate::display::SettingsHit::BackupRestore + | crate::display::SettingsHit::BackupProtocolCycle + | crate::display::SettingsHit::BackupProtocolOption(_) + | crate::display::SettingsHit::BackupRemotePush + | crate::display::SettingsHit::BackupRemotePull => { self.ctx.window().set_mouse_cursor(CursorIcon::Pointer); return; }, crate::display::SettingsHit::SyncInput(_) + | crate::display::SettingsHit::BackupRemoteField(_) | crate::display::SettingsHit::ProviderField(_) | crate::display::SettingsHit::SshProxyInput(_) // 搜索框是文本字段,光标要变 I 形——和其它输入框一致。 diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 074859e..1bdf40c 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -32,6 +32,7 @@ mod ai_hook; mod ai_providers; mod ai_sessions; mod atomic_file; +mod backup_remote; mod cli; mod clipboard; mod codex_config; @@ -84,6 +85,7 @@ mod ssh_sftp; mod string; mod sync; mod terminal_profiles; +mod tray; mod update_check; mod ux; mod window_context; @@ -303,6 +305,10 @@ fn nebula(mut options: Options) -> Result<(), Box> { { ai_hook::spawn_server(window_event_loop.create_proxy()); ai_hook::spawn_config_guard(); + // 托盘 attention(T1-3):常驻图标 + agent 状态菜单。开关读原始 + // 设置存储——事件循环还没有任何窗口/Display 可问。 + tray::init(window_event_loop.create_proxy()); + tray::set_enabled(display::tray_enabled()); } #[cfg(all(feature = "x11", not(any(target_os = "macos", windows))))] @@ -385,6 +391,10 @@ fn nebula(mut options: Options) -> Result<(), Box> { // Start event loop and block until shutdown. let result = processor.run(window_event_loop); + // 摘掉托盘图标:进程退出后残影会留在通知区直到用户悬停。 + #[cfg(windows)] + tray::shutdown(); + // `Processor` must be dropped before calling `FreeConsole`. // // This is needed for ConPTY backend. Otherwise a deadlock can occur. diff --git a/nebula_app/src/sync.rs b/nebula_app/src/sync.rs index c95b9b5..48fa0fc 100644 --- a/nebula_app/src/sync.rs +++ b/nebula_app/src/sync.rs @@ -46,6 +46,8 @@ const SYNC_KEYS: &[&str] = &[ "powerline", "keep_session", "restore_session", + "resume_ai", + "tray", "panel_resize", "sidebar_w", "drawer_w", diff --git a/nebula_app/src/tray.rs b/nebula_app/src/tray.rs new file mode 100644 index 0000000..08e6bd8 --- /dev/null +++ b/nebula_app/src/tray.rs @@ -0,0 +1,590 @@ +//! 系统托盘常驻图标 + agent attention 状态(future_planning `T1-3`)。 +//! +//! 终端最常见的姿态是「压在别的窗口底下跑着 agent」:任务栏闪烁会被忽略、 +//! toast 会过期,而托盘图标一直在。任一 agent 停下来等输入时图标翻转成 +//! attention 态(右下角橙点),右键菜单列出所有 agent pane 与状态,点击 +//! 直达来源 pane——复用 toast 点击同一条 [`EventType::FocusWindow`] 路径, +//! 因此聚焦语义(还原最小化、选中 tab、聚焦 pane)与通知横幅完全一致。 +//! +//! # 线程模型 +//! +//! `Shell_NotifyIconW` 的回调消息需要一个窗口收;不能挂在 winit 的窗口上 +//! (fork 治理红线:不往窗口层塞策略)。托盘因此拥有自己的线程:一扇永不 +//! 显示的 Win32 窗口 + 独立消息泵。app 侧只做两件事——把 agent 清单写进 +//! [`STATE`]、`PostMessageW` 摇醒托盘线程;托盘线程反向只通过 +//! `EventLoopProxy` 发用户事件。两个方向都不共享锁跨越阻塞调用。 +//! +//! 状态推送挂在既有的 1 Hz chrome 时钟(与会话自动保存同一节拍):托盘是 +//! 环境信息面,秒级延迟无感,换来的是零新增定时器与天然去抖。 + +#![cfg_attr(not(windows), allow(dead_code))] + +use winit::window::WindowId; + +/// 托盘菜单里的一行:一个正在跑 AI CLI 的 pane。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrayAgent { + /// 宿主窗口(点击后 `FocusWindow` 的路由目标)。 + pub window: WindowId, + pub pane: u64, + /// 菜单文案:「claude · nebula」(程序名 + cwd 尾段)。 + pub label: String, + /// CLI 停在半路等用户(权限确认/提问),比「回合完成」更强。 + pub needs_attention: bool, +} + +#[cfg(windows)] +pub use win::{init, set_enabled, shutdown, update}; + +#[cfg(not(windows))] +pub fn init(_proxy: winit::event_loop::EventLoopProxy) {} +#[cfg(not(windows))] +pub fn set_enabled(_enabled: bool) {} +#[cfg(not(windows))] +pub fn update(_agents: Vec) {} +#[cfg(not(windows))] +pub fn shutdown() {} + +#[cfg(windows)] +mod win { + use std::cell::RefCell; + use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; + use std::sync::{Mutex, OnceLock}; + + use winit::event_loop::EventLoopProxy; + + use super::TrayAgent; + use crate::event::{Event, EventType}; + + /// 与 notify.rs 的 toast 图标同一份官方 logo,托盘不会出现第二个品牌形态。 + const LOGO_PNG: &[u8] = include_bytes!("../../extra/logo/nebula.png"); + + /// 托盘回调(lParam 携带鼠标消息;未 SETVERSION 的经典 v0 语义, + /// Win7 至今行为一致,不依赖 v4 的坐标打包)。 + const WM_APP_TRAY: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 1; + /// STATE.agents 变了:重读并 NIM_MODIFY 图标/气泡提示。 + const WM_APP_REFRESH: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 2; + /// STATE.enabled 变了:NIM_ADD / NIM_DELETE。 + const WM_APP_SET: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 3; + /// 进程收尾:删图标 + 退出消息泵(不删的话图标要等用户悬停才消失)。 + const WM_APP_SHUTDOWN: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 4; + + /// 菜单命令 id 的起点;`MENU_AGENT_BASE + i` = 聚焦第 i 个 agent。 + const MENU_AGENT_BASE: usize = 1000; + /// 「显示 Nebula」——没有 agent 时托盘仍要能把窗口捞回来。 + const MENU_SHOW: usize = 1; + + struct Shared { + agents: Vec, + enabled: bool, + } + + static PROXY: OnceLock> = OnceLock::new(); + static STATE: Mutex = Mutex::new(Shared { agents: Vec::new(), enabled: false }); + /// 托盘窗口句柄(isize 形态跨线程存取;0 = 线程未起或窗口未建)。 + static HWND: AtomicIsize = AtomicIsize::new(0); + static THREAD_STARTED: AtomicBool = AtomicBool::new(false); + + fn state() -> std::sync::MutexGuard<'static, Shared> { + // 中毒只表示某线程带锁 panic;清单本身是普通数据,继续用。 + STATE.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// 装好事件代理。真正的线程按需(首次启用)才起。 + pub fn init(proxy: EventLoopProxy) { + let _ = PROXY.set(proxy); + } + + /// 开/关托盘图标(设置·高级)。开 = 保证线程在跑并 NIM_ADD; + /// 关 = 只删图标,线程留着(重开是高频往复,不值得反复建窗)。 + pub fn set_enabled(enabled: bool) { + state().enabled = enabled; + if enabled { + ensure_thread(); + } + post(WM_APP_SET); + } + + /// 发布最新 agent 清单。内容没变就不打扰托盘线程(1 Hz 调用方无需去抖)。 + pub fn update(agents: Vec) { + { + let mut shared = state(); + if !shared.enabled || shared.agents == agents { + return; + } + shared.agents = agents; + } + post(WM_APP_REFRESH); + } + + /// 进程退出前删掉图标。best-effort:托盘线程没起过就什么都不用做。 + pub fn shutdown() { + post(WM_APP_SHUTDOWN); + } + + fn post(message: u32) { + let hwnd = HWND.load(Ordering::Acquire); + if hwnd != 0 { + // SAFETY: hwnd 由托盘线程发布且只在 WM_APP_SHUTDOWN 后失效; + // PostMessageW 对已销毁窗口安全失败。 + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::PostMessageW( + hwnd as _, + message, + 0, + 0, + ); + } + } + } + + fn ensure_thread() { + if THREAD_STARTED.swap(true, Ordering::SeqCst) { + return; + } + if let Err(err) = + std::thread::Builder::new().name("nebula-tray".into()).spawn(tray_thread) + { + THREAD_STARTED.store(false, Ordering::SeqCst); + log::warn!("tray: failed to spawn tray thread: {err}"); + } + } + + // ─── 托盘线程 ──────────────────────────────────────────────────────── + + /// 托盘线程本地资源:两个状态图标 + 图标是否已挂在通知区。 + struct Icons { + normal: isize, + attention: isize, + added: bool, + } + + thread_local! { + static ICONS: RefCell> = const { RefCell::new(None) }; + } + + fn tray_thread() { + use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + CW_USEDEFAULT, CreateWindowExW, DispatchMessageW, GetMessageW, MSG, RegisterClassW, + TranslateMessage, WNDCLASSW, WS_OVERLAPPED, + }; + + let class_name = wide("NebulaTrayWindow"); + // SAFETY: 类名/实例句柄在调用期间有效;重复注册返回 0 也无妨—— + // 单进程只会走到这里一次(THREAD_STARTED 闸)。 + unsafe { + let hinstance = GetModuleHandleW(std::ptr::null()); + let wc = WNDCLASSW { + style: 0, + lpfnWndProc: Some(tray_wnd_proc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: hinstance, + hIcon: std::ptr::null_mut(), + hCursor: std::ptr::null_mut(), + hbrBackground: std::ptr::null_mut(), + lpszMenuName: std::ptr::null(), + lpszClassName: class_name.as_ptr(), + }; + RegisterClassW(&wc); + // 隐藏顶层窗口(不是 message-only:通知区回调对 HWND_MESSAGE + // 窗口在部分 Windows 版本上不投递)。永不 ShowWindow。 + let hwnd = CreateWindowExW( + 0, + class_name.as_ptr(), + wide("Nebula Tray").as_ptr(), + WS_OVERLAPPED, + CW_USEDEFAULT, + CW_USEDEFAULT, + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + hinstance, + std::ptr::null(), + ); + if hwnd.is_null() { + log::warn!("tray: CreateWindowExW failed; tray icon disabled"); + return; + } + HWND.store(hwnd as isize, Ordering::Release); + + let icons = build_icons(); + ICONS.with(|slot| *slot.borrow_mut() = Some(Icons { + normal: icons.0 as isize, + attention: icons.1 as isize, + added: false, + })); + // init 与首次 set_enabled 之间可能已经积累了状态:起跑先对齐。 + apply_enabled(hwnd); + + let mut msg: MSG = std::mem::zeroed(); + while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + } + + unsafe extern "system" fn tray_wnd_proc( + hwnd: windows_sys::Win32::Foundation::HWND, + msg: u32, + wparam: usize, + lparam: isize, + ) -> isize { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + DefWindowProcW, PostQuitMessage, WM_LBUTTONUP, WM_RBUTTONUP, + }; + match msg { + WM_APP_TRAY => { + match lparam as u32 { + // 左键 = 最短路径:直达最需要人的 pane。 + WM_LBUTTONUP => focus_best(), + WM_RBUTTONUP => show_menu(hwnd), + _ => {}, + } + 0 + }, + WM_APP_REFRESH => { + refresh_icon(hwnd); + 0 + }, + WM_APP_SET => { + apply_enabled(hwnd); + 0 + }, + WM_APP_SHUTDOWN => { + remove_icon(hwnd); + // SAFETY: 本线程的消息泵,退出后线程结束。 + unsafe { PostQuitMessage(0) }; + 0 + }, + // SAFETY: 转发给默认过程,参数原样透传。 + _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, + } + } + + /// 左键/菜单兜底的聚焦策略:先挑等输入的,再挑任意 agent,最后裸窗口。 + fn focus_best() { + let target = { + let shared = state(); + shared + .agents + .iter() + .find(|agent| agent.needs_attention) + .or_else(|| shared.agents.first()) + .cloned() + }; + send_focus(target); + } + + fn send_focus(agent: Option) { + let Some(proxy) = PROXY.get() else { return }; + let event = match agent { + Some(agent) => { + Event::new(EventType::FocusWindow { pane: Some(agent.pane) }, agent.window) + }, + // 没有 agent:把第一扇窗捞到前台(processor 对 None 窗口 id 的 + // FocusWindow 就是这个语义)。 + None => Event::new(EventType::FocusWindow { pane: None }, None), + }; + let _ = proxy.send_event(event); + } + + fn show_menu(hwnd: windows_sys::Win32::Foundation::HWND) { + use windows_sys::Win32::Foundation::POINT; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + AppendMenuW, CreatePopupMenu, DestroyMenu, GetCursorPos, MF_GRAYED, MF_SEPARATOR, + MF_STRING, SetForegroundWindow, TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON, + TrackPopupMenu, WM_NULL, + }; + + // 菜单期间用快照:TrackPopupMenu 是模态泵,STATE 可能被 1 Hz 更新, + // 命令 id 必须映射回打开菜单那一刻的清单。 + let agents = state().agents.clone(); + + // SAFETY: 菜单句柄本函数创建/销毁;文案缓冲活过 AppendMenuW(菜单 + // 复制内容)。SetForegroundWindow + WM_NULL 是 TrackPopupMenu 在 + // 托盘场景的官方仪式(否则菜单点外部不收合)。 + unsafe { + let menu = CreatePopupMenu(); + if menu.is_null() { + return; + } + if agents.is_empty() { + let text = wide("没有正在运行的 agent"); + AppendMenuW(menu, MF_STRING | MF_GRAYED, 0, text.as_ptr()); + } + for (index, agent) in agents.iter().enumerate() { + // 状态点进文案:菜单不画自定义图形,实心/空心圈已经把 + // 「等你」和「在跑」分开。 + let mark = if agent.needs_attention { "● " } else { "○ " }; + let suffix = if agent.needs_attention { " — 等待输入" } else { "" }; + let text = wide(&format!("{mark}{}{suffix}", agent.label)); + AppendMenuW(menu, MF_STRING, MENU_AGENT_BASE + index, text.as_ptr()); + } + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + let show = wide("显示 Nebula"); + AppendMenuW(menu, MF_STRING, MENU_SHOW, show.as_ptr()); + + let mut point = POINT { x: 0, y: 0 }; + GetCursorPos(&mut point); + SetForegroundWindow(hwnd); + let cmd = TrackPopupMenu( + menu, + TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, + point.x, + point.y, + 0, + hwnd, + std::ptr::null(), + ) as usize; + windows_sys::Win32::UI::WindowsAndMessaging::PostMessageW(hwnd, WM_NULL, 0, 0); + DestroyMenu(menu); + + if cmd == MENU_SHOW { + send_focus(None); + } else if cmd >= MENU_AGENT_BASE { + send_focus(agents.get(cmd - MENU_AGENT_BASE).cloned()); + } + } + } + + // ─── 通知区图标 ────────────────────────────────────────────────────── + + fn notify_data( + hwnd: windows_sys::Win32::Foundation::HWND, + ) -> windows_sys::Win32::UI::Shell::NOTIFYICONDATAW { + // SAFETY: NOTIFYICONDATAW 是纯 POD,零值是合法起点。 + let mut data: windows_sys::Win32::UI::Shell::NOTIFYICONDATAW = + unsafe { std::mem::zeroed() }; + data.cbSize = std::mem::size_of::() as u32; + data.hWnd = hwnd; + data.uID = 1; + data + } + + /// 依据 STATE 计算图标形态并 NIM_MODIFY;未挂图标时什么都不做。 + fn refresh_icon(hwnd: windows_sys::Win32::Foundation::HWND) { + use windows_sys::Win32::UI::Shell::{NIF_ICON, NIF_TIP, NIM_MODIFY, Shell_NotifyIconW}; + + let (agent_count, attention_count) = { + let shared = state(); + let attention = + shared.agents.iter().filter(|agent| agent.needs_attention).count(); + (shared.agents.len(), attention) + }; + ICONS.with(|slot| { + let slot = slot.borrow(); + let Some(icons) = slot.as_ref().filter(|icons| icons.added) else { return }; + let mut data = notify_data(hwnd); + data.uFlags = NIF_ICON | NIF_TIP; + data.hIcon = + (if attention_count > 0 { icons.attention } else { icons.normal }) as _; + let tip = if agent_count == 0 { + "Nebula".to_owned() + } else if attention_count > 0 { + format!("Nebula — {attention_count} 个 agent 等待输入") + } else { + format!("Nebula — {agent_count} 个 agent 运行中") + }; + copy_tip(&mut data.szTip, &tip); + // SAFETY: data 完整初始化且 hwnd 属于本线程。 + unsafe { Shell_NotifyIconW(NIM_MODIFY, &data) }; + }); + } + + fn apply_enabled(hwnd: windows_sys::Win32::Foundation::HWND) { + use windows_sys::Win32::UI::Shell::{ + NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, Shell_NotifyIconW, + }; + + let enabled = state().enabled; + ICONS.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(icons) = slot.as_mut() else { return }; + if enabled && !icons.added { + let mut data = notify_data(hwnd); + data.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; + data.uCallbackMessage = WM_APP_TRAY; + data.hIcon = icons.normal as _; + copy_tip(&mut data.szTip, "Nebula"); + // SAFETY: 同上;失败(如 explorer 未起)留 added=false, + // 下一次 WM_APP_SET 重试。 + if unsafe { Shell_NotifyIconW(NIM_ADD, &data) } != 0 { + icons.added = true; + } + } else if !enabled && icons.added { + let data = notify_data(hwnd); + // SAFETY: 删除只需要 hWnd/uID。 + unsafe { + windows_sys::Win32::UI::Shell::Shell_NotifyIconW( + windows_sys::Win32::UI::Shell::NIM_DELETE, + &data, + ) + }; + icons.added = false; + } + }); + if enabled { + refresh_icon(hwnd); + } + } + + fn remove_icon(hwnd: windows_sys::Win32::Foundation::HWND) { + ICONS.with(|slot| { + let mut slot = slot.borrow_mut(); + if let Some(icons) = slot.as_mut().filter(|icons| icons.added) { + let data = notify_data(hwnd); + // SAFETY: 同 apply_enabled 的删除分支。 + unsafe { + windows_sys::Win32::UI::Shell::Shell_NotifyIconW( + windows_sys::Win32::UI::Shell::NIM_DELETE, + &data, + ) + }; + icons.added = false; + } + }); + } + + // ─── 图标像素 ──────────────────────────────────────────────────────── + + /// 从内嵌 logo 造两个 HICON:常态 + attention(右下角橙点)。 + /// 失败退化为空句柄——Shell_NotifyIconW 会用系统默认图标,托盘仍可用。 + fn build_icons() -> ( + windows_sys::Win32::UI::WindowsAndMessaging::HICON, + windows_sys::Win32::UI::WindowsAndMessaging::HICON, + ) { + use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON}; + + // SAFETY: GetSystemMetrics 无前置条件。 + let size = unsafe { GetSystemMetrics(SM_CXSMICON) }.clamp(16, 64) as u32; + let Ok(decoded) = image::load_from_memory(LOGO_PNG) else { + log::warn!("tray: embedded logo failed to decode"); + return (std::ptr::null_mut(), std::ptr::null_mut()); + }; + let base = image::imageops::resize( + &decoded.to_rgba8(), + size, + size, + image::imageops::FilterType::Lanczos3, + ); + let normal = create_icon(size, base.as_raw()); + + let mut attention_pixels = base.clone(); + draw_attention_dot(&mut attention_pixels, size); + let attention = create_icon(size, attention_pixels.as_raw()); + (normal, attention) + } + + /// 右下角实心橙点(约 5/8 直径比)。挑警示橙而不是主题 accent:托盘 + /// 图标不知道主题,且橙点在亮暗任务栏上对比都够。 + fn draw_attention_dot(pixels: &mut image::RgbaImage, size: u32) { + let radius = (size as f32) * 0.30; + let center = (size as f32) - radius - 0.5; + for y in 0..size { + for x in 0..size { + let dx = x as f32 - center; + let dy = y as f32 - center; + let distance = (dx * dx + dy * dy).sqrt(); + // 1px 羽化边,免得小尺寸下是一颗锯齿方块。 + let coverage = (radius - distance + 0.5).clamp(0.0, 1.0); + if coverage > 0.0 { + let pixel = pixels.get_pixel_mut(x, y); + let blend = |src: u8, dst: u8| -> u8 { + (src as f32 * coverage + dst as f32 * (1.0 - coverage)) as u8 + }; + *pixel = image::Rgba([ + blend(255, pixel[0]), + blend(140, pixel[1]), + blend(0, pixel[2]), + blend(255, pixel[3]), + ]); + } + } + } + } + + /// RGBA(直 alpha)→ HICON:32bpp 预乘 BGRA DIB + 单色掩码。 + fn create_icon( + size: u32, + rgba: &[u8], + ) -> windows_sys::Win32::UI::WindowsAndMessaging::HICON { + use windows_sys::Win32::Graphics::Gdi::{ + BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateBitmap, CreateDIBSection, DIB_RGB_COLORS, + DeleteObject, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, ICONINFO}; + + // SAFETY: header 描述的缓冲由 CreateDIBSection 分配并在拷贝期间有效; + // 两个位图在 CreateIconIndirect 后立即释放(图标持有自己的副本)。 + unsafe { + let mut header: BITMAPINFOHEADER = std::mem::zeroed(); + header.biSize = std::mem::size_of::() as u32; + header.biWidth = size as i32; + // 负高 = 自顶向下,行序与解码像素一致,免得手工翻转。 + header.biHeight = -(size as i32); + header.biPlanes = 1; + header.biBitCount = 32; + header.biCompression = BI_RGB; + let info = BITMAPINFO { bmiHeader: header, bmiColors: std::mem::zeroed() }; + + let mut bits: *mut core::ffi::c_void = std::ptr::null_mut(); + let color = CreateDIBSection( + std::ptr::null_mut(), + &info, + DIB_RGB_COLORS, + &mut bits, + std::ptr::null_mut(), + 0, + ); + if color.is_null() || bits.is_null() { + return std::ptr::null_mut(); + } + let out = std::slice::from_raw_parts_mut(bits.cast::(), rgba.len()); + for (dst, src) in out.chunks_exact_mut(4).zip(rgba.chunks_exact(4)) { + let alpha = src[3] as u32; + // 预乘:DWM 按 premultiplied 合成托盘图标,直 alpha 会在 + // 深色任务栏上泛白边。 + dst[0] = (src[2] as u32 * alpha / 255) as u8; + dst[1] = (src[1] as u32 * alpha / 255) as u8; + dst[2] = (src[0] as u32 * alpha / 255) as u8; + dst[3] = src[3]; + } + + let mask = CreateBitmap(size as i32, size as i32, 1, 1, std::ptr::null()); + let icon_info = ICONINFO { + fIcon: 1, + xHotspot: 0, + yHotspot: 0, + hbmMask: mask, + hbmColor: color, + }; + let icon = CreateIconIndirect(&icon_info); + DeleteObject(color); + DeleteObject(mask); + icon + } + } + + // ─── 小工具 ───────────────────────────────────────────────────────── + + fn wide(text: &str) -> Vec { + text.encode_utf16().chain(Some(0)).collect() + } + + /// UTF-16 截断拷贝进 szTip(128 wchar,含 NUL)。 + fn copy_tip(tip: &mut [u16; 128], text: &str) { + let mut cursor = 0; + for unit in text.encode_utf16() { + if cursor >= tip.len() - 1 { + break; + } + tip[cursor] = unit; + cursor += 1; + } + tip[cursor] = 0; + } +} diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 373b32d..f5f4a16 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -2199,6 +2199,54 @@ impl WindowContext { true } + /// 托盘菜单的数据面(T1-3):本窗口所有正在跑 AI CLI 的 pane。与侧栏 + /// 徽章同一事实源(`running_program` + `needs_attention`),托盘因此 + /// 永远和 pane 徽章一致,不另立第二份 agent 状态。 + pub fn tray_agents(&self) -> Vec { + /// 托盘只列 agent 类 CLI(hook 直报的四家 + 图标表认识的其他家)。 + /// vim/cargo 这类长命令不进托盘——它们不会「停下来等你回答」。 + fn is_agent(program: &str) -> bool { + matches!( + program, + "claude" + | "codex" + | "opencode" + | "pi" + | "gemini" + | "copilot" + | "cursor-agent" + | "grok" + | "grok-cli" + | "aider" + | "goose" + | "crush" + ) + } + self.panes + .iter() + .filter_map(|pane| { + let state = &pane.nebula_state; + let program = state.running_program.as_deref().filter(|p| is_agent(p))?; + // 「哪个项目」比「哪个 pane id」有意义:取 cwd 尾段。 + let place = std::path::Path::new(state.cwd.trim()) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let label = if place.is_empty() { + program.to_owned() + } else { + format!("{program} · {place}") + }; + Some(crate::tray::TrayAgent { + window: self.display.window.id(), + pane: pane.id, + label, + needs_attention: state.needs_attention, + }) + }) + .collect() + } + /// 后台修复请求(spec 001)的结果落地:pane 归属本窗口即认领(返回 /// true,AiHook 同款路由契约)。写入前校验 seq——条子已被用户撤掉、或 /// 新失败已顶掉旧请求时,迟到的响应直接丢弃。 @@ -2219,6 +2267,20 @@ impl WindowContext { true } + /// 远程备份线程收尾:设置页状态行是第一现场,message bar 兜底通知 + /// 没开设置页的窗口。 + pub fn handle_backup_remote_done(&mut self, message: &str, error: bool) { + self.display.backup_remote_done(message, error); + let ty = if error { + crate::message_bar::MessageType::Error + } else { + crate::message_bar::MessageType::Warning + }; + self.message_buffer.push(crate::message_bar::Message::new(format!("备份:{message}"), ty)); + self.dirty = true; + self.display.window.request_redraw(); + } + /// 同步线程收尾(spec 003):消息进 message bar;拉到新历史时热加载 /// (ghost 补全立即吃到另一台机器的命令)。settings 变化不用管—— /// mtime 监视在下一帧自动 reload。