feat(menu): add Restart action between separator and Exit

One-click relaunch of the running binary via a detached cmd.exe handoff:
timeout /t 1 /nobreak >/dev/null & start "" "<exe>" — the 1s wait outlives the
parent so the relaunched instance acquires Global\ClaudeCodeUsageBubble
without ERROR_ALREADY_EXISTS.

- New IDM_RESTART (33) wired into show_context_menu + on_menu_command.
- Match arm placed above the IDM_LANG_BASE guard so future ids in the
  static band can't be swallowed by the dynamic-language catch-all.
- Settings flushed defensively before quit (clone-then-save to avoid
  blocking the UI thread on disk I/O while holding lock_state).
- Rejects current_exe paths containing '%' (same defense as
  update::install — cmd.exe expands %var% inside quotes).
- New 'restart' string in LocaleStrings + translation in all 8 locales.
This commit is contained in:
2026-05-18 10:39:19 +07:00
parent 38ae4dff09
commit f1dfe15000
10 changed files with 74 additions and 1 deletions
+65 -1
View File
@@ -6,6 +6,8 @@
// message-only window owned by this module.
use std::collections::HashMap;
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -69,6 +71,7 @@ const IDM_MODEL_CHATGPT: u16 = 21;
const IDM_START_WITH_WINDOWS: u16 = 30;
const IDM_RESET_POSITION: u16 = 31;
const IDM_VERSION_ACTION: u16 = 32;
const IDM_RESTART: u16 = 33;
const IDM_LANG_SYSTEM: u16 = 40;
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
// clear of it (and any future tray ids in the 5x band).
@@ -385,8 +388,12 @@ pub fn on_menu_command(id: u32, _owner_hwnd: HWND) {
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
}
IDM_LANG_SYSTEM => set_language(None),
x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize),
// Static ids in the 30-99 band must match BEFORE the dynamic
// language guard, otherwise `x >= IDM_LANG_BASE` would swallow any
// future id that creeps into the >=100 range.
tray::IDM_TOGGLE_WIDGET => toggle_widget_visibility(),
IDM_RESTART => restart_app(),
x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize),
_ => {}
}
}
@@ -1034,6 +1041,7 @@ fn show_context_menu(owner_hwnd: HWND) {
if snap.widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
);
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
append_item(menu, IDM_EXIT, &snap.strings.exit, MENU_ITEM_FLAGS(0));
let mut pt = POINT::default();
@@ -1361,6 +1369,62 @@ fn set_update_check_interval(value: Option<u64>) {
}
}
// ---------- Restart ----------
// Windows CreateProcess flags. Match the values used by `update::install`
// so the cmd-handoff child detaches cleanly without flashing a console.
const RESTART_CREATE_NO_WINDOW: u32 = 0x0800_0000;
const RESTART_DETACHED_PROCESS: u32 = 0x0000_0008;
/// Relaunch the running binary via a detached cmd.exe handoff.
///
/// The 1-second `timeout` gives the current process time to release the
/// `Global\ClaudeCodeUsageBubble` mutex before the relaunched instance's
/// `CreateMutexW` runs, otherwise the new instance would see
/// `ERROR_ALREADY_EXISTS` and exit immediately.
fn restart_app() {
// Defensive flush — bubble positions and most settings already persist
// on change, but a final save is cheap insurance. Snapshot then drop the
// lock before the disk write so the UI thread doesn't block on I/O.
let snap = lock_state().as_ref().map(|s| s.settings.clone());
if let Some(s) = snap {
settings::save(&s);
}
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
log::error!("restart: current_exe failed: {e}");
return;
}
};
let exe_str = exe.to_string_lossy();
// cmd.exe expands `%var%` inside double quotes, so a path containing `%`
// would let the environment leak into the relaunch. Refuse — matches the
// defense already used in `update::install`.
if exe_str.contains('%') {
log::error!("restart: refusing path containing '%': {exe_str}");
return;
}
let exe_str = exe_str.replace('"', "");
let cmd = format!(r#"timeout /t 1 /nobreak >nul & start "" "{exe_str}""#);
let spawned = Command::new("cmd.exe")
.raw_arg("/c")
.raw_arg(format!("\"{cmd}\""))
.creation_flags(RESTART_CREATE_NO_WINDOW | RESTART_DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
match spawned {
Ok(_) => {
log::info!("restart: cmd handoff spawned, posting quit");
unsafe { PostQuitMessage(0) };
}
Err(e) => log::error!("restart: cmd spawn failed: {e}"),
}
}
// ---------- Start-with-Windows ----------
fn is_startup_enabled() -> bool {
+1
View File
@@ -29,6 +29,7 @@ auto_check_hourly = "Stündlich"
auto_check_daily = "Täglich"
auto_check_weekly = "Wöchentlich"
exit = "Beenden"
restart = "Neu starten"
show_widget = "Widget anzeigen"
session_window = "5h"
weekly_window = "7d"
+1
View File
@@ -29,6 +29,7 @@ auto_check_hourly = "Hourly"
auto_check_daily = "Daily"
auto_check_weekly = "Weekly"
exit = "Exit"
restart = "Restart"
show_widget = "Show widget"
session_window = "5h"
weekly_window = "7d"
+1
View File
@@ -29,6 +29,7 @@ auto_check_hourly = "Cada hora"
auto_check_daily = "Cada día"
auto_check_weekly = "Cada semana"
exit = "Salir"
restart = "Reiniciar"
show_widget = "Mostrar widget"
session_window = "5h"
weekly_window = "7d"
+1
View File
@@ -29,6 +29,7 @@ auto_check_hourly = "Toutes les heures"
auto_check_daily = "Quotidienne"
auto_check_weekly = "Hebdomadaire"
exit = "Quitter"
restart = "Redémarrer"
show_widget = "Afficher le widget"
session_window = "5h"
weekly_window = "7j"
+1
View File
@@ -29,6 +29,7 @@ auto_check_hourly = "1時間ごと"
auto_check_daily = "毎日"
auto_check_weekly = "毎週"
exit = "終了"
restart = "再起動"
show_widget = "ウィジェットを表示"
session_window = "5時間"
weekly_window = "7日"
+1
View File
@@ -29,6 +29,7 @@ auto_check_hourly = "매시간"
auto_check_daily = "매일"
auto_check_weekly = "매주"
exit = "종료"
restart = "다시 시작"
show_widget = "위젯 표시"
session_window = "5시간"
weekly_window = "7일"
+1
View File
@@ -29,6 +29,7 @@ auto_check_hourly = "Per uur"
auto_check_daily = "Dagelijks"
auto_check_weekly = "Wekelijks"
exit = "Afsluiten"
restart = "Opnieuw starten"
show_widget = "Widget tonen"
session_window = "5u"
weekly_window = "7d"
+1
View File
@@ -29,6 +29,7 @@ auto_check_hourly = "每小時"
auto_check_daily = "每天"
auto_check_weekly = "每週"
exit = "結束"
restart = "重新啟動"
show_widget = "顯示小工具"
session_window = "5 小時"
weekly_window = "7 日"
+1
View File
@@ -49,6 +49,7 @@ pub struct LocaleStrings {
pub auto_check_daily: String,
pub auto_check_weekly: String,
pub exit: String,
pub restart: String,
pub show_widget: String,
pub session_window: String,
pub weekly_window: String,