The win7 skin is frameless so 7.css can draw the Aero caption, and on GTK that means undecorated, which takes the window manager's resize borders and corner grips with it. All three win7 windows were completely unresizable. electrobun 1.18.1 offers nothing to re-enable, verified against the shipped binary rather than assumed: libNativeWrapper exports startWindowMove/ stopWindowMove with no resize counterpart, imports gtk_window_begin_move_drag but not gtk_window_begin_resize_drag, dragRegions.ts implements moving only, and BrowserWindow has neither setResizable nor min-size options. But the window is still programmatically resizable, so only the input affordance is missing: eight handles overlay the frame and drag it through a new windowGetFrame/windowSetFrame pair on the shared windowControlHandlers factory, which gives all three window kinds resize with no per-window wiring. Three things that would have made this quietly wrong: - dragRegions.ts starts a window MOVE from a mousedown listener on document, never checks defaultPrevented, and is not suppressed by a pointerdown handler either (the compatibility mousedown still fires). Every handle carries app-region-no-drag, which short-circuits its check first. - deltas come from screenX, not clientX: a north/west resize moves the window origin under a stationary pointer, so client coords feed that back into the next delta and the window runs away. - the min-size clamp re-anchors the opposite edge; clamping width alone lets x keep tracking the pointer, walking the window off-screen while it looks frozen. This is a stopgap and the point is that it is removable on sight. The frame math is a dependency-free module so scripts/resize-math-probe.ts asserts all eight edges without a window (verified it catches the unanchored-clamp bug: x jumps to 5100 instead of parking at 460). scripts/check-electrobun-resize.ts scans the installed electrobun for the primitives this is waiting on and prints the removal checklist when they appear; it runs from postinstall so a version bump surfaces it, never fails an install, and is silent otherwise. Both trigger paths were simulated. The component header carries the full checklist; docs cover the platform consequence and the dragRegions traps. Harness diffs exactly 0 in both themes; hover and menu probes still clean. Not yet exercised with a real pointer — that needs a live window. Co-Authored-By: Claude <noreply@anthropic.com>
27 KiB
27 KiB
Gotchas — landmines this codebase has already stepped on
Every one of these caused a real bug or debugging spiral. Read before touching the adjacent code.
Angular (v22, zoneless, signals, OnPush)
- Never consume push streams through signal+effect. An effect that reads a signal and (directly or transitively) writes signals re-triggers itself — this froze the entire app on the first transfer. Push messages (transferUpdate etc.) go through plain callback registries in RpcService. Signal coalescing would also silently drop intermediate progress snapshots.
- No signal writes during template render (NG0600). Anything that loads
panels from code paths reachable during render defers with
queueMicrotask(seePanelsService.newWorkspace). [value]on<select>is applied before@foroptions render and silently selects nothing. Use per-option[selected]bindings (connection manager stack picker).- Template bindings with special characters:
[class.bg-primary/15]works;[class.shadow-[...]](brackets) does NOT parse — use[style.box-shadow]. Static class attributes may use arbitrary values freely. - Functions called from templates ARE reactive: signals read inside them
register on the view's consumer (call-stack-based tracking). That's why
iconClass(entry)reacting tosettings.iconColors()works. - Dedicated tools > shell: IDE diagnostics between paired template/class
edits are often stale mid-sequence — trust
bun run build:ui(full AOT type-check), not the first red squiggle. - Tailwind v4 canonical class names:
wrap-break-wordnotbreak-words,accent-primarynotaccent-[var(--primary)],z-60notz-[60]. Every color used in a template must resolve through@theme inline. - The win7 skin layer outranks Tailwind utilities. 7.css is imported
layer(skin), ordered aboveutilities, so any utility you add to abutton/input/select/textarea/tableis silently dead under that skin. If it matters, neutralize in the@layer skinblock inapp/skins/7like/7like.css— and noterevert-layeronly reachesutilitiesfrom insideskin; used fromskinfixit falls back onto 7.css and is a no-op. This bit once already. @import … layer(name)works through@tailwindcss/postcss(it wraps the imported AST), but the deep path is mandatory: 7.css ships noexportsmap and Tailwind's resolver reads onlystyle/mainFields, so a bare@import "7.css"resolves to nothing. Use7.css/dist/7.scoped.css.- Stylesheet
url()must resolve at BUILD time: assets referenced fromstyles.csslive underui/src/(esbuild emits them hashed intomedia/and rewrites the url), NOTui/public/— a public path fails the build with "Could not resolve". - Two
classattributes on one element: the second is silently dropped. No warning, no build error — the layout classes simply never apply. This collapsed the tab strip and two window headers at once (fe028a9), because a scripted edit appendedclass="…"to elements that already had one. When removing a conditional binding, check what the branch contained: the bindings there were genuinely dead, but the layout classes inside them were not, and nothing replaced them. - A component
template:is a TS template literal, so no backticks inside it — not even in an HTML comment. A backtick in a comment terminates the literal and the errors point at the prose, not at the quoting.
The interface scale (root font-size lever)
- electrobun's native page zoom is a silent no-op on Linux — do not reach for
it.
webviewSetPageZoomin the shippedlibNativeWrapper.so(v1.18.1, and the_cef.so, and the copy in our own build output) is a compiled empty-body stub: the disassembly has nocallinstruction, andwebkit_web_view_set_zoom_levelis not even in the library's imports.webviewGetPageZoomreturns a hardcoded1.0from.rodata, so the failure is unobservable through the API — set 2.0, read back "1.0 = success". The JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS. - Everything the UI sizes with must be rem. The scale works by setting the
root font-size, so a px value freezes while everything around it grows. The
three px reservoirs were: the panel-chrome tokens (
--row-hetc.), 93text-[11px]arbitraries, and 7.css itself — 370 px values, zero rem, with--w7-fontreaching only 15 rules' typography. 7like.css'sskinfixcarries a scale bridge re-expressing 7.css's vars and hardcoded box metrics in rem. 1px hairlines and focus rects stay px deliberately (crisp at any scale; thetrborder exists to stop selection shifting rows). - Restating a 7.css rule in
skinfixalso outranks 7.css's VARIANTS of that rule — by layer, regardless of specificity. The title-bar-controls restatement silently shrank the close button from 48px to 29px (7.css widens it viabutton[aria-label=Close]), and the tablist padding flattened the active tab's 4px raise. Caught only by the harness pixel diff. When bridging a rule, grep 7.scoped.css for every variant that overrides the same properties and restate those too. - px constants in TS that mirror rem-sized CSS are latent scale bugs. The
shadlike context menu's
MENU_W = 224was a hardcoded mirror ofw-56(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through@core/metricsremPx()at use time — it reads the LIVE root font-size, and per call, since the scale changes mid-session. The editor's canvas-basedcharWidthis cached keyed on the computed font string for the same reason.
Hover states and the frame edge
- A child's box-shadow is NOT clipped by its parent's border or radius. The
close button's hover glow (7.css:
0 0 7px 3px #e68e75on a::before) painted across the window's top border and onto the desktop beyond it — the caption buttons sit flush against the top edge (border-top: 0), and the frame's rounded transparent corners clip nothing. Fixed withclip-path: inset(0 0 -1rem 0)on.w7-frame > .title-bar: glow stops at the frame like the real DWM, bottom bleed (authentic) stays open. Note clip-path also clips hit-testing — safe here because the drag region is the title-bar's own box, entirely inside the clip. - Hover states CAN be tested headlessly:
scripts/hover-probe.tscopies the built stylesheet with:hoverrewritten to.simhover, renders the frame markup with the state forced, and pixel-scans artifact regions, exiting non-zero on hover-introduced red outside the frame. Firefox--screenshothas no pointer andimport -windowcaptures the real pointer's state, so the class rewrite is the only programmatic hover on this machine. Re-run it after touching title-bar or hover styling.
The two skin trees (ui/src/app/skins/)
.win7is not a name we may change.7.scoped.cssis precompiled with every selector prefixed by that literal class, so the skin id inAppSettingsstays"win7"even though its folder is7like/. The folder names are cosmetic; the class and the persisted setting value are not.- Reach
core/through the@core/*alias, never a relative path. Skin components sit at varying depths and get moved; the alias is what let 67 imports survive two relocations untouched. A../../core/...path is a future merge conflict. @font-faceurl()resolves relative to the file that declares it. When the win7 rules moved three directories deeper,./fonts/...would silently have 404'd — build still green, app just renders a fallback font. The fonts therefore live inside the skin that uses them.- The
@layerstatement must stay the first statement inui/src/styles.css. It is one of only two rule types allowed above@import, and it is what sortsskinaboveutilities. Every@import(Tailwind and both skin sheets) must also precede all other rules. - Duplicate selectors across skins are safe.
app-file-panelexists in both trees; standalone components resolve through their importer'simports:array, never a global registry, and the trees are never mounted together. - The build DOES type-check components nothing imports. ngtsc compiles the
whole program, so a skin that is currently unreachable still fails
bun run build:uion a type or template error. Verified deliberately by injecting an error into an unreferenced file — worth knowing before assuming a green build proves nothing about the inactive skin.
The win7 skin (7.css)
- Unlayered declarations beat every
@layer, regardless of specificity.html, body { background: … }in styles.css is unlayered, so noskinfixrule can override it — the win7 transparent-corner reset has to be unlayered too. The frame's rounded corners only read as rounded because nothing paints an opaque full-bleed ground behind them; anything that does fills the corners and the window becomes a rectangle with a curve drawn on it. - 7.css restates
border-colorat (0,3,1) via.win7 button:not(:disabled):not(:hover)and its:hovertwin. A two-class selector like.win7 .w7-flat(0,2,0) loses to it, so paint suppression for flat buttons lives inskinfixwhere layer order settles it. The.w7-toolbararm only ever won by tying on specificity and landing later in the same layer — an accidentw7-flatdid not share, which is why it rendered a bordered slab while its neighbours went flat. overflow-x: autoalso clips vertically. CSS refuses to pairvisiblewith a non-visible axis, so the computedoverflow-ybecomesautotoo. 7.css raises the active tab withmargin-top: -2px; inside the horizontally scrolling tab strip that put its top border outside the scroll box and deleted it silently. The strip reservespadding-top: 2pxto compensate — mirroring thepadding-left: 3px7.css already reserves for the same tab's-3pxleft margin.- 7.css hardcodes light-mode literals outside its own var system —
color: #222on tablist buttons, black/white bevels in the ListView and menu. Each one has to be routed through a--w7-*token declared in:root.win7and overridden in both dark blocks (the[data-theme]attribute one and theprefers-color-schemeone; system dark sets no attribute). - The skin layer can also override
position, and that breaks LAYOUT, not just paint.ul[role=menu]carriesposition: relativein 7.css (for the gutter stripe's::beforeand submenu anchoring), which outranked Tailwind's.fixedon the app's own context menu.[style.left/top.px]then became flow offsets from the menu's static position — the last child of theh-screenframe — so every right-click menu rendered below the viewport, unreachable behindoverflow: hidden. The backdrop is a plain<div>, which 7.css does not match, so it stayed genuinely fixed and ate the next click: the symptom was "right-click does nothing, then the app ignores me once". Fixed with aposition: fixedrestatement inskinfix, scoped to aw7-ctxmarker class so 7.css's own menus keep theirs. Regressed in0ae2a62, when the menu root became a realul[role=menu]— i.e. adopting the framework's markup is what exposed it. A resting pixel diff cannot catch this class of bug: nothing about the idle window changes.scripts/menu-probe.tsrenders the menu at declared coordinates and scans whether it landed there, with a marker-less control menu as its own discriminator. - 7.css's button effects reach anything that IS a button — including menu
items — and one of them fades out over a FULL SECOND.
button::beforeis an Aero glow overlay withopacity: 0, raised to 1 on hover, andbutton:not(:hover):beforecarriestransition: opacity 1s linear. menu.css suppresses it withcontent: noneon the:hoverarm only, so while an item is hovered nothing paints, but the moment the pointer leaves, the pseudo returns with opacity animating 1 → 0: a blue ghost trailing the cursor down the menu. Killingcontentin every state is the fix — suppressing the transition alone leaves a glow that simply snaps. Two more from the same family:button:focusrunspulse-animinfinitely (1s ease infinite alternate), which matters because the context menu moves REAL focus between items for keyboard nav, and the hover paint is 7.css's translucent gradient wash — the same one the ListView selection dropped. All three are overridden together, scoped tow7-ctx. - Its selectors are exact, and a near-miss looks exactly like "7.css doesn't
support this".
menu[role=tablist] buttonis a DESCENDANT selector, so every button inside the strip becomes a tab — the+and the right-hand tools must live outside it..window.glass > .title-baris direct-child, so an inner wrapper breaks the glass coupling. A wrong guess here already produced one hand-written lookalike that was deleted.
Electrobun
- Window
closeis not cancellable (response type{}; only the app-levelbefore-quitsupportsevent.response = {allow:false}). All unsaved-state guards must either intercept app-controlled close paths (⌘W, buttons) or survive the close (editor draft mirroring). - One rpc instance per window — BrowserView binding steals the rpc's transport; a shared instance silently sends to the wrong webview.
- RPC before socket open is silently lost. Everything gates on the
socket-open promise (RpcService
ready). - The emitter has no
off(). Window event closures must not capture records you want garbage collected. electrobun builddefaults to the dev channel; release builds need--env=stable. The stable app name differs from the dev one (miniSCPvsminiSCP-dev), including the app-data dir implications when testing.- Tray: never call
setImageafter construction on macOS — the template treatment is lost and the icon renders black on dark menu bars. ApplicationMenuis macOS-only in our usage; accelerators on Windows are single-character only.ContextMenu.showContextMenuis a silent no-op on Linux (electrobun's owndist/api/bun/proc/linux.mdsays so; the app also prints "Application menus are not supported on Linux" at boot). Because the UI suppresses the webview's own right-click menu, that left EVERY panel context menu dead on Linux — no error, nothing happens. The menu is plain HTML now (ui/src/app/skins/*/context-menu.ts) on every platform; don't reintroduce the native one for "nativeness" on mac/win without keeping a Linux path.
Linux
ERR_DLOPEN_FAILEDonbun run startmeans a missing system library, not broken app code.libNativeWrapper.solinkslibayatana-appindicator3.so.1hard (for the tray), so the app cannot start at all without it — even though the tray is a minor feature. Diagnose withldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'; thelibasar.so => not foundline in that output is a red herring (it ships beside the wrapper and resolves at load). Install list per distro is in platform-notes.md.- Black window on NVIDIA +
Failed to create GBM buffer of size WxH: Invalid argument(usually withX11 Error: GLXBadWindow (code 170)). The app boots, logs "miniSCP started", reports no error — and paints nothing. WebKitGTK's DMABUF renderer can't allocate buffers on the NVIDIA proprietary driver;WEBKIT_DISABLE_DMABUF_RENDERER=1is the upstream workaround.- The app cannot apply this to itself. WebKit reads the variable when it
forks its web process, and Bun's
process.envwrites do not propagate to child processes (verified: a spawned child sees it unset). Setting it insrc/bun/index.ts— even in a module imported beforeelectrobun/bun— does nothing. It must be in the environment before the app process starts. - Hence every launch path injects it:
scripts/linux-gpu.ts(launchEnv()) forbun run dev/bun run start, and theminiSCP.shwrapper thatrelease.tswrites besidebin/launcherin the tarball. A new launch path must calllaunchEnv()or it will render black on NVIDIA. - Gated on
/proc/driver/nvidiaso Intel/AMD keep hardware compositing, and an explicitWEBKIT_DISABLE_DMABUF_RENDERERfrom the user always wins. - A "does it boot?" log check does NOT catch this — grep for the GBM line, or look at the window.
- The app cannot apply this to itself. WebKit reads the variable when it
forks its web process, and Bun's
bun run startleaves the old instance running, andimport -window miniSCPgrabs the OLDEST match. Instances pile up across rebuilds, so every screenshot silently comes from a stale build and the fix you just made looks like it did nothing. Cost three wrong readings in one session, including a "the CSS is in the bundle but does not apply" dead end. Kill first (pkill -f dev-linux-x64), confirm withpgrep -cf 'dev-linux-x64/miniSCP-dev/bin/launcher', then launch and capture. Notepkill -f 'electrobun dev'also matches the shell running it and kills your own command (exit 144) — match on the build path instead. That is only half the trap:pkill -f dev-linux-x64does the same thing, because the pattern appears in the killing command's OWN argv. Break the literal with a character class —pkill -f 'dev-linux-x[6]4'— the wayps | grep [x]yzhas always worked.- A frameless window cannot be resized, and
preventDefaultwill not save you from the drag region. Undecorating the GTK window (whattitleBarStyle: "hidden"does) takes the WM's resize borders with it, and electrobun exposes no resize gesture — hence the synthesized handles inui/src/app/skins/7like/window-resize/(a STOPGAP; that file carries the removal checklist, andscripts/check-electrobun-resize.tsfires frompostinstallwhen upstream makes it deletable). Three traps live in that code, all verified againstdist/api/bun/preload/dragRegions.ts:- It starts a window move from a
mousedownlistener ondocument, in the bubble phase. It never readsdefaultPrevented, soevent.preventDefault()does nothing to stop it. - A
pointerdownhandler does not stop it either: the compatibilitymousedownstill fires afterwards, and stopping propagation on a pointer event has no effect on a mouse event. This is the trap that looks fixed in every test until the window jumps. isAppRegionDragchecks.electrobun-webkit-app-region-no-dragfirst and short-circuits, so that class — on the handle itself — is the only reliable suppressor. Without it, every resize drag also drags the window. Also: deltas must come fromscreenX/screenY. North/west resizes move the window's origin, which moves the webview's client origin under a stationary pointer, soclientXfeeds that movement back into the next delta and the window runs away. And the minimum-size clamp must re-anchor the opposite edge — clamping the width alone letsxkeep tracking the pointer, walking the window off-screen while it looks frozen (scripts/resize-math-probe.tsasserts exactly that, and was verified to catch it).
- It starts a window move from a
- Prefer the headless probes over launching the app.
bun run startopens a real window that takes focus, which interrupts whatever the user is doing; there is no Xvfb/xvfb-run on this machine and Wayland offers no launch-without-focus.scripts/shot.ts(+--dark),hover-probe.tsandmenu-probe.tsall render through headless Firefox and open nothing. Launch the real app only for what Gecko genuinely cannot answer — WebKitGTK behaviour, the native frame, transparency, live RPC — ask first, and batch every live check into a single launch. - X11 capture cannot answer "is this pixel transparent?" for the frameless
window.
import -windowgrabs the window's own pixels with no compositing, so a transparent corner and an opaque black one both come out black;png32:just fills alpha with 1 and proves nothing. XWayland's root window is not the composited desktop, soimport -window rootfails too. Frame transparency and the maximized corners need a human looking at the screen — say so rather than reporting a screenshot as confirmation. - Benign startup noise on Linux — non-fatal, the app runs normally:
libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib(upstream electrobun's choice, not ours). - Window-open flash prevention is three-layer — remove any one and the
white/inverted blink returns: (1) windows are created
hidden: trueand revealed byshowWhenReadyon the webview's firstdom-ready(800 ms fallback), (2) the bun preload bakes the persisteddata-theme/tone/accent attributes before first paint (buildPreload), (3)ui/src/index.htmlcarries an inline<style>with the html background per theme — its oklch values MUST stay in sync with the--backgroundtokens in styles.css. The win7 skin adds a fourth value to keep in sync:buildPreloadalso bakes the.win7class, and index.html carries a matchinghtml.win7arm for all four theme states. Miss it and every win7 window flashes white. - The skin is a LAUNCH-TIME choice. Never apply one to a running window.
This shipped broken once:
SettingsService.applySkintoggled the.win7class the moment the setting changed, which was harmless when there was one component tree and became a broken window as soon as there were two — 7.css painted onto shadlike markup that has no.window.glass, no.title-bar, nomenu[role=tablist]and no<table>ListView. Each skin's CSS is written against its OWN tree's markup; the class must only ever be written bybuildPreload, for the tree that window actually booted.- A reload does not rescue it. The preload is a persistent
WebKitUserScriptinjected at DOCUMENT_START, so its string is frozen at webview construction —webview.loadURL()re-runs it with the OLD__APP_SKIN. (updatePreloadScriptToWebViewexists in the FFI but is reachable only from electrobun's internal<electrobun-webview>handler.) titleBarStyle/transparentare construction-time only — no setters exist, so a live window can never be re-framed. win7 → modern is the dangerous direction: the modern tree draws no title bar, so it would land in a frameless transparent window with no way to move, minimize or close.- Don't "fix" this by recreating the main window either:
exitOnLastWindowClosedis true, the mainrpcis captured by six push closures, the tray captureswin, listeners cannot be removed (nooff()) — and it would drop every session and tab. - Bun pins it:
initSessionSkinfreezes the skin before any window exists, andbuildPreload/chromeOptionsread that, never the live settings. Skip the pin and a window opened after a pending change boots the other skin.
- A reload does not rescue it. The preload is a persistent
- Dev vs prod URL:
getMainViewUrl()resolves the ng-serve dev server only whenDEV_SERVER_URLis set AND the updater channel isdevAND the server answers a HEAD request; secondary windows must reuse the resolved URL, never hardcodeviews://. - Upstream Bun panic (
FFI_Callback_threadsafe_call→web_worker.spin): rare segfault under long sessions; upstream electrobun×Bun issue, restart recovers, nothing app-side to fix.
Domain logic
- sessionId ≠ filesystem identity. Two panels on the same server are two
SSH sessions. Every same-file/nested-path guard compares
fsKey(resolvedIP:port/"local"); comparing sessionId caused a truncate-to-zero data-loss bug. When fsKeys differ but paths collide, the probe-file check unmasks aliased hosts. Never "simplify" this back. createWriteStreamtruncates BEFORE the source is read — that's why self-copy is destructive, and why guards must run before any stream opens.- The synthetic
'..'row: type'dir', must be excluded from selection, targets(), filtering match-count, kind/rights/owner display, and drag sources. Any new row-level feature must decide what'..'does. Semaphore.wake()must reserve slots synchronously; waking waiters that increment asynchronously loses budget under concurrent release.- Settings: the backend spread-merges (
{...DEFAULT_SETTINGS, ...data}) so new keys auto-default, but the UI SettingsService reads field-by-field — a new setting silently stays at default until wired in all five spots. - The editor draft mirror sends full content per tick (2 MB cap upstream); don't shrink the 400 ms debounce.
panel.touchedgates localDir auto-navigation;connectTokenguards against a slow connect resolving into a panel the user already reused.
i18n
- Translations live in
src/shared/i18n/*.ts(TS, not JSON) — deliberate: it sidestepsresolveJsonModuledifferences between the Angular esbuild and Bun builds and gives full typing.en.tsis the source of truth; other locales are partial and fall back to English then the raw key. i18n.t()is reactive because it reads thelocalesignal — that's what makes live language switching work with no pipe. If you cache a translated string in a plain field it WON'T update on switch; callt()in the template (or in a signal-reading getter) instead. Same fori18n.date()/size().- Dictionary values are TS string literals: French/other apostrophes break
single-quoted entries (
'…c'est…'). Use double quotes for any value containing an apostrophe. Plurals are{ one, other }maps (not ICU strings); zh/ja supply onlyother. - Native menus can't use the Angular service —
menu.tsgoes through backendi18n.ts, and the app menu must be rebuilt when the locale changes (wired in index.tssettingsChanged), or it keeps the old language. The context menu no longer has this problem: it is Angular now and readsI18nServicedirectly. eu/zh/jaare AI drafts pending native review (see the i18n README).- Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
result. A normal
refresh()is masked bybusy(input blocked), but the silent variant (poll/watch) runs while the user can navigate — without the guard, a poll that started in dir A resolves and stamps A's listing over the dir B the user just opened. It also never callsscrollCursorIntoView(scroll must stay put) and returns early ifbusy()or already refreshing.
Build / tooling
- Angular CLI rejects Bun's Node-compat version —
scripts/node-version-shim.tsis preloaded viabunfig.tomlat the root AND inui/. Removing either breaksng. - ssh2's optional native deps must stay
external: ["cpu-features", "*.node"]inelectrobun.config.ts— bundling them breaks the build; ssh2 falls back to pure JS at runtime. bun run --cwd ui build(flag order matters;bun --cwd ui run builddoes not do what you want).version.gen.tsis gitignored and regenerated by postinstall + every build script; a fresh checkout failstscuntilbun install(or any script) has run once. Never commit it — it would bump the commit count it records.- Icon pipeline is
scripts/gen-icons.ts(resvg) — regenerate after touchingassets/logo.svgorassets/tray/tray-glyph.svg; the .ico and tray PNGs are build inputs, not build outputs.