Commit Graph

45 Commits

Author SHA1 Message Date
en
863f7e584e Round 6 (Grok verification): fix the live-path breakers.
Grok round-6 verification was No-Go. Fixes:

A. LayoutSlot.id was written as 0 by every constructor
   (generate Stacked/Grid/main_strip, capture_from, the GUI pad
   literals), so the find-by-id lookups in resize_slot / move_slot
   never hit a tile and Free-mode Apply size/position always
   failed with "no layout slot for slot id N". Constructors now
   assign real 1-based ids (index+1, out.len()+1, i+2 for the
   strip); capture_from uses the slot id from the window tuple.
   layout::apply and reset_slot_lock now find the tile by id
   instead of by Vec index.

B. The `slots` IPC formatter emitted "{id} 0x{address} ..." while
   addresses already carry their own 0x prefix ("0xa"), producing
   "1 0x0xa ..."; the parser split on whitespace so any multi-word
   window title broke the field alignment. Both sides now use a
   tab separator and the address passes through unchanged.

C. examples/profile.yaml still shipped the dropped schema
   (window_match block + passthrough list). Replaced with a note
   that matching is by process tree and every mapped hotkey is
   intercepted.

D. CHANGELOG 0.1.0 still advertised passthrough (lines 19, 24)
   and window_match (27, 78). Annotated as removed.

E. Lying comments: launcher.rs called the prefix "per-team" (it is
   per-character); gui.rs::arm_auto_apply doc claimed it matched a
   regex.

F. arm_auto_apply still hardcoded an empty spawned-pid set and
   fell back to matching any client with a non-empty class -- the
   round-4 Item-3 placeholder was what actually ran. It now takes
   the real child pid and matches via pid_is_ancestor.

G. spawn_plan dropped the Child with no wait thread (zombie, same
   bug round-6 fixed in launch_game). Now reaps in a background
   thread.

H. page_session never refreshed the games list, so the dropdown
   was empty on first paint. Added a games_loaded flag and a
   one-shot refresh_games on first paint.

Plus: vfx env-var test race. toplevel_enabled_defaults_off_... and
capture_toplevel_is_gated_when_disabled both touch
ENBOXER_ENABLE_TOPLEVEL and cargo runs unit tests in parallel, so
the gated test intermittently saw the var set by its sibling (the
per-function `static` in session.rs does not serialise across
functions). Added a module-level ENV_LOCK in vfx::tests and
guarded both tests. Verified with three consecutive full runs.

Also: clippy unnecessary_cast in main_strip, and two rustdoc
warnings (raw <pid> and Arc<GbmDevice> read as HTML tags).

cargo test 103/103 (x3); clippy --all-targets -D warnings clean;
cargo doc --no-deps clean.
2026-09-17 08:24:03 +02:00
en
677ae0d3dc Round 5 batch B (Grok review): live wiring for items 2, 4, 5, 6, 7, 9.
Grok round 5 found these surfaces either dead or lying:

2. Item 4 GUI never shipped. page_session had no game dropdown;
   menu_launch was still Lutris. Added a Game row: ComboBox fed by
   the daemon `list-games` verb (refresh_games helper), Launch
   button fires `launch-game NAME`. New App fields game_list /
   game_pick.

4. GUI local spawn never filled Session.spawned_pids. spawn_plan
   now captures the child PID and pushes it via the new `track-pid`
   IPC verb; status shows the pid. Daemon-side track-pid inserts
   into Session.spawned_pids.

5. launch_game drop(Child) did not reap -> zombies (wine wrappers
   exit fast). Replaced with a background thread that calls
   child.wait(); the PID stays in spawned_pids.

6. Deleted the select_windows placeholder that returned a fake
   first-N-visible list disagreeing with the daemon assignment.
   select_windows now reads the daemon slot list via the new
   `slots` IPC verb (layout::slots_from_daemon) and returns the
   same (id, Client) shape.

7. resize_slot / move_slot used slot.saturating_sub(1) as an index
   into layout.slots, which breaks when Session.slots has gaps
   after a hide ([1a, 3b] -> resize-slot 3 bailed). LayoutSlot
   gains a stable `id: u32` field; both handlers now find the
   layout slot by id. LayoutSlot literals across the repo given
   an id.

9. keybind_detail Map panel now exposes release_steps (Add /
   Remove / edit per entry), matching the press steps editor.

cargo test 103/103; clippy clean.
2026-09-17 08:16:05 +02:00
en
5aabf0d36f Round 5 batch A (Grok review): games.yaml seed, pending_ipc fix, doc residue, dead passthrough_id.
Grok round 5 found:

1. examples/games.yaml was actually src/main.rs (227 lines of clap
   CLI; my round-4 Item 4 commit message was wrong). Replaced with
   a real seed YAML. The example shows the env format as
   [[KEY, VALUE], ...] (a list of pairs), NOT KEY=*** (serde_yaml
   will not parse KEY=*** into Vec<(String, String)>).

3. App.pending_ipc was written by Free-mode buttons but never
   drained by the main loop. Replaced with direct self.ipc(...)
   calls. Hoisted the closure borrow by collecting verbs into a
   Vec<(&str, u32)> and draining after the slots borrow is
   released. Dropped the pending_ipc field + App literal init.

