Skip to content

Commit 3c60a54

Browse files
committed
Localize Tauri menus
1 parent ccd08ef commit 3c60a54

6 files changed

Lines changed: 576 additions & 83 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"scripts": {
99
"pretest": "rimraf ./test/tmp",
1010
"test": "vitest --config ./vitest.config.mts --watch=false",
11+
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
12+
"test:all": "npm run typecheck && npm test && npm run test:rust",
1113
"typecheck": "tsc --noEmit",
1214
"lint": "eslint \"src/**/*.{ts,tsx}\"",
1315
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",

src-tauri/src/app_menu.rs

Lines changed: 102 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
//! events so the existing `useOnBroadcast` subscribers in the renderer
88
//! fire unchanged.
99
10-
use tauri::menu::{
11-
Menu, MenuItemBuilder, PredefinedMenuItem, Submenu, SubmenuBuilder,
12-
};
10+
use tauri::menu::{Menu, MenuItemBuilder, PredefinedMenuItem, Submenu, SubmenuBuilder};
1311
use tauri::{AppHandle, Runtime};
1412

13+
use crate::i18n::{menu_labels, MenuLabels};
14+
1515
// ---- menu item ids ---------------------------------------------------------
1616
// Every custom item gets a stable id routed through the global
1717
// `on_menu_event` handler in `lib.rs`.
@@ -30,24 +30,37 @@ pub const HOMEPAGE_URL: &str = "https://switchhosts.vercel.app/home/";
3030
/// Build and install the application menu. Called once from
3131
/// `lib.rs::run`'s setup hook.
3232
pub fn install<R: Runtime>(app: &AppHandle<R>) -> Result<(), tauri::Error> {
33+
refresh(app)
34+
}
35+
36+
/// Rebuild and reinstall the application menu after language changes.
37+
pub fn refresh<R: Runtime>(app: &AppHandle<R>) -> Result<(), tauri::Error> {
3338
let menu = build_menu(app)?;
3439
app.set_menu(menu)?;
3540
Ok(())
3641
}
3742

3843
fn build_menu<R: Runtime>(app: &AppHandle<R>) -> Result<Menu<R>, tauri::Error> {
39-
let file_menu = build_file_menu(app)?;
40-
let edit_menu = build_edit_menu(app)?;
41-
let view_menu = build_view_menu(app)?;
42-
let window_menu = build_window_menu(app)?;
43-
let help_menu = build_help_menu(app)?;
44+
let labels = menu_labels(app);
45+
let file_menu = build_file_menu(app, &labels)?;
46+
let edit_menu = build_edit_menu(app, &labels)?;
47+
let view_menu = build_view_menu(app, &labels)?;
48+
let window_menu = build_window_menu(app, &labels)?;
49+
let help_menu = build_help_menu(app, &labels)?;
4450

4551
#[cfg(target_os = "macos")]
4652
{
47-
let app_menu = build_macos_app_menu(app)?;
53+
let app_menu = build_macos_app_menu(app, &labels)?;
4854
Menu::with_items(
4955
app,
50-
&[&app_menu, &file_menu, &edit_menu, &view_menu, &window_menu, &help_menu],
56+
&[
57+
&app_menu,
58+
&file_menu,
59+
&edit_menu,
60+
&view_menu,
61+
&window_menu,
62+
&help_menu,
63+
],
5164
)
5265
}
5366
#[cfg(not(target_os = "macos"))]
@@ -62,80 +75,96 @@ fn build_menu<R: Runtime>(app: &AppHandle<R>) -> Result<Menu<R>, tauri::Error> {
6275
// ---- macOS app submenu (About, Hide, Quit) --------------------------------
6376

6477
#[cfg(target_os = "macos")]
65-
fn build_macos_app_menu<R: Runtime>(app: &AppHandle<R>) -> Result<Submenu<R>, tauri::Error> {
66-
let about = MenuItemBuilder::with_id(MENU_ID_ABOUT, "About SwitchHosts").build(app)?;
78+
fn build_macos_app_menu<R: Runtime>(
79+
app: &AppHandle<R>,
80+
labels: &MenuLabels,
81+
) -> Result<Submenu<R>, tauri::Error> {
82+
let about = MenuItemBuilder::with_id(MENU_ID_ABOUT, labels.about_app).build(app)?;
83+
let services = PredefinedMenuItem::services(app, Some(labels.services))?;
84+
let hide = PredefinedMenuItem::hide(app, Some(labels.hide_app))?;
85+
let hide_others = PredefinedMenuItem::hide_others(app, Some(labels.hide_others))?;
86+
let show_all = PredefinedMenuItem::show_all(app, Some(labels.show_all))?;
87+
let quit = PredefinedMenuItem::quit(app, Some(labels.quit_app))?;
88+
6789
SubmenuBuilder::new(app, "SwitchHosts")
6890
.item(&about)
6991
.separator()
70-
.services()
92+
.item(&services)
7193
.separator()
72-
.hide()
73-
.hide_others()
74-
.show_all()
94+
.item(&hide)
95+
.item(&hide_others)
96+
.item(&show_all)
7597
.separator()
76-
.quit()
98+
.item(&quit)
7799
.build()
78100
}
79101

80102
// ---- File ------------------------------------------------------------------
81103

82-
fn build_file_menu<R: Runtime>(app: &AppHandle<R>) -> Result<Submenu<R>, tauri::Error> {
83-
let new_item = MenuItemBuilder::with_id(MENU_ID_NEW, "New")
104+
fn build_file_menu<R: Runtime>(
105+
app: &AppHandle<R>,
106+
labels: &MenuLabels,
107+
) -> Result<Submenu<R>, tauri::Error> {
108+
let new_item = MenuItemBuilder::with_id(MENU_ID_NEW, labels.new)
84109
.accelerator("CmdOrCtrl+N")
85110
.build(app)?;
86-
let prefs = MenuItemBuilder::with_id(MENU_ID_PREFERENCES, "Preferences\u{2026}")
111+
let prefs = MenuItemBuilder::with_id(MENU_ID_PREFERENCES, labels.preferences)
87112
.accelerator("CmdOrCtrl+,")
88113
.build(app)?;
89114

90-
let mut builder = SubmenuBuilder::new(app, "File");
115+
let mut builder = SubmenuBuilder::new(app, labels.file);
91116

92117
// On Windows/Linux put About at the top of File (Electron does this)
93118
#[cfg(not(target_os = "macos"))]
94119
{
95-
let about = MenuItemBuilder::with_id(MENU_ID_ABOUT, "About SwitchHosts").build(app)?;
120+
let about = MenuItemBuilder::with_id(MENU_ID_ABOUT, labels.about_app).build(app)?;
96121
builder = builder.item(&about).separator();
97122
}
98123

99-
builder = builder
100-
.item(&new_item)
101-
.separator()
102-
.item(&prefs);
124+
builder = builder.item(&new_item).separator().item(&prefs);
103125

104126
#[cfg(not(target_os = "macos"))]
105127
{
106-
builder = builder
107-
.separator()
108-
.item(&PredefinedMenuItem::quit(app, None)?);
128+
let quit = PredefinedMenuItem::quit(app, Some(labels.quit))?;
129+
builder = builder.separator().item(&quit);
109130
}
110131

111132
#[cfg(target_os = "macos")]
112133
{
113-
builder = builder
114-
.separator()
115-
.item(&PredefinedMenuItem::close_window(app, None)?);
134+
let close_window = PredefinedMenuItem::close_window(app, Some(labels.close_window))?;
135+
builder = builder.separator().item(&close_window);
116136
}
117137

118138
builder.build()
119139
}
120140

121141
// ---- Edit ------------------------------------------------------------------
122142

123-
fn build_edit_menu<R: Runtime>(app: &AppHandle<R>) -> Result<Submenu<R>, tauri::Error> {
124-
let comment = MenuItemBuilder::with_id(MENU_ID_COMMENT, "Comment / Uncomment")
143+
fn build_edit_menu<R: Runtime>(
144+
app: &AppHandle<R>,
145+
labels: &MenuLabels,
146+
) -> Result<Submenu<R>, tauri::Error> {
147+
let comment = MenuItemBuilder::with_id(MENU_ID_COMMENT, labels.comment_uncomment)
125148
.accelerator("CmdOrCtrl+/")
126149
.build(app)?;
127-
let find = MenuItemBuilder::with_id(MENU_ID_FIND, "Find\u{2026}")
150+
let find = MenuItemBuilder::with_id(MENU_ID_FIND, labels.find)
128151
.accelerator("CmdOrCtrl+F")
129152
.build(app)?;
130-
131-
SubmenuBuilder::new(app, "Edit")
132-
.undo()
133-
.redo()
153+
let undo = PredefinedMenuItem::undo(app, Some(labels.undo))?;
154+
let redo = PredefinedMenuItem::redo(app, Some(labels.redo))?;
155+
let cut = PredefinedMenuItem::cut(app, Some(labels.cut))?;
156+
let copy = PredefinedMenuItem::copy(app, Some(labels.copy))?;
157+
let paste = PredefinedMenuItem::paste(app, Some(labels.paste))?;
158+
let select_all = PredefinedMenuItem::select_all(app, Some(labels.select_all))?;
159+
160+
SubmenuBuilder::new(app, labels.edit)
161+
.item(&undo)
162+
.item(&redo)
134163
.separator()
135-
.cut()
136-
.copy()
137-
.paste()
138-
.select_all()
164+
.item(&cut)
165+
.item(&copy)
166+
.item(&paste)
167+
.item(&select_all)
139168
.separator()
140169
.item(&comment)
141170
.item(&find)
@@ -144,45 +173,53 @@ fn build_edit_menu<R: Runtime>(app: &AppHandle<R>) -> Result<Submenu<R>, tauri::
144173

145174
// ---- View ------------------------------------------------------------------
146175

147-
fn build_view_menu<R: Runtime>(app: &AppHandle<R>) -> Result<Submenu<R>, tauri::Error> {
148-
let builder = SubmenuBuilder::new(app, "View");
176+
fn build_view_menu<R: Runtime>(
177+
app: &AppHandle<R>,
178+
labels: &MenuLabels,
179+
) -> Result<Submenu<R>, tauri::Error> {
180+
let builder = SubmenuBuilder::new(app, labels.view);
149181

150182
#[cfg(target_os = "macos")]
151-
let builder = builder
152-
.item(&PredefinedMenuItem::fullscreen(app, None)?)
153-
.separator();
154-
155-
builder
156-
.item(&PredefinedMenuItem::minimize(app, None)?)
157-
.item(&PredefinedMenuItem::maximize(app, None)?)
158-
.build()
183+
let builder = {
184+
let fullscreen = PredefinedMenuItem::fullscreen(app, Some(labels.fullscreen))?;
185+
builder.item(&fullscreen).separator()
186+
};
187+
188+
let minimize = PredefinedMenuItem::minimize(app, Some(labels.minimize))?;
189+
let maximize = PredefinedMenuItem::maximize(app, Some(labels.maximize))?;
190+
builder.item(&minimize).item(&maximize).build()
159191
}
160192

161193
// ---- Window ----------------------------------------------------------------
162194

163-
fn build_window_menu<R: Runtime>(app: &AppHandle<R>) -> Result<Submenu<R>, tauri::Error> {
164-
let mut builder = SubmenuBuilder::new(app, "Window");
165-
builder = builder.minimize().close_window();
195+
fn build_window_menu<R: Runtime>(
196+
app: &AppHandle<R>,
197+
labels: &MenuLabels,
198+
) -> Result<Submenu<R>, tauri::Error> {
199+
let minimize = PredefinedMenuItem::minimize(app, Some(labels.minimize))?;
200+
let close_window = PredefinedMenuItem::close_window(app, Some(labels.close_window))?;
201+
let mut builder = SubmenuBuilder::new(app, labels.window);
202+
builder = builder.item(&minimize).item(&close_window);
166203

167204
#[cfg(target_os = "macos")]
168205
{
169-
builder = builder
170-
.separator()
171-
.item(&PredefinedMenuItem::maximize(app, None)?);
206+
let maximize = PredefinedMenuItem::maximize(app, Some(labels.maximize))?;
207+
builder = builder.separator().item(&maximize);
172208
}
173209

174210
builder.build()
175211
}
176212

177213
// ---- Help ------------------------------------------------------------------
178214

179-
fn build_help_menu<R: Runtime>(app: &AppHandle<R>) -> Result<Submenu<R>, tauri::Error> {
180-
let feedback = MenuItemBuilder::with_id(MENU_ID_FEEDBACK, "Feedback")
181-
.build(app)?;
182-
let homepage = MenuItemBuilder::with_id(MENU_ID_HOMEPAGE, "Homepage")
183-
.build(app)?;
215+
fn build_help_menu<R: Runtime>(
216+
app: &AppHandle<R>,
217+
labels: &MenuLabels,
218+
) -> Result<Submenu<R>, tauri::Error> {
219+
let feedback = MenuItemBuilder::with_id(MENU_ID_FEEDBACK, labels.feedback).build(app)?;
220+
let homepage = MenuItemBuilder::with_id(MENU_ID_HOMEPAGE, labels.homepage).build(app)?;
184221

185-
SubmenuBuilder::new(app, "Help")
222+
SubmenuBuilder::new(app, labels.help)
186223
.item(&feedback)
187224
.item(&homepage)
188225
.build()

src-tauri/src/commands.rs

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use tauri::menu::{MenuBuilder, MenuItemBuilder};
1717
use tauri::{AppHandle, Emitter, Manager, Runtime, State, WebviewWindow, Wry};
1818
use tauri_plugin_dialog::DialogExt;
1919

20+
use crate::app_menu;
2021
use crate::find::{self, FindHistoryEntry, FindOptions};
2122
use crate::hosts_apply::{self, ApplyHistoryItem, HostsApplyError};
2223
use crate::http;
@@ -200,15 +201,16 @@ pub async fn config_update(
200201
///
201202
/// - `http_api_on` / `http_api_only_local` → start, stop or rebind
202203
/// the local HTTP API server.
204+
/// - `locale` → rebuild native application and tray menus.
205+
/// - `hide_dock_icon` → apply the macOS Dock policy and update the tray
206+
/// toggle label.
203207
///
204-
/// More keys will land here as Phase 2 progresses (theme switch,
205-
/// hide_dock_icon, etc.). Always reads the *fresh* config snapshot
206-
/// rather than trusting the patch, so a rebind picks up both keys
207-
/// even if only one of them was in the patch.
208+
/// Always reads the *fresh* config snapshot rather than trusting the
209+
/// patch, so a rebind picks up both keys even if only one of them was
210+
/// in the patch.
208211
///
209-
/// Pinned to `Wry` because the only side effect today is the HTTP
210-
/// API server, which is itself pinned to `Wry` (see the comment in
211-
/// `http_api.rs`).
212+
/// Pinned to `Wry` because the HTTP API server is itself pinned to
213+
/// `Wry` (see the comment in `http_api.rs`).
212214
fn apply_side_effects(
213215
app: &AppHandle<Wry>,
214216
state: &AppState,
@@ -217,6 +219,28 @@ fn apply_side_effects(
217219
let touches_http_api = touched_keys
218220
.iter()
219221
.any(|k| *k == "http_api_on" || *k == "http_api_only_local");
222+
let touches_locale = touched_keys.iter().any(|k| *k == "locale");
223+
224+
if touches_locale {
225+
if let Err(e) = app_menu::refresh(app) {
226+
log::warn!("failed to refresh app menu: {e}");
227+
}
228+
tray::refresh_menu(app);
229+
}
230+
231+
#[cfg(target_os = "macos")]
232+
{
233+
let touches_hide_dock_icon = touched_keys.iter().any(|k| *k == "hide_dock_icon");
234+
if touches_hide_dock_icon {
235+
let hide = {
236+
let cfg = state.config.lock().expect("config mutex poisoned");
237+
cfg.hide_dock_icon
238+
};
239+
lifecycle::apply_dock_icon_policy(app, hide);
240+
tray::refresh_menu(app);
241+
}
242+
}
243+
220244
if touches_http_api {
221245
let (on, only_local) = {
222246
let cfg = state.config.lock().expect("config mutex poisoned");

0 commit comments

Comments
 (0)