Krolyxon
Commit ab1dc3b

Branches

Tags

Refs →

remageab1dc3b

Add user commands, IPC socket and remage msg

Commands run async with confirm prompts, output modes, after/refresh and
chained bindings. The socket speaks newline-delimited JSON with queries and
event subscriptions. Adds -i/-0/-o.
Author
krolyxon
Date
2026-11-04 21:46 +0530
Parent
c498905
Commit
ab1dc3bdce2c46ef67b511f28b7ff9b9ee9b104e

+1934 −55

10 files changed

  1. M CLAUDE.md +19 −3
  2. M src/app.rs +504 −26
  3. M src/cli.rs +62 −2
  4. M src/config/default.toml +14 −5
  5. M src/exec.rs +628 −0
  6. M src/ipc.rs +557 −1
  7. M src/main.rs +80 −10
  8. M src/state/mod.rs +24 −3
  9. M src/state/queue.rs +19 −4
  10. M src/ui/mod.rs +27 −1
M CLAUDE.md View
@@ -268,12 +268,19 @@ stdin = "files"
268268
269269 **Command fields:**
270270 - **Placeholders:** `{file} {files} {dir} {name} {stem} {ext} {index} {count} {width} {height}`.
271+ `{file}`, `{dir}`, `{name}`, `{stem}`, `{ext}`, `{index}`, `{width}` and `{height}` describe the
272+ current image; `{files}` and `{count}` (and `REMAGE_COUNT`) describe the targets. Only these
273+ names are substituted, so `awk '{print $1}'` survives. In an exec array, an element containing
274+ `{files}` repeats once per target (`--in={files}` → `--in=a --in=b`), and a leading `~/` in the
275+ template expands. In a shell string every value is shell-quoted, so don't quote placeholders.
276+ A command that mentions file placeholders (or reads stdin) refuses to run with no targets.
271277 - **`targets`:** `auto` (marked files if any, otherwise the current one; the default), `current`,
272278 `marked` or `all`.
273279 - **`stdin`:** `none`, `files` (newline-separated) or `files0` (NUL-separated).
274280 - **Environment:** `REMAGE_FILE`, `REMAGE_INDEX`, `REMAGE_COUNT`, `REMAGE_MODE`, `REMAGE_SOCKET`,
275281 `REMAGE_PID`.
276−- **`confirm`:** turns the command into an inline y/n prompt. remage has no modal dialogs.
282+- **`confirm`:** turns the command into an inline y/n prompt. remage has no modal dialogs. `y`
283+ runs it and any other key (or `cancel`) drops it. The prompt shows even with the bar hidden.
277284 - **`output`:**
278285 - `status` (default): the last stdout line goes to the status line.
279286 - `actions`: every stdout line is run as an action.
@@ -282,7 +289,10 @@ stdin = "files"
282289 or `[ERROR: <code> <first stderr line>]`.
283290 - **`after`:** actions to run on success. The default is `refresh`, which re-stats the targets,
284291 drops missing files and re-decodes changed ones. That way delete and rotate scripts work with no
285− extra setup.
292+ extra setup. "Changed" means the mtime differs from the one recorded when the file was decoded
293+ or thumbnailed. A bare `refresh` (key, IPC) re-checks the whole queue.
294+- **Chains:** in a binding array, the actions after a `run` (and the command's `after`) wait for
295+ it to exit successfully; a failure or a declined prompt drops the rest.
286296 - **Unbound commands** still appear in the `:` palette.
287297
288298 **IPC socket.** It lives at `$XDG_RUNTIME_DIR/remage/<pid>.sock` and speaks newline-delimited JSON.
@@ -293,6 +303,12 @@ stdin = "files"
293303 - **Replies:** `{"ok":true,"data":…}` or `{"ok":false,"error":"…"}`.
294304 - **Client:** `remage msg <action>`, `remage msg --query marked` and `remage msg --subscribe image`.
295305 It targets `$REMAGE_SOCKET`, otherwise the newest instance; `--pid` picks one explicitly.
306+ Strings and string lists print one per line, anything else as JSON; errors go to stderr with
307+ exit 1. Subscriptions print one JSON event per line.
308+- **Details:** `"action"` also takes an array. Events are `image` (`path`, 1-based `index`,
309+ `count`), `marks` (`count`, `files`) and `mode`. The socket is 0600 in a 0700 directory,
310+ sockets of dead pids are cleaned up at startup, and the socket is removed on exit once any
311+ in-flight reply (to a `quit`) has gone out.
296312 - **Uses:** Hyprland binds, long-running watchers and other tools driving the viewer.
297313
298314 ### Config
@@ -460,7 +476,7 @@ Each milestone should end with a usable binary.
460476 - **M3 Grid + marks** ✓ (2026-11-04). Cached grid opens in ~85 ms, 0% CPU when idle.
461477 - Freedesktop thumbnails, worker priorities and the GPU atlas.
462478 - Grid navigation, visual mode, marks and smooth scrolling.
463−- **M4 Commands + IPC**
479+- **M4 Commands + IPC** ✓ (2026-11-04). Tested end to end over the socket and with `wtype`.
464480 - `[commands]` with async exec, confirm, output modes and `after`/`refresh`.
465481 - The socket, `remage msg` and subscriptions; `-i`/`-o`/`-0`.
466482 - **M5 Formats + fidelity**
M src/app.rs View
@@ -4,7 +4,9 @@ use std::collections::{HashMap, HashSet, VecDeque};
44 use std::path::{Path, PathBuf};
55 use std::sync::atomic::{AtomicU32, Ordering};
66 use std::sync::{Arc, Mutex};
7−use std::time::{Duration, Instant};
7+use std::time::{Duration, Instant, SystemTime};
8+
9+use serde_json::{Value, json};
810
911 use winit::application::ApplicationHandler;
1012 use winit::dpi::LogicalSize;
@@ -16,11 +18,13 @@ use winit::window::{Fullscreen, Window, WindowId};
1618 use crate::action::{Action, ModeArg, Switch, Zoom};
1719 use crate::cache::ImageCache;
1820 use crate::config::watch::ConfigWatch;
19−use crate::config::{Config, LoadError, Loaded};
21+use crate::config::{Config, LoadError, Loaded, Output, Targets, suggest};
2022 use crate::decode::{self, DecodedImage};
23+use crate::exec;
2124 use crate::input::keymap::{KeyTables, Keymap};
2225 use crate::input::keys::{Key, Scroll};
2326 use crate::input::{self};
27+use crate::ipc::{self, Query, Request, Topic};
2428 use crate::render::{Renderer, text};
2529 use crate::scan;
2630 use crate::state::view::{Dir, FitMode};
@@ -51,6 +55,8 @@ const DOUBLE_CLICK: Duration = Duration::from_millis(400);
5155 pub enum UserEvent {
5256 Decoded {
5357 path: PathBuf,
58+ /// The file's mtime when decoding started, for `refresh`.
59+ mtime: Option<SystemTime>,
5460 result: Result<Arc<DecodedImage>, String>,
5561 },
5662 /// Unknown-extension files that turned out to be images.
@@ -65,8 +71,15 @@ pub enum UserEvent {
6571 Thumb {
6672 path: PathBuf,
6773 px: u32,
74+ mtime: Option<SystemTime>,
6875 result: Result<Thumb, ThumbError>,
6976 },
77+ /// Output or the exit of a user command.
78+ Command { id: u64, event: exec::Event },
79+ /// `refresh` re-checked these files: their mtime, or `None` if they are gone.
80+ Refreshed(Vec<(PathBuf, Option<SystemTime>)>),
81+ /// A request on the IPC socket.
82+ Ipc(ipc::Incoming),
7083 }
7184
7285 /// Command-line settings that beat the config, across reloads too.
@@ -95,6 +108,43 @@ pub struct Options {
95108 pub overrides: Overrides,
96109 /// Never write the shared thumbnail cache (`-p`).
97110 pub private: bool,
111+ /// The IPC socket, passed to commands as `$REMAGE_SOCKET`.
112+ pub socket: Option<PathBuf>,
113+}
114+
115+/// Where a chain of actions came from, which decides what some of them act on.
116+#[derive(Clone, Debug, Default)]
117+struct Ctx {
118+ /// The cursor, for mouse-triggered zoom.
119+ anchor: Option<[f32; 2]>,
120+ /// A command's targets, which its `refresh` re-checks (otherwise the whole queue).
121+ targets: Option<Arc<[PathBuf]>>,
122+}
123+
124+/// A user command from spawn to exit.
125+struct Job {
126+ name: String,
127+ output: Output,
128+ targets: Arc<[PathBuf]>,
129+ /// The command's `after` actions, then whatever followed `run` in its binding: they wait
130+ /// for a successful exit.
131+ then: Vec<Action>,
132+ /// stdout lines run as actions so far.
133+ lines: usize,
134+}
135+
136+/// An IPC connection streaming events.
137+struct Subscriber {
138+ topics: Vec<Topic>,
139+ tx: crossbeam_channel::Sender<String>,
140+}
141+
142+/// What subscribers last heard about.
143+#[derive(Clone, Debug, PartialEq)]
144+struct Published {
145+ image: (Option<PathBuf>, usize, usize),
146+ marks: u64,
147+ mode: Mode,
98148 }
99149
100150 /// Thumbnail jobs for the current cell size.
@@ -153,6 +203,16 @@ pub struct App {
153203 held: HashMap<PhysicalKey, Dir>,
154204 started: Instant,
155205 shown_window: bool,
206+ /// File mtimes when they were last decoded or thumbnailed, so `refresh` spots changes.
207+ stamps: HashMap<PathBuf, SystemTime>,
208+ jobs: HashMap<u64, Job>,
209+ next_job: u64,
210+ /// A command waiting for `y` at its confirm prompt.
211+ confirm: Option<(Job, exec::Prepared)>,
212+ subscribers: Vec<Subscriber>,
213+ published: Option<Published>,
214+ /// `quit-no-output` ran: `-o` prints nothing.
215+ no_output: bool,
156216 pub error: Option<anyhow::Error>,
157217 }
158218
@@ -196,6 +256,13 @@ impl App {
196256 held: HashMap::new(),
197257 started: Instant::now(),
198258 shown_window: false,
259+ stamps: HashMap::new(),
260+ jobs: HashMap::new(),
261+ next_job: 1,
262+ confirm: None,
263+ subscribers: Vec::new(),
264+ published: None,
265+ no_output: false,
199266 error: None,
200267 };
201268 // Decode the first image while the window and GPU come up.
@@ -220,12 +287,17 @@ impl App {
220287 self.pool
221288 .submit(JobKey::Decode(path.clone()), prio, move || {
222289 let t = Instant::now();
290+ let mtime = mtime(&path);
223291 let result = decode::decode(&path, max_dim.load(Ordering::Relaxed))
224292 .map(Arc::new)
225293 .map_err(|e| e.to_string());
226294 log::debug!("decoded {} in {:.1?}", path.display(), t.elapsed());
227295 lock(&arriving).insert(path.clone());
228− let _ = proxy.send_event(UserEvent::Decoded { path, result });
296+ let _ = proxy.send_event(UserEvent::Decoded {
297+ path,
298+ mtime,
299+ result,
300+ });
229301 });
230302 }
231303
@@ -377,19 +449,36 @@ impl App {
377449 self.pool
378450 .submit(JobKey::Thumb(path.clone()), prio, move || {
379451 let t = Instant::now();
452+ let mtime = mtime(&path);
380453 let result = thumbs::load(&path, px, &cache);
381454 log::trace!("thumbnail {} in {:.1?}", path.display(), t.elapsed());
382− let _ = proxy.send_event(UserEvent::Thumb { path, px, result });
455+ let _ = proxy.send_event(UserEvent::Thumb {
456+ path,
457+ px,
458+ mtime,
459+ result,
460+ });
383461 });
384462 }
385463
386− fn on_thumb(&mut self, path: PathBuf, px: u32, result: Result<Thumb, ThumbError>) {
464+ fn on_thumb(
465+ &mut self,
466+ path: PathBuf,
467+ px: u32,
468+ mtime: Option<SystemTime>,
469+ result: Result<Thumb, ThumbError>,
470+ ) {
387471 self.thumbs.requested.remove(&path);
388472 if px != self.thumbs.cell {
389473 return;
390474 }
391475 match result {
392− Ok(t) => self.thumbs.arrivals.push_back((path, t)),
476+ Ok(t) => {
477+ if let Some(m) = mtime {
478+ self.stamps.insert(path.clone(), m);
479+ }
480+ self.thumbs.arrivals.push_back((path, t));
481+ }
393482 Err(e) => {
394483 // Undecodable: it leaves the queue, as in image mode.
395484 log::warn!("{}: {e}", path.display());
@@ -514,13 +603,21 @@ impl App {
514603 self.request_redraw();
515604 }
516605
517− fn on_decoded(&mut self, path: PathBuf, result: Result<Arc<DecodedImage>, String>) {
606+ fn on_decoded(
607+ &mut self,
608+ path: PathBuf,
609+ mtime: Option<SystemTime>,
610+ result: Result<Arc<DecodedImage>, String>,
611+ ) {
518612 lock(&self.arriving).remove(&path);
519613 let now = Instant::now();
520614 let is_current = self.state.queue.current() == Some(path.as_path());
521615 let in_grid = self.state.in_grid();
522616 match result {
523617 Ok(img) => {
618+ if let Some(m) = mtime {
619+ self.stamps.insert(path.clone(), m);
620+ }
524621 let wanted = self.window_paths();
525622 let keep: Vec<&Path> = wanted.iter().map(PathBuf::as_path).collect();
526623 self.cache.insert(path.clone(), img.clone(), &keep);
@@ -559,23 +656,371 @@ impl App {
559656 }
560657 }
561658
562− /// Run actions in order; `anchor` is the cursor for mouse-triggered ones.
563− fn run(&mut self, actions: Vec<Action>, anchor: Option<[f32; 2]>, el: &ActiveEventLoop) {
564− for action in actions {
565− if action == Action::Cancel {
566− self.keymap.cancel();
567− self.state.pending.clear();
568− self.config_error = false;
659+ /// Run actions in order. A `run` suspends the rest of the chain until its command exits
660+ /// successfully. Errors are shown on the status line; the first is also returned for IPC.
661+ fn run(&mut self, actions: Vec<Action>, ctx: &Ctx, el: &ActiveEventLoop) -> Result<(), String> {
662+ let mut rest = actions.into_iter();
663+ while let Some(action) = rest.next() {
664+ match &action {
665+ Action::Run(name) => return self.start_command(name, rest.collect()),
666+ Action::Cancel => {
667+ self.keymap.cancel();
668+ self.state.pending.clear();
669+ self.config_error = false;
670+ self.answer(false);
671+ }
672+ Action::QuitNoOutput => self.no_output = true,
673+ _ => {}
569674 }
570− let fx = self.state.apply_at(action, anchor, Instant::now());
571− self.effects(fx, el);
675+ let fx = self.state.apply_at(action, ctx.anchor, Instant::now());
676+ self.effects(fx, ctx, el);
572677 if fx.quit {
573− return;
678+ break;
679+ }
680+ }
681+ Ok(())
682+ }
683+
684+ /// Start `[commands.<name>]`, or ask first when it has a `confirm`.
685+ fn start_command(&mut self, name: &str, then: Vec<Action>) -> Result<(), String> {
686+ let now = Instant::now();
687+ let fail = |state: &mut AppState, msg: String| {
688+ state.notify(format!("error: {msg}"), MsgKind::Error, now);
689+ Err(msg)
690+ };
691+ let Some(cmd) = self.config.commands.get(name).cloned() else {
692+ let hint = suggest(name, self.config.commands.keys().map(String::as_str))
693+ .map(|s| format!(", did you mean `{s}`?"))
694+ .unwrap_or_default();
695+ self.request_redraw();
696+ return fail(&mut self.state, format!("no command named `{name}`{hint}"));
697+ };
698+ let q = &self.state.queue;
699+ let targets: Arc<[PathBuf]> =
700+ exec::targets(cmd.targets, q.current(), &q.marked(), q.files()).into();
701+ if targets.is_empty() && exec::needs_files(&cmd) {
702+ let why = if cmd.targets == Targets::Marked {
703+ "no marked files"
704+ } else {
705+ "no images"
706+ };
707+ self.request_redraw();
708+ return fail(&mut self.state, format!("{name}: {why}"));
709+ }
710+ let size = self
711+ .state
712+ .shown
713+ .as_ref()
714+ .filter(|s| Some(s.path.as_path()) == q.current())
715+ .map(|s| s.size);
716+ let ctx = exec::Context {
717+ current: q.current(),
718+ index: q.index(),
719+ targets: &targets,
720+ size,
721+ mode: self.state.mode.name(),
722+ socket: self.opts.socket.as_deref(),
723+ };
724+ let prepared = exec::prepare(&cmd, &ctx);
725+ let question = cmd.confirm.as_deref().map(|c| exec::expand_text(c, &ctx));
726+ let mut chain = cmd.after.0.unwrap_or_default();
727+ chain.extend(then);
728+ let job = Job {
729+ name: name.to_string(),
730+ output: cmd.output,
731+ targets,
732+ then: chain,
733+ lines: 0,
734+ };
735+ match question {
736+ Some(q) => {
737+ self.answer(false);
738+ self.state.prompt = Some(q);
739+ self.confirm = Some((job, prepared));
740+ self.request_redraw();
574741 }
742+ None => self.spawn_job(job, prepared),
575743 }
744+ Ok(())
745+ }
746+
747+ /// Settle a confirm prompt: `y` runs the command, anything else drops it.
748+ fn answer(&mut self, yes: bool) {
749+ let Some((job, prepared)) = self.confirm.take() else {
750+ return;
751+ };
752+ self.state.prompt = None;
753+ if yes {
754+ self.spawn_job(job, prepared);
755+ }
756+ self.request_redraw();
576757 }
577758
578− fn effects(&mut self, fx: Effects, event_loop: &ActiveEventLoop) {
759+ fn spawn_job(&mut self, job: Job, prepared: exec::Prepared) {
760+ let id = self.next_job;
761+ self.next_job += 1;
762+ log::info!(
763+ "run {}: {} {:?}",
764+ job.name,
765+ prepared.program.to_string_lossy(),
766+ prepared.args
767+ );
768+ let proxy = self.proxy.clone();
769+ exec::spawn(&job.name, prepared, job.output, move |event| {
770+ let _ = proxy.send_event(UserEvent::Command { id, event });
771+ });
772+ self.state.running.push((id, job.name.clone()));
773+ self.jobs.insert(id, job);
774+ self.request_redraw();
775+ }
776+
777+ fn on_command(&mut self, id: u64, event: exec::Event, el: &ActiveEventLoop) {
778+ let now = Instant::now();
779+ match event {
780+ exec::Event::Line(line) => {
781+ let Some(job) = self.jobs.get_mut(&id) else {
782+ return;
783+ };
784+ job.lines += 1;
785+ let name = job.name.clone();
786+ let ctx = Ctx {
787+ anchor: None,
788+ targets: Some(job.targets.clone()),
789+ };
790+ match line.parse::<Action>() {
791+ Ok(a) => {
792+ let _ = self.run(vec![a], &ctx, el);
793+ }
794+ Err(e) => {
795+ log::warn!("{name}: {line}: {e}");
796+ self.state
797+ .notify(format!("error: {name}: {e}"), MsgKind::Error, now);
798+ self.request_redraw();
799+ }
800+ }
801+ }
802+ exec::Event::Done(outcome) => {
803+ let Some(job) = self.jobs.remove(&id) else {
804+ return;
805+ };
806+ self.state.running.retain(|(j, _)| *j != id);
807+ self.request_redraw();
808+ if !outcome.success() {
809+ let msg = format!("error: {}", outcome.error());
810+ log::warn!("{}: {msg}", job.name);
811+ self.state.notify(msg, MsgKind::Error, now);
812+ return;
813+ }
814+ log::debug!("{}: done", job.name);
815+ match (job.output, outcome.last_line) {
816+ (Output::Status, Some(line)) => self.state.notify(line, MsgKind::Info, now),
817+ // Actions speak for themselves.
818+ (Output::Actions, _) if job.lines > 0 => {}
819+ _ => self.state.notify("done", MsgKind::Info, now),
820+ }
821+ let ctx = Ctx {
822+ anchor: None,
823+ targets: Some(job.targets),
824+ };
825+ let _ = self.run(job.then, &ctx, el);
826+ }
827+ }
828+ }
829+
830+ /// Re-check `targets` (default: the whole queue, plus the current file) on disk, off-thread.
831+ fn refresh(&mut self, targets: Option<Arc<[PathBuf]>>) {
832+ let mut files: Vec<PathBuf> = match targets {
833+ Some(t) => t.to_vec(),
834+ None => self.state.queue.files().to_vec(),
835+ };
836+ if let Some(cur) = self.state.queue.current()
837+ && !files.iter().any(|f| f == cur)
838+ {
839+ files.push(cur.to_path_buf());
840+ }
841+ let proxy = self.proxy.clone();
842+ std::thread::spawn(move || {
843+ let found = files
844+ .into_iter()
845+ .map(|p| {
846+ let m = std::fs::metadata(&p)
847+ .ok()
848+ .map(|m| m.modified().unwrap_or(SystemTime::UNIX_EPOCH));
849+ (p, m)
850+ })
851+ .collect();
852+ let _ = proxy.send_event(UserEvent::Refreshed(found));
853+ });
854+ }
855+
856+ /// Drop files that vanished and reload ones that changed since they were loaded.
857+ fn on_refreshed(&mut self, found: Vec<(PathBuf, Option<SystemTime>)>) {
858+ let cur = self.state.queue.current().map(Path::to_path_buf);
859+ let showing = |s: &AppState, p: &Path| {
860+ s.mode == Mode::Image && s.shown.as_ref().is_some_and(|s| s.path == p)
861+ };
862+ let mut navigated = false;
863+ let mut stale = Vec::new();
864+ let mut gone = 0;
865+ for (path, mtime) in found {
866+ match mtime {
867+ None => {
868+ if self.state.queue.remove(&path) {
869+ gone += 1;
870+ navigated |= cur.as_ref() == Some(&path);
871+ }
872+ self.cache.remove(&path);
873+ self.stamps.remove(&path);
874+ self.state.thumbs.remove(&path);
875+ }
876+ Some(t) if self.stamps.get(&path).is_some_and(|s| *s != t) => stale.push(path),
877+ Some(_) => {}
878+ }
879+ }
880+ let mut reload_current = false;
881+ for path in &stale {
882+ self.cache.remove(path);
883+ self.stamps.remove(path);
884+ self.state.thumbs.remove(path);
885+ self.uploads.retain(|(p, _)| p != path);
886+ if cur.as_ref() == Some(path) && showing(&self.state, path) {
887+ // Keep it on screen until the new version replaces it.
888+ reload_current = true;
889+ } else if let Some(g) = &mut self.gfx {
890+ g.renderer.retain(|p| p != path);
891+ }
892+ }
893+ if gone + stale.len() > 0 {
894+ log::debug!("refresh: {gone} gone, {} changed", stale.len());
895+ }
896+ if reload_current && let Some(cur) = &cur {
897+ self.decode(cur, Priority::Current);
898+ }
899+ if gone + stale.len() > 0 {
900+ self.sync(navigated);
901+ self.request_redraw();
902+ }
903+ }
904+
905+ fn on_ipc(&mut self, inc: ipc::Incoming, el: &ActiveEventLoop) {
906+ let reply = match inc.request {
907+ Request::Actions(actions) => match self.run(actions, &Ctx::default(), el) {
908+ Ok(()) => ipc::ok(Value::Null),
909+ Err(e) => ipc::error(&e),
910+ },
911+ Request::Query(q) => ipc::ok(self.query(q)),
912+ Request::Subscribe(topics) => match inc.events {
913+ Some(tx) => {
914+ if self.published.is_none() {
915+ self.published = Some(self.snapshot());
916+ }
917+ self.subscribers.push(Subscriber { topics, tx });
918+ ipc::ok(Value::Null)
919+ }
920+ None => ipc::error("no event channel"),
921+ },
922+ };
923+ let _ = inc.reply.send(reply);
924+ }
925+
926+ fn query(&self, q: Query) -> Value {
927+ let s = &self.state;
928+ let paths = |ps: &mut dyn Iterator<Item = &Path>| -> Value {
929+ ps.map(ipc::path_json).collect::<Vec<_>>().into()
930+ };
931+ match q {
932+ Query::Current => s.queue.current().map_or(Value::Null, ipc::path_json),
933+ Query::Marked => paths(&mut s.queue.marked().into_iter()),
934+ Query::Queue => paths(&mut s.queue.files().iter().map(PathBuf::as_path)),
935+ Query::State => {
936+ let shown = s
937+ .shown
938+ .as_ref()
939+ .filter(|sh| Some(sh.path.as_path()) == s.queue.current());
940+ let fullscreen = self
941+ .gfx
942+ .as_ref()
943+ .is_some_and(|g| g.window.fullscreen().is_some());
944+ json!({
945+ "mode": s.mode.name(),
946+ "current": s.queue.current().map(ipc::path_json),
947+ "index": (!s.queue.is_empty()).then(|| s.queue.index() + 1),
948+ "count": s.queue.len(),
949+ "marked": s.queue.marked_count(),
950+ "width": shown.map(|sh| sh.size[0]),
951+ "height": shown.map(|sh| sh.size[1]),
952+ "format": shown.map(|sh| sh.format),
953+ "zoom": shown.map(|_| (s.view.scale() * 1000.0).round() / 10.0),
954+ "fit": s.settings.fit,
955+ "bar": s.bar,
956+ "fullscreen": fullscreen,
957+ "skipped": s.skipped,
958+ "running": s.running.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>(),
959+ "pid": std::process::id(),
960+ })
961+ }
962+ }
963+ }
964+
965+ fn snapshot(&self) -> Published {
966+ let q = &self.state.queue;
967+ Published {
968+ image: (q.current().map(Path::to_path_buf), q.index(), q.len()),
969+ marks: q.marks_version(),
970+ mode: self.state.mode,
971+ }
972+ }
973+
974+ /// Tell subscribers what changed since they last heard.
975+ fn publish(&mut self) {
976+ if self.subscribers.is_empty() {
977+ self.published = None;
978+ return;
979+ }
980+ let now = self.snapshot();
981+ let Some(before) = self.published.replace(now.clone()) else {
982+ return;
983+ };
984+ let q = &self.state.queue;
985+ let mut events = Vec::new();
986+ if before.image != now.image {
987+ let data = json!({
988+ "path": q.current().map(ipc::path_json),
989+ "index": (!q.is_empty()).then(|| q.index() + 1),
990+ "count": q.len(),
991+ });
992+ events.push((Topic::Image, data));
993+ }
994+ if before.marks != now.marks {
995+ let files: Vec<Value> = q.marked().into_iter().map(ipc::path_json).collect();
996+ events.push((
997+ Topic::Marks,
998+ json!({ "count": files.len(), "files": files }),
999+ ));
1000+ }
1001+ if before.mode != now.mode {
1002+ events.push((Topic::Mode, json!(now.mode.name())));
1003+ }
1004+ for (topic, data) in events {
1005+ let line = ipc::event(topic, data);
1006+ self.subscribers
1007+ .retain(|s| !s.topics.contains(&topic) || s.tx.send(line.clone()).is_ok());
1008+ }
1009+ }
1010+
1011+ /// Marked files for `-o`, unless the user quit with `quit-no-output`.
1012+ pub fn marked_output(&self) -> Option<Vec<PathBuf>> {
1013+ (!self.no_output).then(|| {
1014+ self.state
1015+ .queue
1016+ .marked()
1017+ .into_iter()
1018+ .map(Path::to_path_buf)
1019+ .collect()
1020+ })
1021+ }
1022+
1023+ fn effects(&mut self, fx: Effects, ctx: &Ctx, event_loop: &ActiveEventLoop) {
5791024 if fx.quit {
5801025 event_loop.exit();
5811026 return;
@@ -601,6 +1046,9 @@ impl App {
6011046 self.reload_requested = true;
6021047 self.watch.reload();
6031048 }
1049+ if fx.refresh {
1050+ self.refresh(ctx.targets.clone());
1051+ }
6041052 if let Some(key) = fx.sort {
6051053 let mut files = self.state.queue.files().to_vec();
6061054 let proxy = self.proxy.clone();
@@ -653,13 +1101,22 @@ impl App {
6531101 }
6541102 return;
6551103 }
1104+ if self.confirm.is_some() {
1105+ // The prompt takes the next key: `y` runs the command, anything else cancels.
1106+ if !event.repeat
1107+ && let Some(key) = input::key(&event.logical_key, self.modifiers)
1108+ {
1109+ self.answer(key == Key::plain('y') || key == Key::plain('Y'));
1110+ }
1111+ return;
1112+ }
6561113 if event.repeat {
6571114 // Repeats bypass the trie: only a completed, repeatable binding repeats.
6581115 if let Some((k, actions)) = &self.repeat
6591116 && *k == event.physical_key
6601117 {
6611118 let actions = actions.clone();
662− self.run(actions, None, event_loop);
1119+ let _ = self.run(actions, &Ctx::default(), event_loop);
6631120 }
6641121 return;
6651122 }
@@ -684,7 +1141,7 @@ impl App {
6841141 if !actions.is_empty() && actions.iter().all(Action::repeats) {
6851142 self.repeat = Some((event.physical_key, actions.clone()));
6861143 }
687− self.run(actions, None, event_loop);
1144+ let _ = self.run(actions, &Ctx::default(), event_loop);
6881145 }
6891146 }
6901147
@@ -694,7 +1151,11 @@ impl App {
6941151 self.update_pending(Instant::now());
6951152 if let Some(actions) = self.keymap.lookup(self.state.mode, &[key]) {
6961153 let actions = actions.to_vec();
697− self.run(actions, Some(self.cursor), event_loop);
1154+ let ctx = Ctx {
1155+ anchor: Some(self.cursor),
1156+ ..Ctx::default()
1157+ };
1158+ let _ = self.run(actions, &ctx, event_loop);
6981159 }
6991160 }
7001161
@@ -780,7 +1241,7 @@ impl App {
7801241 if double {
7811242 actions.push(Action::Mode(ModeArg::Image));
7821243 }
783− self.run(actions, None, event_loop);
1244+ let _ = self.run(actions, &Ctx::default(), event_loop);
7841245 }
7851246
7861247 fn redraw(&mut self) {
@@ -896,6 +1357,10 @@ fn font_families(c: &Config) -> [String; 3] {
8961357 ]
8971358 }
8981359
1360+fn mtime(path: &Path) -> Option<SystemTime> {
1361+ std::fs::metadata(path).and_then(|m| m.modified()).ok()
1362+}
1363+
8991364 fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
9001365 m.lock().unwrap_or_else(|e| e.into_inner())
9011366 }
@@ -977,9 +1442,13 @@ impl ApplicationHandler<UserEvent> for App {
9771442 self.sync(false);
9781443 }
9791444
980− fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UserEvent) {
1445+ fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
9811446 match event {
982− UserEvent::Decoded { path, result } => self.on_decoded(path, result),
1447+ UserEvent::Decoded {
1448+ path,
1449+ mtime,
1450+ result,
1451+ } => self.on_decoded(path, mtime, result),
9831452 UserEvent::Found(paths) => {
9841453 log::debug!("sniffing found {} more images", paths.len());
9851454 let was_empty = self.state.queue.is_empty();
@@ -1006,7 +1475,15 @@ impl ApplicationHandler<UserEvent> for App {
10061475 self.state.queue.reorder(files);
10071476 self.sync(false);
10081477 }
1009− UserEvent::Thumb { path, px, result } => self.on_thumb(path, px, result),
1478+ UserEvent::Thumb {
1479+ path,
1480+ px,
1481+ mtime,
1482+ result,
1483+ } => self.on_thumb(path, px, mtime, result),
1484+ UserEvent::Command { id, event } => self.on_command(id, event, event_loop),
1485+ UserEvent::Refreshed(found) => self.on_refreshed(found),
1486+ UserEvent::Ipc(inc) => self.on_ipc(inc, event_loop),
10101487 }
10111488 }
10121489
@@ -1107,7 +1584,7 @@ impl ApplicationHandler<UserEvent> for App {
11071584 let runs = self.keymap.timeout(self.state.mode);
11081585 self.update_pending(now);
11091586 for run in runs {
1110− self.run(run.counted(), None, event_loop);
1587+ let _ = self.run(run.counted(), &Ctx::default(), event_loop);
11111588 }
11121589 }
11131590 if let Some(t) = self.grid_rest
@@ -1121,6 +1598,7 @@ impl ApplicationHandler<UserEvent> for App {
11211598 self.decode(&cur, Priority::Preload);
11221599 }
11231600 }
1601+ self.publish();
11241602 let (redraw, mut next) = self.state.tick(now);
11251603 if let Some(t) = self.grid_rest {
11261604 let at = t + GRID_REST;
M src/cli.rs View
@@ -2,17 +2,37 @@
22
33 use std::path::PathBuf;
44
5−use clap::Parser;
5+use clap::{Parser, Subcommand};
66
77 use crate::state::queue::SortKey;
88 use crate::state::view::FitMode;
99
1010 #[derive(Parser, Debug)]
11−#[command(name = "remage", version, about = "Keyboard-centric image viewer")]
11+#[command(
12+ name = "remage",
13+ version,
14+ about = "Keyboard-centric image viewer",
15+ args_conflicts_with_subcommands = true
16+)]
1217 pub struct Cli {
18+ #[command(subcommand)]
19+ pub command: Option<Sub>,
20+
1321 /// Files or directories to open [default: .]
1422 pub paths: Vec<PathBuf>,
1523
24+ /// Read file paths from stdin, one per line
25+ #[arg(short = 'i', long)]
26+ pub stdin: bool,
27+
28+ /// Paths on stdin and printed by -o are NUL-separated
29+ #[arg(short = '0', long)]
30+ pub null: bool,
31+
32+ /// Print the marked files on exit
33+ #[arg(short = 'o', long)]
34+ pub print_marked: bool,
35+
1636 /// Descend into directories
1737 #[arg(short, long)]
1838 pub recursive: bool,
@@ -78,6 +98,32 @@ pub struct Cli {
7898 pub check_config: bool,
7999 }
80100
101+#[derive(Subcommand, Debug)]
102+pub enum Sub {
103+ /// Send an action or query to a running remage
104+ Msg {
105+ /// The instance to talk to [default: $REMAGE_SOCKET, else the newest]
106+ #[arg(long, value_name = "PID")]
107+ pid: Option<u32>,
108+
109+ /// Print current, marked, queue or state
110+ #[arg(long, value_name = "Q", conflicts_with_all = ["subscribe", "action"])]
111+ query: Option<String>,
112+
113+ /// Stream events (image, marks, mode) as JSON lines
114+ #[arg(long, value_name = "EV", conflicts_with = "action")]
115+ subscribe: Vec<String>,
116+
117+ /// The action, e.g. `goto 5`
118+ #[arg(
119+ value_name = "ACTION",
120+ trailing_var_arg = true,
121+ allow_hyphen_values = true
122+ )]
123+ action: Vec<String>,
124+ },
125+}
126+
81127 fn parse_geometry(s: &str) -> Result<(u32, u32), String> {
82128 let (w, h) = s
83129 .split_once(['x', 'X'])
@@ -99,6 +145,20 @@ mod tests {
99145 Cli::command().debug_assert();
100146 }
101147
148+ #[test]
149+ fn msg_subcommand() {
150+ let c = Cli::try_parse_from(["remage", "msg", "goto", "5"]).unwrap();
151+ match c.command {
152+ Some(Sub::Msg { action, .. }) => assert_eq!(action, ["goto", "5"]),
153+ other => panic!("{other:?}"),
154+ }
155+ let c = Cli::try_parse_from(["remage", "msg", "slideshow-delay", "-1"]).unwrap();
156+ assert!(matches!(c.command, Some(Sub::Msg { action, .. }) if action[1] == "-1"));
157+ assert!(Cli::try_parse_from(["remage", "msg", "--query", "x", "next"]).is_err());
158+ let c = Cli::try_parse_from(["remage", "-io0", "a.png"]).unwrap();
159+ assert!(c.stdin && c.print_marked && c.null && c.command.is_none());
160+ }
161+
102162 #[test]
103163 fn geometry() {
104164 assert_eq!(parse_geometry("800x600"), Ok((800, 600)));
M src/config/default.toml View
@@ -178,16 +178,25 @@ preset = "nothing-dark" # nothing-dark | nothing-light
178178 #
179179 # [commands.copy]
180180 # desc = "Copy image"
181−# run = "wl-copy --type image/png < {file}" # string: sh -c, placeholders shell-quoted
181+# run = "wl-copy --type image/png < {file}" # string: sh -c, placeholders shell-quoted for you
182182 #
183183 # [commands.delete]
184184 # desc = "Delete files"
185185 # run = ["rm", "--", "{files}"]
186−# confirm = "Delete {count} file(s)?"
186+# confirm = "Delete {count} file(s)?" # asks inline; y runs it, any other key cancels
187187 #
188−# Fields: targets = auto | current | marked | all, stdin = none | files | files0,
189−# output = status | actions | ignore, after = actions run on success (default "refresh").
190−# Placeholders: {file} {files} {dir} {name} {stem} {ext} {index} {count} {width} {height}.
188+# [commands.legacy] # an nsxiv key-handler works unchanged
189+# run = ["~/.config/nsxiv/exec/key-handler", "w"]
190+# stdin = "files"
191+#
192+# Fields: targets = auto (marked, else current) | current | marked | all,
193+# stdin = none | files | files0, output = status (last stdout line) | actions (each line runs
194+# as an action) | ignore, after = actions run on success (default "refresh": drop deleted
195+# files, reload changed ones).
196+# Placeholders: {file} {dir} {name} {stem} {ext} {index} {width} {height} describe the current
197+# image; {files} and {count} the targets. In an array, an element containing {files} repeats
198+# once per target. Commands also see $REMAGE_FILE $REMAGE_INDEX $REMAGE_COUNT $REMAGE_MODE
199+# $REMAGE_SOCKET $REMAGE_PID. In a binding array, actions after a `run` wait for it to succeed.
191200 [commands]
192201
193202 # External decoders for anything else; they write PNG, PPM or TIFF to stdout.
M src/exec.rs View
@@ -1 +1,629 @@
11 //! User commands: placeholder expansion, spawning, confirmation and output handling.
2+//!
3+//! Placeholders describe the current image (`{file} {dir} {name} {stem} {ext} {index} {width}
4+//! {height}`) and the targets (`{files} {count}`). In a shell string (`sh -c`) every value is
5+//! shell-quoted; in an exec array an element containing `{files}` repeats once per target.
6+//! Anything else in braces is left alone, so `awk '{print $1}'` survives.
7+
8+use std::ffi::{OsStr, OsString};
9+use std::io::{BufRead, BufReader, Read, Write};
10+use std::os::unix::ffi::{OsStrExt, OsStringExt};
11+use std::os::unix::process::ExitStatusExt;
12+use std::path::{Path, PathBuf};
13+use std::process::{ExitStatus, Stdio};
14+
15+use crate::config::{Command, CommandLine, Output, Stdin, Targets};
16+
17+/// What a command can see of the viewer when it starts.
18+#[derive(Clone, Debug, Default)]
19+pub struct Context<'a> {
20+ pub current: Option<&'a Path>,
21+ /// 0-based position of `current` in the queue.
22+ pub index: usize,
23+ pub targets: &'a [PathBuf],
24+ /// Pixel size of the current image, when known.
25+ pub size: Option<[u32; 2]>,
26+ pub mode: &'a str,
27+ pub socket: Option<&'a Path>,
28+}
29+
30+/// A command ready to spawn.
31+#[derive(Clone, Debug, PartialEq)]
32+pub struct Prepared {
33+ pub program: OsString,
34+ pub args: Vec<OsString>,
35+ pub env: Vec<(&'static str, OsString)>,
36+ pub stdin: Option<Vec<u8>>,
37+}
38+
39+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40+enum Var {
41+ File,
42+ Files,
43+ Dir,
44+ Name,
45+ Stem,
46+ Ext,
47+ Index,
48+ Count,
49+ Width,
50+ Height,
51+}
52+
53+const VARS: &[(&str, Var)] = &[
54+ ("file", Var::File),
55+ ("files", Var::Files),
56+ ("dir", Var::Dir),
57+ ("name", Var::Name),
58+ ("stem", Var::Stem),
59+ ("ext", Var::Ext),
60+ ("index", Var::Index),
61+ ("count", Var::Count),
62+ ("width", Var::Width),
63+ ("height", Var::Height),
64+];
65+
66+enum Piece<'t> {
67+ Lit(&'t str),
68+ Var(Var),
69+}
70+
71+/// Split a template into literal text and known placeholders.
72+fn pieces(template: &str) -> Vec<Piece<'_>> {
73+ let mut out = Vec::new();
74+ let mut rest = template;
75+ while let Some(open) = rest.find('{') {
76+ let var = rest[open + 1..].find('}').and_then(|close| {
77+ let name = &rest[open + 1..open + 1 + close];
78+ VARS.iter()
79+ .find(|(n, _)| *n == name)
80+ .map(|(_, v)| (*v, open + close + 2))
81+ });
82+ match var {
83+ Some((v, end)) => {
84+ if open > 0 {
85+ out.push(Piece::Lit(&rest[..open]));
86+ }
87+ out.push(Piece::Var(v));
88+ rest = &rest[end..];
89+ }
90+ None => {
91+ out.push(Piece::Lit(&rest[..=open]));
92+ rest = &rest[open + 1..];
93+ }
94+ }
95+ }
96+ if !rest.is_empty() {
97+ out.push(Piece::Lit(rest));
98+ }
99+ out
100+}
101+
102+fn mentions(template: &str, f: impl Fn(Var) -> bool) -> bool {
103+ pieces(template)
104+ .iter()
105+ .any(|p| matches!(p, Piece::Var(v) if f(*v)))
106+}
107+
108+/// The value of a single-valued placeholder.
109+fn value(v: Var, ctx: &Context) -> OsString {
110+ let cur = ctx.current;
111+ let os = |s: Option<&OsStr>| s.map(OsStr::to_os_string).unwrap_or_default();
112+ match v {
113+ Var::File => os(cur.map(Path::as_os_str)),
114+ Var::Dir => os(cur.and_then(Path::parent).map(Path::as_os_str)),
115+ Var::Name => os(cur.and_then(Path::file_name)),
116+ Var::Stem => os(cur.and_then(Path::file_stem)),
117+ Var::Ext => os(cur.and_then(Path::extension)),
118+ Var::Index if cur.is_some() => (ctx.index + 1).to_string().into(),
119+ Var::Count => ctx.targets.len().to_string().into(),
120+ Var::Width => ctx
121+ .size
122+ .map(|s| s[0].to_string())
123+ .unwrap_or_default()
124+ .into(),
125+ Var::Height => ctx
126+ .size
127+ .map(|s| s[1].to_string())
128+ .unwrap_or_default()
129+ .into(),
130+ Var::Index | Var::Files => OsString::new(),
131+ }
132+}
133+
134+/// Quote for `sh`: safe words stay bare, everything else goes in single quotes.
135+pub fn shell_quote(s: &OsStr) -> Vec<u8> {
136+ let b = s.as_bytes();
137+ let safe = |c: &u8| c.is_ascii_alphanumeric() || b"_-./,:@%+=".contains(c);
138+ if !b.is_empty() && b.iter().all(safe) {
139+ return b.to_vec();
140+ }
141+ let mut out = Vec::with_capacity(b.len() + 2);
142+ out.push(b'\'');
143+ for &c in b {
144+ if c == b'\'' {
145+ out.extend_from_slice(b"'\\''");
146+ } else {
147+ out.push(c);
148+ }
149+ }
150+ out.push(b'\'');
151+ out
152+}
153+
154+/// A shell script with every placeholder quoted; `{files}` becomes the quoted targets.
155+fn expand_shell(template: &str, ctx: &Context) -> OsString {
156+ let mut out = Vec::new();
157+ for p in pieces(template) {
158+ match p {
159+ Piece::Lit(s) => out.extend_from_slice(s.as_bytes()),
160+ Piece::Var(Var::Files) => {
161+ for (i, f) in ctx.targets.iter().enumerate() {
162+ if i > 0 {
163+ out.push(b' ');
164+ }
165+ out.extend(shell_quote(f.as_os_str()));
166+ }
167+ }
168+ Piece::Var(v) => out.extend(shell_quote(&value(v, ctx))),
169+ }
170+ }
171+ OsString::from_vec(out)
172+}
173+
174+/// One exec argument; with `{files}` in it, one argument per target.
175+fn expand_arg(template: &str, ctx: &Context) -> Vec<OsString> {
176+ let pieces = pieces(template);
177+ let build = |file: Option<&Path>| {
178+ let mut out = OsString::new();
179+ for p in &pieces {
180+ match p {
181+ Piece::Lit(s) => out.push(s),
182+ Piece::Var(Var::Files) => out.push(file.map_or(OsStr::new(""), Path::as_os_str)),
183+ Piece::Var(v) => out.push(value(*v, ctx)),
184+ }
185+ }
186+ out
187+ };
188+ if pieces.iter().any(|p| matches!(p, Piece::Var(Var::Files))) {
189+ ctx.targets.iter().map(|f| build(Some(f))).collect()
190+ } else {
191+ vec![build(None)]
192+ }
193+}
194+
195+/// Plain text for the confirm prompt and messages: no quoting, lossy.
196+pub fn expand_text(template: &str, ctx: &Context) -> String {
197+ let mut out = String::new();
198+ for p in pieces(template) {
199+ match p {
200+ Piece::Lit(s) => out.push_str(s),
201+ Piece::Var(Var::Files) => {
202+ let names: Vec<_> = ctx
203+ .targets
204+ .iter()
205+ .map(|f| crate::ui::file_name(f))
206+ .collect();
207+ out.push_str(&names.join(" "));
208+ }
209+ Piece::Var(v) => out.push_str(&value(v, ctx).to_string_lossy()),
210+ }
211+ }
212+ out
213+}
214+
215+/// `~/…` at the start of an exec element means the home directory.
216+fn tilde(s: &str) -> std::borrow::Cow<'_, str> {
217+ match (s.strip_prefix("~/"), std::env::var_os("HOME")) {
218+ (Some(rest), Some(home)) => format!("{}/{rest}", home.to_string_lossy()).into(),
219+ _ => s.into(),
220+ }
221+}
222+
223+/// Whether the command needs at least one file to make sense.
224+pub fn needs_files(cmd: &Command) -> bool {
225+ let files = |v: Var| v != Var::Count;
226+ cmd.stdin != Stdin::None
227+ || match &cmd.run {
228+ CommandLine::Shell(s) => mentions(s, files),
229+ CommandLine::Exec(v) => v.iter().any(|a| mentions(a, files)),
230+ }
231+}
232+
233+/// The files a command acts on. `marked` is in queue order.
234+pub fn targets(
235+ t: Targets,
236+ current: Option<&Path>,
237+ marked: &[&Path],
238+ all: &[PathBuf],
239+) -> Vec<PathBuf> {
240+ let cur = || current.map(Path::to_path_buf).into_iter().collect();
241+ let marks = || marked.iter().map(|p| p.to_path_buf()).collect();
242+ match t {
243+ Targets::Auto if marked.is_empty() => cur(),
244+ Targets::Auto | Targets::Marked => marks(),
245+ Targets::Current => cur(),
246+ Targets::All => all.to_vec(),
247+ }
248+}
249+
250+pub fn prepare(cmd: &Command, ctx: &Context) -> Prepared {
251+ let (program, args) = match &cmd.run {
252+ CommandLine::Shell(s) => (
253+ OsString::from("sh"),
254+ vec!["-c".into(), expand_shell(s, ctx)],
255+ ),
256+ CommandLine::Exec(v) => {
257+ let mut all = v.iter().flat_map(|a| expand_arg(&tilde(a), ctx));
258+ let program = all.next().unwrap_or_default();
259+ (program, all.collect())
260+ }
261+ };
262+ let sep = match cmd.stdin {
263+ Stdin::None => None,
264+ Stdin::Files => Some(b'\n'),
265+ Stdin::Files0 => Some(0),
266+ };
267+ let stdin = sep.map(|sep| {
268+ let mut buf = Vec::new();
269+ for f in ctx.targets {
270+ buf.extend_from_slice(f.as_os_str().as_bytes());
271+ buf.push(sep);
272+ }
273+ buf
274+ });
275+ let mut env = vec![
276+ ("REMAGE_FILE", value(Var::File, ctx)),
277+ ("REMAGE_INDEX", value(Var::Index, ctx)),
278+ ("REMAGE_COUNT", value(Var::Count, ctx)),
279+ ("REMAGE_MODE", ctx.mode.into()),
280+ ("REMAGE_PID", std::process::id().to_string().into()),
281+ ];
282+ if let Some(s) = ctx.socket {
283+ env.push(("REMAGE_SOCKET", s.as_os_str().to_os_string()));
284+ }
285+ Prepared {
286+ program,
287+ args,
288+ env,
289+ stdin,
290+ }
291+}
292+
293+/// Progress of a running command.
294+#[derive(Debug)]
295+pub enum Event {
296+ /// A stdout line, with `output = "actions"`.
297+ Line(String),
298+ Done(Outcome),
299+}
300+
301+#[derive(Debug)]
302+pub struct Outcome {
303+ /// The exit status, or why the command couldn't start.
304+ pub status: Result<ExitStatus, String>,
305+ /// The last non-empty stdout line, with `output = "status"`.
306+ pub last_line: Option<String>,
307+ pub first_stderr: Option<String>,
308+}
309+
310+impl Outcome {
311+ pub fn success(&self) -> bool {
312+ self.status.as_ref().is_ok_and(ExitStatus::success)
313+ }
314+
315+ /// `<code> <first stderr line>` for the status line.
316+ pub fn error(&self) -> String {
317+ let code = match &self.status {
318+ Err(e) => return e.clone(),
319+ Ok(s) => match (s.code(), s.signal()) {
320+ (Some(c), _) => c.to_string(),
321+ (None, Some(sig)) => format!("signal {sig}"),
322+ (None, None) => "failed".into(),
323+ },
324+ };
325+ match &self.first_stderr {
326+ Some(line) => format!("{code} {line}"),
327+ None => code,
328+ }
329+ }
330+}
331+
332+/// Lines are read lossily and capped so a runaway command can't eat memory.
333+const MAX_LINE: usize = 64 << 10;
334+
335+fn read_line(r: &mut impl BufRead, buf: &mut Vec<u8>) -> Option<String> {
336+ buf.clear();
337+ match r.by_ref().take(MAX_LINE as u64).read_until(b'\n', buf) {
338+ Ok(0) | Err(_) => None,
339+ Ok(_) => {
340+ if buf.last() != Some(&b'\n') && buf.len() == MAX_LINE {
341+ // Skip the rest of an over-long line.
342+ let mut sink = Vec::new();
343+ let _ = r.read_until(b'\n', &mut sink);
344+ }
345+ Some(String::from_utf8_lossy(buf).trim_end().to_string())
346+ }
347+ }
348+}
349+
350+/// Run `p` on its own threads; `sink` gets stdout lines (for `actions`) and then the outcome.
351+pub fn spawn(name: &str, p: Prepared, output: Output, sink: impl Fn(Event) + Send + 'static) {
352+ let label = name.to_string();
353+ let run = move || {
354+ let mut cmd = std::process::Command::new(&p.program);
355+ cmd.args(&p.args)
356+ .envs(p.env.iter().map(|(k, v)| (k, v)))
357+ .stdin(if p.stdin.is_some() {
358+ Stdio::piped()
359+ } else {
360+ Stdio::null()
361+ })
362+ .stdout(if output == Output::Ignore {
363+ Stdio::null()
364+ } else {
365+ Stdio::piped()
366+ })
367+ .stderr(Stdio::piped());
368+ let mut child = match cmd.spawn() {
369+ Ok(c) => c,
370+ Err(e) => {
371+ let program = p.program.to_string_lossy();
372+ return sink(Event::Done(Outcome {
373+ status: Err(format!("{program}: {e}")),
374+ last_line: None,
375+ first_stderr: None,
376+ }));
377+ }
378+ };
379+ log::debug!("command {label}: pid {}", child.id());
380+ if let (Some(mut w), Some(data)) = (child.stdin.take(), p.stdin) {
381+ // Its own thread: a child that writes before reading must not deadlock us.
382+ std::thread::spawn(move || {
383+ let _ = w.write_all(&data);
384+ });
385+ }
386+ let stderr = child.stderr.take().map(|e| {
387+ std::thread::spawn(move || {
388+ let mut r = BufReader::new(e);
389+ let mut buf = Vec::new();
390+ let mut first = None;
391+ while let Some(line) = read_line(&mut r, &mut buf) {
392+ if first.is_none() && !line.trim().is_empty() {
393+ first = Some(line.trim().to_string());
394+ }
395+ }
396+ first
397+ })
398+ });
399+ let mut last_line = None;
400+ if let Some(out) = child.stdout.take() {
401+ let mut r = BufReader::new(out);
402+ let mut buf = Vec::new();
403+ while let Some(line) = read_line(&mut r, &mut buf) {
404+ if line.trim().is_empty() {
405+ continue;
406+ }
407+ match output {
408+ Output::Actions => sink(Event::Line(line)),
409+ _ => last_line = Some(line.trim().to_string()),
410+ }
411+ }
412+ }
413+ let status = child.wait().map_err(|e| e.to_string());
414+ let first_stderr = stderr.and_then(|t| t.join().ok()).flatten();
415+ sink(Event::Done(Outcome {
416+ status,
417+ last_line,
418+ first_stderr,
419+ }));
420+ };
421+ if let Err(e) = std::thread::Builder::new()
422+ .name("remage-command".into())
423+ .spawn(run)
424+ {
425+ log::error!("command {name}: {e}");
426+ }
427+}
428+
429+#[cfg(test)]
430+mod tests {
431+ use super::*;
432+ use crate::config::ActionList;
433+
434+ fn cmd(run: CommandLine) -> Command {
435+ Command {
436+ desc: String::new(),
437+ run,
438+ targets: Targets::Auto,
439+ stdin: Stdin::None,
440+ confirm: None,
441+ output: Output::Status,
442+ after: ActionList::default(),
443+ }
444+ }
445+
446+ fn ctx<'a>(targets: &'a [PathBuf]) -> Context<'a> {
447+ Context {
448+ current: Some(Path::new("/pix/it's a.png")),
449+ index: 4,
450+ targets,
451+ size: Some([640, 480]),
452+ mode: "image",
453+ socket: Some(Path::new("/run/remage/1.sock")),
454+ }
455+ }
456+
457+ fn files() -> Vec<PathBuf> {
458+ vec!["/pix/a b.png".into(), "/pix/c.jpg".into()]
459+ }
460+
461+ #[test]
462+ fn quoting() {
463+ let q = |s: &str| String::from_utf8(shell_quote(OsStr::new(s))).unwrap();
464+ assert_eq!(q("/a/b-c_d.png"), "/a/b-c_d.png");
465+ assert_eq!(q("a b"), "'a b'");
466+ assert_eq!(q("it's"), "'it'\\''s'");
467+ assert_eq!(q(""), "''");
468+ assert_eq!(q("$(rm -rf ~)"), "'$(rm -rf ~)'");
469+ }
470+
471+ #[test]
472+ fn shell_strings_quote_every_placeholder() {
473+ let f = files();
474+ let p = prepare(
475+ &cmd(CommandLine::Shell(
476+ "cp {files} {dir} && echo {name} {stem}.{ext} {index}/{count} {width}x{height} \
477+ | awk '{print $1}' {nope}"
478+ .into(),
479+ )),
480+ &ctx(&f),
481+ );
482+ assert_eq!(p.program, "sh");
483+ assert_eq!(
484+ p.args[1],
485+ "cp '/pix/a b.png' /pix/c.jpg /pix && echo 'it'\\''s a.png' 'it'\\''s a'.png 5/2 \
486+ 640x480 | awk '{print $1}' {nope}"
487+ );
488+ }
489+
490+ #[test]
491+ fn exec_arrays_expand_files_per_target() {
492+ let f = files();
493+ let p = prepare(
494+ &cmd(CommandLine::Exec(vec![
495+ "rm".into(),
496+ "--".into(),
497+ "{files}".into(),
498+ "--x={files}".into(),
499+ "{file}".into(),
500+ ])),
501+ &ctx(&f),
502+ );
503+ assert_eq!(p.program, "rm");
504+ let args: Vec<_> = p.args.iter().map(|a| a.to_str().unwrap()).collect();
505+ assert_eq!(
506+ args,
507+ [
508+ "--",
509+ "/pix/a b.png",
510+ "/pix/c.jpg",
511+ "--x=/pix/a b.png",
512+ "--x=/pix/c.jpg",
513+ "/pix/it's a.png"
514+ ]
515+ );
516+ // `~/` expands in the template, never in file names.
517+ let p = prepare(
518+ &cmd(CommandLine::Exec(vec!["~/bin/x".into(), "{file}".into()])),
519+ &Context {
520+ current: Some(Path::new("~/a.png")),
521+ ..ctx(&f)
522+ },
523+ );
524+ assert!(!p.program.to_string_lossy().starts_with('~'));
525+ assert_eq!(p.args[0], "~/a.png");
526+ }
527+
528+ #[test]
529+ fn stdin_and_env() {
530+ let f = files();
531+ let mut c = cmd(CommandLine::Exec(vec!["handler".into()]));
532+ assert!(prepare(&c, &ctx(&f)).stdin.is_none());
533+ assert!(!needs_files(&c));
534+ c.stdin = Stdin::Files0;
535+ let p = prepare(&c, &ctx(&f));
536+ assert_eq!(p.stdin.as_deref(), Some(&b"/pix/a b.png\0/pix/c.jpg\0"[..]));
537+ assert!(needs_files(&c));
538+ let env: std::collections::HashMap<_, _> = p.env.into_iter().collect();
539+ assert_eq!(env["REMAGE_FILE"], "/pix/it's a.png");
540+ assert_eq!(env["REMAGE_INDEX"], "5");
541+ assert_eq!(env["REMAGE_COUNT"], "2");
542+ assert_eq!(env["REMAGE_MODE"], "image");
543+ assert_eq!(env["REMAGE_SOCKET"], "/run/remage/1.sock");
544+ assert!(needs_files(&cmd(CommandLine::Shell("echo {name}".into()))));
545+ assert!(!needs_files(&cmd(CommandLine::Shell(
546+ "echo {count}".into()
547+ ))));
548+ }
549+
550+ #[test]
551+ fn confirm_text() {
552+ let f = files();
553+ assert_eq!(
554+ expand_text("Delete {count} file(s)? {files}", &ctx(&f)),
555+ "Delete 2 file(s)? a b.png c.jpg"
556+ );
557+ }
558+
559+ #[test]
560+ fn target_sets() {
561+ let all = files();
562+ let cur = Some(Path::new("/pix/c.jpg"));
563+ let none: [&Path; 0] = [];
564+ let marked = [Path::new("/pix/a b.png")];
565+ assert_eq!(targets(Targets::Auto, cur, &none, &all), [all[1].clone()]);
566+ assert_eq!(targets(Targets::Auto, cur, &marked, &all), [all[0].clone()]);
567+ assert_eq!(
568+ targets(Targets::Current, cur, &marked, &all),
569+ [all[1].clone()]
570+ );
571+ assert!(targets(Targets::Marked, cur, &none, &all).is_empty());
572+ assert_eq!(targets(Targets::All, None, &none, &all), all);
573+ }
574+
575+ fn run(run: CommandLine, output: Output, stdin: Option<&[u8]>) -> (Vec<String>, Outcome) {
576+ let (tx, rx) = crossbeam_channel::unbounded();
577+ let mut p = prepare(&cmd(run), &ctx(&[]));
578+ p.stdin = stdin.map(<[u8]>::to_vec);
579+ spawn("test", p, output, move |e| {
580+ let _ = tx.send(e);
581+ });
582+ let mut lines = Vec::new();
583+ loop {
584+ match rx.recv_timeout(std::time::Duration::from_secs(10)) {
585+ Ok(Event::Line(l)) => lines.push(l),
586+ Ok(Event::Done(o)) => return (lines, o),
587+ Err(e) => panic!("{e}"),
588+ }
589+ }
590+ }
591+
592+ #[test]
593+ fn spawning_reports_output_and_errors() {
594+ let (_, o) = run(
595+ CommandLine::Shell("echo one; echo; echo two; echo $REMAGE_MODE".into()),
596+ Output::Status,
597+ None,
598+ );
599+ assert!(o.success());
600+ assert_eq!(o.last_line.as_deref(), Some("image"));
601+
602+ let (lines, o) = run(
603+ CommandLine::Shell("cat; echo next".into()),
604+ Output::Actions,
605+ Some(b"goto 3\n"),
606+ );
607+ assert!(o.success() && o.last_line.is_none());
608+ assert_eq!(lines, ["goto 3", "next"]);
609+
610+ let (_, o) = run(
611+ CommandLine::Shell("echo oops >&2; echo more >&2; exit 3".into()),
612+ Output::Ignore,
613+ None,
614+ );
615+ assert!(!o.success());
616+ assert_eq!(o.error(), "3 oops");
617+
618+ let (_, o) = run(
619+ CommandLine::Exec(vec!["/nonexistent/remage-test".into()]),
620+ Output::Status,
621+ None,
622+ );
623+ assert!(
624+ o.error().starts_with("/nonexistent/remage-test: "),
625+ "{}",
626+ o.error()
627+ );
628+ }
629+}
M src/ipc.rs View
@@ -1 +1,557 @@
1−//! Unix-socket server speaking newline-delimited JSON, with event subscriptions.
1+//! Unix-socket server speaking newline-delimited JSON, with event subscriptions, and the
2+//! `remage msg` client.
3+//!
4+//! Requests: `{"action":"goto 5"}` (or an array of actions), `{"query":"current"}` and
5+//! `{"subscribe":["image","marks","mode"]}`, which turns the connection into an event stream.
6+//! Replies: `{"ok":true,"data":…}` or `{"ok":false,"error":"…"}`; events:
7+//! `{"event":"image","data":…}`.
8+
9+use std::io::{BufRead, BufReader, Read, Write};
10+use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
11+use std::os::unix::net::{UnixListener, UnixStream};
12+use std::path::{Path, PathBuf};
13+use std::sync::Arc;
14+use std::sync::atomic::{AtomicUsize, Ordering};
15+use std::time::{Duration, Instant, SystemTime};
16+
17+use crossbeam_channel::{Receiver, Sender};
18+use serde_json::{Value, json};
19+
20+use crate::action::Action;
21+
22+/// Longest request line accepted.
23+const MAX_REQUEST: u64 = 1 << 20;
24+/// How long a connection waits for the event loop to answer.
25+const REPLY_TIMEOUT: Duration = Duration::from_secs(5);
26+
27+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28+pub enum Query {
29+ Current,
30+ Marked,
31+ Queue,
32+ State,
33+}
34+
35+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
36+pub enum Topic {
37+ Image,
38+ Marks,
39+ Mode,
40+}
41+
42+impl Topic {
43+ pub fn name(self) -> &'static str {
44+ match self {
45+ Topic::Image => "image",
46+ Topic::Marks => "marks",
47+ Topic::Mode => "mode",
48+ }
49+ }
50+}
51+
52+#[derive(Clone, Debug, PartialEq)]
53+pub enum Request {
54+ Actions(Vec<Action>),
55+ Query(Query),
56+ Subscribe(Vec<Topic>),
57+}
58+
59+fn word<T: Copy>(what: &str, s: &str, opts: &[(&str, T)]) -> Result<T, String> {
60+ opts.iter()
61+ .find(|(k, _)| *k == s)
62+ .map(|(_, v)| *v)
63+ .ok_or_else(|| {
64+ let names: Vec<_> = opts.iter().map(|(k, _)| *k).collect();
65+ format!("unknown {what} `{s}`: expected {}", names.join(", "))
66+ })
67+}
68+
69+const QUERIES: &[(&str, Query)] = &[
70+ ("current", Query::Current),
71+ ("marked", Query::Marked),
72+ ("queue", Query::Queue),
73+ ("state", Query::State),
74+];
75+
76+const TOPICS: &[(&str, Topic)] = &[
77+ ("image", Topic::Image),
78+ ("marks", Topic::Marks),
79+ ("mode", Topic::Mode),
80+];
81+
82+/// A string or an array of strings.
83+fn strings(v: &Value, what: &str) -> Result<Vec<String>, String> {
84+ match v {
85+ Value::String(s) => Ok(vec![s.clone()]),
86+ Value::Array(a) => a
87+ .iter()
88+ .map(|e| {
89+ e.as_str()
90+ .map(str::to_string)
91+ .ok_or_else(|| format!("`{what}` must be a string or an array of strings"))
92+ })
93+ .collect(),
94+ _ => Err(format!("`{what}` must be a string or an array of strings")),
95+ }
96+}
97+
98+pub fn parse_request(line: &str) -> Result<Request, String> {
99+ let v: Value = serde_json::from_str(line).map_err(|e| format!("invalid JSON: {e}"))?;
100+ let Value::Object(obj) = v else {
101+ return Err("expected a JSON object".into());
102+ };
103+ let mut keys = obj.keys();
104+ let (Some(key), None) = (keys.next(), keys.next()) else {
105+ return Err("expected exactly one of `action`, `query`, `subscribe`".into());
106+ };
107+ let val = &obj[key];
108+ match key.as_str() {
109+ "action" => {
110+ let actions = strings(val, "action")?
111+ .iter()
112+ .map(|s| s.parse::<Action>().map_err(|e| e.to_string()))
113+ .collect::<Result<Vec<_>, _>>()?;
114+ if actions.is_empty() {
115+ return Err("no actions".into());
116+ }
117+ Ok(Request::Actions(actions))
118+ }
119+ "query" => {
120+ let q = val.as_str().ok_or("`query` must be a string")?;
121+ Ok(Request::Query(word("query", q, QUERIES)?))
122+ }
123+ "subscribe" => {
124+ let mut topics = Vec::new();
125+ for t in strings(val, "subscribe")? {
126+ let t = word("event", &t, TOPICS)?;
127+ if !topics.contains(&t) {
128+ topics.push(t);
129+ }
130+ }
131+ if topics.is_empty() {
132+ return Err("no events to subscribe to".into());
133+ }
134+ Ok(Request::Subscribe(topics))
135+ }
136+ other => Err(format!(
137+ "unknown request `{other}`: expected `action`, `query` or `subscribe`"
138+ )),
139+ }
140+}
141+
142+// Envelopes are written by hand so `ok` and `event` come first (serde_json sorts keys).
143+
144+pub fn ok(data: Value) -> String {
145+ if data.is_null() {
146+ r#"{"ok":true}"#.into()
147+ } else {
148+ format!(r#"{{"ok":true,"data":{data}}}"#)
149+ }
150+}
151+
152+pub fn error(msg: &str) -> String {
153+ format!(r#"{{"ok":false,"error":{}}}"#, Value::from(msg))
154+}
155+
156+pub fn event(topic: Topic, data: Value) -> String {
157+ format!(r#"{{"event":"{}","data":{data}}}"#, topic.name())
158+}
159+
160+pub fn path_json(p: &Path) -> Value {
161+ Value::String(p.to_string_lossy().into_owned())
162+}
163+
164+/// `$XDG_RUNTIME_DIR/remage`, or a per-user directory in `/tmp`.
165+pub fn socket_dir() -> PathBuf {
166+ match std::env::var_os("XDG_RUNTIME_DIR").filter(|d| !d.is_empty()) {
167+ Some(d) => PathBuf::from(d).join("remage"),
168+ None => {
169+ let user = std::env::var("USER").unwrap_or_else(|_| "user".into());
170+ std::env::temp_dir().join(format!("remage-{user}"))
171+ }
172+ }
173+}
174+
175+fn pid_of(path: &Path) -> Option<u32> {
176+ path.file_name()?
177+ .to_str()?
178+ .strip_suffix(".sock")?
179+ .parse()
180+ .ok()
181+}
182+
183+fn alive(pid: u32) -> bool {
184+ Path::new("/proc").join(pid.to_string()).exists()
185+}
186+
187+/// Live instances' sockets, newest first.
188+fn instances(dir: &Path) -> Vec<PathBuf> {
189+ let Ok(entries) = std::fs::read_dir(dir) else {
190+ return Vec::new();
191+ };
192+ let mut found: Vec<(SystemTime, PathBuf)> = entries
193+ .flatten()
194+ .map(|e| e.path())
195+ .filter(|p| pid_of(p).is_some_and(alive))
196+ .map(|p| {
197+ let t = std::fs::metadata(&p)
198+ .and_then(|m| m.modified())
199+ .unwrap_or(SystemTime::UNIX_EPOCH);
200+ (t, p)
201+ })
202+ .collect();
203+ found.sort_by_key(|f| std::cmp::Reverse(f.0));
204+ found.into_iter().map(|(_, p)| p).collect()
205+}
206+
207+/// A request from a connection, with where its reply goes.
208+pub struct Incoming {
209+ pub request: Request,
210+ pub reply: Sender<String>,
211+ /// For subscriptions: where events go after the reply.
212+ pub events: Option<Sender<String>>,
213+}
214+
215+/// The listening socket. Dropping it removes the socket file once replies have gone out.
216+pub struct Server {
217+ path: PathBuf,
218+ /// Replies handed to connection threads but not yet written.
219+ in_flight: Arc<AtomicUsize>,
220+}
221+
222+impl Server {
223+ pub fn path(&self) -> &Path {
224+ &self.path
225+ }
226+}
227+
228+impl Drop for Server {
229+ fn drop(&mut self) {
230+ // A `quit` sent over the socket still gets its reply.
231+ let until = Instant::now() + Duration::from_millis(200);
232+ while self.in_flight.load(Ordering::Acquire) > 0 && Instant::now() < until {
233+ std::thread::sleep(Duration::from_millis(2));
234+ }
235+ let _ = std::fs::remove_file(&self.path);
236+ }
237+}
238+
239+/// Listen on `<socket_dir>/<pid>.sock`; `deliver` hands each request to the event loop.
240+pub fn serve(deliver: impl Fn(Incoming) -> bool + Send + Sync + 'static) -> anyhow::Result<Server> {
241+ let dir = socket_dir();
242+ std::fs::DirBuilder::new()
243+ .recursive(true)
244+ .mode(0o700)
245+ .create(&dir)?;
246+ // Sockets of instances that died without cleaning up.
247+ if let Ok(entries) = std::fs::read_dir(&dir) {
248+ for p in entries.flatten().map(|e| e.path()) {
249+ if pid_of(&p).is_some_and(|pid| !alive(pid)) {
250+ let _ = std::fs::remove_file(&p);
251+ }
252+ }
253+ }
254+ let path = dir.join(format!("{}.sock", std::process::id()));
255+ let _ = std::fs::remove_file(&path);
256+ let listener = UnixListener::bind(&path)?;
257+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
258+ let in_flight = Arc::new(AtomicUsize::new(0));
259+ let deliver = Arc::new(deliver);
260+ let flight = in_flight.clone();
261+ std::thread::Builder::new()
262+ .name("remage-ipc".into())
263+ .spawn(move || {
264+ for stream in listener.incoming() {
265+ let Ok(stream) = stream else { continue };
266+ let deliver = deliver.clone();
267+ let flight = flight.clone();
268+ let _ = std::thread::Builder::new()
269+ .name("remage-ipc-conn".into())
270+ .spawn(move || connection(stream, &*deliver, &flight));
271+ }
272+ })?;
273+ Ok(Server { path, in_flight })
274+}
275+
276+fn connection(stream: UnixStream, deliver: &dyn Fn(Incoming) -> bool, in_flight: &AtomicUsize) {
277+ let Ok(read) = stream.try_clone() else { return };
278+ let mut reader = BufReader::new(read.take(u64::MAX));
279+ let mut out = stream;
280+ let mut line = String::new();
281+ loop {
282+ line.clear();
283+ reader.get_mut().set_limit(MAX_REQUEST);
284+ match reader.read_line(&mut line) {
285+ Ok(0) | Err(_) => return,
286+ Ok(_) if !line.ends_with('\n') && line.len() as u64 >= MAX_REQUEST => {
287+ let _ = writeln!(out, "{}", error("request too long"));
288+ return;
289+ }
290+ Ok(_) => {}
291+ }
292+ if line.trim().is_empty() {
293+ continue;
294+ }
295+ let request = match parse_request(line.trim()) {
296+ Ok(r) => r,
297+ Err(e) => {
298+ if writeln!(out, "{}", error(&e)).is_err() {
299+ return;
300+ }
301+ continue;
302+ }
303+ };
304+ let subscribe = matches!(request, Request::Subscribe(_));
305+ let (reply_tx, reply_rx) = crossbeam_channel::bounded(1);
306+ let (events_tx, events_rx) = if subscribe {
307+ let (tx, rx) = crossbeam_channel::unbounded();
308+ (Some(tx), Some(rx))
309+ } else {
310+ (None, None)
311+ };
312+ in_flight.fetch_add(1, Ordering::AcqRel);
313+ let sent = deliver(Incoming {
314+ request,
315+ reply: reply_tx,
316+ events: events_tx,
317+ });
318+ let reply = if sent {
319+ reply_rx
320+ .recv_timeout(REPLY_TIMEOUT)
321+ .unwrap_or_else(|_| error("no reply from the viewer"))
322+ } else {
323+ error("the viewer is shutting down")
324+ };
325+ let written = writeln!(out, "{reply}").is_ok();
326+ in_flight.fetch_sub(1, Ordering::AcqRel);
327+ if !written {
328+ return;
329+ }
330+ if let Some(rx) = events_rx {
331+ stream_events(&mut out, &rx);
332+ return;
333+ }
334+ }
335+}
336+
337+fn stream_events(out: &mut UnixStream, rx: &Receiver<String>) {
338+ while let Ok(ev) = rx.recv() {
339+ if writeln!(out, "{ev}").is_err() {
340+ return;
341+ }
342+ }
343+}
344+
345+/// `remage msg` arguments.
346+pub struct ClientArgs {
347+ pub pid: Option<u32>,
348+ pub query: Option<String>,
349+ pub subscribe: Vec<String>,
350+ pub action: Vec<String>,
351+}
352+
353+fn find_socket(pid: Option<u32>) -> Result<PathBuf, String> {
354+ if let Some(pid) = pid {
355+ let p = socket_dir().join(format!("{pid}.sock"));
356+ return if p.exists() {
357+ Ok(p)
358+ } else {
359+ Err(format!(
360+ "no remage with pid {pid} ({} missing)",
361+ p.display()
362+ ))
363+ };
364+ }
365+ if let Some(s) = std::env::var_os("REMAGE_SOCKET").filter(|s| !s.is_empty()) {
366+ return Ok(PathBuf::from(s));
367+ }
368+ instances(&socket_dir())
369+ .into_iter()
370+ .next()
371+ .ok_or_else(|| "no running remage found".into())
372+}
373+
374+/// Run the client; returns the process exit code.
375+pub fn client(args: ClientArgs) -> i32 {
376+ match client_inner(args) {
377+ Ok(code) => code,
378+ Err(e) => {
379+ eprintln!("remage msg: {e}");
380+ 1
381+ }
382+ }
383+}
384+
385+fn client_inner(args: ClientArgs) -> Result<i32, String> {
386+ let request = if let Some(q) = &args.query {
387+ json!({ "query": q })
388+ } else if !args.subscribe.is_empty() {
389+ let topics: Vec<&str> = args
390+ .subscribe
391+ .iter()
392+ .flat_map(|s| s.split(','))
393+ .map(str::trim)
394+ .filter(|s| !s.is_empty())
395+ .collect();
396+ json!({ "subscribe": topics })
397+ } else if !args.action.is_empty() {
398+ json!({ "action": args.action.join(" ") })
399+ } else {
400+ return Err("nothing to send: give an action, --query or --subscribe".into());
401+ };
402+ let path = find_socket(args.pid)?;
403+ let mut stream = UnixStream::connect(&path).map_err(|e| format!("{}: {e}", path.display()))?;
404+ writeln!(stream, "{request}").map_err(|e| e.to_string())?;
405+ let mut reader = BufReader::new(stream);
406+ let mut line = String::new();
407+ if reader.read_line(&mut line).map_err(|e| e.to_string())? == 0 {
408+ return Err("the viewer closed the connection".into());
409+ }
410+ let reply: Value = serde_json::from_str(&line).map_err(|e| format!("bad reply: {e}"))?;
411+ if reply["ok"] != Value::Bool(true) {
412+ let msg = reply["error"].as_str().unwrap_or("request failed");
413+ return Err(msg.to_string());
414+ }
415+ let stdout = std::io::stdout();
416+ let mut out = stdout.lock();
417+ if !args.subscribe.is_empty() {
418+ loop {
419+ line.clear();
420+ match reader.read_line(&mut line) {
421+ Ok(0) | Err(_) => return Ok(0),
422+ Ok(_) => {
423+ if out.write_all(line.as_bytes()).is_err() || out.flush().is_err() {
424+ return Ok(0);
425+ }
426+ }
427+ }
428+ }
429+ }
430+ print_data(&mut out, &reply["data"]).map_err(|e| e.to_string())?;
431+ Ok(0)
432+}
433+
434+/// Strings and lists of strings print one per line, for shell pipelines; the rest as JSON.
435+fn print_data(out: &mut impl Write, data: &Value) -> std::io::Result<()> {
436+ match data {
437+ Value::Null => Ok(()),
438+ Value::String(s) => writeln!(out, "{s}"),
439+ Value::Array(a) if a.iter().all(Value::is_string) => {
440+ for s in a.iter().filter_map(Value::as_str) {
441+ writeln!(out, "{s}")?;
442+ }
443+ Ok(())
444+ }
445+ other => writeln!(out, "{other}"),
446+ }
447+}
448+
449+#[cfg(test)]
450+mod tests {
451+ use super::*;
452+
453+ #[test]
454+ fn requests_parse() {
455+ assert_eq!(
456+ parse_request(r#"{"action":"goto 5"}"#),
457+ Ok(Request::Actions(vec![Action::Goto(5)]))
458+ );
459+ assert_eq!(
460+ parse_request(r#"{"action":["next","mark on"]}"#),
461+ Ok(Request::Actions(vec![
462+ Action::Next(1),
463+ Action::Mark(crate::action::Switch::On)
464+ ]))
465+ );
466+ assert_eq!(
467+ parse_request(r#"{"query":"marked"}"#),
468+ Ok(Request::Query(Query::Marked))
469+ );
470+ assert_eq!(
471+ parse_request(r#"{"subscribe":["image","mode","image"]}"#),
472+ Ok(Request::Subscribe(vec![Topic::Image, Topic::Mode]))
473+ );
474+ assert_eq!(
475+ parse_request(r#"{"subscribe":"marks"}"#),
476+ Ok(Request::Subscribe(vec![Topic::Marks]))
477+ );
478+ let e = |s: &str| parse_request(s).unwrap_err();
479+ assert!(e("nope").contains("invalid JSON"));
480+ assert!(e("[]").contains("object"));
481+ assert!(e(r#"{"action":"nxt"}"#).contains("did you mean `next`"));
482+ assert!(e(r#"{"query":"all"}"#).contains("expected current"));
483+ assert!(e(r#"{"action":"next","query":"state"}"#).contains("exactly one"));
484+ assert!(e(r#"{"subscribe":[]}"#).contains("no events"));
485+ assert!(e(r#"{"frob":1}"#).contains("unknown request"));
486+ }
487+
488+ #[test]
489+ fn replies() {
490+ assert_eq!(ok(Value::Null), r#"{"ok":true}"#);
491+ assert_eq!(ok(json!(3)), r#"{"ok":true,"data":3}"#);
492+ assert_eq!(error("x"), r#"{"ok":false,"error":"x"}"#);
493+ assert_eq!(
494+ event(Topic::Mode, json!("grid")),
495+ r#"{"event":"mode","data":"grid"}"#
496+ );
497+ let mut out = Vec::new();
498+ print_data(&mut out, &json!(["a", "b"])).unwrap();
499+ print_data(&mut out, &json!({"k": 1})).unwrap();
500+ assert_eq!(String::from_utf8(out).unwrap(), "a\nb\n{\"k\":1}\n");
501+ }
502+
503+ #[test]
504+ fn socket_names() {
505+ assert_eq!(pid_of(Path::new("/run/remage/123.sock")), Some(123));
506+ assert_eq!(pid_of(Path::new("/run/remage/x.sock")), None);
507+ assert!(alive(std::process::id()));
508+ }
509+
510+ #[test]
511+ fn round_trip_over_a_socket() {
512+ let dir = std::env::temp_dir().join(format!("remage-ipc-{}", std::process::id()));
513+ std::fs::create_dir_all(&dir).unwrap();
514+ let path = dir.join("t.sock");
515+ let _ = std::fs::remove_file(&path);
516+ let listener = UnixListener::bind(&path).unwrap();
517+ let flight = Arc::new(AtomicUsize::new(0));
518+ let f = flight.clone();
519+ std::thread::spawn(move || {
520+ let (s, _) = listener.accept().unwrap();
521+ connection(
522+ s,
523+ &|inc: Incoming| {
524+ let reply = match inc.request {
525+ Request::Query(Query::Current) => ok(json!("/a.png")),
526+ Request::Subscribe(_) => {
527+ let ev = inc.events.unwrap();
528+ let _ = ev.send(event(Topic::Mode, json!("grid")));
529+ ok(Value::Null)
530+ }
531+ _ => error("unexpected"),
532+ };
533+ inc.reply.send(reply).is_ok()
534+ },
535+ &f,
536+ );
537+ });
538+ let mut s = UnixStream::connect(&path).unwrap();
539+ let mut r = BufReader::new(s.try_clone().unwrap());
540+ let mut line = String::new();
541+ writeln!(s, "{{\"query\":\"current\"}}\n\nbad").unwrap();
542+ r.read_line(&mut line).unwrap();
543+ assert_eq!(line.trim(), r#"{"ok":true,"data":"/a.png"}"#);
544+ line.clear();
545+ r.read_line(&mut line).unwrap();
546+ assert!(line.contains("invalid JSON"), "{line}");
547+ writeln!(s, "{{\"subscribe\":\"mode\"}}").unwrap();
548+ line.clear();
549+ r.read_line(&mut line).unwrap();
550+ assert_eq!(line.trim(), r#"{"ok":true}"#);
551+ line.clear();
552+ r.read_line(&mut line).unwrap();
553+ assert_eq!(line.trim(), r#"{"event":"mode","data":"grid"}"#);
554+ assert_eq!(flight.load(Ordering::Acquire), 0);
555+ std::fs::remove_dir_all(&dir).unwrap();
556+ }
557+}
M src/main.rs View
@@ -17,7 +17,10 @@ mod thumbs;
1717 mod ui;
1818 mod worker;
1919
20−use std::path::Path;
20+use std::ffi::OsString;
21+use std::io::{Read, Write};
22+use std::os::unix::ffi::{OsStrExt, OsStringExt};
23+use std::path::{Path, PathBuf};
2124 use std::time::Instant;
2225
2326 use clap::Parser;
@@ -31,6 +34,20 @@ fn main() -> anyhow::Result<()> {
3134 let started = Instant::now();
3235 env_logger::init();
3336 let cli = cli::Cli::parse();
37+ if let Some(cli::Sub::Msg {
38+ pid,
39+ query,
40+ subscribe,
41+ action,
42+ }) = cli.command
43+ {
44+ std::process::exit(ipc::client(ipc::ClientArgs {
45+ pid,
46+ query,
47+ subscribe,
48+ action,
49+ }));
50+ }
3451 if cli.dump_config {
3552 print!("{}", config::dump());
3653 return Ok(());
@@ -55,15 +72,25 @@ fn main() -> anyhow::Result<()> {
5572 log::debug!("config loaded after {:.1?}", started.elapsed());
5673 let cfg = &loaded.config;
5774
58− let scan = scan::scan(
59− &cli.paths,
60− &scan::ScanOptions {
61− recursive: cli.recursive || cfg.general.recursive,
62− siblings: !cli.no_siblings && cfg.general.open_siblings,
63− sort: cli.sort.unwrap_or(cfg.general.sort),
64− reverse: cli.reverse || cfg.general.reverse,
65− },
66− );
75+ let mut paths = cli.paths.clone();
76+ if cli.stdin {
77+ paths.extend(read_stdin_paths(cli.null)?);
78+ }
79+ let scan = if cli.stdin && paths.is_empty() {
80+ // An empty list on stdin opens nothing, not the current directory.
81+ scan::Scan::default()
82+ } else {
83+ scan::scan(
84+ &paths,
85+ &scan::ScanOptions {
86+ recursive: cli.recursive || cfg.general.recursive,
87+ // A list of files is just those files.
88+ siblings: !cli.stdin && !cli.no_siblings && cfg.general.open_siblings,
89+ sort: cli.sort.unwrap_or(cfg.general.sort),
90+ reverse: cli.reverse || cfg.general.reverse,
91+ },
92+ )
93+ };
6794 log::debug!(
6895 "scanned {} files ({} to sniff) in {:.1?}",
6996 scan.files.len(),
@@ -104,6 +131,23 @@ fn main() -> anyhow::Result<()> {
104131 }
105132 });
106133 }
134+ let server = {
135+ let proxy = proxy.clone();
136+ let proxy = std::sync::Mutex::new(proxy);
137+ match ipc::serve(move |inc| {
138+ let proxy = proxy.lock().unwrap_or_else(|e| e.into_inner());
139+ proxy.send_event(UserEvent::Ipc(inc)).is_ok()
140+ }) {
141+ Ok(s) => {
142+ log::debug!("IPC socket {}", s.path().display());
143+ Some(s)
144+ }
145+ Err(e) => {
146+ log::warn!("IPC socket: {e}");
147+ None
148+ }
149+ }
150+ };
107151 let watch = {
108152 let proxy = proxy.clone();
109153 config::watch::spawn(cli.config.clone(), loaded.watch.clone(), move |r| {
@@ -117,18 +161,44 @@ fn main() -> anyhow::Result<()> {
117161 class: cli.class.unwrap_or_else(|| cfg.window.class.clone()),
118162 overrides,
119163 private: cli.private,
164+ socket: server.as_ref().map(|s| s.path().to_path_buf()),
120165 };
121166 let mut app = App::new(opts, state, proxy, loaded, watch);
122167 if let Some(e) = config_error {
123168 app.config_failed(&e);
124169 }
125170 event_loop.run_app(&mut app)?;
171+ if cli.print_marked
172+ && let Some(marked) = app.marked_output()
173+ {
174+ let sep = if cli.null { b'\0' } else { b'\n' };
175+ let mut out = std::io::stdout().lock();
176+ for p in marked {
177+ out.write_all(p.as_os_str().as_bytes())?;
178+ out.write_all(&[sep])?;
179+ }
180+ out.flush()?;
181+ }
182+ // Lets in-flight IPC replies (to a `quit`, say) go out, then removes the socket.
183+ drop(server);
126184 match app.error.take() {
127185 Some(e) => Err(e),
128186 None => Ok(()),
129187 }
130188 }
131189
190+/// `-i`: paths from stdin, one per line (NUL-separated with `-0`).
191+fn read_stdin_paths(null: bool) -> anyhow::Result<Vec<PathBuf>> {
192+ let mut buf = Vec::new();
193+ std::io::stdin().lock().read_to_end(&mut buf)?;
194+ let sep = if null { 0 } else { b'\n' };
195+ Ok(buf
196+ .split(|b| *b == sep)
197+ .filter(|l| !l.is_empty())
198+ .map(|l| PathBuf::from(OsString::from_vec(l.to_vec())))
199+ .collect())
200+}
201+
132202 /// `--check-config`: report and exit, non-zero on errors.
133203 fn check_config(path: Option<&Path>) -> ! {
134204 match config::load(path) {
M src/state/mod.rs View
@@ -42,6 +42,16 @@ pub enum Mode {
4242 Visual,
4343 }
4444
45+impl Mode {
46+ pub fn name(self) -> &'static str {
47+ match self {
48+ Mode::Image => "image",
49+ Mode::Grid => "grid",
50+ Mode::Visual => "visual",
51+ }
52+ }
53+}
54+
4555 #[derive(Clone, Debug, PartialEq)]
4656 pub struct Settings {
4757 pub fit: FitMode,
@@ -191,6 +201,8 @@ pub struct Effects {
191201 pub fullscreen: Option<Switch>,
192202 pub reload: bool,
193203 pub reload_config: bool,
204+ /// Re-check files on disk: drop missing ones, re-decode changed ones.
205+ pub refresh: bool,
194206 /// Re-sort the queue (stat-based keys need the filesystem, so the loop does it).
195207 pub sort: Option<SortKey>,
196208 pub quit: bool,
@@ -220,6 +232,10 @@ pub struct AppState {
220232 pub message: Option<Message>,
221233 /// Keys typed so far of an incomplete sequence.
222234 pub pending: String,
235+ /// An inline yes/no question (`DELETE 3 FILES?`) waiting for `y`.
236+ pub prompt: Option<String>,
237+ /// User commands still running, oldest first: (job id, name).
238+ pub running: Vec<(u64, String)>,
223239 window: [f32; 2],
224240 scale_factor: f32,
225241 holds: Vec<Hold>,
@@ -247,6 +263,8 @@ impl AppState {
247263 skipped: 0,
248264 message: None,
249265 pending: String::new(),
266+ prompt: None,
267+ running: Vec::new(),
250268 window: [1.0, 1.0],
251269 scale_factor: 1.0,
252270 holds: Vec::new(),
@@ -617,6 +635,9 @@ impl AppState {
617635 }
618636 }
619637 Action::ReloadConfig => fx.reload_config = true,
638+ Action::Refresh => fx.refresh = true,
639+ // The event loop runs commands: they spawn processes and may wait for a `y`.
640+ Action::Run(_) => fx.redraw = false,
620641 Action::Echo(msg) => self.notify(msg, MsgKind::Info, now),
621642 Action::Cancel => self.message = None,
622643 Action::Quit | Action::QuitNoOutput => fx.quit = true,
@@ -645,9 +666,7 @@ impl AppState {
645666 | Action::Search
646667 | Action::Filter(_)
647668 | Action::FilterClear
648− | Action::Refresh
649− | Action::Open(_)
650− | Action::Run(_)) => {
669+ | Action::Open(_)) => {
651670 self.notify(
652671 format!("{} not available yet", other.name()),
653672 MsgKind::Info,
@@ -919,6 +938,8 @@ mod tests {
919938 s.message.as_ref().map(|m| m.text.as_str()),
920939 Some("palette not available yet")
921940 );
941+ assert!(s.apply(Action::Refresh, now).refresh);
942+ assert!(!s.apply(Action::Run("x".into()), now).redraw);
922943 s.apply(Action::Echo("hi".into()), now);
923944 assert_eq!(s.message.as_ref().map(|m| m.text.as_str()), Some("hi"));
924945 assert!(s.apply(Action::Random, now).navigated);
M src/state/queue.rs View
@@ -31,6 +31,8 @@ pub struct Queue {
3131 index: usize,
3232 /// Marked files, by path so reordering keeps them.
3333 marks: HashSet<PathBuf>,
34+ /// Bumped on every change to the marks, so watchers can spot one.
35+ marks_version: u64,
3436 }
3537
3638 impl Queue {
@@ -43,6 +45,7 @@ impl Queue {
4345 files,
4446 index: 0,
4547 marks: HashSet::new(),
48+ marks_version: 0,
4649 };
4750 q.index = q.clamp(index);
4851 q
@@ -97,7 +100,9 @@ impl Queue {
97100 return false;
98101 };
99102 let removed = self.files.remove(i);
100− self.marks.remove(&removed);
103+ if self.marks.remove(&removed) {
104+ self.marks_version += 1;
105+ }
101106 if i < self.index {
102107 self.index -= 1;
103108 }
@@ -186,27 +191,36 @@ impl Queue {
186191 self.marks.len()
187192 }
188193
194+ pub fn marks_version(&self) -> u64 {
195+ self.marks_version
196+ }
197+
189198 /// Mark or unmark file `i`. Returns whether anything changed.
190199 pub fn set_mark(&mut self, i: usize, on: bool) -> bool {
191200 let Some(f) = self.files.get(i) else {
192201 return false;
193202 };
194− if on {
203+ let changed = if on {
195204 self.marks.insert(f.clone())
196205 } else {
197206 self.marks.remove(f)
198− }
207+ };
208+ self.marks_version += changed as u64;
209+ changed
199210 }
200211
201212 pub fn mark_all(&mut self) {
202213 self.marks = self.files.iter().cloned().collect();
214+ self.marks_version += 1;
203215 }
204216
205217 pub fn unmark_all(&mut self) {
206218 self.marks.clear();
219+ self.marks_version += 1;
207220 }
208221
209222 pub fn invert_marks(&mut self) {
223+ self.marks_version += 1;
210224 self.marks = self
211225 .files
212226 .iter()
@@ -216,7 +230,6 @@ impl Queue {
216230 }
217231
218232 /// Marked files in queue order.
219− #[allow(dead_code)] // `-o` and command targets arrive in M4
220233 pub fn marked(&self) -> Vec<&Path> {
221234 self.files
222235 .iter()
@@ -442,7 +455,9 @@ mod tests {
442455 let mut q = Queue::new(paths(&["a", "b", "c", "d"]), None, 0);
443456 assert!(q.set_mark(1, true));
444457 assert!(q.set_mark(3, true));
458+ let v = q.marks_version();
445459 assert!(!q.set_mark(3, true));
460+ assert_eq!(q.marks_version(), v);
446461 assert_eq!(q.marked_count(), 2);
447462 q.reverse();
448463 assert!(q.is_marked(0) && q.is_marked(2));
M src/ui/mod.rs View
@@ -11,7 +11,7 @@ use crate::render::image::AlphaStyle;
1111 use crate::render::prims::Prim;
1212 use crate::render::text::{Align, FontRole, TextItem};
1313 use crate::render::{ImageDraw, Scene};
14−use crate::state::{AlphaBg, AppState, GRID_NAME_SIZE, Mode, MsgKind, ThumbState};
14+use crate::state::{AlphaBg, AppState, BAR_HEIGHT, GRID_NAME_SIZE, Mode, MsgKind, ThumbState};
1515
1616 /// Space Mono label tracking.
1717 const LABEL_TRACKING: f32 = 0.08;
@@ -72,6 +72,10 @@ pub fn scene<'a>(
7272 }
7373 if bar_h > 0.0 {
7474 status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure);
75+ } else if state.prompt.is_some() {
76+ // A question can't hide with the bar: it overlays the image until answered.
77+ let bar_h = (BAR_HEIGHT * sf).round();
78+ status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure);
7579 }
7680 scene
7781 }
@@ -259,6 +263,16 @@ fn status_bar(
259263 if !state.pending.is_empty() {
260264 spans.push((state.pending.clone(), theme.text_disabled));
261265 }
266+ if let Some((_, name)) = state.running.last() {
267+ let more = match state.running.len() {
268+ 1 => String::new(),
269+ n => format!(" +{}", n - 1),
270+ };
271+ spans.push((
272+ format!("[RUNNING {}{more}]", name.to_uppercase()),
273+ theme.text_secondary,
274+ ));
275+ }
262276 if let Some(m) = &state.message {
263277 let color = if m.kind == MsgKind::Error {
264278 theme.accent
@@ -344,6 +358,18 @@ fn status_bar(
344358 right_w += gap + count_w + 6.0 * sf + 2.0 * r;
345359 }
346360
361+ // A pending question takes the filename's place: `DELETE 3 FILES? Y / N`.
362+ if let Some(q) = &state.prompt {
363+ let mut item = label("", theme.text_display, sf, pad, y, Align::Left);
364+ item.spans = vec![
365+ (format!("{} ", q.to_uppercase()), theme.text_display),
366+ ("Y / N".into(), theme.text_secondary),
367+ ];
368+ item.max_width = Some((w - 2.0 * pad - right_w - 32.0 * sf).max(0.0));
369+ scene.texts.push(item);
370+ return;
371+ }
372+
347373 // The filename takes whatever the right side leaves, ellipsised in the middle.
348374 if let Some(cur) = state.queue.current() {
349375 let room = (w - 2.0 * pad - right_w - 32.0 * sf).max(0.0);