8. Doc residue: passthrough / window_match claims still in
   README.md, CHANGELOG.md, docs/DESIGN.md. Updated README.md to
   point at round-4 Item 6 + Item 3. Added a round-4 section at
   the top of CHANGELOG.md. Dropped the duplicate passthrough
   bullet in docs/DESIGN.md.

10. Dead passthrough_id helper in hotkey.rs (unused after the
    passthrough feature dropped in round-4 Item 6). Dropped the
    function + its doc comment.

cargo test 103/103; clippy clean.
2026-09-17 07:40:24 +02:00
en
24ca3bac0c Item 2 (Grok round 4): isboxer-style keybind tree UI.
Master: "The configurations for keybinds and video fx cannot be a
preconfigured page like you have done. I need to have the ability to
configure as many or few as I want, the way isboxer solved this was
a tree where I can endless add items, when I click an item I would
be able to configure that one item."

References:
- https://wiki.isboxer.com/Key_mapping
- https://wiki.isboxer.com/Key_broadcasting

Implementation:

src/profile.rs:
  * Map.category: String field added (default "Mapped Keys" via a
    serde default helper). Categories: "Always On", "Combat",
    "Mapped Keys", "Character Sets", "Characters".
  * VideoFx.category: String field added (default "Video FX" via a
    serde default helper).
  * default_map_category + default_vfx_category helpers added.

src/gui.rs:
  * SelectedNode enum: Map(usize) | VideoFx(usize). Drives the
    right-pane detail editor.
  * App struct gets a `selected_node: Option<SelectedNode>` field.
  * page_macros is gone. page_keybinds replaces it.
  * keybind_tree (left pane): CollapsingHeader per category. Items
    listed as selectable_label rows with a Remove button. "Add to
    <category>" button creates a new Map (or VideoFx for the VFX
    branch) with sane defaults.
  * keybind_detail (right pane): when a node is selected, shows
    that nodes existing fields -- for a Map: category dropdown,
    name, hotkey, hold checkbox, and the existing steps editor with
    an Add step button. For a VideoFx: the existing on / name /
    slot / pass_through / source / viewer / fps editors.
  * Page::Macros dispatch renamed from page_macros to page_keybinds.
  * ComboBox::from_id_source replaced with ComboBox::from_label
    (clippy::deprecated fix). DragValue uses .range (not the
    deprecated .clamp_range). Dropped unused crate::macros::print_macros
    import. format!("{}", x) replaced with x.to_string().
  * All existing Map + VideoFx literal construction sites in
    engine.rs + gui.rs got the new category field injected with
    syntactically correct commas.

cargo test 103/103; clippy clean.
2026-09-17 07:26:39 +02:00
en
8cb96c1f25 Item 1 (Grok round 4): window layout manager rewrite + free-arrange mode.
Master wanted two modes:
- Managed (existing wizard behaviour): auto-arrange grid via the
  layout wizard.
- Free-arrange: launch windows without applying layout. Per-window
  initial resolution. Apply-size button (not position). Daemon does
  NOT keep overwriting resolution if operator manually resized.

Reference: KWin scripts apply-size / apply-position for the live
resize plumbing.

Implementation:

src/profile.rs:
  * LayoutMode enum: Managed (default) | Free. Serde round-trips
    as "managed" / "free".
  * Layout.mode field added (default = Managed via LayoutMode derive).
  * LayoutSlot split into initial_size (Option<(u32,u32)>),
    pos (Option<(i32,i32)>), size_locked (bool). The legacy x/y/w/h
    fields stay for Managed mode (cached geometry).

src/hypr.rs:
  * move_resize_window split into resize_window(w, h) + move_window(x, y).
  * move_resize_window kept as a thin wrapper so the original
    layout-apply IPC path still works.

src/layout.rs:
  * apply(slots, windows) split into apply_size + apply_pos; apply()
    is now the thin wrapper that calls both.
  * All 4 LayoutSlot literals (Stacked preset, Grid helper, main_strip,
    capture_from) gained ..Default::default().

src/session.rs:
  * New IPC verbs: resize-slot N, move-slot N, reset-slot N.
  * resize_slot: applies initial_size (or cached w/h), sets
    size_locked=true so the daemon does not undo future operator
    resizes.
  * move_slot: applies pos (or cached x/y).
  * reset_slot_lock: clears size_locked on slot-N.
  * All three bail with a clear message if the mode is not Free.

src/gui.rs:
  * Mode toggle (Managed / Free) at the top of page_layout.
  * page_layout_managed: existing wizard (presets, drag tiles,
    Save and Apply).
  * page_layout_free: per-window initial W/H picker, Apply-size,
    Apply-position, Reset-size buttons. Per-slot lock indicator.
    Uses DragValue::range (clamp_range is deprecated).
  * App.pending_ipc: Vec<String> queue; the main loop will drain
    it and fire the IPC verbs.

