11 Commits
Author SHA1 Message Date
topkoa 7212ce70a8 build: bundle feedBack-plugin-stem-splitter
Adds the Stem Splitter plugin to clone_slopsmith()'s bundled-plugin
list so packaged builds ship it. The repo uses the feedBack-plugin-*
casing (capital B), so the lowercase prefix strip can't derive the
dirname — explicit :stem_splitter, matching the plugin id, same as
rig_builder.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-18 21:26:30 -04:00
topkoa 47fe1492a6 fix(panes): a minimized pane window was unrecoverable — remove the button
Reported in testing: pop out the stem mixer, minimize it, and it is gone. Not
hidden — gone. It appears in no taskbar, no alt-tab list, nothing.

The chain:

  - A pane window is skipTaskbar, so it never masquerades as a second fee[dB]ack.
  - It is parented to the main window (so it can't go behind the app), which makes
    it an OWNED window — and Windows will not give an owned window a taskbar
    button in any case.
  - On minimize we then hid it "to the tray".

So a minimized pane had no taskbar entry and no alt-tab entry, and the only route
back was the tray — which Windows tucks behind the overflow chevron by default.
"It's in the tray" is not an answer when the user cannot see the tray.

The window is no longer minimizable. Every remaining way to put a pane away is one
the user can undo from something visible:

  - close it        → the panel returns to the app, where it came from
  - the tray        → per-pane toggle, plus Show/Hide all panes
  - the chip's stub → in the app, exactly where the panel used to be

Plus a restore() on 'minimize' in case anything else (a window manager, a
shortcut, a future code path) minimizes it anyway.

This is the same class of mistake as hiding the panel behind the pop-out chip: a
place to put something, with no way back that the user can find.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 22:36:53 -04:00
topkoa 0878ae2682 feat(panes): a pane window can never go behind the app
A pane is a control surface for the thing you are looking at. Clicking the
highway to play — the single most common thing anyone does here — raises the
main window, and a plain sibling window slides straight behind it. You would
fish the mixer back out from behind the game every time you touched the game.
That is not a pop-out, it is a hiding place.

Parent each pane window to the main window. That is the precise amount of "in
front": the pane always floats above fee[dB]ack, and behaves like any other
window against everything else.

Deliberately NOT setAlwaysOnTop. That would put a pane above the user's browser
and editor too — a surprising thing to inflict on someone for opening a mixer.
alwaysOnTop still composes on top of this for a pane the user explicitly wants
above everything.

The trade, accepted: the OS ties parent and child together, so minimizing
fee[dB]ack hides its panes and restoring brings them back. That is what a
companion window should do.

Failure is non-fatal — a pane that can be buried is still a working pane.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 22:32:30 -04:00
topkoa 0cde745f03 fix(panes): let toggleWindow be the authority, not a stale hasWindow() check
The tray menu asked "do we own a window for this pane?" and then acted on the
answer. Windows are destroyed asynchronously, so between the question and the
act the answer can go stale: hasWindow() says yes, the window is destroyed,
toggleWindow() returns false, the handler has already committed to the
main-process path and returns — and the click lands on nothing.

A tray item that silently does nothing is the worst possible failure here,
because the tray IS the recovery path when a pane is out of sight.

toggleWindow() already reports whether it did anything. Use that: if it toggled,
we're done; if it didn't, we never had that window (or just lost it), and only
the renderer can decide what opening the pane means — it might belong in the
dock, and its element lives there.

hasWindow()/hasPaneWindow() existed only to support the racy check, so they're
gone rather than left lying around for someone to reintroduce the race with.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 21:30:38 -04:00
topkoa 43c2e41c07 fix(panes): a corrupt config entry must not take down the main process
savedFor() assumed paneWindows[paneId] was an object. It isn't necessarily:
the desktop config is a JSON file on disk, hand-editable, and writable by any
build. `{"camera_director": null}` passes the own-property check and then throws
on `saved.bounds`.

This runs in the MAIN process, at window-adoption time. A TypeError there is not
a bad pane — it is the app failing.

Anything that isn't a plain object now degrades to "nothing saved", which is
exactly what an unreadable entry means. Same guard on `paneWindows` itself (a
string or an array would have got past `?? {}`).

persist() gets the same treatment, and for a sharper reason: it read the map,
copied every entry forward, and wrote it back. A corrupt entry would have been
faithfully preserved on every save — so a single hand-edit would keep crashing
the next launch, forever. Corrupt entries are now dropped rather than carried.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 21:24:04 -04:00
topkoa 030559dd38 fix(panes): only the main window may drive the tray; clamp fallback defaults
1. pane:sync was accepted from ANY renderer with the preload bridge. Pane
   windows are same-origin top-level frames, so preload's isMainFrame gate hands
   them the bridge too — which means a pane window (or any allowed pop-up) could
   send pane:sync and overwrite the tray's registry, most simply by pushing an
   empty list and emptying the menu.

   Exactly one renderer owns the pane registry. It is now accepted from that one
   only: event.sender must be the main window's webContents.

2. sanitizeWindowBounds returned sizing.defaultWidth/Height unclamped. The min
   clamp only runs when `saved` parses, so a caller whose defaults undercut its
   own minimums would get a window below the floor on precisely the paths where
   nothing is saved — first launch, or a corrupt config — and a correctly sized
   one everywhere else.

   That is the worst shape a bug can have: invisible in the common case, and
   visible only to a new user. The fallback is clamped to the floor now, with a
   test.

window-bounds: 14/14.
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 21:18:05 -04:00
topkoa 313d1e5d5b fix(panes): persist alwaysOnTop, flush geometry on quit, test the sizing override
Five more from review.

1. alwaysOnTop was RESTORED but never SAVED. A dead read: the only way to turn
   it on was to hand-edit the config file. There is now one snapshot() that
   decides what "remembered" means, used by the debounced save, the flush on
   close, and the flush on quit — so the three can never disagree about it again.

2. GEOMETRY WAS LOST ON QUIT. closeAllPanes() calls destroy(), and destroy()
   does not fire 'close' — so the flush wired to that event never ran on the one
   path every user takes. Combined with the 400ms debounce: move a pane, quit two
   seconds later, and its position was gone. Every pane is flushed before its
   window is destroyed.

3. The tray-icon comment claimed a 16px PNG; build:ts copies the 32px one. Also
   spelled out what the macOS consequence actually is (a colour icon rather than
   one that adapts to light/dark menu bars), rather than gesturing at it.

4. sanitizeWindowBounds' new `sizing` parameter had no tests — and it is the
   whole reason the function was touched. Without it a 380x560 pane restored
   through the MAIN window's 800x600 floor is silently inflated to three times
   the size the plugin asked for. Four tests now pin it: a small pane is not
   inflated, the min clamp uses the override, corrupt input falls back to the
   override's defaults, and — the one that protects everyone else — omitting
   `sizing` leaves the main window's behaviour byte-for-byte unchanged.

window-bounds tests: 13/13.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 21:12:04 -04:00
topkoa 3d9e8481ea fix(panes): break the tray/host import cycle; debounce saves; restore before show
Five findings from CodeRabbit on #103.

1. CIRCULAR IMPORT. pane-tray imported pane-hosts while pane-hosts imported
   pane-tray. In the main process that is not a style question: whichever module
   loses the load race sees the other's exports half-initialised, and it fails at
   whatever moment the graph happens to resolve in — which is to say, not on your
   machine.

   One direction only now: pane-hosts → pane-tray. What the tray needs from the
   host (toggle/showAll/hideAll/hasWindow/getMainWindow) is INJECTED through
   initTray(), wired in main.ts.

2. SYNCHRONOUS DISK WRITES ON EVERY DRAG FRAME. `save()` was wired straight to
   'moved'/'resized', and setDesktopConfig is writeFileSync + renameSync. On
   macOS that is dozens of blocking writes per second, in the main process,
   while the user drags.

   Debounced to 400ms — and then flushed on 'close', because a debounce that
   drops the last move is worse than no debounce: nudge a pane, close it a moment
   later, and you would lose the position you just chose, which is the exact thing
   remembered geometry exists to prevent. 'close' (not 'closed') because the
   window has to still exist to be measured.

3. A MINIMIZED PANE COULD NOT BE BROUGHT BACK. Panes go to the tray by being
   minimized and then hidden — and hiding a minimized window does not un-minimize
   it. So show() from the tray restored a window that was still minimized:
   present, but not on screen. Which reads as the tray being broken. Everything
   now goes through reveal(), which restores first.

4. PROTOTYPE POLLUTION VIA PANE ID. A pane id arrives from the RENDERER (it is
   the tail of the frame name window.open() supplied) and is used as a KEY in the
   persisted paneWindows map. `__proto__` is not an id, it is a way to mutate
   Object.prototype from a plugin. Rejected on write, the map is rebuilt on a
   null-prototype object, and reads are own-property checks — otherwise a polluted
   or hand-edited config hands back geometry for a pane that was never saved.

5. Removed getMainWindowRef(), exported and referenced nowhere.

Not applicable: the finding about req.width/req.height/req.title reaching
BrowserWindow unvalidated. That was the `pane:open` IPC, which no longer exists —
main does not create pane windows at all now (the renderer must, so it can adopt
its element into them). The sizes now come from the window Electron already made.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 21:05:11 -04:00
topkoa 938ddded60 feat(panes)!: dress the renderer's pane window, don't create it
Follows the core change: a pane is now the plugin's REAL panel element,
moved into the pop-out window and still running the plugin's own code
(got-feedback/feedback#928).

That forces one thing here, and it is worth being loud about it:

  WE MUST NOT CREATE THE PANE WINDOW.

To move a live DOM node into another window, the renderer needs a handle on
that window's document. A BrowserWindow we construct in the main process
gives it no such handle. So the renderer opens the window itself with
window.open(), Electron's setWindowOpenHandler turns that into a real
BrowserWindow anyway, and we recognise it in did-create-window by the frame
name the renderer gave it (`fbpane-<paneId>`) and attach the OS behaviour:
remembered bounds, off the taskbar, minimize-to-tray, listed in the tray.

Create the window here instead and the whole feature collapses back into
"reimplement the panel in the pop-out and sync it over IPC" — which is
exactly what we just deleted.

The IPC surface shrinks to two channels, because main never creates or
destroys a pane window and never looks inside one:

  pane:sync    renderer → main   the registry, so the tray can list panes
  pane:toggle  main → renderer   the tray asking for a pane; only the
                                 renderer knows what opening one means (it
                                 might belong in the dock, and its element
                                 lives there)

Gone: pane:open, pane:close, pane:focus, pane:setAlwaysOnTop, pane:closed.
The renderer holds the WindowProxy for a window it opened, so it already
knows when the user closes it — and it has to, because its element is inside
and must be brought home.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 18:14:49 -04:00
topkoa 39251f3d12 feat(panes): pane pop-out windows + the system tray
feedBack core gained a pane system (window.feedBack.panes): live UI — a
mixer, a camera rig, a readout — authored once and hostable anywhere. In
a browser it pops out via window.open(). This gives it the desktop
treatment: a real BrowserWindow that remembers where you put it, can
float above everything, and lives in the system tray.

First Tray in the app. It exists because a popped-out pane is furniture:
you want it out of the way while you play and back instantly when you
don't — not hunted for behind the main window, and not cluttering the
taskbar. Minimizing a pane sends it to the tray; the tray menu lists
every pane with a checkmark and toggles it.

## The renderer owns the truth

Main never looks inside a pane. It owns OS surfaces only — windows and
their geometry — and learns what panes exist from a `pane:sync` push. The
tray menu is a VIEW of the renderer's registry, not a second copy of it.
When the tray toggles a pane it has no window for, it asks the renderer,
because only the renderer knows what opening one means (it might belong
in the dock).

## The pane window loads OUR origin, and that is load-bearing

A pane is fed over BroadcastChannel, which only reaches windows in the
same Chromium instance and origin. Push the URL anywhere else and the
pane opens looking perfect and never updates again. So `pane:open`
validates the URL against the same origin predicate the navigation guards
use (makeRendererOriginPredicate) and refuses anything else outright —
which also means we can never open arbitrary web content with the full
preload bridge attached. It is the same reason main.ts's
setWindowOpenHandler answers same-origin URLs with `allow` rather than
`deny` + openExternal.

## Details that bite

- sanitizeWindowBounds hard-floored at the MAIN window's 800x600. A 380x560
  pane restored through it would be silently inflated threefold. It now takes
  a WindowSizing; the main window passes its old values as the default, so
  every existing call site and the existing test are byte-for-byte unchanged.
- Pane geometry lives in the DESKTOP config, not the renderer's localStorage
  — localStorage is shared with the pane windows themselves (same origin), so
  a second writer there would race. setDesktopConfig merges shallowly, so
  paneWindows is read-modify-written or one pane's save would drop the rest.
- Pane windows are destroyed when the main window closes. Without the
  renderer there is nothing on the other end of their channel, so they would
  sit showing a frozen playhead forever — and a pane HIDDEN in the tray is
  still an open window, which would stop `window-all-closed` from ever firing
  and leave the app running as an invisible process.
- Geometry is persisted on move/resize, not only on close: a pane window can
  outlive the app in a crash, and the entire point is that you never place it
  twice.
- Electron's 'minimize' is not cancellable here (the listener takes no event),
  so a pane hides right after minimizing rather than preventing it. The window
  is skipTaskbar, so there is no animation to see.
- The tray icon is copied to dist/main/ by build:ts, the same trick
  splash.html and spinner.json use — so __dirname resolves it identically in
  dev and inside a packaged asar, with no app.isPackaged branch and nothing
  added to electron-builder's extraResources. An unreadable icon logs and
  skips the tray rather than creating an invisible one whose menu no one can
  ever reach.

Needs the matching core change (got-feedback/feedback#928), which registers
the `desktop` host when this bridge is present and falls back to a browser
pop-up when it isn't. An older core simply never calls these channels.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 17:22:44 -04:00
topkoaandClaude Opus 4.8 9d424ece9d Rebrand splash/loading dialog Slopsmith -> fee[dB]ack
The startup splash window and its initial status message still showed
the old "Slopsmith" name. Update the brand label, the static status
line, and the JS fallback in splash.html, plus the main-process startup
status snapshot that overrides it, to the new "fee[dB]ack" branding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-06-19 08:56:26 -04:00