diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7ae53..9f8adee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ ## [Unreleased] +## [0.1.3] - 2026-10-18 + +### 修复 + +- 创建卷遇到 `HRESULT=0x800700B7`(已存在)时给出中文说明,并把会话里已有、列表缺失的卷重新同步进列表。 + - Map volume-create `0x800700B7` (already exists) to a clear message and re-list orphan session VHDs. +- 程序已在运行时再次点桌面图标:不再弹「不能多开」,直接显示并前置已有窗口(含最小化 / 托盘隐藏)。 + - A second launch focuses the existing window (including tray-hidden) instead of showing an already-running dialog. + +### 新增 + +- 删除容器时可单独确认是否同时删除关联命名卷(默认不删,避免误清数据库等数据)。 + - Removing a container can optionally delete attached named volumes; off by default. +- 关闭窗口首次提示:最小化到系统托盘(容器继续运行)或退出并停止本会话所有运行中容器;选择会写入设置,可随时改。托盘图标常驻,可显示窗口 / 退出并保留容器 / 退出并停止容器。 + - First close asks minimize-to-tray vs quit-and-stop; preference is saved. Resident tray icon for show / quit-keep / quit-stop. +- 设置页底部「关于」:版本号、GitHub / 网站链接;对照 GitHub Releases 检查更新,有新版本时跳转到对应 Release 页。 + - Settings → About: version, GitHub/website links, and a GitHub Releases update check that opens the release page. + ### 变更 - 启动门禁只读注册表 `HKLM\...\Lxss\Msi\Version`(与 WSL 自身一致),不再调用 `wsl --version`。缺键即视为未安装。SDK 加载后的版本仍用 `wslc::Service::version()`。 @@ -15,6 +33,9 @@ - 移除未使用的 `session_stats` / `SessionStats` / `Cmd::Stats` 路径。 - Remove unused session-stats plumbing. +- 补齐界面文案国际化(引擎徽章、关于 / 更新检查、Inspect·Exec 标题、内存/卷容量单位、卷删除部分失败提示等);统一卡片 / 徽章 / 日志面板 / 模态框宽度等可复用样式。 + - Complete remaining UI i18n and share card / badge / log-pane / modal helpers across screens. + ## [0.1.2] - 2026-10-11 ### 修复 @@ -60,7 +81,8 @@ - 容器日志走 [`timeroute/wslc-rs`](https://github.com/timeroute/wslc-rs) 的 `LogsOptions`(`wslc --session wslc-desktop container logs`)。 - Container logs use the `wslc-rs` fork rather than a separate CLI path. -[Unreleased]: https://github.com/timeroute/wslc-desktop/compare/v0.1.2...HEAD +[Unreleased]: https://github.com/timeroute/wslc-desktop/compare/v0.1.3...HEAD +[0.1.3]: https://github.com/timeroute/wslc-desktop/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/timeroute/wslc-desktop/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/timeroute/wslc-desktop/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/timeroute/wslc-desktop/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index d6b77e2..8792bdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2865,6 +2865,24 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d337c9acf058238e45ce0570d8a5958439242889492f91ae2d8e324211a61ccd" +[[package]] +name = "gpui-tray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c01af03fe16178c6051e7611ae1699d4d6d5b537d579e38aea7efeec0af2a433" +dependencies = [ + "async-channel", + "gpui-pre", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "thiserror 2.0.21", + "windows 0.62.2", + "zbus", +] + [[package]] name = "granit-parser" version = "1.3.0" @@ -8466,10 +8484,12 @@ dependencies = [ [[package]] name = "wslc-desktop" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "gpui-kit", + "gpui-tray", + "raw-window-handle", "serde", "serde_json", "smol", diff --git a/Cargo.toml b/Cargo.toml index 6f9a2a8..cfc3c02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wslc-desktop" -version = "0.1.2" +version = "0.1.3" edition = "2024" description = "Lightweight desktop manager for Windows WSL Containers (WSLc)" license = "Apache-2.0" @@ -24,20 +24,23 @@ upgrade-guid = "31263ED9-28F7-4608-AF04-A8CAE4591EB0" [dependencies] anyhow = "1" gpui-kit = "0.7.1" +raw-window-handle = "0.6" serde = { version = "1", features = ["derive"] } serde_json = "1" smol = "2" sys-locale = "0.3" +# GitHub release check (About) + Windows NuGet SDK fetch. +ureq = { version = "2.12", default-features = true, features = ["json"] } # Official WSL Containers SDK bindings (`wslcsdk.dll`). Windows host only. -# ureq/zip: download matching Microsoft.WSL.Containers native DLL at runtime. +# zip: extract matching Microsoft.WSL.Containers native DLL at runtime. [target.'cfg(windows)'.dependencies] # Own fork of wslc-rs (OpenContainer / Logs / named volumes / extra SDK exports). # Bump `rev` after pushing patches to https://github.com/timeroute/wslc-rs . # Prefer git over vendor so desktop tracks the fork tip without copying the tree. wslc = { git = "https://github.com/timeroute/wslc-rs", rev = "9fbcd72f6967f79d0dc805648120eaec77364a2e" } -ureq = { version = "2.12", default-features = true, features = ["json"] } zip = { version = "2", default-features = false, features = ["deflate"] } +gpui-tray = { version = "0.1", default-features = false, features = ["gpui-kit", "menu-state"] } windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_System_Registry", diff --git a/README.md b/README.md index 58cc3ec..74baea2 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ | **Containers** | 创建(端口 / bind / 命名卷);行内 Start / Stop / Restart / Logs / Inspect / Exec / Remove;catalog + `OpenContainer` 跨重启恢复 | | **Images** | 拉取弹窗;行内创建容器 / 删除 | | **Volumes** | 弹窗创建(1/2/5/10 GiB);行内删除 | -| **设置** | 可滚动;语言、主题、会话 CPU / 内存(下次新建会话生效) | +| **设置** | 可滚动;语言、主题、关闭窗口行为、会话 CPU / 内存(下次新建会话生效);关于(版本 / GitHub / 检查更新) | -Logs 走 fork 的 `LogsOptions`(底层 `wslc --session wslc-desktop container logs`)。退出应用**不会** `terminate` 会话,便于下次重连。 +Logs 走 fork 的 `LogsOptions`(底层 `wslc --session wslc-desktop container logs`)。关闭窗口可最小化到托盘(容器继续运行),或退出并停止运行中的容器;删除容器时命名卷需单独确认。会话本身不会在退出时 `terminate`,便于下次重连。 ## 从源码构建 @@ -54,7 +54,7 @@ cargo test ```powershell cargo wix # 产物:target\wix\WSLc.Desktop--x86_64.msi -# 当前 0.1.2:WSLc.Desktop-0.1.2-x86_64.msi +# 当前 0.1.3:WSLc.Desktop-0.1.3-x86_64.msi ``` 可选:`cargo wix --no-build`(已有 release 产物时)、`cargo wix --sign`(需 `signtool`)。 @@ -64,9 +64,9 @@ cargo wix 打 `v*` tag 会触发 [Windows MSI](.github/workflows/windows-msi.yml) 工作流:构建 MSI 并创建 Release 挂载安装包。 ```powershell -# 版本与 Cargo.toml 对齐,例如 0.1.2 → v0.1.2 -git tag v0.1.2 -git push origin v0.1.2 +# 版本与 Cargo.toml 对齐,例如 0.1.3 → v0.1.3 +git tag v0.1.3 +git push origin v0.1.3 ``` 也可:push / PR 到 `main` 仅上传 artifact;或 Actions → **Windows MSI** → **Run workflow**。 diff --git a/src/app/close.rs b/src/app/close.rs new file mode 100644 index 0000000..3564651 --- /dev/null +++ b/src/app/close.rs @@ -0,0 +1,265 @@ + +use gpui_kit::component::{ + ActiveTheme, IconName, Sizable as _, StyledExt as _, + button::{Button, ButtonVariants as _}, + h_flex, v_flex, +}; +use gpui_kit::*; +use smol::Timer; + +use crate::i18n::{self}; +use crate::wslc::{CloseBehavior, WslcClient}; + +use super::widgets::{modal_panel, modal_scrim}; +use super::AppView; + +impl AppView { + /// Window close / Alt+F4 path. Returns `false` to keep the window open. + pub(crate) fn handle_window_close_request( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.allow_window_close { + return true; + } + if self.close_prompt_open { + return false; + } + match self.session_prefs.close_behavior { + CloseBehavior::Ask => { + self.close_prompt_open = true; + cx.notify(); + false + } + CloseBehavior::MinimizeToTray => { + self.minimize_to_tray(window, cx); + false + } + CloseBehavior::QuitAndStopContainers => { + self.begin_quit_and_stop_containers(cx); + false + } + } + } + + pub(crate) fn dismiss_close_prompt(&mut self, cx: &mut Context) { + self.close_prompt_open = false; + cx.notify(); + } + + pub(crate) fn choose_minimize_to_tray( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + self.session_prefs.close_behavior = CloseBehavior::MinimizeToTray; + let _ = self.persist_close_behavior(cx); + self.close_prompt_open = false; + self.minimize_to_tray(window, cx); + } + + pub(crate) fn choose_quit_and_stop(&mut self, cx: &mut Context) { + self.session_prefs.close_behavior = CloseBehavior::QuitAndStopContainers; + let _ = self.persist_close_behavior(cx); + self.close_prompt_open = false; + self.begin_quit_and_stop_containers(cx); + } + + pub(crate) fn set_close_behavior( + &mut self, + behavior: CloseBehavior, + cx: &mut Context, + ) { + self.session_prefs.close_behavior = behavior; + match self.persist_close_behavior(cx) { + Ok(()) => self.set_status(self.t().settings_close_saved, cx), + Err(err) => { + let msg = i18n::fmt1(self.t().error_prefix, "err", err); + self.set_status(msg, cx); + } + } + } + + fn persist_close_behavior(&mut self, _cx: &mut Context) -> Result<(), String> { + let prefs = self.session_prefs.clone().clamp(); + self.session_prefs = prefs.clone(); + WslcClient::save_session_prefs(prefs).map_err(|e| format!("{e}")) + } + + pub(crate) fn minimize_to_tray(&mut self, window: &mut Window, cx: &mut Context) { + self.close_prompt_open = false; + self.window_hidden = true; + hide_native_window(window); + self.set_status(self.t().minimized_to_tray, cx); + cx.notify(); + } + + pub(crate) fn show_from_tray(&mut self, window: &mut Window, cx: &mut Context) { + self.window_hidden = false; + show_native_window(window); + window.activate_window(); + cx.notify(); + } + + /// Quit the UI process without stopping containers (tray / explicit keep). + pub(crate) fn quit_keep_containers(&mut self, cx: &mut Context) { + self.allow_window_close = true; + self.close_prompt_open = false; + cx.quit(); + } + + pub(crate) fn begin_quit_and_stop_containers(&mut self, cx: &mut Context) { + self.close_prompt_open = false; + let Some(client) = self.client.clone() else { + self.allow_window_close = true; + cx.quit(); + return; + }; + + if self.busy { + // Already mid-op; still quit after a best-effort stop on a fresh thread. + } + + self.set_status(self.t().stopping_containers_on_quit, cx); + let template = self.t().quit_stopped_containers; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("wslc-quit-stop".into()) + .spawn(move || { + let result = client + .stop_all_running_containers() + .map(|count| i18n::fmt1(template, "count", count.to_string())); + let _ = tx.send(result); + }) + .expect("spawn quit-stop thread"); + + self.busy = true; + self.busy_rx = Some(rx); + self.pending_quit_after_stop = true; + self.busy_poll_generation = self.busy_poll_generation.wrapping_add(1); + let generation = self.busy_poll_generation; + cx.notify(); + + cx.spawn(async move |this, cx| { + loop { + Timer::after(std::time::Duration::from_millis(50)).await; + let cont = this + .update(cx, |this, cx| { + if this.busy_poll_generation != generation { + return false; + } + this.poll_busy(cx) + }) + .unwrap_or(false); + if !cont { + break; + } + } + }) + .detach(); + } + + pub(crate) fn finish_quit_after_stop(&mut self, cx: &mut Context) { + self.pending_quit_after_stop = false; + self.allow_window_close = true; + cx.quit(); + } + + pub(crate) fn render_close_prompt_modal(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + + modal_scrim( + "close-prompt-modal", + cx, + modal_panel(cx) + .child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child(div().font_semibold().text_base().child(t.close_prompt_title)) + .child( + Button::new("close-prompt-dismiss") + .ghost() + .small() + .icon(IconName::Close) + .tooltip(t.close) + .on_click(cx.listener(|this, _, _, cx| { + this.dismiss_close_prompt(cx); + })), + ), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t.close_prompt_body), + ) + .child( + v_flex() + .gap_2() + .child( + Button::new("close-prompt-minimize") + .label(t.close_prompt_minimize) + .on_click(cx.listener(|this, _, window, cx| { + this.choose_minimize_to_tray(window, cx); + })), + ) + .child( + Button::new("close-prompt-quit-stop") + .danger() + .label(t.close_prompt_quit_stop) + .on_click(cx.listener(|this, _, _, cx| { + this.choose_quit_and_stop(cx); + })), + ), + ), + ) + } +} + +fn hide_native_window(window: &mut Window) { + #[cfg(windows)] + { + if let Some(hwnd) = native_hwnd(window) { + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow( + hwnd, + windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE, + ); + } + return; + } + } + window.minimize_window(); +} + +fn show_native_window(window: &mut Window) { + #[cfg(windows)] + { + if let Some(hwnd) = native_hwnd(window) { + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow( + hwnd, + windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW, + ); + } + return; + } + } + window.activate_window(); +} + +#[cfg(windows)] +fn native_hwnd(window: &Window) -> Option { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + // UFCS: GPUI's `Window::window_handle()` returns `AnyWindowHandle` and shadows + // the raw-window-handle trait method of the same name. + let handle = HasWindowHandle::window_handle(window).ok()?; + match handle.as_raw() { + RawWindowHandle::Win32(win32) => Some(win32.hwnd.get() as _), + _ => None, + } +} diff --git a/src/app/confirm.rs b/src/app/confirm.rs index 3a73901..225291d 100644 --- a/src/app/confirm.rs +++ b/src/app/confirm.rs @@ -1,9 +1,11 @@ use gpui_kit::component::{ - ActiveTheme, Sizable as _, StyledExt as _, + ActiveTheme, IconName, Sizable as _, StyledExt as _, button::{Button, ButtonVariants as _}, - h_flex, + checkbox::Checkbox, + h_flex, v_flex, }; +use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::i18n::{self}; @@ -18,6 +20,7 @@ impl AppView { || self.busy || self.pull_phase == PullPhase::Pulling || self.confirm.is_some() + || self.close_prompt_open { return; } @@ -30,16 +33,61 @@ impl AppView { cx.notify(); } + pub(crate) fn set_confirm_remove_volumes(&mut self, checked: bool, cx: &mut Context) { + if let Some(ConfirmAction::RemoveContainer { + remove_volumes, + named_volumes, + .. + }) = self.confirm.as_mut() + { + if !named_volumes.is_empty() { + *remove_volumes = checked; + cx.notify(); + } + } + } + pub(crate) fn submit_confirm(&mut self, cx: &mut Context) { let Some(action) = self.confirm.take() else { return; }; match action { - ConfirmAction::RemoveContainer { key, .. } => { - let template = self.t().removed; + ConfirmAction::RemoveContainer { + key, + remove_volumes, + named_volumes, + .. + } => { + let removed = self.t().removed; + let removed_with_volumes = self.t().removed_container_with_volumes; + let volumes_partial = self.t().removed_volumes_partial; self.with_busy(cx, move |client| { client.remove_container(&key)?; - Ok(i18n::fmt1(template, "name", &key)) + if remove_volumes { + let mut failed = Vec::new(); + for name in &named_volumes { + if let Err(err) = client.remove_volume(name) { + failed.push(format!("{name}: {err}")); + } + } + if failed.is_empty() { + Ok(i18n::fmt2( + removed_with_volumes, + "name", + &key, + "volumes", + named_volumes.join(", "), + )) + } else { + Err(anyhow::anyhow!(i18n::fmt1( + volumes_partial, + "err", + failed.join("; ") + ))) + } + } else { + Ok(i18n::fmt1(removed, "name", &key)) + } }); } ConfirmAction::RemoveImage { reference } => { @@ -66,15 +114,33 @@ impl AppView { .as_ref() .expect("confirm modal rendered only when open"); let title = t.confirm_remove_title; - let body = match action { - ConfirmAction::RemoveContainer { label, .. } => { - i18n::fmt1(t.confirm_remove_container, "name", label) + let (body, volumes_row) = match action { + ConfirmAction::RemoveContainer { + label, + named_volumes, + remove_volumes, + .. + } => { + let body = i18n::fmt1(t.confirm_remove_container, "name", label); + let volumes_row = if named_volumes.is_empty() { + None + } else { + Some(( + i18n::fmt1( + t.confirm_remove_container_volumes, + "volumes", + named_volumes.join(", "), + ), + *remove_volumes, + )) + }; + (body, volumes_row) } ConfirmAction::RemoveImage { reference } => { - i18n::fmt1(t.confirm_remove_image, "name", reference) + (i18n::fmt1(t.confirm_remove_image, "name", reference), None) } ConfirmAction::RemoveVolume { name } => { - i18n::fmt1(t.confirm_remove_volume, "name", name) + (i18n::fmt1(t.confirm_remove_volume, "name", name), None) } }; @@ -82,14 +148,50 @@ impl AppView { "confirm-remove-modal", cx, modal_panel(cx) - .w(px(420.)) - .child(div().font_semibold().text_base().child(title)) + .child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child(div().font_semibold().text_base().child(title)) + .child( + Button::new("confirm-dismiss") + .ghost() + .small() + .icon(IconName::Close) + .tooltip(t.close) + .on_click(cx.listener(|this, _, _, cx| { + this.dismiss_confirm(cx); + })), + ), + ) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) .child(body), ) + .when_some(volumes_row, |panel, (hint, selected)| { + panel.child( + v_flex() + .gap_2() + .pt_1() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.confirm_remove_container_volumes_hint), + ) + .child( + Checkbox::new("confirm-remove-volumes") + .label(hint) + .checked(selected) + .on_change(cx.listener(|this, checked, _, cx| { + this.set_confirm_remove_volumes(*checked, cx); + })), + ), + ) + }) .child( h_flex() .justify_end() diff --git a/src/app/containers.rs b/src/app/containers.rs index a8e8bbe..16f0a77 100644 --- a/src/app/containers.rs +++ b/src/app/containers.rs @@ -16,10 +16,13 @@ use crate::wslc::{ }; use super::AppView; -use super::parsing::{parse_bind_mounts, parse_named_volumes, parse_port_mappings}; +use super::parsing::{ + named_volumes_attached, parse_bind_mounts, parse_named_volumes, parse_port_mappings, +}; use super::state::{ConfirmAction, PullPhase}; use super::widgets::{ - list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, settings_card, + list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, scroll_log_pane, + settings_card, }; impl AppView { @@ -360,7 +363,7 @@ impl AppView { return; }; let key = Self::container_key(item); - let title = format!("{} · inspect", key); + let title = i18n::fmt1(self.t().container_inspect_heading, "name", &key); let failed = self.t().container_inspect_failed; self.with_busy(cx, move |client| { let text = client @@ -411,7 +414,7 @@ impl AppView { return; } let command = self.exec_input.read(cx).value().to_string(); - let title = format!("{key} · exec"); + let title = i18n::fmt1(self.t().container_exec_heading, "name", &key); let failed = self.t().container_exec_failed; self.exec_modal_open = false; self.with_busy(cx, move |client| { @@ -631,8 +634,15 @@ impl AppView { } else { item.name.clone() }; + let named_volumes = + named_volumes_attached(&item.volumes, &this.volumes); this.request_confirm( - ConfirmAction::RemoveContainer { key, label }, + ConfirmAction::RemoveContainer { + key, + label, + named_volumes, + remove_volumes: false, + }, cx, ); })), @@ -1098,23 +1108,13 @@ impl AppView { ), ) .child( - div() - .id("container-logs-body") - .h(px(320.)) - .w_full() - .overflow_y_scroll() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background.opacity(0.65)) - .p_2() - .child( - div() - .text_xs() - .font_family("monospace") - .text_color(cx.theme().foreground) - .child(body), - ), + scroll_log_pane("container-logs-body", 320., cx).child( + div() + .text_xs() + .font_family("monospace") + .text_color(cx.theme().foreground) + .child(body), + ), ) .child( h_flex().justify_end().child( diff --git a/src/app/images.rs b/src/app/images.rs index 61602f7..506cc16 100644 --- a/src/app/images.rs +++ b/src/app/images.rs @@ -22,7 +22,8 @@ use super::state::{ConfirmAction, PullPhase, MAX_PULL_LOG_LINES}; use super::AppView; use super::poll::recv_all; use super::widgets::{ - list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, + list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, scroll_log_pane, + status_badge, }; impl AppView { @@ -174,15 +175,7 @@ impl AppView { PullPhase::Success => cx.theme().green, PullPhase::Failed => cx.theme().red, }; - div() - .px_2() - .py_0p5() - .rounded(cx.theme().radius) - .bg(color.opacity(0.18)) - .text_color(color) - .text_xs() - .font_semibold() - .child(self.pull_phase.label(self.t())) + status_badge(self.pull_phase.label(self.t()), color, cx) } pub(crate) fn render_image_row( &self, @@ -341,30 +334,20 @@ impl AppView { ), ) .child( - div() - .id("pull-log") - .h(px(220.)) - .w_full() - .overflow_y_scroll() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background.opacity(0.65)) - .p_2() - .child( - v_flex().gap_0p5().w_full().map(|list| { - if log_lines.is_empty() { - list.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.pull_logs_placeholder), - ) - } else { - list.children(log_lines) - } - }), - ), + scroll_log_pane("pull-log", 220., cx).child( + v_flex().gap_0p5().w_full().map(|list| { + if log_lines.is_empty() { + list.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.pull_logs_placeholder), + ) + } else { + list.children(log_lines) + } + }), + ), ) .when(can_close, |el| { el.child( diff --git a/src/app/mod.rs b/src/app/mod.rs index bf62606..c4c968e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -10,6 +10,7 @@ use crate::wslc::{ TemplateStore, VolumeInfo, WslcClient, }; +mod close; mod confirm; mod containers; mod images; @@ -21,12 +22,21 @@ mod sdk_setup; mod settings; mod shell; mod state; +pub(crate) mod tray_icon; mod volumes; mod widgets; pub use state::Section; -use state::{ConfirmAction, PullPhase, SdkSetupPhase}; +use state::{ConfirmAction, PullPhase, SdkSetupPhase, UpdateCheckPhase}; + +/// Shared handles so tray actions can reach the main window / view. +pub struct DesktopShell { + pub window: AnyWindowHandle, + pub view: WeakEntity, +} + +impl Global for DesktopShell {} pub struct AppView { client: Option, @@ -74,6 +84,10 @@ pub struct AppView { exec_modal_open: bool, exec_target_key: SharedString, confirm: Option, + close_prompt_open: bool, + window_hidden: bool, + allow_window_close: bool, + pending_quit_after_stop: bool, pull_phase: PullPhase, pull_status: SharedString, pull_progress: f32, @@ -92,6 +106,8 @@ pub struct AppView { sdk_logs_open: bool, sdk_logs_copied: bool, sdk_log_error: Option, + update_check: UpdateCheckPhase, + update_check_poll_generation: u64, } impl AppView { @@ -177,6 +193,10 @@ impl AppView { exec_modal_open: false, exec_target_key: SharedString::default(), confirm: None, + close_prompt_open: false, + window_hidden: false, + allow_window_close: false, + pending_quit_after_stop: false, pull_phase: PullPhase::Idle, pull_status: t.pull_hint.into(), pull_progress: 0.0, @@ -194,6 +214,8 @@ impl AppView { sdk_logs_open: false, sdk_logs_copied: false, sdk_log_error: None, + update_check: UpdateCheckPhase::Idle, + update_check_poll_generation: 0, }; view.begin_sdk_setup(false, cx); view @@ -256,13 +278,17 @@ impl Render for AppView { .when(self.confirm.is_some(), |root| { root.child(self.render_confirm_modal(cx)) }) + .when(self.close_prompt_open, |root| { + root.child(self.render_close_prompt_modal(cx)) + }) .when( self.busy && !show_setup && !self.pull_modal_open && !self.volume_modal_open && !self.exec_modal_open - && self.confirm.is_none(), + && self.confirm.is_none() + && !self.close_prompt_open, |root| root.child(self.render_busy_overlay(cx)), ) } diff --git a/src/app/overview.rs b/src/app/overview.rs index 4eda05f..88b4c66 100644 --- a/src/app/overview.rs +++ b/src/app/overview.rs @@ -7,13 +7,14 @@ use gpui_kit::component::{ }; use gpui_kit::*; -use crate::wslc::BackendKind; +use crate::i18n; +use crate::wslc::{APP_VERSION, BackendKind}; use super::state::Section; +use super::widgets::settings_card; use super::AppView; const SESSION_NAME: &str = "wslc-desktop"; -const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); impl AppView { pub(crate) fn render_overview(&self, cx: &mut Context) -> impl IntoElement { @@ -96,7 +97,11 @@ impl AppView { if self.status.memory_mb == 0 { t.overview_na.to_string() } else { - format!("{} MiB", self.status.memory_mb) + i18n::fmt1( + t.memory_mib, + "mb", + self.status.memory_mb.to_string(), + ) }, None, cx, @@ -206,15 +211,9 @@ impl AppView { cx: &Context, child: impl IntoElement, ) -> impl IntoElement { - v_flex() + settings_card(cx) .flex_1() .min_w_0() - .gap_3() - .p_4() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().secondary.opacity(0.28)) .child(div().font_medium().text_sm().child(title)) .child(child) } diff --git a/src/app/parsing.rs b/src/app/parsing.rs index 68b1156..babb220 100644 --- a/src/app/parsing.rs +++ b/src/app/parsing.rs @@ -1,4 +1,4 @@ -use crate::wslc::{PortMapping, VolumeMapping}; +use crate::wslc::{PortMapping, VolumeInfo, VolumeMapping}; pub(crate) fn parse_port_mappings(raw: &str) -> Result, String> { let mut out = Vec::new(); @@ -77,6 +77,46 @@ fn looks_like_windows_drive(host: &str) -> bool { bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' } +/// Session VHD volume names referenced by a container's mounts label. +/// +/// Only names that exist in `known` are returned, so bind mounts and inspect +/// host paths are never treated as deletable named volumes. +pub(crate) fn named_volumes_attached(volumes_label: &str, known: &[VolumeInfo]) -> Vec { + if volumes_label.trim().is_empty() || known.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + for part in volumes_label.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let part = part.strip_prefix("vol:").unwrap_or(part); + let lower = part.to_ascii_lowercase(); + let core = if lower.ends_with(":ro") { + part[..part.len() - 3].trim_end() + } else { + part + }; + let host = if let Some(idx) = core.find(":/") { + core[..idx].trim() + } else { + core.trim() + }; + if host.is_empty() + || host.contains('\\') + || host.contains('/') + || looks_like_windows_drive(host) + { + continue; + } + if known.iter().any(|v| v.name == host) && !out.iter().any(|n| n == host) { + out.push(host.to_string()); + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -94,4 +134,29 @@ mod tests { assert!(!bind[0].named); assert!(bind[0].read_only); } + + #[test] + fn extracts_only_known_named_volumes() { + let known = vec![ + VolumeInfo { + name: "data".into(), + driver: "vhd".into(), + mountpoint: String::new(), + created: String::new(), + size: "1 GiB".into(), + }, + VolumeInfo { + name: "db".into(), + driver: "vhd".into(), + mountpoint: String::new(), + created: String::new(), + size: "2 GiB".into(), + }, + ]; + let names = named_volumes_attached( + r"vol:data:/data, C:\logs:/var/log, vol:db:/var/lib/mysql:ro, other:/tmp", + &known, + ); + assert_eq!(names, vec!["data".to_string(), "db".to_string()]); + } } diff --git a/src/app/refresh.rs b/src/app/refresh.rs index f30b209..5f86815 100644 --- a/src/app/refresh.rs +++ b/src/app/refresh.rs @@ -1,7 +1,7 @@ use std::sync::mpsc; use std::time::Duration; -use gpui_kit::component::{ActiveTheme, Theme, ThemeMode, h_flex, label::Label, v_flex}; +use gpui_kit::component::{Theme, ThemeMode, label::Label}; use gpui_kit::*; use smol::Timer; @@ -11,7 +11,7 @@ use crate::wslc::{BackendKind, EngineStatus, WslcClient}; use super::AppView; use super::poll::recv_all; use super::state::PullPhase; -use super::widgets::modal_scrim; +use super::widgets::{modal_panel, modal_scrim}; impl AppView { pub(crate) fn apply_locale( @@ -25,6 +25,7 @@ impl AppView { } self.locale = locale; let t = self.t(); + super::tray_icon::refresh_menu(cx); self.name_input.update(cx, |state, cx| { state.set_placeholder(t.name_placeholder, window, cx); }); @@ -270,7 +271,7 @@ impl AppView { } /// Returns `true` while the background op is still running. - fn poll_busy(&mut self, cx: &mut Context) -> bool { + pub(crate) fn poll_busy(&mut self, cx: &mut Context) -> bool { let Some(rx) = self.busy_rx.take() else { return false; }; @@ -295,12 +296,28 @@ impl AppView { if self.create_modal_open { self.create_modal_open = false; } - self.set_status(msg, cx); - self.refresh(cx); + if self.pending_quit_after_stop { + self.set_status(msg, cx); + self.finish_quit_after_stop(cx); + } else { + self.set_status(msg, cx); + self.refresh(cx); + } } Err(err) => { - let msg = i18n::fmt1(self.t().error_prefix, "err", err.to_string()); + let raw = err.to_string(); + let msg = if let Some(name) = raw.strip_prefix("__VOLUME_EXISTS__\0") { + // Catalog may have been repaired; refresh so the row appears. + self.refresh(cx); + i18n::fmt1(self.t().volume_already_exists, "name", name) + } else { + i18n::fmt1(self.t().error_prefix, "err", raw) + }; self.set_status(msg, cx); + if self.pending_quit_after_stop { + // Still leave so a stuck stop cannot pin the UI forever. + self.finish_quit_after_stop(cx); + } } } cx.notify(); @@ -322,22 +339,10 @@ impl AppView { modal_scrim( "busy-overlay", cx, - h_flex() + modal_panel(cx) + .w(px(280.)) .items_center() - .justify_center() - .px_6() - .py_4() - .rounded(cx.theme().radius_lg) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background) - .shadow_md() - .child( - v_flex() - .gap_1() - .items_center() - .child(Label::new(t.working).text_sm()), - ), + .child(Label::new(t.working).text_sm()), ) } } diff --git a/src/app/sdk_setup.rs b/src/app/sdk_setup.rs index ad5e576..a92761d 100644 --- a/src/app/sdk_setup.rs +++ b/src/app/sdk_setup.rs @@ -19,7 +19,7 @@ use crate::wslc::{ use super::AppView; use super::poll::recv_all; use super::state::{MAX_SDK_LOG_LINES, SdkSetupPhase}; -use super::widgets::{modal_panel, modal_scrim}; +use super::widgets::{modal_panel, modal_scrim, scroll_log_pane, status_badge}; impl AppView { pub(crate) fn append_sdk_log(&mut self, line: impl Into) { @@ -301,17 +301,7 @@ impl AppView { .child(index.to_string()), ) .child(div().flex_1().text_sm().font_medium().child(title)) - .child( - div() - .px_2() - .py_0p5() - .rounded(cx.theme().radius) - .bg(color.opacity(0.18)) - .text_color(color) - .text_xs() - .font_semibold() - .child(badge), - ), + .child(status_badge(badge, color, cx)), ) .when_some(detail, |row, (text, color)| { row.child(div().pl_6().text_xs().text_color(color).child(text)) @@ -435,17 +425,8 @@ impl AppView { .when(logs_open, |panel| { panel .child( - div() - .id("sdk-setup-logs") - .h(px(220.)) - .w_full() - .overflow_y_scroll() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background.opacity(0.65)) - .p_2() - .child(v_flex().gap_0p5().w_full().map(|list| { + scroll_log_pane("sdk-setup-logs", 220., cx).child( + v_flex().gap_0p5().w_full().map(|list| { if logs.is_empty() { list.child( div() diff --git a/src/app/settings.rs b/src/app/settings.rs index 965a258..07fb514 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -1,17 +1,23 @@ +use std::sync::mpsc; +use std::time::Duration; use gpui_kit::component::{ - ActiveTheme, Sizable as _, StyledExt as _, ThemeMode, + ActiveTheme, Disableable as _, Sizable as _, ThemeMode, button::{Button, ButtonVariants as _}, - h_flex, - v_flex, + h_flex, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; +use smol::Timer; -use crate::i18n::Locale; -use crate::wslc::{SessionPrefs, WslcClient}; +use crate::i18n::{self, Locale}; +use crate::wslc::{ + APP_VERSION, CloseBehavior, GITHUB_REPO_URL, HOMEPAGE_URL, SessionPrefs, UpdateStatus, + WslcClient, check_latest, open_url, releases_url, +}; -use super::widgets::settings_card; +use super::state::UpdateCheckPhase; +use super::widgets::settings_card_section; use super::AppView; const CPU_CHOICES: [u32; 5] = [1, 2, 4, 6, 8]; @@ -24,7 +30,96 @@ impl AppView { match WslcClient::save_session_prefs(prefs) { Ok(()) => self.set_status(self.t().settings_prefs_saved, cx), Err(err) => { - let msg = crate::i18n::fmt1(self.t().error_prefix, "err", err.to_string()); + let msg = i18n::fmt1(self.t().error_prefix, "err", err.to_string()); + self.set_status(msg, cx); + } + } + } + + /// Open Settings and kick off a GitHub release check if needed. + pub(crate) fn open_settings(&mut self, cx: &mut Context) { + self.section = super::state::Section::Settings; + self.ensure_update_check(cx); + cx.notify(); + } + + /// Start a release check when idle / after a previous failure. + pub(crate) fn ensure_update_check(&mut self, cx: &mut Context) { + match &self.update_check { + UpdateCheckPhase::Idle | UpdateCheckPhase::Failed { .. } => { + self.begin_update_check(cx); + } + UpdateCheckPhase::Checking + | UpdateCheckPhase::UpToDate { .. } + | UpdateCheckPhase::Available { .. } => {} + } + } + + pub(crate) fn begin_update_check(&mut self, cx: &mut Context) { + if matches!(self.update_check, UpdateCheckPhase::Checking) { + return; + } + let (tx, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("wslc-update-check".into()) + .spawn(move || { + let _ = tx.send(check_latest(APP_VERSION)); + }) + .expect("spawn update-check thread"); + + self.update_check = UpdateCheckPhase::Checking; + self.update_check_poll_generation = self.update_check_poll_generation.wrapping_add(1); + let generation = self.update_check_poll_generation; + cx.notify(); + + cx.spawn(async move |this, cx| { + loop { + Timer::after(Duration::from_millis(50)).await; + let cont = this + .update(cx, |this, cx| { + if this.update_check_poll_generation != generation { + return false; + } + match rx.try_recv() { + Ok(Ok(UpdateStatus::UpToDate { latest })) => { + this.update_check = UpdateCheckPhase::UpToDate { latest }; + cx.notify(); + false + } + Ok(Ok(UpdateStatus::Available { latest, url })) => { + this.update_check = UpdateCheckPhase::Available { latest, url }; + cx.notify(); + false + } + Ok(Err(err)) => { + this.update_check = UpdateCheckPhase::Failed { message: err }; + cx.notify(); + false + } + Err(mpsc::TryRecvError::Empty) => true, + Err(mpsc::TryRecvError::Disconnected) => { + this.update_check = UpdateCheckPhase::Failed { + message: this.t().op_channel_disconnected.to_string(), + }; + cx.notify(); + false + } + } + }) + .unwrap_or(false); + if !cont { + break; + } + } + }) + .detach(); + } + + fn open_external_url(&mut self, url: &str, cx: &mut Context) { + match open_url(url) { + Ok(()) => {} + Err(err) => { + let msg = i18n::fmt1(self.t().settings_about_open_failed, "err", err); self.set_status(msg, cx); } } @@ -36,6 +131,25 @@ impl AppView { let theme_mode = self.theme_mode; let cpu = self.session_prefs.cpu_count; let memory = self.session_prefs.memory_mb; + let close_behavior = self.session_prefs.close_behavior; + let update_status_line = match &self.update_check { + UpdateCheckPhase::Idle => String::new(), + UpdateCheckPhase::Checking => t.settings_about_checking.to_string(), + UpdateCheckPhase::UpToDate { latest } => { + i18n::fmt1(t.settings_about_up_to_date, "version", latest.clone()) + } + UpdateCheckPhase::Available { latest, .. } => { + i18n::fmt1(t.settings_about_update_available, "version", latest.clone()) + } + UpdateCheckPhase::Failed { message } => { + i18n::fmt1(t.settings_about_check_failed, "err", message.clone()) + } + }; + let update_available_url = match &self.update_check { + UpdateCheckPhase::Available { url, .. } => Some(url.clone()), + _ => None, + }; + let checking = matches!(self.update_check, UpdateCheckPhase::Checking); div() .id("settings-panel") @@ -54,15 +168,7 @@ impl AppView { .child(t.settings_intro), ) .child( - settings_card(cx) - .child(div().font_medium().text_sm().child(t.language)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.language_hint), - ) - .child( + settings_card_section(t.language, t.language_hint, cx).child( h_flex() .gap_2() .child( @@ -86,15 +192,7 @@ impl AppView { ), ) .child( - settings_card(cx) - .child(div().font_medium().text_sm().child(t.theme)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.theme_hint), - ) - .child( + settings_card_section(t.theme, t.theme_hint, cx).child( h_flex() .gap_2() .child( @@ -118,14 +216,56 @@ impl AppView { ), ) .child( - settings_card(cx) - .child(div().font_medium().text_sm().child(t.settings_session)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.settings_session_hint), - ) + settings_card_section(t.settings_close, t.settings_close_hint, cx).child( + h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new("close-ask") + .small() + .label(t.settings_close_ask) + .when(close_behavior == CloseBehavior::Ask, |b| { + b.primary() + }) + .on_click(cx.listener(|this, _, _, cx| { + this.set_close_behavior(CloseBehavior::Ask, cx); + })), + ) + .child( + Button::new("close-minimize") + .small() + .label(t.settings_close_minimize) + .when( + close_behavior == CloseBehavior::MinimizeToTray, + |b| b.primary(), + ) + .on_click(cx.listener(|this, _, _, cx| { + this.set_close_behavior( + CloseBehavior::MinimizeToTray, + cx, + ); + })), + ) + .child( + Button::new("close-quit-stop") + .small() + .label(t.settings_close_quit_stop) + .when( + close_behavior + == CloseBehavior::QuitAndStopContainers, + |b| b.primary(), + ) + .on_click(cx.listener(|this, _, _, cx| { + this.set_close_behavior( + CloseBehavior::QuitAndStopContainers, + cx, + ); + })), + ), + ), + ) + .child( + settings_card_section(t.settings_session, t.settings_session_hint, cx) .child( div() .text_xs() @@ -156,7 +296,11 @@ impl AppView { |mb| { Button::new(("settings-mem", mb as usize)) .small() - .label(format!("{} MiB", mb)) + .label(i18n::fmt1( + t.memory_mib, + "mb", + mb.to_string(), + )) .when(memory == mb, |b| b.primary()) .on_click(cx.listener(move |this, _, _, cx| { this.session_prefs.memory_mb = mb; @@ -170,13 +314,88 @@ impl AppView { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!( - "{} · {} CPU · {} MiB", - SessionPrefs::path().display(), - self.session_prefs.cpu_count, - self.session_prefs.memory_mb + .child(i18n::fmt2( + &i18n::fmt1( + t.settings_session_summary, + "path", + SessionPrefs::path().display().to_string(), + ), + "cpu", + self.session_prefs.cpu_count.to_string(), + "memory", + self.session_prefs.memory_mb.to_string(), )), ), + ) + .child( + settings_card_section(t.settings_about, t.settings_about_hint, cx) + .child( + div().text_sm().child(format!( + "{} {}", + t.settings_about_version, APP_VERSION + )), + ) + .child( + h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new("about-github") + .small() + .label(t.settings_about_github) + .on_click(cx.listener(|this, _, _, cx| { + this.open_external_url(GITHUB_REPO_URL, cx); + })), + ) + .child( + Button::new("about-homepage") + .small() + .label(t.settings_about_homepage) + .on_click(cx.listener(|this, _, _, cx| { + this.open_external_url(HOMEPAGE_URL, cx); + })), + ) + .child( + Button::new("about-check") + .small() + .label(t.settings_about_check) + .disabled(checking) + .on_click(cx.listener(|this, _, _, cx| { + this.begin_update_check(cx); + })), + ) + .when_some(update_available_url.clone(), |row, url| { + row.child( + Button::new("about-open-release") + .small() + .primary() + .label(t.settings_about_open_release) + .on_click(cx.listener(move |this, _, _, cx| { + this.open_external_url(&url, cx); + })), + ) + }), + ) + .when(!update_status_line.is_empty(), |card| { + let accent = match &self.update_check { + UpdateCheckPhase::Available { .. } => cx.theme().foreground, + UpdateCheckPhase::Failed { .. } => cx.theme().red, + UpdateCheckPhase::UpToDate { .. } => cx.theme().green, + _ => cx.theme().muted_foreground, + }; + card.child( + div() + .text_xs() + .text_color(accent) + .child(update_status_line), + ) + }) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(releases_url()), + ), ), ) } diff --git a/src/app/shell.rs b/src/app/shell.rs index 39e8c17..7472e7c 100644 --- a/src/app/shell.rs +++ b/src/app/shell.rs @@ -13,29 +13,21 @@ use crate::i18n::{self}; use crate::wslc::BackendKind; use super::state::Section; +use super::widgets::status_badge; use super::AppView; impl AppView { pub(crate) fn render_engine_badge(&self, cx: &Context) -> impl IntoElement { + let t = self.t(); let (label, color) = match self.status.backend { - BackendKind::Live => ("LIVE", cx.theme().green), - BackendKind::Unavailable => ("OFF", cx.theme().red), + BackendKind::Live => (t.engine_badge_live, cx.theme().green), + BackendKind::Unavailable => (t.engine_badge_off, cx.theme().red), }; h_flex() .items_center() .gap_2() - .child( - div() - .px_2() - .py_0p5() - .rounded(cx.theme().radius) - .bg(color.opacity(0.18)) - .text_color(color) - .text_xs() - .font_semibold() - .child(label), - ) + .child(status_badge(label, color, cx)) .child( div() .text_xs() @@ -70,7 +62,7 @@ impl AppView { div() .font_semibold() .text_sm() - .child("WSLc Desktop"), + .child(t.app_name), ) .child( div() @@ -148,13 +140,11 @@ impl AppView { cx.listener(|this, _, window, cx| { window.prevent_default(); cx.stop_propagation(); - this.section = Section::Settings; - cx.notify(); + this.open_settings(cx); }), ) .on_click(cx.listener(|this, _, _, cx| { - this.section = Section::Settings; - cx.notify(); + this.open_settings(cx); })), ), ) diff --git a/src/app/state.rs b/src/app/state.rs index 28b45f2..d0f8de5 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -54,9 +54,34 @@ pub(crate) enum SdkSetupPhase { Failed, } +/// Background GitHub Releases check for the Settings → About card. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) enum UpdateCheckPhase { + #[default] + Idle, + Checking, + UpToDate { + latest: String, + }, + Available { + latest: String, + url: String, + }, + Failed { + message: String, + }, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ConfirmAction { - RemoveContainer { key: String, label: String }, + RemoveContainer { + key: String, + label: String, + /// Session named volumes attached to this container (may be empty). + named_volumes: Vec, + /// When true, also delete `named_volumes` after the container is removed. + remove_volumes: bool, + }, RemoveImage { reference: String }, RemoveVolume { name: String }, } @@ -82,6 +107,7 @@ impl super::AppView { || self.logs_modal_open || self.exec_modal_open || self.confirm.is_some() + || self.close_prompt_open || self.volume_modal_open || (self.create_modal_open && self.busy) } diff --git a/src/app/tray_icon.rs b/src/app/tray_icon.rs new file mode 100644 index 0000000..837ecd0 --- /dev/null +++ b/src/app/tray_icon.rs @@ -0,0 +1,120 @@ +//! System tray resident icon (Windows). Non-Windows builds are no-ops. + +use gpui_kit::*; + +/// Actions dispatched from the tray menu / tray activation. +pub mod actions { + use gpui_kit::actions; + actions!( + wslc_desktop_tray, + [ShowMainWindow, QuitKeepContainers, QuitStopContainers] + ); +} + +pub use actions::{QuitKeepContainers, QuitStopContainers, ShowMainWindow}; + +#[cfg(windows)] +mod windows_tray { + use std::sync::Arc; + + use gpui_kit::{App, Global, MenuItem, QuitMode}; + use gpui_tray::{Icon, Tray}; + + use super::{QuitKeepContainers, QuitStopContainers, ShowMainWindow}; + use crate::i18n::Locale; + + pub struct AppTray(pub Tray); + + impl Global for AppTray {} + + pub fn install(cx: &mut App) { + cx.set_quit_mode(QuitMode::Explicit); + + let locale = Locale::from_system(); + let tooltip = locale.strings().tray_tooltip; + match Tray::builder() + .icon(make_icon()) + .tooltip(tooltip) + .on_activate(ShowMainWindow) + .menu(build_menu) + .build(cx) + { + Ok(tray) => cx.set_global(AppTray(tray)), + Err(err) => { + // Tray is best-effort; close-prompt / minimize still work via taskbar minimize. + eprintln!("WSLc Desktop: tray icon unavailable: {err}"); + } + } + } + + fn build_menu(cx: &mut App) -> Vec { + let locale = Locale::from_system(); + // Prefer the live UI locale when the main view already chose one. + let t = if let Some(shell) = cx.try_global::() { + shell + .view + .upgrade() + .map(|view| view.read(cx).t()) + .unwrap_or_else(|| locale.strings()) + } else { + locale.strings() + }; + vec![ + MenuItem::action(t.tray_show_window, ShowMainWindow), + MenuItem::separator(), + MenuItem::action(t.tray_quit_keep, QuitKeepContainers), + MenuItem::action(t.tray_quit_stop, QuitStopContainers), + ] + } + + fn make_icon() -> Icon { + // Simple 32×32 teal mark so we do not need image decoding at startup. + const SIZE: u32 = 32; + let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize]; + for y in 0..SIZE { + for x in 0..SIZE { + let i = ((y * SIZE + x) * 4) as usize; + let edge = x < 2 || y < 2 || x >= SIZE - 2 || y >= SIZE - 2; + let inner = x >= 8 && x < 24 && y >= 8 && y < 24; + if edge { + rgba[i] = 0x0f; + rgba[i + 1] = 0x76; + rgba[i + 2] = 0x6e; + rgba[i + 3] = 0xff; + } else if inner { + rgba[i] = 0x14; + rgba[i + 1] = 0xb8; + rgba[i + 2] = 0xa6; + rgba[i + 3] = 0xff; + } else { + rgba[i] = 0x13; + rgba[i + 1] = 0x42; + rgba[i + 2] = 0x52; + rgba[i + 3] = 0xff; + } + } + } + Icon::from_rgba(Arc::<[u8]>::from(rgba), SIZE, SIZE).expect("tray icon rgba") + } +} + +#[cfg(windows)] +pub use windows_tray::install; + +#[cfg(not(windows))] +pub fn install(_cx: &mut App) {} + +/// Refresh tray menu labels after a locale change (Windows only). +pub fn refresh_menu(cx: &mut App) { + #[cfg(windows)] + { + if let Some(tray) = cx.try_global::() { + let tray = tray.0.clone(); + let _ = tray.refresh_menu(cx); + } + } + #[cfg(not(windows))] + { + let _ = cx; + } +} diff --git a/src/app/volumes.rs b/src/app/volumes.rs index 69f819f..6cf6f09 100644 --- a/src/app/volumes.rs +++ b/src/app/volumes.rs @@ -155,7 +155,6 @@ impl AppView { "create-volume-modal", cx, modal_panel(cx) - .w(px(480.)) .child(div().font_semibold().text_base().child(t.create_volume_title)) .child( div() @@ -177,7 +176,11 @@ impl AppView { h_flex().gap_2().children(VOLUME_SIZES_GIB.into_iter().map(|gib| { Button::new(("volume-modal-size", gib as usize)) .small() - .label(format!("{gib} GiB")) + .label(crate::i18n::fmt1( + t.volume_size_gib, + "gib", + gib.to_string(), + )) .when(size_gib == gib, |b| b.primary()) .disabled(creating) .on_click(cx.listener(move |this, _, _, cx| { diff --git a/src/app/widgets.rs b/src/app/widgets.rs index 47d1033..656b0aa 100644 --- a/src/app/widgets.rs +++ b/src/app/widgets.rs @@ -3,6 +3,9 @@ use gpui_kit::*; use super::AppView; +/// Shared width for confirm / close / most modal panels. +pub(crate) const MODAL_WIDTH: f32 = 480.; + impl AppView { pub(crate) fn empty_state( &self, @@ -123,6 +126,7 @@ pub(crate) fn modal_panel(cx: &Context) -> Div { .m_6() .p_5() .gap_3() + .w(px(MODAL_WIDTH)) .rounded(cx.theme().radius_lg) .border_1() .border_color(cx.theme().border) @@ -130,6 +134,41 @@ pub(crate) fn modal_panel(cx: &Context) -> Div { .shadow_md() } +/// Compact status / phase pill (engine LIVE, pull phase, setup step). +pub(crate) fn status_badge( + label: impl Into, + color: Hsla, + cx: &Context, +) -> impl IntoElement { + div() + .px_2() + .py_0p5() + .rounded(cx.theme().radius) + .bg(color.opacity(0.18)) + .text_color(color) + .text_xs() + .font_semibold() + .child(label.into()) +} + +/// Bordered scrollable log body used by pull / setup / container logs modals. +pub(crate) fn scroll_log_pane( + id: impl Into, + height: f32, + cx: &Context, +) -> Stateful
{ + div() + .id(id) + .h(px(height)) + .w_full() + .overflow_y_scroll() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background.opacity(0.65)) + .p_2() +} + /// Settings / overview card surface. pub(crate) fn settings_card(cx: &Context) -> Div { v_flex() @@ -140,3 +179,19 @@ pub(crate) fn settings_card(cx: &Context) -> Div { .border_color(cx.theme().border) .bg(cx.theme().secondary.opacity(0.28)) } + +/// Settings card with shared title + muted hint header. +pub(crate) fn settings_card_section( + title: impl Into, + hint: impl Into, + cx: &Context, +) -> Div { + settings_card(cx) + .child(div().font_medium().text_sm().child(title.into())) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(hint.into()), + ) +} diff --git a/src/i18n.rs b/src/i18n.rs index d9399d4..118a2e7 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -39,6 +39,7 @@ impl Locale { } pub struct Strings { + pub app_name: &'static str, pub app_subtitle: &'static str, pub browse: &'static str, pub overview: &'static str, @@ -62,7 +63,11 @@ pub struct Strings { pub overview_volumes: &'static str, pub overview_status_live: &'static str, pub overview_status_off: &'static str, + pub engine_badge_live: &'static str, + pub engine_badge_off: &'static str, pub overview_na: &'static str, + pub memory_mib: &'static str, + pub volume_size_gib: &'static str, pub overview_persist_hint: &'static str, pub refresh: &'static str, @@ -87,6 +92,8 @@ pub struct Strings { pub confirm_remove_title: &'static str, pub confirm_remove_container: &'static str, + pub confirm_remove_container_volumes: &'static str, + pub confirm_remove_container_volumes_hint: &'static str, pub confirm_remove_image: &'static str, pub confirm_remove_volume: &'static str, @@ -108,6 +115,7 @@ pub struct Strings { pub create_volume_intro: &'static str, pub volume_name_label: &'static str, pub volume_name_empty: &'static str, + pub volume_already_exists: &'static str, pub pull_image: &'static str, pub pull_hint: &'static str, @@ -193,7 +201,9 @@ pub struct Strings { pub container_logs_title: &'static str, pub container_inspect_title: &'static str, + pub container_inspect_heading: &'static str, pub container_exec_title: &'static str, + pub container_exec_heading: &'static str, pub container_exec_intro: &'static str, pub container_exec_placeholder: &'static str, pub container_exec_run: &'static str, @@ -217,7 +227,43 @@ pub struct Strings { pub settings_session_hint: &'static str, pub settings_cpu: &'static str, pub settings_memory: &'static str, + pub settings_session_summary: &'static str, pub settings_prefs_saved: &'static str, + pub settings_close: &'static str, + pub settings_close_hint: &'static str, + pub settings_close_ask: &'static str, + pub settings_close_minimize: &'static str, + pub settings_close_quit_stop: &'static str, + pub settings_close_saved: &'static str, + pub settings_about: &'static str, + pub settings_about_hint: &'static str, + pub settings_about_version: &'static str, + pub settings_about_github: &'static str, + pub settings_about_homepage: &'static str, + pub settings_about_check: &'static str, + pub settings_about_checking: &'static str, + pub settings_about_up_to_date: &'static str, + pub settings_about_update_available: &'static str, + pub settings_about_open_release: &'static str, + pub settings_about_check_failed: &'static str, + pub settings_about_open_failed: &'static str, + + pub close_prompt_title: &'static str, + pub close_prompt_body: &'static str, + pub close_prompt_minimize: &'static str, + pub close_prompt_quit_stop: &'static str, + /// Used by the Windows system-tray menu builder. + #[cfg_attr(not(windows), allow(dead_code))] + pub tray_tooltip: &'static str, + #[cfg_attr(not(windows), allow(dead_code))] + pub tray_show_window: &'static str, + #[cfg_attr(not(windows), allow(dead_code))] + pub tray_quit_keep: &'static str, + #[cfg_attr(not(windows), allow(dead_code))] + pub tray_quit_stop: &'static str, + pub minimized_to_tray: &'static str, + pub stopping_containers_on_quit: &'static str, + pub quit_stopped_containers: &'static str, pub overview_cpu: &'static str, pub overview_memory: &'static str, @@ -230,6 +276,8 @@ pub struct Strings { pub stopped: &'static str, pub restarted: &'static str, pub removed: &'static str, + pub removed_container_with_volumes: &'static str, + pub removed_volumes_partial: &'static str, pub created_container: &'static str, pub created_volume: &'static str, pub pulling_image: &'static str, @@ -251,6 +299,7 @@ pub struct Strings { } pub static EN: Strings = Strings { + app_name: "WSLc Desktop", app_subtitle: "WSL Containers", browse: "Browse", overview: "Overview", @@ -274,8 +323,12 @@ pub static EN: Strings = Strings { overview_volumes: "Volumes", overview_status_live: "Connected (LIVE)", overview_status_off: "Unavailable (OFF)", + engine_badge_live: "LIVE", + engine_badge_off: "OFF", overview_na: "—", - overview_persist_hint: "Containers and volumes are restored after app restart (session is not terminated on exit).", + memory_mib: "{mb} MiB", + volume_size_gib: "{gib} GiB", + overview_persist_hint: "Closing the window can minimize to the tray (containers keep running) or quit and stop running containers. Named volumes are never deleted unless you confirm them separately.", overview_cpu: "CPU", overview_memory: "Memory", @@ -301,6 +354,8 @@ pub static EN: Strings = Strings { confirm_remove_title: "Confirm remove", confirm_remove_container: "Remove container “{name}”? This cannot be undone.", + confirm_remove_container_volumes: "Also delete named volumes: {volumes}", + confirm_remove_container_volumes_hint: "Volumes hold database files and other data. Leave this off to keep them.", confirm_remove_image: "Remove image “{name}”? This cannot be undone.", confirm_remove_volume: "Remove volume “{name}”? This cannot be undone.", @@ -322,6 +377,7 @@ pub static EN: Strings = Strings { create_volume_intro: "Creates a dynamic VHD in this desktop session. Pick a size, then create.", volume_name_label: "Name", volume_name_empty: "Volume name is empty", + volume_already_exists: "Volume “{name}” already exists (HRESULT 0x800700B7). It was restored to the list if missing. Pick another name, or delete the volume first.", pull_image: "Pull image", pull_hint: "Enter an image reference and pull", @@ -407,7 +463,9 @@ pub static EN: Strings = Strings { container_logs_title: "Container logs", container_inspect_title: "Container inspect", + container_inspect_heading: "{name} · Inspect", container_exec_title: "Exec", + container_exec_heading: "{name} · Exec", container_exec_intro: "Run a one-shot command (non-interactive). Example: uname -a", container_exec_placeholder: "uname -a", container_exec_run: "Run", @@ -420,7 +478,7 @@ pub static EN: Strings = Strings { pull_ok: "OK", pull_failed: "FAILED", - settings_intro: "Language, appearance, and session resources. Language defaults from the system locale (Chinese → 中文, otherwise English).", + settings_intro: "Language, appearance, close-window behavior, session resources, and about. Language defaults from the system locale (Chinese → 中文, otherwise English).", language: "Language", language_hint: "UI language for menus, buttons, and status text.", theme: "Theme", @@ -431,7 +489,38 @@ pub static EN: Strings = Strings { settings_session_hint: "CPU and memory for the wslc-desktop session. Saved immediately; applied the next time a new session is created (existing sessions keep their original limits).", settings_cpu: "CPU count", settings_memory: "Memory (MiB)", + settings_session_summary: "{path} · {cpu} CPU · {memory} MiB", settings_prefs_saved: "Session preferences saved", + settings_close: "Close window", + settings_close_hint: "Choose what the window close button does. “Ask each time” restores the first-run prompt.", + settings_close_ask: "Ask each time", + settings_close_minimize: "Minimize to tray (keep containers)", + settings_close_quit_stop: "Quit and stop running containers", + settings_close_saved: "Close behavior saved", + settings_about: "About", + settings_about_hint: "App version, project links, and a check against the latest GitHub Release.", + settings_about_version: "Version", + settings_about_github: "GitHub", + settings_about_homepage: "Website", + settings_about_check: "Check for updates", + settings_about_checking: "Checking for updates…", + settings_about_up_to_date: "You are on the latest version ({version}).", + settings_about_update_available: "A newer version is available: {version}.", + settings_about_open_release: "Open release page", + settings_about_check_failed: "Could not check for updates: {err}", + settings_about_open_failed: "Could not open link: {err}", + + close_prompt_title: "Close WSLc Desktop", + close_prompt_body: "Minimize keeps the manager in the system tray and leaves containers running. Quit and stop shuts down every running container in this session, then exits the app. Your choice is saved and can be changed later in Settings.", + close_prompt_minimize: "Minimize to tray (keep containers)", + close_prompt_quit_stop: "Quit and stop running containers", + tray_tooltip: "WSLc Desktop", + tray_show_window: "Show window", + tray_quit_keep: "Quit app (keep containers running)", + tray_quit_stop: "Quit and stop running containers", + minimized_to_tray: "Minimized to tray · containers still running", + stopping_containers_on_quit: "Stopping running containers before quit…", + quit_stopped_containers: "Stopped {count} container(s); quitting…", list_containers_failed: "List containers failed: {err}", list_images_failed: "List images failed: {err}", @@ -441,6 +530,8 @@ pub static EN: Strings = Strings { stopped: "Stopped {name}", restarted: "Restarted {name}", removed: "Removed {name}", + removed_container_with_volumes: "Removed {name} and volumes {volumes}", + removed_volumes_partial: "Container removed; volume delete failed: {err}", created_container: "Created {name}", created_volume: "Created volume {name}", pulling_image: "Pulling {name}…", @@ -462,6 +553,7 @@ pub static EN: Strings = Strings { }; pub static ZH: Strings = Strings { + app_name: "WSLc Desktop", app_subtitle: "WSL 容器", browse: "浏览", overview: "总览", @@ -485,8 +577,12 @@ pub static ZH: Strings = Strings { overview_volumes: "卷", overview_status_live: "已连接(LIVE)", overview_status_off: "不可用(OFF)", + engine_badge_live: "LIVE", + engine_badge_off: "OFF", overview_na: "—", - overview_persist_hint: "退出应用不会终止会话;重启后会自动恢复容器与卷。", + memory_mib: "{mb} MiB", + volume_size_gib: "{gib} GiB", + overview_persist_hint: "关闭窗口时可最小化到托盘(容器继续运行),或退出并停止运行中的容器。命名卷不会被顺手删掉,除非你在删除容器时单独确认。", overview_cpu: "CPU", overview_memory: "内存", @@ -512,6 +608,8 @@ pub static ZH: Strings = Strings { confirm_remove_title: "确认删除", confirm_remove_container: "删除容器「{name}」?此操作不可撤销。", + confirm_remove_container_volumes: "同时删除命名卷:{volumes}", + confirm_remove_container_volumes_hint: "命名卷里可能有数据库等数据。默认不勾选,避免误删。", confirm_remove_image: "删除镜像「{name}」?此操作不可撤销。", confirm_remove_volume: "删除卷「{name}」?此操作不可撤销。", @@ -533,6 +631,7 @@ pub static ZH: Strings = Strings { create_volume_intro: "在本桌面会话中创建动态 VHD。选择容量后创建。", volume_name_label: "名称", volume_name_empty: "卷名称为空", + volume_already_exists: "卷「{name}」已存在(HRESULT 0x800700B7)。若列表里没有,已尝试重新同步。请换个名字,或先删除该卷后再创建。", pull_image: "拉取镜像", pull_hint: "输入镜像引用后拉取", @@ -618,7 +717,9 @@ pub static ZH: Strings = Strings { container_logs_title: "容器日志", container_inspect_title: "容器检查", + container_inspect_heading: "{name} · 检查", container_exec_title: "执行命令", + container_exec_heading: "{name} · 执行", container_exec_intro: "运行一次性命令(非交互)。例如:uname -a", container_exec_placeholder: "uname -a", container_exec_run: "运行", @@ -631,7 +732,7 @@ pub static ZH: Strings = Strings { pull_ok: "成功", pull_failed: "失败", - settings_intro: "语言、外观与会话资源。语言默认跟随系统区域设置(中文 → 中文,否则英文)。", + settings_intro: "语言、外观、关闭窗口行为、会话资源与关于信息。语言默认跟随系统区域设置(中文 → 中文,否则英文)。", language: "语言", language_hint: "菜单、按钮与状态栏使用的界面语言。", theme: "主题", @@ -642,7 +743,38 @@ pub static ZH: Strings = Strings { settings_session_hint: "wslc-desktop 会话的 CPU 与内存。立即保存;仅在新建会话时生效(已有会话仍沿用原限额)。", settings_cpu: "CPU 数量", settings_memory: "内存(MiB)", + settings_session_summary: "{path} · {cpu} CPU · {memory} MiB", settings_prefs_saved: "会话偏好已保存", + settings_close: "关闭窗口", + settings_close_hint: "设置点关闭按钮时的行为。「每次询问」会恢复首次关闭时的提示。", + settings_close_ask: "每次询问", + settings_close_minimize: "最小化到托盘(容器继续运行)", + settings_close_quit_stop: "退出并停止运行中的容器", + settings_close_saved: "关闭行为已保存", + settings_about: "关于", + settings_about_hint: "应用版本、项目链接,以及对照 GitHub Release 检查是否有新版本。", + settings_about_version: "版本", + settings_about_github: "GitHub", + settings_about_homepage: "项目网站", + settings_about_check: "检查更新", + settings_about_checking: "正在检查更新…", + settings_about_up_to_date: "当前已是最新版本({version})。", + settings_about_update_available: "发现新版本:{version}。", + settings_about_open_release: "打开发布页", + settings_about_check_failed: "检查更新失败:{err}", + settings_about_open_failed: "无法打开链接:{err}", + + close_prompt_title: "关闭 WSLc Desktop", + close_prompt_body: "最小化会把管理器收进系统托盘,容器继续运行。「退出并停止」会停止本会话中所有运行中的容器,然后退出应用。本次选择会自动保存,之后可在设置中修改。", + close_prompt_minimize: "最小化到托盘(容器继续运行)", + close_prompt_quit_stop: "退出并停止运行中的容器", + tray_tooltip: "WSLc Desktop", + tray_show_window: "显示窗口", + tray_quit_keep: "退出应用(容器继续运行)", + tray_quit_stop: "退出并停止运行中的容器", + minimized_to_tray: "已最小化到托盘 · 容器仍在运行", + stopping_containers_on_quit: "正在停止运行中的容器,随后退出…", + quit_stopped_containers: "已停止 {count} 个容器;正在退出…", list_containers_failed: "列出容器失败: {err}", list_images_failed: "列出镜像失败: {err}", @@ -652,6 +784,8 @@ pub static ZH: Strings = Strings { stopped: "已停止 {name}", restarted: "已重启 {name}", removed: "已删除 {name}", + removed_container_with_volumes: "已删除 {name} 及卷 {volumes}", + removed_volumes_partial: "容器已删除,但删除卷失败:{err}", created_container: "已创建 {name}", created_volume: "已创建卷 {name}", pulling_image: "正在拉取 {name}…", diff --git a/src/main.rs b/src/main.rs index 25cafb8..cd7c50a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,8 @@ mod wslc; use gpui_kit::component::{Theme, ThemeMode, TitleBar}; use gpui_kit::*; -use app::AppView; +use app::tray_icon::{self, QuitKeepContainers, QuitStopContainers, ShowMainWindow}; +use app::{AppView, DesktopShell}; actions!(wslc_desktop, [Quit]); @@ -17,7 +18,8 @@ fn main() { let _instance = match single_instance::try_acquire() { Ok(lock) => Some(lock), Err(single_instance::AcquireError::AlreadyRunning) => { - single_instance::notify_already_running(); + // Focus the running window (including tray-hidden) instead of a dialog. + single_instance::activate_existing_instance(); return; } Err(single_instance::AcquireError::Failed(_)) => { @@ -33,10 +35,35 @@ fn main() { // Override init's default Light theme before the first frame is shown. Theme::change(ThemeMode::Dark, None, cx); + tray_icon::install(cx); + cx.bind_keys([KeyBinding::new("alt-f4", Quit, None)]); cx.on_action(|_: &Quit, cx: &mut App| { - cx.quit(); + dispatch_close_request(cx); + }); + cx.on_action(|_: &ShowMainWindow, cx: &mut App| { + show_main_window(cx); + }); + cx.on_action(|_: &QuitKeepContainers, cx: &mut App| { + if let Some(shell) = cx.try_global::() { + let view = shell.view.clone(); + let _ = view.update(cx, |this, cx| { + this.quit_keep_containers(cx); + }); + } else { + cx.quit(); + } + }); + cx.on_action(|_: &QuitStopContainers, cx: &mut App| { + if let Some(shell) = cx.try_global::() { + let view = shell.view.clone(); + let _ = view.update(cx, |this, cx| { + this.begin_quit_and_stop_containers(cx); + }); + } else { + cx.quit(); + } }); let window_options = WindowOptions { @@ -44,11 +71,50 @@ fn main() { ..TitleBar::window_options() }; - gpui_kit::open_window(window_options, cx, |window, cx| { + let (window_handle, view) = gpui_kit::open_window(window_options, cx, |window, cx| { window.activate_window(); + // Title must stay stable: second-instance activation finds this window by title. window.set_window_title("WSLc Desktop"); - cx.new(|cx| AppView::new(window, cx)) + let view = cx.new(|cx| AppView::new(window, cx)); + let weak = view.downgrade(); + window.on_window_should_close(cx, move |window, cx| { + weak.update(cx, |this, cx| this.handle_window_close_request(window, cx)) + .unwrap_or(true) + }); + view }) .expect("Failed to open window"); + + cx.set_global(DesktopShell { + window: window_handle, + view: view.downgrade(), + }); + }); +} + +fn dispatch_close_request(cx: &mut App) { + let Some(shell) = cx.try_global::() else { + cx.quit(); + return; + }; + let window = shell.window; + let view = shell.view.clone(); + let _ = window.update(cx, |_, window, cx| { + let _ = view.update(cx, |this, cx| { + let _ = this.handle_window_close_request(window, cx); + }); + }); +} + +fn show_main_window(cx: &mut App) { + let Some(shell) = cx.try_global::() else { + return; + }; + let window = shell.window; + let view = shell.view.clone(); + let _ = window.update(cx, |_, window, cx| { + let _ = view.update(cx, |this, cx| { + this.show_from_tray(window, cx); + }); }); } diff --git a/src/single_instance.rs b/src/single_instance.rs index 7536024..08eb8fd 100644 --- a/src/single_instance.rs +++ b/src/single_instance.rs @@ -1,4 +1,7 @@ //! Per-user single-instance guard so two app processes cannot share one session. +//! +//! A second launch finds the existing main window, shows it (even when hidden +//! to the tray), and exits silently — no "already running" MessageBox. pub struct InstanceLock { #[cfg(windows)] @@ -29,11 +32,14 @@ pub fn try_acquire() -> Result { } } -/// Tell the user another instance is already open (Windows MessageBox). -pub fn notify_already_running() { +/// Bring the already-running instance's main window to the foreground. +/// +/// Used when a second process hits the single-instance mutex: show the window +/// even if it was minimized or hidden to the tray, then exit without a dialog. +pub fn activate_existing_instance() { #[cfg(windows)] { - notify_windows(); + activate_windows(); } } @@ -61,24 +67,27 @@ fn acquire_windows() -> Result { } #[cfg(windows)] -fn notify_windows() { - use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONINFORMATION, MB_OK}; - - let (title, body) = if crate::i18n::Locale::from_system() == crate::i18n::Locale::Zh { - ( - "WSLc Desktop", - "WSLc Desktop 已在运行,不能同时打开多个实例。", - ) - } else { - ( - "WSLc Desktop", - "WSLc Desktop is already running. Only one instance is allowed.", - ) +fn activate_windows() { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + BringWindowToTop, FindWindowW, IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE, + SW_SHOW, }; - let title = wide(title); - let body = wide(body); + + // Must match `window.set_window_title("WSLc Desktop")` in `main`. + let title = wide("WSLc Desktop"); unsafe { - MessageBoxW(std::ptr::null_mut(), body.as_ptr(), title.as_ptr(), MB_OK | MB_ICONINFORMATION); + let hwnd = FindWindowW(std::ptr::null(), title.as_ptr()); + if hwnd.is_null() { + return; + } + if IsIconic(hwnd) != 0 { + ShowWindow(hwnd, SW_RESTORE); + } else { + // Covers normal windows and SW_HIDE (tray minimize). + ShowWindow(hwnd, SW_SHOW); + } + let _ = BringWindowToTop(hwnd); + let _ = SetForegroundWindow(hwnd); } } diff --git a/src/wslc/errors.rs b/src/wslc/errors.rs new file mode 100644 index 0000000..6861d39 --- /dev/null +++ b/src/wslc/errors.rs @@ -0,0 +1,25 @@ +//! Shared helpers for mapping SDK / Win32 errors into UI-friendly conditions. + +/// Win32 `ERROR_ALREADY_EXISTS` (183) as HRESULT `0x800700B7`. +pub fn is_already_exists_error(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("0x800700b7") + || lower.contains("already exists") + || lower.contains("cannot create a file when that file already exists") +} + +#[cfg(test)] +mod tests { + use super::is_already_exists_error; + + #[test] + fn detects_hresult_already_exists() { + assert!(is_already_exists_error( + "WSLC call failed: HRESULT=0x800700b7, Cannot create a file when that file already exists." + )); + assert!(is_already_exists_error("volume already exists: data")); + assert!(!is_already_exists_error( + "WSLC call failed: HRESULT=0x80070005, Access denied" + )); + } +} diff --git a/src/wslc/live.rs b/src/wslc/live.rs index e7f9065..453674b 100644 --- a/src/wslc/live.rs +++ b/src/wslc/live.rs @@ -9,6 +9,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Result}; use super::catalog::{Catalog, PersistedContainer}; +use super::errors::is_already_exists_error; use super::prefs::SessionPrefs; use super::{ split_ref, BackendKind, ContainerInfo, CreateContainerRequest, EngineStatus, ImageInfo, @@ -677,18 +678,38 @@ impl LiveState { return Err(anyhow!("volume name is empty")); } if self.volumes.iter().any(|v| v.name == name) { - return Err(anyhow!("volume already exists: {name}")); + return Err(anyhow!("__VOLUME_EXISTS__\0{name}")); } let size_bytes = if size_bytes == 0 { DEFAULT_VOLUME_BYTES } else { size_bytes.max(64 * 1024 * 1024) }; - self.session - .create_vhd_volume( - wslc::VhdOptions::new(name, size_bytes).vhd_type(wslc::VhdType::Dynamic), - ) - .map_err(|e| anyhow!("{e}"))?; + match self.session.create_vhd_volume( + wslc::VhdOptions::new(name, size_bytes).vhd_type(wslc::VhdType::Dynamic), + ) { + Ok(()) => {} + Err(err) => { + let msg = err.to_string(); + // HRESULT 0x800700B7 = Win32 ERROR_ALREADY_EXISTS. The VHD can + // already live in the session while our local catalog is empty + // (partial create, cleared catalog, etc.). Adopt it so the UI + // shows the volume again instead of a raw HRESULT. + if is_already_exists_error(&msg) { + self.ensure_volume_listed(name, size_bytes); + return Err(anyhow!("__VOLUME_EXISTS__\0{name}")); + } + return Err(anyhow!("{msg}")); + } + } + self.ensure_volume_listed(name, size_bytes); + Ok(()) + } + + fn ensure_volume_listed(&mut self, name: &str, size_bytes: u64) { + if self.volumes.iter().any(|v| v.name == name) { + return; + } self.volumes.insert( 0, VolumeInfo { @@ -700,7 +721,6 @@ impl LiveState { }, ); let _ = self.persist_catalog(); - Ok(()) } fn remove_volume(&mut self, name: &str) -> Result<()> { diff --git a/src/wslc/mod.rs b/src/wslc/mod.rs index 71c4572..968176c 100644 --- a/src/wslc/mod.rs +++ b/src/wslc/mod.rs @@ -10,6 +10,7 @@ use std::thread; use anyhow::{Result, anyhow}; mod catalog; +mod errors; #[cfg(windows)] mod live; mod paths; @@ -24,12 +25,16 @@ pub mod setup_log; mod syspath; pub mod templates; mod types; +mod update_check; #[cfg(windows)] mod wsl_probe; -pub use prefs::SessionPrefs; +pub use prefs::{CloseBehavior, SessionPrefs}; pub use templates::{ContainerTemplate, TemplateEditError, TemplateStore, UpsertKind}; pub use types::*; +pub use update_check::{ + APP_VERSION, GITHUB_REPO_URL, HOMEPAGE_URL, UpdateStatus, check_latest, open_url, releases_url, +}; /// Minimum WSL release the first-launch check accepts (`2.9.3`). pub fn min_wsl_version_text() -> String { @@ -341,6 +346,25 @@ impl WslcClient { } } + /// Stop every running container in this desktop session. Returns how many were stopped. + pub fn stop_all_running_containers(&self) -> Result { + let containers = self.list_containers(true)?; + let mut stopped = 0usize; + for item in containers { + if !item.is_running() { + continue; + } + let key = if item.name.is_empty() { + item.id.clone() + } else { + item.name.clone() + }; + self.stop_container(&key)?; + stopped += 1; + } + Ok(stopped) + } + pub fn restart_container(&self, name_or_id: &str) -> Result<()> { match &self.inner.backend { #[cfg(windows)] diff --git a/src/wslc/prefs.rs b/src/wslc/prefs.rs index 0ae5f59..a0c38c7 100644 --- a/src/wslc/prefs.rs +++ b/src/wslc/prefs.rs @@ -1,4 +1,4 @@ -//! Desktop preferences (session CPU / memory). Applied on session start. +//! Desktop preferences (session CPU / memory / close behavior). Applied on session start. use std::fs; use std::path::PathBuf; @@ -10,10 +10,25 @@ use super::paths::{app_data_dir, write_atomic}; pub const DEFAULT_CPU_COUNT: u32 = 2; pub const DEFAULT_MEMORY_MB: u32 = 2048; +/// What happens when the user closes the main window. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum CloseBehavior { + /// First close (or after reset): ask minimize vs quit-and-stop. + #[default] + Ask, + /// Hide the window; keep the tray icon and leave containers running. + MinimizeToTray, + /// Stop every running container in this session, then quit the app. + QuitAndStopContainers, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SessionPrefs { pub cpu_count: u32, pub memory_mb: u32, + #[serde(default)] + pub close_behavior: CloseBehavior, } impl Default for SessionPrefs { @@ -21,6 +36,7 @@ impl Default for SessionPrefs { Self { cpu_count: DEFAULT_CPU_COUNT, memory_mb: DEFAULT_MEMORY_MB, + close_behavior: CloseBehavior::Ask, } } } @@ -70,9 +86,17 @@ mod tests { let p = SessionPrefs { cpu_count: 4, memory_mb: 4096, + close_behavior: CloseBehavior::MinimizeToTray, }; let bytes = serde_json::to_vec(&p).unwrap(); let back: SessionPrefs = serde_json::from_slice(&bytes).unwrap(); assert_eq!(back, p); } + + #[test] + fn prefs_default_close_ask_when_missing() { + let json = br#"{"cpu_count":2,"memory_mb":2048}"#; + let back: SessionPrefs = serde_json::from_slice(json).unwrap(); + assert_eq!(back.close_behavior, CloseBehavior::Ask); + } } diff --git a/src/wslc/update_check.rs b/src/wslc/update_check.rs new file mode 100644 index 0000000..9f4b96b --- /dev/null +++ b/src/wslc/update_check.rs @@ -0,0 +1,150 @@ +//! App version + GitHub Releases latest check (open browser for updates). + +use std::process::Command; + +use serde::Deserialize; + +use super::sdk_version::SemVer; + +/// Built from `Cargo.toml` `[package].version`. +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Repository homepage from `Cargo.toml` `[package].repository`. +pub const GITHUB_REPO_URL: &str = env!("CARGO_PKG_REPOSITORY"); + +/// Project site from `Cargo.toml` `[package].homepage`. +pub const HOMEPAGE_URL: &str = env!("CARGO_PKG_HOMEPAGE"); + +const RELEASES_URL: &str = "https://github.com/timeroute/wslc-desktop/releases"; +const LATEST_API: &str = + "https://api.github.com/repos/timeroute/wslc-desktop/releases/latest"; + +/// Result of comparing the running build to GitHub `releases/latest`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpdateStatus { + /// Installed version is newest, or equal to the latest tag. + UpToDate { latest: String }, + /// A newer non-draft release exists. + Available { latest: String, url: String }, +} + +#[derive(Debug, Deserialize)] +struct LatestReleaseJson { + tag_name: String, + html_url: String, + #[serde(default)] + draft: bool, + #[serde(default)] + prerelease: bool, +} + +/// Strip a leading `v`/`V` so `v0.1.2` parses like `0.1.2`. +pub fn normalize_version_tag(tag: &str) -> &str { + let t = tag.trim(); + t.strip_prefix('v') + .or_else(|| t.strip_prefix('V')) + .unwrap_or(t) +} + +/// Compare `current` (e.g. `CARGO_PKG_VERSION`) to a release tag. +pub fn compare_to_tag(current: &str, tag: &str) -> Option { + let cur = SemVer::parse(normalize_version_tag(current))?; + let remote = SemVer::parse(normalize_version_tag(tag))?; + Some(cur.cmp(&remote)) +} + +/// Fetch GitHub `releases/latest` and compare to `current_version`. +pub fn check_latest(current_version: &str) -> Result { + let release = fetch_latest_release()?; + if release.draft || release.prerelease { + return Err("latest release is draft/prerelease".into()); + } + let latest = normalize_version_tag(&release.tag_name).to_string(); + let Some(order) = compare_to_tag(current_version, &release.tag_name) else { + return Err(format!("unparseable version tag: {}", release.tag_name)); + }; + match order { + std::cmp::Ordering::Less => Ok(UpdateStatus::Available { + latest, + url: release.html_url, + }), + std::cmp::Ordering::Equal | std::cmp::Ordering::Greater => { + Ok(UpdateStatus::UpToDate { latest }) + } + } +} + +fn fetch_latest_release() -> Result { + let resp = ureq::get(LATEST_API) + .set("User-Agent", "wslc-desktop") + .set("Accept", "application/vnd.github+json") + .call() + .map_err(|e| format!("GitHub API: {e}"))?; + resp.into_json::() + .map_err(|e| format!("GitHub API JSON: {e}")) +} + +/// Releases list page (fallback when a specific release URL is unavailable). +pub fn releases_url() -> &'static str { + RELEASES_URL +} + +/// Open an http(s) URL in the default browser. +pub fn open_url(url: &str) -> Result<(), String> { + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err("refusing to open non-http URL".into()); + } + #[cfg(windows)] + { + // `start` is a cmd builtin; empty title arg avoids swallowing the URL. + Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn() + .map(|_| ()) + .map_err(|e| format!("open URL: {e}")) + } + #[cfg(not(windows))] + { + Command::new("xdg-open") + .arg(url) + .spawn() + .map(|_| ()) + .map_err(|e| format!("open URL: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_v_prefix() { + assert_eq!(normalize_version_tag("v0.1.2"), "0.1.2"); + assert_eq!(normalize_version_tag("V1.0.0"), "1.0.0"); + assert_eq!(normalize_version_tag("0.1.2"), "0.1.2"); + } + + #[test] + fn compares_release_tags() { + assert_eq!( + compare_to_tag("0.1.2", "v0.1.2"), + Some(std::cmp::Ordering::Equal) + ); + assert_eq!( + compare_to_tag("0.1.1", "v0.1.2"), + Some(std::cmp::Ordering::Less) + ); + assert_eq!( + compare_to_tag("0.2.0", "v0.1.9"), + Some(std::cmp::Ordering::Greater) + ); + assert!(compare_to_tag("0.1.2", "not-a-tag").is_none()); + } + + #[test] + fn package_metadata_urls_are_https() { + assert!(GITHUB_REPO_URL.starts_with("https://")); + assert!(HOMEPAGE_URL.starts_with("https://")); + assert!(!APP_VERSION.is_empty()); + } +}