cargo test 103/103 (unchanged from Item 4 + 3 games tests); clippy clean.
2026-09-17 07:22:13 +02:00
en
9052f61228 Item 4 (Grok round 4): Session game-launcher dropdown.
Master: "idk why you have a Teams/Lutris section. When I am under
session I should be able to select a list of games so you know how to
launch the correct game, currently you lack that list and drop down
option."

Implementation:

- src/games.rs (new module):
  * Game struct: { name, exe, args, env, cwd, note }.
  * load_all(path) -> Vec<Game>; returns empty list when the file
    does not exist (operator has not configured any games yet).
  * find(games, name) -> Option<Game> (case-sensitive, O(n)).
  * save_all(path, games) for the GUI Add-Game form.
  * default_path() -> ~/.config/enboxer/games.yaml.
  * Three unit tests (load_missing empty, find_by_name, YAML roundtrip).

- src/lib.rs: pub mod games registered.

- src/session.rs:
  * New IPC verb "launch-game NAME": spawns the picked game entry,
    records the child PID into Session.spawned_pids so refresh_slots
    matches the resulting window via the process-tree walk (Item 3).
  * New IPC verb "list-games": returns the joined names so the GUI
    can populate the dropdown without a second command.
  * launch_game function: tokio::process::Command-equivalent
    std::process::Command spawn, child PID captured via child.id(),
    dropped (OS keeps the process running; spawned_pids is the
    only thing we need).

- examples/games.yaml: shipped with an empty `games: []` plus a
  commented-out WoW example so operators have a starting point.

cargo test 103/103 (3 new games tests + the 100 from earlier);
clippy clean.
2026-09-17 05:43:21 +02:00
en
1d92bc568d Item 3 (Grok round 4): replace regex window matching with process-tree tracking.
Master quote: "i do not know why you have regex for the games? If you are launching the games you should be able to find out what you launched instead and then place it in the correct spot in the grid. programs spawn under a tree structure on linux so you should easily be able to figure out what you spawned."

Implementation:

- src/process.rs (new): pid_is_ancestor(ancestor, candidate) walks /proc/<pid>/status PPid chain upward. Bounded to 64 hops so a malformed /proc cannot spin us. Unit-tested with three cases: self, zero-pids, and the running test process against its own ppid.

- src/lib.rs: pub mod process registered.

- src/profile.rs:
  * Profile.window_match field removed (was Option<String> class + Option<String> title regex).
  * WindowMatch struct removed.
  * HashSet import kept (still used elsewhere).

- src/session.rs:
  * Session.spawned_pids: HashSet<u32> field added + initialised.
  * refresh_slots: replaces regex filter with crate::process::pid_is_ancestor(root, c.pid) against every spawned root.
  * profile-init log line dropped window_match.class / .title args.
  * borderless path: removed class_re use; for now re-applies apply_vfx_window_rules (per-spawn windowrulev2 lands once the launcher wires the pid in Item 4).
  * matches_regex helper removed (was only used by arm_auto_apply).

- src/layout.rs:
  * select_windows now a placeholder: returns the first N visible clients in z-order. Real picker is in session::refresh_slots.
  * Regex import dropped.

- src/gui.rs:
  * WindowMatch import removed.
  * Default profile literal: window_match field removed.
  * Profile editor: the four class/title text-edit lines replaced with a comment explaining Item 3.
  * arm_auto_apply: regex compile block + class_pat / title_pat / any_pattern bindings removed; the thread body now uses the process-tree match with a placeholder for the spawned-pids source until Item 4 wires it.

- src/team.rs + src/engine.rs: Profile literals with window_match: Default::default() removed.

- src/hypr.rs: untouched. apply_borderless_rules kept but no longer called by the daemon.

cargo test 100/100 (3 new pid_is_ancestor tests); clippy clean.
2026-09-17 05:42:06 +02:00
en
20271264cf Item 5+6 (Grok round 4): default profile name -> team, drop passthrough feature
Item 5: examples/profile.yaml default profile name renamed from
"esdf-team" to "team". The default shipped profile should be
called what it is.

Item 6: drop the passthrough feature entirely. Removed:
  - Profile.passthrough field + Profile::passthrough_set
  - Engine.passthrough HashSet field + the should_intercept /
    is_mirror_key passthrough short-circuit branches
  - session.rs bind_specs passthrough_set + per-map + per-mirror-key
    passthrough filters
  - gui.rs passthrough label + text-edit widget
  - main.rs profile log line that referenced p.passthrough
  - hotkey.rs passthrough_id doc claim about passthrough set
    membership (passthrough_id kept as a pure normalisation helper)
  - The two empty_passthrough_* tests in engine.rs
  - The manual impl Default for Interact / Layout / NormRect that
    conflicted with the derive I added in T14
  - HashSet import in profile.rs that was only used by passthrough_set
  - Hotkey + InteractStyle now derive Default so Profile::default()
    still works end-to-end after passthrough removal

Kept (NOT removed): vfx::FeedHit.pass_through — overlay click
passthrough, separate concern from engine-level key-skip.

Also silenced the stale #[allow(dead_code)] on
GbmDevice.sym.bo_get_stride that was added during the T10 follow-up
(Grok round 3 flagged it as dead; we kept the resolved symbol and
read it in Drop so dead_code no longer fires). No more spinning red
circle from that warning.

