authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 15:32:13-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 16:46:24-07:00
loged9fa1dfef1a45353ef91324558faf063a170d53
tree40acf40673c069f06b779186d3a37254b6e8467b
parent597b520398003c06d80386f49e48cf0f4de4cb18
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: Linux asks with its own dialogs where zenity and kdialog are missing

Fedora Silverblue and other image-based GNOME systems ship neither tool, so every question answered no (deleting a section, emptying the Recycle Bin, Uninstall, installing an update), alerts and notices only reached stderr, and the page date, time and SMB sign-in could not be asked. Where neither tool runs, the app now runs itself as a small dialog drawn with the interface kit (snowbound --dialog), which answers on stdout as zenity does. Assisted-by: claude-opus-5.5

4 files changed, 390 insertions(+), 14 deletions(-)

crates/snowbound/linux/README.md+2-1
......@@ -41,7 +41,8 @@ Snowbound.
4141 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.
4242- fontconfig, and X11 or Wayland with libxkbcommon.
4343- The desktop's portal (xdg-desktop-portal) for choosing, printing and opening
44 files, and zenity or kdialog for the page date and time dialogs and alerts.
44 files. Questions, alerts and the page date and time use zenity or kdialog
45 where installed, and Snowbound's own dialogs otherwise.
4546
4647## Known limits
4748
crates/snowbound/src/dialog_linux.rs created+351
......@@ -0,0 +1,351 @@
1//! Snowbound's own message, question and entry dialogs, for desktops with neither zenity nor
2//! kdialog, as GNOME's image-based systems ship: the app runs itself as the dialog, which
3//! answers on stdout and exits successfully once accepted.
4
5use draw::Renderer;
6use std::{error::Error, ffi::OsString, process::Command, sync::Arc, time::Instant};
7use ui::{Axis, Id, Spec, Ui, children, fill, fit, px};
8use winit::{
9 application::ApplicationHandler,
10 dpi::LogicalSize,
11 event::{ElementState, WindowEvent},
12 event_loop::{ActiveEventLoop, EventLoop},
13 keyboard::{Key, NamedKey},
14 window::{Window, WindowId},
15};
16
17/// The argument that runs the app as a dialog.
18pub const DIALOG: &str = "--dialog";
19
20pub enum Ask<'a> {
21 /// A message with an OK button.
22 Message,
23 /// Whether to go ahead: buttons for `cancel` and for `action`.
24 Question { cancel: &'a str, action: &'a str },
25 /// A line of text, starting as `value`.
26 Entry { value: &'a str },
27 /// A user name, starting as `user`, and a password, answered as zenity does: `user|password`.
28 Login { user: &'a str },
29}
30
31/// Shows `title` and `detail` as `ask` asks, then waits: the answer, empty but for an entry,
32/// or None when cancelled.
33pub fn ask(title: &str, detail: &str, ask: Ask) -> Option<String> {
34 let (kind, extra): (&str, &[&str]) = match &ask {
35 Ask::Message => ("message", &[]),
36 Ask::Question { cancel, action } => ("question", &[cancel, action]),
37 Ask::Entry { value } => ("entry", &[value]),
38 Ask::Login { user } => ("login", &[user]),
39 };
40 let output = crate::loader::executable()
41 .and_then(|exe| {
42 Command::new(exe)
43 .args([DIALOG, kind, title, detail])
44 .args(extra)
45 .stderr(std::process::Stdio::inherit())
46 .output()
47 })
48 .inspect_err(|error| eprintln!("Cannot show {title:?}: {error}"))
49 .ok()?;
50 output
51 .status
52 .success()
53 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
54}
55
56/// The dialog `ask` would show, given the arguments after `DIALOG`.
57pub fn run(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn Error>> {
58 let mut next = || {
59 args.next()
60 .and_then(|arg| arg.into_string().ok())
61 .unwrap_or_default()
62 };
63 let [kind, title, detail, first, second] = [(); 5].map(|()| next());
64 let mut dialog = Dialog {
65 kind,
66 title,
67 detail,
68 buttons: [first, second],
69 fields: Default::default(),
70 shown: None,
71 accepted: false,
72 };
73 if dialog.kind == "entry" || dialog.kind == "login" {
74 dialog.fields[0] = std::mem::take(&mut dialog.buttons[0]);
75 }
76 EventLoop::new()?.run_app(&mut dialog)?;
77 if !dialog.accepted {
78 std::process::exit(1);
79 }
80 let [first, second] = &dialog.fields;
81 match dialog.kind.as_str() {
82 "entry" => print!("{first}"),
83 "login" => print!("{first}|{second}"),
84 _ => {}
85 }
86 Ok(())
87}
88
89struct Dialog {
90 kind: String,
91 title: String,
92 detail: String,
93 /// A question's cancel and action labels.
94 buttons: [String; 2],
95 /// An entry's text, or a login's user name and password.
96 fields: [String; 2],
97 shown: Option<Shown>,
98 accepted: bool,
99}
100
101struct Shown {
102 window: Arc<Window>,
103 surface: crate::surface::Surface,
104 renderer: Renderer,
105 ui: Ui,
106}
107
108fn field(index: usize) -> Id {
109 Id::ROOT.child(("field", index))
110}
111
112impl Dialog {
113 fn finish(&mut self, event_loop: &ActiveEventLoop, accepted: bool) {
114 self.accepted = accepted;
115 // The window's resources end while the event loop still holds its display.
116 self.shown = None;
117 event_loop.exit();
118 }
119
120 /// Lays out and paints the dialog; Some once a button answers it.
121 fn frame(&mut self) -> Result<Option<bool>, Box<dyn Error>> {
122 let Some(shown) = &mut self.shown else {
123 return Ok(None);
124 };
125 let ui = &mut shown.ui;
126 let scale = shown.window.scale_factor() as f32;
127 let size = shown.window.inner_size();
128 ui.begin(
129 [size.width as f32 / scale, size.height as f32 / scale],
130 scale,
131 Instant::now(),
132 );
133 let theme = ui.theme.clone();
134 ui.open(
135 "dialog",
136 Spec {
137 axis: Axis::Y,
138 size: [fill(), fill()],
139 fill: Some(theme.popup),
140 pad: [20.0, 16.0],
141 gap: 10.0,
142 role: Some(accesskit::Role::Dialog),
143 ..Spec::default()
144 },
145 );
146 let text = |ui: &mut Ui, part, text: &str, bold| {
147 ui.leaf(
148 part,
149 Spec {
150 size: [fill(), fit()],
151 text: Some(text),
152 bold,
153 overflow: ui::Overflow::Wrap,
154 ..Spec::default()
155 },
156 );
157 };
158 text(ui, "title", &self.title, true);
159 if !self.detail.is_empty() {
160 text(ui, "detail", &self.detail, false);
161 }
162 let entry = Spec {
163 size: [fill(), px(theme.font_size * 2.0)],
164 fill: Some(theme.base),
165 border: Some(theme.accent),
166 radius: 4.0,
167 pad: [6.0, 0.0],
168 ..Spec::default()
169 };
170 let fields = match self.kind.as_str() {
171 "entry" => 1,
172 "login" => 2,
173 _ => 0,
174 };
175 if fields > 0 && ui.focused().is_none() {
176 ui.set_focus(Some(field(0)));
177 }
178 for (index, value) in self.fields.iter_mut().take(fields).enumerate() {
179 if index == 1 {
180 text(ui, "password label", "Password:", false);
181 ui::password_field(ui, field(index), value, "", entry.clone());
182 } else {
183 ui::text_field(ui, field(index), value, "", entry.clone());
184 }
185 }
186 ui.leaf(
187 "space",
188 Spec {
189 size: [fill(), fill()],
190 ..Spec::default()
191 },
192 );
193 ui.open(
194 "buttons",
195 Spec {
196 size: [fill(), children()],
197 gap: 8.0,
198 ..Spec::default()
199 },
200 );
201 ui.leaf(
202 "space",
203 Spec {
204 size: [fill(), px(0.0)],
205 ..Spec::default()
206 },
207 );
208 let [cancel, action] = match self.kind.as_str() {
209 "question" => [self.buttons[0].as_str(), self.buttons[1].as_str()],
210 "message" => ["", "OK"],
211 _ => ["Cancel", "OK"],
212 };
213 let mut answer = None;
214 if !cancel.is_empty() && ui::button(ui, "cancel", cancel).clicked {
215 answer = Some(false);
216 }
217 if ui::button(ui, "action", action).clicked {
218 answer = Some(true);
219 }
220 ui.close();
221 ui.close();
222 ui.end();
223 shown.window.set_cursor(ui.cursor().unwrap_or_default());
224 if let Some(frame) = shown.surface.frame(&shown.renderer)? {
225 let interface = ui.layers();
226 let layers: Vec<_> = interface
227 .iter()
228 .filter_map(|layer| match layer {
229 ui::Layer::Primitives(primitives) => Some(primitives.layer(scale)),
230 ui::Layer::Custom { .. } => None,
231 })
232 .collect();
233 shown
234 .renderer
235 .draw(&frame.target, shown.surface.size, theme.popup, &layers)
236 .map_err(|error| format!("Dialog drawing failed: {error:?}"))?;
237 shown.surface.present(&shown.renderer, frame);
238 }
239 if ui.wants_frame() {
240 shown.window.request_redraw();
241 }
242 Ok(answer)
243 }
244}
245
246impl ApplicationHandler for Dialog {
247 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
248 if self.shown.is_some() {
249 return;
250 }
251 let height = match self.kind.as_str() {
252 "login" => 260.0,
253 "entry" => 200.0,
254 _ => 170.0,
255 };
256 let shown = (|| -> Result<Shown, Box<dyn Error>> {
257 let window = Arc::new(
258 event_loop.create_window(
259 crate::platform::window_attributes()
260 .with_title(&self.title)
261 .with_inner_size(LogicalSize::new(440.0, height))
262 .with_resizable(false),
263 )?,
264 );
265 let (surface, renderer) =
266 pollster::block_on(crate::surface::Surface::new(window.clone(), false))?;
267 let appearance = crate::platform::appearance(&window);
268 window.set_theme(Some(appearance));
269 let ui = Ui::new(
270 crate::theme(appearance, false, false),
271 crate::platform::double_click_interval(),
272 );
273 Ok(Shown {
274 window,
275 surface,
276 renderer,
277 ui,
278 })
279 })();
280 match shown {
281 Ok(shown) => {
282 shown.window.request_redraw();
283 self.shown = Some(shown);
284 }
285 Err(error) => {
286 eprintln!("Cannot show {:?}: {error}", self.title);
287 self.finish(event_loop, false);
288 }
289 }
290 }
291
292 fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
293 let Some(shown) = &mut self.shown else {
294 return;
295 };
296 let scale = shown.window.scale_factor() as f32;
297 let input = match event {
298 WindowEvent::CloseRequested => return self.finish(event_loop, false),
299 WindowEvent::RedrawRequested => {
300 match self.frame() {
301 Ok(Some(answer)) => self.finish(event_loop, answer),
302 Ok(None) => {}
303 Err(error) => {
304 eprintln!("{error}");
305 self.finish(event_loop, false);
306 }
307 }
308 return;
309 }
310 WindowEvent::Resized(size) => {
311 shown.surface.size = [size.width, size.height];
312 shown.surface.configure(&shown.renderer);
313 return shown.window.request_redraw();
314 }
315 WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => {
316 match event.logical_key {
317 Key::Named(NamedKey::Enter) => return self.finish(event_loop, true),
318 Key::Named(NamedKey::Escape) => return self.finish(event_loop, false),
319 key => ui::Event::Key {
320 key,
321 text: event.text.map(|text| text.to_string()),
322 },
323 }
324 }
325 WindowEvent::ModifiersChanged(modifiers) => ui::Event::Modifiers(modifiers.state()),
326 WindowEvent::Ime(ime) => ui::Event::Ime(ime),
327 WindowEvent::CursorMoved { position, .. } => {
328 ui::Event::PointerMoved([position.x as f32 / scale, position.y as f32 / scale])
329 }
330 WindowEvent::CursorLeft { .. } => ui::Event::PointerLeft,
331 WindowEvent::MouseInput { state, button, .. } => {
332 // A press away from the controls moves the window, which has no title bar
333 // under winit's Adwaita frame.
334 if state == ElementState::Pressed
335 && shown.ui.cursor().is_none()
336 && let Err(error) = shown.window.drag_window()
337 {
338 eprintln!("{error}");
339 }
340 ui::Event::Button {
341 button,
342 pressed: state == ElementState::Pressed,
343 at: Instant::now(),
344 }
345 }
346 _ => return,
347 };
348 shown.ui.event(input);
349 shown.window.request_redraw();
350 }
351}
crates/snowbound/src/linux.rs+26-13
......@@ -1,7 +1,9 @@
11//! Linux, on X11 and Wayland: window controls drawn in the title bar, files chosen and opened
2//! through the XDG desktop portal, other dialogs through zenity or kdialog, the colour scheme
3//! from the settings portal, and text conventions from the C library's locale.
2//! through the XDG desktop portal, other dialogs through zenity, kdialog or Snowbound's own,
3//! the colour scheme from the settings portal, and text conventions from the C library's
4//! locale.
45
6use crate::dialog::Ask;
57use canvas::date::DateField;
68use std::{
79 collections::HashMap,
......@@ -1014,7 +1016,7 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,
10141016 ["--password", "--username", &format!("--title={title}")],
10151017 ["--password", &title],
10161018 )
1017 .map_err(str::to_owned)?
1019 .unwrap_or_else(|_| crate::dialog::ask(&title, "User name:", Ask::Login { user: &user }))
10181020 .ok_or_else(|| "Signing in was canceled".to_owned())?;
10191021 // zenity answers "user|password"; kdialog only the password.
10201022 let (typed, password) = asked
......@@ -1211,7 +1213,15 @@ pub fn edit_date(
12111213 "--date-format=%Y-%m-%d",
12121214 ],
12131215 ["--calendar", title, "--dateformat", "yyyy-MM-dd"],
1214 )?,
1216 )
1217 .unwrap_or_else(|_| {
1218 let value = format(&tm, c"%Y-%m-%d");
1219 crate::dialog::ask(
1220 title,
1221 "Date, as year-month-day:",
1222 Ask::Entry { value: &value },
1223 )
1224 }),
12151225 DateField::Time => {
12161226 let current = format(&tm, c"%H:%M");
12171227 dialog(
......@@ -1222,7 +1232,11 @@ pub fn edit_date(
12221232 &format!("--entry-text={current}"),
12231233 ],
12241234 ["--inputbox", "Time, as hours and minutes:", &current],
1225 )?
1235 )
1236 .unwrap_or_else(|_| {
1237 let value = Ask::Entry { value: &current };
1238 crate::dialog::ask(title, "Time, as hours and minutes:", value)
1239 })
12261240 }
12271241 };
12281242 let Some(answer) = answer else {
......@@ -1275,7 +1289,8 @@ fn merge_date(
12751289 Ok((updated, date_labels(&tm)))
12761290}
12771291
1278/// Runs zenity, or kdialog where zenity is missing; the answer is None when cancelled.
1292/// Runs zenity, or kdialog where zenity is missing; the answer is None when cancelled, and an
1293/// error when neither is installed.
12791294fn dialog<const Z: usize, const K: usize>(
12801295 zenity: [&str; Z],
12811296 kdialog: [&str; K],
......@@ -1455,7 +1470,7 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
14551470 }
14561471}
14571472
1458/// Asks whether to go ahead with `action`; false when no tool can ask.
1473/// Asks whether to go ahead with `action`, through zenity or kdialog where installed.
14591474pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
14601475 let status = Command::new("zenity")
14611476 .args([
......@@ -1474,10 +1489,7 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool
14741489 });
14751490 match status {
14761491 Ok(status) => status.success(),
1477 Err(_) => {
1478 eprintln!("Install zenity or kdialog to answer: {message}");
1479 false
1480 }
1492 Err(_) => crate::dialog::ask(message, detail, Ask::Question { cancel, action }).is_some(),
14811493 }
14821494}
14831495
......@@ -1541,7 +1553,8 @@ pub fn inform(message: &str, detail: &str) {
15411553 show(["--info", "--msgbox"], message, detail);
15421554}
15431555
1544/// Shows `message` and `detail` in zenity's or else kdialog's dialog of the `kinds`.
1556/// Shows `message` and `detail` in zenity's or else kdialog's dialog of the `kinds`, or else
1557/// in Snowbound's own.
15451558fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) {
15461559 let (title, text) = (message.to_owned(), detail.to_owned());
15471560 std::thread::spawn(move || {
......@@ -1558,7 +1571,7 @@ fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) {
15581571 .status()
15591572 });
15601573 if shown.is_err() {
1561 eprintln!("{title}: {text}");
1574 crate::dialog::ask(&title, &text, Ask::Message);
15621575 }
15631576 });
15641577}
crates/snowbound/src/main.rs+11
......@@ -10,6 +10,9 @@ mod conflict_render;
1010#[cfg(target_os = "linux")]
1111#[path = "desktop_linux.rs"]
1212mod desktop;
13#[cfg(target_os = "linux")]
14#[path = "dialog_linux.rs"]
15mod dialog;
1316mod guide;
1417mod history;
1518#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]
......@@ -5926,6 +5929,14 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
59265929fn main() -> Result<(), Box<dyn Error>> {
59275930 #[cfg(target_os = "linux")]
59285931 loader::preload();
5932 // Before the crash log, which the app showing the dialog keeps writing.
5933 #[cfg(target_os = "linux")]
5934 if std::env::args_os()
5935 .nth(1)
5936 .is_some_and(|arg| arg == dialog::DIALOG)
5937 {
5938 return dialog::run(std::env::args_os().skip(2));
5939 }
59295940 platform::with_pool(launch)
59305941}
59315942