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....@@ -41,7 +41,8 @@ Snowbound.
41 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.41 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.
42- fontconfig, and X11 or Wayland with libxkbcommon.42- fontconfig, and X11 or Wayland with libxkbcommon.
43- The desktop's portal (xdg-desktop-portal) for choosing, printing and opening43- 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
46## Known limits47## 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 @@...@@ -1,7 +1,9 @@
1//! Linux, on X11 and Wayland: window controls drawn in the title bar, files chosen and opened1//! 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 scheme2//! through the XDG desktop portal, other dialogs through zenity, kdialog or Snowbound's own,
3//! from the settings portal, and text conventions from the C library's locale.3//! the colour scheme from the settings portal, and text conventions from the C library's
4//! locale.
45
6use crate::dialog::Ask;
5use canvas::date::DateField;7use canvas::date::DateField;
6use std::{8use std::{
7 collections::HashMap,9 collections::HashMap,
...@@ -1014,7 +1016,7 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,...@@ -1014,7 +1016,7 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,
1014 ["--password", "--username", &format!("--title={title}")],1016 ["--password", "--username", &format!("--title={title}")],
1015 ["--password", &title],1017 ["--password", &title],
1016 )1018 )
1017 .map_err(str::to_owned)?1019 .unwrap_or_else(|_| crate::dialog::ask(&title, "User name:", Ask::Login { user: &user }))
1018 .ok_or_else(|| "Signing in was canceled".to_owned())?;1020 .ok_or_else(|| "Signing in was canceled".to_owned())?;
1019 // zenity answers "user|password"; kdialog only the password.1021 // zenity answers "user|password"; kdialog only the password.
1020 let (typed, password) = asked1022 let (typed, password) = asked
...@@ -1211,7 +1213,15 @@ pub fn edit_date(...@@ -1211,7 +1213,15 @@ pub fn edit_date(
1211 "--date-format=%Y-%m-%d",1213 "--date-format=%Y-%m-%d",
1212 ],1214 ],
1213 ["--calendar", title, "--dateformat", "yyyy-MM-dd"],1215 ["--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 }),
1215 DateField::Time => {1225 DateField::Time => {
1216 let current = format(&tm, c"%H:%M");1226 let current = format(&tm, c"%H:%M");
1217 dialog(1227 dialog(
...@@ -1222,7 +1232,11 @@ pub fn edit_date(...@@ -1222,7 +1232,11 @@ pub fn edit_date(
1222 &format!("--entry-text={current}"),1232 &format!("--entry-text={current}"),
1223 ],1233 ],
1224 ["--inputbox", "Time, as hours and minutes:", &current],1234 ["--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 })
1226 }1240 }
1227 };1241 };
1228 let Some(answer) = answer else {1242 let Some(answer) = answer else {
...@@ -1275,7 +1289,8 @@ fn merge_date(...@@ -1275,7 +1289,8 @@ fn merge_date(
1275 Ok((updated, date_labels(&tm)))1289 Ok((updated, date_labels(&tm)))
1276}1290}
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.
1279fn dialog<const Z: usize, const K: usize>(1294fn dialog<const Z: usize, const K: usize>(
1280 zenity: [&str; Z],1295 zenity: [&str; Z],
1281 kdialog: [&str; K],1296 kdialog: [&str; K],
...@@ -1455,7 +1470,7 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {...@@ -1455,7 +1470,7 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
1455 }1470 }
1456}1471}
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.
1459pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {1474pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
1460 let status = Command::new("zenity")1475 let status = Command::new("zenity")
1461 .args([1476 .args([
...@@ -1474,10 +1489,7 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool...@@ -1474,10 +1489,7 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool
1474 });1489 });
1475 match status {1490 match status {
1476 Ok(status) => status.success(),1491 Ok(status) => status.success(),
1477 Err(_) => {1492 Err(_) => crate::dialog::ask(message, detail, Ask::Question { cancel, action }).is_some(),
1478 eprintln!("Install zenity or kdialog to answer: {message}");
1479 false
1480 }
1481 }1493 }
1482}1494}
14831495
...@@ -1541,7 +1553,8 @@ pub fn inform(message: &str, detail: &str) {...@@ -1541,7 +1553,8 @@ pub fn inform(message: &str, detail: &str) {
1541 show(["--info", "--msgbox"], message, detail);1553 show(["--info", "--msgbox"], message, detail);
1542}1554}
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.
1545fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) {1558fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) {
1546 let (title, text) = (message.to_owned(), detail.to_owned());1559 let (title, text) = (message.to_owned(), detail.to_owned());
1547 std::thread::spawn(move || {1560 std::thread::spawn(move || {
...@@ -1558,7 +1571,7 @@ fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) {...@@ -1558,7 +1571,7 @@ fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) {
1558 .status()1571 .status()
1559 });1572 });
1560 if shown.is_err() {1573 if shown.is_err() {
1561 eprintln!("{title}: {text}");1574 crate::dialog::ask(&title, &text, Ask::Message);
1562 }1575 }
1563 });1576 });
1564}1577}
crates/snowbound/src/main.rs+11
...@@ -10,6 +10,9 @@ mod conflict_render;...@@ -10,6 +10,9 @@ mod conflict_render;
10#[cfg(target_os = "linux")]10#[cfg(target_os = "linux")]
11#[path = "desktop_linux.rs"]11#[path = "desktop_linux.rs"]
12mod desktop;12mod desktop;
13#[cfg(target_os = "linux")]
14#[path = "dialog_linux.rs"]
15mod dialog;
13mod guide;16mod guide;
14mod history;17mod history;
15#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]18#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]
...@@ -5926,6 +5929,14 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -5926,6 +5929,14 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
5926fn main() -> Result<(), Box<dyn Error>> {5929fn main() -> Result<(), Box<dyn Error>> {
5927 #[cfg(target_os = "linux")]5930 #[cfg(target_os = "linux")]
5928 loader::preload();5931 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 }
5929 platform::with_pool(launch)5940 platform::with_pool(launch)
5930}5941}
59315942