Skip to main content

max / shop

Answer --app-id, and take a value attached with equals Alloy's sway config runs the launcher as $term --app-id=alloy-menu -e /usr/bin/alloy-menu and shop has never had --app-id. Until this morning it did not matter much: the old argv scan looked for four flags by name and ignored everything else, so the launcher opened a window that was not the one sway's `for_window [app_id="alloy-menu"] floating enable, resize set 800 500` rule was written for. A quiet bug -- the picker came up tiled. Rejecting unknown options turned that quiet bug into a total one. shop exited 2, and $mod+d stopped opening anything at all. Both halves are fixed here rather than by relaxing the rejection, which is doing its job: the flag was always wrong, and the launcher is what the rejection was supposed to protect. Two things were missing, and either alone would still have broken it: - --app-id, wired to the app_id WindowSpec already carries. The default stays the reverse-DNS name; an override is for a window meant to be caught by a rule rather than to look like every other terminal. - --flag=value. The parser understood only the space form, and the config writes the equals form. Both spellings are ordinary and callers pick whichever reads better, so understanding one of them is understanding none of them. Only the first `=` separates, so a value keeps its own. The launcher's exact command line is now a test.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 18:52 UTC
Signed with PGP, not checked
Commit: 24e722fbc13f0b13a023d77d1cad72d9e0a27930
Parent: 7861672
2 files changed, +90 insertions, -17 deletions
M Cargo.lock +12 -12
@@ -2148,10 +2148,6 @@
2148 2148 source = "registry+https://github.com/rust-lang/crates.io-index"
2149 2149 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
2150 2150
2151 - [[patch.unused]]
2152 - name = "docengine"
2153 - version = "0.7.0"
2154 -
2155 2151 [[patch.unused]]
2156 2152 name = "quasi-axum"
2157 2153 version = "0.3.0"
@@ -2180,14 +2176,6 @@
2180 2176 name = "quasi-webview"
2181 2177 version = "0.3.0"
2182 2178
2183 - [[patch.unused]]
2184 - name = "synckit-client"
2185 - version = "0.8.0"
2186 -
2187 - [[patch.unused]]
2188 - name = "synckit-config"
2189 - version = "0.2.0"
2190 -
2191 2179 [[patch.unused]]
2192 2180 name = "kberg"
2193 2181 version = "0.1.0"
@@ -2203,3 +2191,15 @@
2203 2191 [[patch.unused]]
2204 2192 name = "tagtree"
2205 2193 version = "0.4.0"
2194 +
2195 + [[patch.unused]]
2196 + name = "synckit-client"
2197 + version = "0.8.0"
2198 +
2199 + [[patch.unused]]
2200 + name = "synckit-config"
2201 + version = "0.2.0"
2202 +
2203 + [[patch.unused]]
2204 + name = "docengine"
2205 + version = "0.7.0"
@@ -146,6 +146,14 @@
146 146 /// `--theme ID` overrides the config file for one run, which is how you look
147 147 /// at a theme before committing to it.
148 148 theme: Option<String>,
149 + /// `--app-id ID` sets the xdg-shell app_id this window reports.
150 + ///
151 + /// What a compositor keys its window rules on. Alloy's sway config runs the
152 + /// launcher as `shop --app-id=alloy-menu -e alloy-menu` so that
153 + /// `for_window [app_id="alloy-menu"] floating enable, resize set 800 500`
154 + /// matches it, and a terminal that cannot say who it is makes that rule
155 + /// unwritable: every shop window would be the same window to sway.
156 + app_id: Option<String>,
149 157 }
150 158
151 159 /// Every way argv can end the process before a window exists.
@@ -167,10 +175,13 @@
167 175 of the command line, so it goes last.
168 176 --exec CMD Run CMD through sh -c instead of the login shell.
169 177 --theme ID Use theme ID for this run, ignoring the config file.
178 + --app-id ID Report ID as the window's app id, for window rules.
170 179 --record PATH Tee the terminal output to PATH as raw bytes.
171 180 -h, --help Print this help.
172 181 -V, --version Print the version.
173 182
183 + Options take their value either way: --theme dark or --theme=dark.
184 +
174 185 Config is read from ~/.config/shop/config.toml.";
175 186
176 187 /// Read argv, or say why we are not opening a window.
@@ -195,11 +206,25 @@
195 206 cli.exec_argv = Some(argv).filter(|a| !a.is_empty());
196 207 return Ok(cli);
197 208 }
198 - let mut value = |flag: &str| {
199 - args.next()
200 - .ok_or_else(|| CliExit::Reject(format!("{flag} needs a value")))
209 + // `--flag=value` as well as `--flag value`. Both spellings are ordinary
210 + // and callers pick whichever reads better, so a parser that knows only
211 + // one of them rejects correct command lines. Alloy's sway config writes
212 + // the equals form, and understanding only the space form is exactly how
213 + // the launcher broke.
214 + let (name, inline) = match arg.split_once('=') {
215 + Some((name, value)) if name.starts_with("--") => (name, Some(value.to_string())),
216 + _ => (arg.as_str(), None),
201 217 };
202 - match arg.as_str() {
218 + let mut value = |flag: &str| match &inline {
219 + Some(value) => Ok(value.clone()),
220 + None => args
221 + .next()
222 + .ok_or_else(|| CliExit::Reject(format!("{flag} needs a value"))),
223 + };
224 + match name {
225 + "-h" | "--help" | "-V" | "--version" if inline.is_some() => {
226 + return Err(CliExit::Reject(format!("{name} takes no value")));
227 + }
203 228 "-h" | "--help" => return Err(CliExit::Answer(HELP.into())),
204 229 "-V" | "--version" => {
205 230 return Err(CliExit::Answer(format!(
@@ -210,6 +235,7 @@
210 235 "--exec" => cli.exec_cmd = Some(value("--exec")?),
211 236 "--theme" => cli.theme = Some(value("--theme")?),
212 237 "--record" => cli.record_path = Some(value("--record")?),
238 + "--app-id" => cli.app_id = Some(value("--app-id")?),
213 239 other => {
214 240 return Err(CliExit::Reject(format!(
215 241 "unknown option {other}\nTry 'shop --help'."
@@ -248,6 +274,7 @@
248 274 exec_argv,
249 275 record_path,
250 276 theme: theme_arg,
277 + app_id,
251 278 } = cli;
252 279 let config = Config::load().with_theme(theme_arg);
253 280 let scrollback_lines = config.scrollback_lines;
@@ -277,9 +304,13 @@
277 304 info!(spawn = %spawn_cmd, "spawned child");
278 305
279 306 let conn = Connection::connect_to_env()?;
307 + // The default is the reverse-DNS name a desktop file would use. An
308 + // override is for a window meant to be caught by a rule rather than to
309 + // look like every other terminal: Alloy's launcher asks for `alloy-menu`
310 + // so sway can float it at a fixed size.
280 311 let spec = WindowSpec {
281 312 title: "shop",
282 - app_id: "dev.makecreative.shop",
313 + app_id: app_id.as_deref().unwrap_or("dev.makecreative.shop"),
283 314 min_size: (320, 240),
284 315 initial_size: INITIAL,
285 316 };
@@ -2503,6 +2534,48 @@
2503 2534 assert_eq!(cli.theme, None);
2504 2535 }
2505 2536
2537 + // The exact line in Alloy's sway config, and the regression that put it
2538 + // here: rejecting unknown options turned a flag shop silently ignored into
2539 + // one that exited 2, so $mod+d stopped opening anything at all.
2540 + #[test]
2541 + fn the_launcher_command_line_parses() {
2542 + let cli = parse_args(cmdline(&[
2543 + "--app-id=alloy-menu",
2544 + "-e",
2545 + "/usr/bin/alloy-menu",
2546 + ]))
2547 + .expect("the launcher's own command line must work");
2548 +
2549 + assert_eq!(cli.app_id.as_deref(), Some("alloy-menu"));
2550 + assert_eq!(cli.exec_argv, Some(argv(&["/usr/bin/alloy-menu"])));
2551 + }
2552 +
2553 + #[test]
2554 + fn a_value_can_be_attached_with_equals_or_separated_by_a_space() {
2555 + let attached = parse_args(cmdline(&["--theme=akari-night"])).expect("equals form");
2556 + let separated = parse_args(cmdline(&["--theme", "akari-night"])).expect("space form");
2557 +
2558 + assert_eq!(attached.theme.as_deref(), Some("akari-night"));
2559 + assert_eq!(attached, separated);
2560 + }
2561 +
2562 + // An equals sign in a value is the value's business. Only the first one
2563 + // separates, so a runner command or a path keeps its own.
2564 + #[test]
2565 + fn only_the_first_equals_separates() {
2566 + let cli = parse_args(cmdline(&["--exec=echo a=b"])).expect("equals in a value");
2567 +
2568 + assert_eq!(cli.exec_cmd.as_deref(), Some("echo a=b"));
2569 + }
2570 +
2571 + #[test]
2572 + fn a_flag_that_takes_no_value_refuses_one() {
2573 + let Err(CliExit::Reject(message)) = parse_args(cmdline(&["--help=please"])) else {
2574 + panic!("--help does not take a value");
2575 + };
2576 + assert!(message.contains("takes no value"), "{message}");
2577 + }
2578 +
2506 2579 #[test]
2507 2580 fn dash_e_with_nothing_after_it_is_a_shell_not_an_error() {
2508 2581 // A launcher that built a command line and found no command. Opening a