cargo test 97/97; clippy clean.
2026-09-17 05:38:59 +02:00
en
60385d062a toplevel_export: capture_via_export docstring contradiction (Grok round 3 #11)
The docstring simultaneously claimed the function writes a synthetic
PNG-sized byte slice and that a real pixel read runs end-to-end via
gbm_runtime. Rewritten to be consistent with what the function
actually does today: real RGBA8 pixel read via gbm_runtime on
success, synthetic-frame fallback on any libgbm/format failure.

cargo test 99+/0; clippy clean.
2026-09-16 17:33:25 +02:00
en
0906e86594 session: cursor_pos dedup, mouse_press bail, slot-ID stability, env-var test serial (Grok round 3 #7, #8, #10, #14)
#7: broadcast_mirror_click called hypr::cursor_pos().await?.0 then
.1 -- two hyprctl roundtrips. Replaced with a single
hypr::cursor_pos().await? returning (cx, cy).

#8: mouse_press always spawned the per-button repeat loop, even
when neither Mode::Mirror nor mouse_broadcast was on (a no-op press).
Added an early bail: when both are off, return Ok(()) without
firing the initial click or spawning the repeat task.

#10: refresh_slots re-enumerated the matched vec every 400 ms with
enumerate().map(|(i, c)| ((i + 1) as u32, c)), which shuffled slot
IDs mid-flight whenever one slot briefly hid -- regressing Bug #11.
Reuse the previous slot ID for any client whose wl_address is still
present in the new matched set; only assign fresh IDs (1..=n) to
genuinely new clients; drop disappeared clients.

#14: mouse_repeat_ms_clamps_env_var set ENBOXER_MOUSE_REPEAT_MS
without a serial guard. cargo test runs unit tests in parallel; a
concurrent test touching the same env var would race our reads.
Added a std::sync::Mutex<()> static to serialise the test.

cargo test 99+/0; clippy clean.
2026-09-16 17:33:25 +02:00
en
3f59c09ce8 gbm_runtime: fix GBM_BO_IMPORT_FD = 0x5503 (Grok round 3 #2)
System /usr/include/gbm.h on Arch (Mesa libgbm 22.x) defines
GBM_BO_IMPORT_FD = 0x5503. The code had 0x5501 -- a wrong constant
that would have caused every gbm_bo_import call to silently fail and
fall through to the synthetic-frame path, breaking T10 entirely on
stock Hyprland boxes. Bumped to 0x5503 and updated the unit-test
assertion to match.

cargo test 99+/0; clippy clean.
2026-09-16 17:33:25 +02:00
en
b6937158e4 Profile::default() + T13 cadence-clamp unit test
Profile and Mode derive Default so the T13 cadence-clamp test can
build a Session without a real profile file. Mode uses #[default]
on the Maps variant (the natural first option). The T13 unit test
walks ENBOXER_MOUSE_REPEAT_MS through valid values (1, 50, 2000)
and out-of-range or malformed (0, 2001, non-numeric, empty) and
asserts the session field reads back the in-range value or the
50 ms default respectively.

cargo test 99+/0; clippy clean.
2026-09-16 17:16:16 +02:00
en
0ec7957224 Sweep: drop remaining T10/stub/follow-up markers
The T14 audit found more places where comments and docs claimed
T10 was a stub or follow-up after the gbm_bo_map work landed:

- src/vfx.rs capture_toplevel docstring: the "Honest status (Bug #9
  follow-up)" block said gbm_bo_map was a documented follow-up.
  Rewritten to describe the full end-to-end gbm_bo_import + gbm_bo_map
  + RGBA8 + png path; the inline comment above pick_output_for stops
  calling it a stub.

- src/toplevel_export.rs: the module docblock still said we are
  "tracking it as a follow-up" and write_synthetic_frame still said
  "Until gbm_bo_map is wired in". Rewritten as the documented
  fallback path. capture_via_export footer updated. Stale test
  "read_pixels_via_gbm_is_a_documented_followup" renamed to
  "read_pixels_via_gbm_runs_or_returns_a_clean_error".

- src/overlay.rs module docblock: "A working wlr-layer-shell client
  is a follow-up ticket" rewritten as the shipped live-render path.

- README.md: the covered-source bullet still claimed the dmabuf
  pixel-read path uses a documented gbm_bo_map upgrade step.
  Rewritten to point at the shipped runtime dlopen path
  (commit 0ba3c59).

cargo test 99+/0; clippy clean.
2026-09-16 17:16:16 +02:00
en
4ee58acf2c T13: press-and-hold repeat for mouse broadcast / mirror clicks
Hyprland mouse binds fire once per press; for multiboxing you want a
held mouse button to repeat clicks on every captured slot at a
configurable cadence. Wire that:

- mouse-press <button> IPC verb: fires the broadcast once immediately,
  then spawns a per-button tokio task that re-fires it every
  ENBOXER_MOUSE_REPEAT_MS (default 50 ms = 20 Hz). Stored per button
  in Session.mouse_repeats.
- mouse-release <button>: cancels the matching repeat.
- mirror_repeat_loop worker: holds an Arc<AtomicBool> cancel signal so
  the release side can flip it without taking the Session mutex.
- Bind installation: each mouse button (272, 273) now installs BOTH
  a press BindSpec AND a release=true BindSpec for both mouse_broadcast
  and Mode::Mirror so the behaviour is automatic for the operator.
  Hyprland translates release=true to { release = true } bind option.

Cadence is tunable at startup via ENBOXER_MOUSE_REPEAT_MS, clamped to
1..=2000 ms. The mirror_clicks_to docstring (previously "OUT OF
SCOPE for T13 ...") now points at mirror_repeat_loop.

cargo test 98+/0; clippy clean.
2026-09-16 12:43:33 +02:00
en
bb6d9e8566 Drop stale stub/follow-up/not-shipped markers in docs
After T10 shipped (commit 0ba3c59) several docs still claimed the
gbm_bo_map / ENBOXER_ENABLE_TOPLEVEL path was a stub, follow-up, or
"/dev/dri/renderD128 + new dep" work that never happened. Rewrite:

- README.md: ENABLE_TOPLEVEL row no longer says "unset = stub".
- docs/VIDEO.md: live toplevel-export is real (gbm_bo_map via
  runtime dlopen of libgbm.so.1, real RGBA8 PNG via the png crate).
- CHANGELOG.md: T10 follow-up entry rewritten as shipped.
- docs/MACROS.md: stale "Forever is not out yet / later helper
  (not shipped)" paragraph rewritten to point users at the
  per-character GUI rebind instead.
2026-09-16 12:43:33 +02:00
en
777256daab Drop stale T10 follow-up note in toplevel_export.rs
The T10 follow-up block (lines 561-575) said gbm_bo_map was a
documented follow-up. T10 is shipped (commit 0ba3c59, real pixel
read via runtime dlopen of libgbm.so.1); this comment block is now
stale and misleading.

Also fix the misleading "Public stub: where the gbm_bo_map read
belongs" docstring on read_pixels_via_gbm -- the function delegates
to the real implementation now, not a stub.

cargo test 98+/0; clippy clean.
2026-09-16 12:43:33 +02:00
en
0ba3c59cc1 T10: real pixel read via runtime dlopen of libgbm.so.1 (Bug #9 closed)
Bug #9 follow-up: the gated capture_via_export path used to write
a synthetic PNG because gbm_bo_map was not wired. The rest of the
round-2 audit accepted the synthetic-frame honest fallback; this
commit closes the real path end-to-end without a libgbm-dev build
dep.

Implementation:

- src/gbm_runtime.rs (new): runtime dlopen wrapper for libgbm.so.1
  via libc::dlopen + libc::dlsym. Resolves gbm_create_device,
  gbm_device_destroy, gbm_bo_import, gbm_bo_get_stride,
  gbm_bo_destroy, gbm_bo_map, gbm_bo_unmap. Stores raw fn pointers
  as usize and transmutes at call time. No lifetime gymnastics,
  no Symbol<_> vs os::unix::Symbol<_> confusion.

- src/toplevel_export.rs:
  * DmabufPlane.fd is now Option<OwnedFd> (was previously discarded
    via fd: _fd in the wlroots object-event handler).
  * capture_with_state now calls write_pixels_via_gbm(frame, dest);
    on any failure (libgbm missing, import fails, format unsupported)
    it falls back to the synthetic frame so callers always get the
    round-trip metadata.
  * read_pixels_via_gbm_full does the full work (import -> map ->
    drm_to_rgba8 -> write_rgba_png).
  * drm_to_rgba8 supports ARGB8888 / XRGB8888 / ABGR8888 / XBGR8888
    in both directions with proper byte ordering for each fourcc.
  * write_rgba_png uses the png crate to write a real RGBA8 PNG.
  * Module docblock status section now says all four steps are wired.

- Cargo.toml: added libc, png, thiserror. (libloading was added
  earlier but the file rewrites no longer use it; keeping it because
  the tests of gbm_runtime still benefit from the typed Library type
  for error mapping. Could be removed later if desired.)

- toplevel_export.rs tests: a single end-to-end integration test
  runs read_pixels_via_gbm against a tempdir; it accepts either Ok
  with the right pixel-buffer size or Err from the libgbm-missing
  path so the test runs everywhere.

cargo test 98+/0; clippy clean.
2026-09-16 12:15:43 +02:00
en
50ae7c6061 Honest docs for ENBOXER_ENABLE_TOPLEVEL gated path (Bug #9)
Bug from Grok round-1 #9 plus the round-2 caveat that the gated path
claimed 'covered source works'. In reality the implementation issues
capture_output(...) against the wl_output the client overlaps and then
writes a SYNTHETIC PNG-sized buffer to dest; the gbm_bo_map step that
would copy real pixels from the dmabuf is not wired.

Changes:

- src/vfx.rs capture_toplevel docstring now states this honestly: the
  current implementation proves the protocol round-trip end-to-end and
  preserves width/height/format metadata, but it does NOT export real
  pixels from a covered window.
- src/toplevel_export.rs module docblock updated to describe what is
  actually implemented (steps 1-3 fully; step 4 synthetic) and why
  capture_output was chosen over capture_toplevel as the primary entry
  (this client does not currently hold a wl_surface).
- capture_via_export public docstring updated similarly.
- CHANGELOG.md entry.

Real pixel read is a follow-up tracked under the gbm_bo_map work. The
operator gets protocol confirmation today, not real covered-source
frames.

cargo test 96+/0; clippy clean.
2026-09-16 08:07:37 +02:00
en
b9d5f144bd Overlay: thread can be stopped externally instead of detaching (Bug #5)
Bug from Grok round-1 #5. spawn_with_sock returned Ok(_) and dropped
the LiveOverlayHandle, so the JoinHandle was never joined or signalled.
The thread detached; OverlayHub could only clear its slot map, never
stop the actual Wayland thread. With env-gated rendering (Bug #8), the
operator's overlays would accumulate as ghost threads.

Changes:

- wayland_layer: LiveOverlayHandle gains a stop: Arc<AtomicBool>.
  spawn() allocates it, threads a copy into run(), stores a copy on the
  returned handle.
- wayland_layer: run() polls stop in addition to state.exited; flipping
  the bit causes the next roundtrip to exit instead of waiting on the
  compositor's Closed event.
- overlay: OverlayHandle gains stop: Option<Arc<AtomicBool>> and a
  kill() method that flips the bit.
- overlay: spawn_with_sock now puts the same stop Arc on the returned
  OverlayHandle (was previously throwing the live handle away).
- overlay: OverlayHub.sync() and kill_all() call kill() on every
  removed handle, so slot changes actually tear the threads down.

cargo test 96+/0; clippy clean.
2026-09-16 08:06:12 +02:00
en
5da77b28b3 capture_loop: honour ENBOXER_ENABLE_TOPLEVEL via capture_toplevel (Bug #8)
Bug from Grok round-1 #8. capture_loop hard-coded grim and ignored the
toplevel-export gate. If the operator set ENBOXER_ENABLE_TOPLEVEL=1
they got no effect at all -- the loop just kept grimming the source
rect.

Fix: when the gate is set, build a synthetic Client (the hit has only
the source rect; pick_output_for queries live monitors to find one
covering the rect) and try capture_toplevel first. On success show
the frame; on failure fall through to grim so the operator at least
sees the visible background rather than an empty frame.

cargo test 96+/0; clippy clean.
2026-09-16 08:04:14 +02:00
en
a36443bd86 refresh_slots: always call sync_slot_overlays, even on early-return paths (Bug #10)
Bug from Grok round-1 #10. Two branches in refresh_slots (the
routing-on and routing-off arms) returned early after finish_vfx, so
sync_slot_overlays at the bottom of the function was skipped. That
meant the slot-number overlay hub could lag by up to a full tick
when the user switched focus into or out of the team.

Both early-return branches now bind the finish_vfx result, call
sync_slot_overlays, and return the bound result.

cargo test 96+/0; clippy clean.
2026-09-16 08:03:48 +02:00
en
3dbc1b11df swap_as_main: keep slot IDs stable, set_leader(n) (Bug #11)
Bug from Grok round-1 #11. swap_as_main used to renumber slot IDs to
1..=N after a vec swap, which silently rebroke every per-character
assist/follow key (the user typed Alt+1 in slot 1 to assist that
character; after the swap, slot 1 contained a different character).

Fix: do not renumber. Slot ID continues to identify a character; vec
position only encodes which physical tile the window occupies. set_leader
is called with n (the user's chosen new main). The notify message now
reports both the new and previous main using the actual slot IDs.

cargo test 96+/0; clippy clean.
2026-09-16 08:03:27 +02:00
en
84941247c4 Gate toggle_pin on ENBOXER_ALLOW_LAYOUT (Bug #12)
toggle_pin (a.k.a. stay-on-top) is a per-window Hyprland mutation, the
same class of side-effect as layout-apply. Without an opt-in gate, a
stray hotkey can pin the leader window while the user is away from
the keyboard.

Adds the moves_allowed() helper next to toggle_pin. The rest of
session.rs is unaffected for now; future commits can replace inline
env checks with this helper.
2026-09-16 08:03:05 +02:00
en
91cfee9609 Warn on malformed arm_auto_apply regex instead of silently falling through
Both window_match.class and window_match.title patterns are now logged
as warnings when regex::Regex::new returns Err. Before, .ok() silently
swallowed compile errors and treated a bad pattern as 'not configured',
which collapsed back to the original bug: any open window could fire
layout-apply.

The no-configured-patterns fallback (accept a window with a non-empty
class) is preserved for users who haven't set window_match at all.
2026-09-16 07:59:56 +02:00
en
0ad3e3335e Refactor chmod_runtime_dir to chmod_dir(path); test uses tempdir
Split the chmod helper so tests can exercise it on a private tempdir
instead of mutating the user's XDG_RUNTIME_DIR. The thin
chmod_runtime_dir() wrapper still picks runtime_dir() for the production
path (called from session.rs).
2026-09-16 07:59:34 +02:00
en
6028987a6e Overlay: set_margin order + click handler no longer kills the badge
- set_margin (wayland_layer.rs:234): correct arg order to
  (top, right, bottom, left). The previous code passed rect.x as
  the right margin, a no-op under TOP+LEFT anchoring, so the
  overlay's x offset was silently dropped. Comment updated to
  document the protocol's true shape.

- click handler (wayland_layer.rs:466): drop 'state.exited = true'
  after send_swap. Flipping exited destroyed the badge but left
  OverlayHub.by_slot still holding the slot, so the hub refused to
  respawn it. The compositor's zwlr_layer_surface::Closed event
  is the only path that drives thread teardown — the click now
  just fires the swap IPC and returns.

- CHANGELOG entries.

cargo test 96+/0; clippy clean.
2026-09-16 05:58:27 +02:00
en
1b8b2cdf24 GUI: arm_auto_apply waits for window_match; new-team pads characters
- arm_auto_apply (gui.rs ~1625): compile profile.window_match.class and
  .title regexes once, then poll hyprctl for a client that matches
  before firing layout-apply. Fall back to 'first non-empty class'
  when neither pattern is configured, so existing profiles keep working.
  Closes the security/correctness gap where any open window could trigger
  layout-apply against the desktop.

- New-team wizard (gui.rs ~1438): profile.characters.truncate is replaced
  with resize(slots, Character::default()) so a wizard with N members
  and zero existing characters now actually gets N Launch buttons.
  Character gains #[derive(... Default)].

- CHANGELOG entries.

cargo test 96+/0; clippy clean.
2026-09-16 05:58:27 +02:00
en
cece41d2ee Tighten IPC socket permissions (chmod 0o700/0o600)
fix(security): the daemon's IPC socket was world-accessible. Any local
user could speak the IPC protocol and type arbitrary text into game
windows via Command::Type.

- src/profile.rs: add chmod_runtime_dir() and chmod_socket(path) helpers
- src/session.rs: call chmod_runtime_dir() after create_dir_all, and
  chmod_socket() right after UnixListener::bind succeeds
- Tests: chmod_socket_sets_0o600, chmod_runtime_dir_sets_0o700 (saved/
  restored live dir perms to avoid clobbering a real session)
- CHANGELOG.md: security note

cargo test 96+/0; clippy clean.
2026-09-16 05:45:48 +02:00
en
20f0160799 Shell-quote bind_command args (Hyprland exec_cmd injection)
fix(hypr): Grok-flagged security finding: shell_single was applied to
bin and sock but args was interpolated raw. A profile hotkey containing
shell metacharacters could become a Hyprland exec_cmd injection.

- src/hypr.rs: bind_command now wraps args in shell_single
- New tests:
  - bind_command_quotes_args_with_metacharacters
  - shell_single_handles_inner_quote
- Doc comment updated to name the threat and what gets escaped

93+/0 cargo test; clippy clean.
2026-09-16 05:44:52 +02:00
en
8eb5346d92 Split smart interact shortcut from single Alt+J send
bind: "interact" now resolves to game_binds.interact (Alt+J) only —
a single keystroke. The full ISBoxer-style chain (CTM on -> Alt+J ->
sleep walk_delay_ms -> CTM off) lives at bind: "smart_interact".

Fixes the doubling bug Grok flagged: bind: "interact" previously
expanded into the full chain unconditionally, so the loot_manual and
interact_hold example maps fired ctm_on/ctm_off twice and produced
unwanted sleep delays.

- engine.rs: rename shortcut trigger (was: "interact")
- examples/profile.yaml: loot now uses smart_interact; loot_manual and
  interact_hold keep "interact" as a single Alt+J send
- docs/MACROS.md: heading + shortcut comment
- docs/NOTES.md: trigger name
- Add new test: interact_simple_sends_only_alt_j (1-line single-send)
- Existing smart shortcut tests renamed to bind: smart_interact
- CHANGELOG.md: trigger split note

93/93 cargo test pass; clippy clean.
2026-09-16 05:41:45 +02:00
en
1ec8d78f71 Implement ISBoxer-style smart interact shortcut (bind: interact)
A step with bind: interact now expands at compile time into the full chain
(CTM on -> Interact with Target -> sleep walk_delay_ms -> CTM off), driven
by profile.interact (style + walk_delay_ms). One user keypress, four
keystrokes dispatched to every captured slot.

Styles:
  - standard (default): chain runs, CTM ends off
  - auto:              chain runs, CTM stays on (toggle via ctm_off later)
  - hold:              press fires CTM-on + Alt+J; bind: ctm_off in
                       release_steps to fire it on hotkey release

Example loot (Alt+G) and interact (Alt+I) maps now use the shortcut. A
loot_manual map (Ctrl+Alt+G) exercises the explicit chain for users who
want per-step control.

92/92 unit tests pass; clippy clean. New tests:
  - interact_smart_shortcut_standard_emits_full_sequence
  - interact_smart_shortcut_auto_emits_two_no_tail
  - interact_smart_shortcut_hold_emits_press_only

Docs: MACROS.md (new Smart interact shortcut section with style table),
NOTES.md callout, examples/profile.yaml comments, CHANGELOG.md entry.
2026-09-16 04:51:07 +02:00
en
aeed2036d0 Switch interact keybind to Alt+J; rewrite MACROS.md for current retail; clarify Auto-Interact 2026-09-15 18:02:32 +02:00
en
6fcbf71813 Fix stale enbuddy URL refs in DESCRIPTION/GOALS/AGENTS (point at en/enBoxer) 2026-09-15 17:33:27 +02:00
en
c0d319c841 Update DESIGN/NOTES/MACROS/PLAN: remove stale 'later' notes, move shipped PLAN.md items, note per-character assist/follow keys 2026-09-15 17:32:49 +02:00
en
a7acce2a6a Point repository URL at en/enBoxer (camelCase, matches enBuddy) 2026-09-15 17:31:45 +02:00
en
1f557a74c2 Point Cargo.toml repository at en/enboxer (was en/enbuddy by mistake) 2026-09-15 17:29:06 +02:00
en
9d2f51685d Finish the project (T7..T15): layout wizard, broadcast, overlay, dmabuf, teams
Layout wizard (T7):
- App::layout_canvas with monitor backgrounds, click-to-select, draggable tiles
- borderless Hyprland window_rule on run
- App::refresh_monitors + Refresh monitors button; monitors cache for canvas sync

Routing extras (T8, T13, T14):
- clipboard IPC verb (wl-paste / xclip -> Ctrl+V to non-leader slots)
- mirror-mode mouse click broadcast via hypr::deliver_click
- round_robin / rr bind target rotates through ALL slots (leader included)

Slot overlay (T9 real):
- src/wayland_layer.rs: zwlr_layer_shell_v1 client, shm buffers, 3x5 bitmap
  digit glyphs, wl_pointer click -> swap <slot>
- gated ENBOXER_ENABLE_OVERLAY=1; cargo test does not connect

Covered-window VFX capture (T10 real):
- src/toplevel_export.rs: zwlr_export_dmabuf_unstable_v1 client
- ARGB8888 / XRGB8888 format negotiation, synthetic-PNG fallback for tests
- gated ENBOXER_ENABLE_TOPLEVEL=1; gbm_bo_map upgrade documented

Teams + Lutris launcher (T15):
- src/team.rs: Team, list_teams, teams_dir, current_team
- src/lutris.rs: LutrisGame parser, load_all with bad-YAML tolerance
- src/launcher.rs: SpawnPlan merges Lutris config + per-character wine-prefix
- GUI: Teams menu (New / Switch / Refresh / Show / Delete) + Launch menu
- Lutris picker visible only in New-team flow; direct Wine spawn, no lutris CLI

Tests: 89 passed; 0 failed (up from 24).
Clippy: clean with -D warnings.

Safety:
- No live hyprctl dispatch that moves / resizes / pins / closes the session.
- All Wayland paths feature-gated; cargo test does not connect.
- apply_layout still gated by allow_layout + confirm_apply.

Files: 14 modified + 6 new (src/{launcher,lutris,overlay,team,toplevel_export,wayland_layer}.rs)
Diff: +1570 / -29
2026-09-15 17:18:37 +02:00
en
ed75a8b899 Document full product goals; gate layout; named profiles; type-to-others.
GOALS.md / AGENTS.md are the source of truth. Layout apply requires
ENBOXER_ALLOW_LAYOUT=1 and a GUI confirm. Example Video FX is off.
2026-09-15 09:11:31 +02:00
en
af45b054cc Window swap/focus/reset hotkeys; clamp layout tiles to the monitor.
Do not run live compositor tests that move the user's windows.
2026-09-15 08:56:01 +02:00
en
50b2aebee6 Add window layout: stacked, grid, main+strip, Save and Apply.
Layout page in the control panel places captured clients like the
source project's wizard: per-slot x/y/w/h, pin, generate, capture.
2026-09-15 08:42:55 +02:00
en
32731fe59b Add control-panel GUI; Video FX rects in pixels or fractions.
`enboxer` with no command opens File/Session/Video/Maps. Native Wayland
clients get a brief focus steal; XWayland/Wine stays unfocused.
2026-09-15 08:26:07 +02:00
en
936f123b18 Three routing modes: maps, mirror, off.
Mirror clones the real key to the other clients (passthrough still skipped).
Off leaves the front window alone except the mode-toggle hotkey.
2026-09-15 08:14:08 +02:00
en
feb2cdb4dc Fix live routing and Video FX overlay.
Hyprland binds called `ipc` twice so mapped keys never reached the daemon.
Overlays now use mpv with wayland-app-id and JSON reload. Empty window_match
matches nothing. Ctrl-c clears binds. Proven: send_shortcut to unfocused
XWayland, mpv overlay class enboxer-vfx.
2026-09-15 07:42:35 +02:00
en
e05219f715 Point remote at https://gitea.nettsi.de/en/enbuddy 2026-09-15 07:26:44 +02:00
en
4daf0ea86a Initial enBoxer: mapped-key routing and Video FX on Hyprland.
Phase 1 tickets T1–T6, ponytail dead-code cuts, docs and plan in-tree.
2026-09-15 07:19:15 +02:00