Commit Graph
1030 Commits
Author SHA1 Message Date
logan 52cbef27c4 docs: name the guard that covers every cache table
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m30s
The bullet added with the credit work names
`TestTheCatalogSurvivesAStaleShape`, which pins the table and shape that
failed. The general guard landed the same day and is the one that covers
a table nobody remembered -- flipping the policy back fails it on five,
including both artist-credit tables.
2026-08-17 14:01:28 -04:00
logan c03c0b8ec4 test(database): the next destructive repair fails a test, not a volume
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 3m14s
CI / e2e (push) Canceled after 3m3s
The fix for the dropped catalog pins one table in one wrong shape, which
is the failure that happened. What cost the rebuild was more general: a
destructive repair added at `database.NewDB` -- the chokepoint every
binary in this project shares -- without asking which binary it runs in.
The next one will have a different name and a different reason.

So `TestNoCacheTableIsRetiredHere` asserts the outcome instead: put every
`datamap` Cache table into a shape the schema has moved past, open the
database the way cmd/indexbuild does, and require all of them to still be
there. Driving it from `datamap.ByKind` is what makes it cover tables
nobody remembered -- flipping the policy back fails on five, including
the two artist-credit tables added the same day, where the existing test
fails on one. It asserts the rows survive too, because SQLite does an
implicit DELETE before a DROP and a repair that recreated the table would
look identical. And it accepts an error from `NewDB`, because that is the
documented trade: loud is recoverable, gone is not.

`scripts/index-cache-snapshot.sh` covers the half no test can reach. The
volume holds the only copy of a catalog that costs hours of someone
else's bandwidth to re-derive. `VACUUM INTO` rather than `cp`, since a
byte copy of a live SQLite file is a corrupt file of plausible size; the
resumable staging directory is skipped; and each snapshot is reopened and
asked for its catalog row count before anything is rotated out. A corrupt
source and an empty catalog were both exercised: each exits non-zero,
removes its own output, and leaves the previous snapshots alone.

docs/index-cache.md is the restore, and the reason to bother: a restored
snapshot resolves to `refresh` and folds in the listens since, which is
minutes against the 3-23h this rebuild has been estimating.
2026-08-17 13:56:08 -04:00
yonlu 8c48105ca3 Merge remote-tracking branch 'origin/main' into wails-v3
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m31s
2026-08-17 13:52:34 -04:00
logan 1c4d6ca9a1 ci: stop booking three hours of runner on every push
Build & publish Arch package / arch-package (push) Successful in 2m34s
CI / check (push) Canceled after 53s
CI / e2e (push) Canceled after 0s
The catalog this job derives was dropped by the stale-shape repair (see
`fix(database): never retire the catalog the index build derives`, which
prevents a recurrence but cannot undo it), so `mode=auto` now resolves to
a full ~205 GB import from the dumps.

That import runs on every push to main with a 3h budget, on a runner of
capacity 1 -- so ordinary CI has been queuing behind it since the merge,
and each further push books another three hours. The damage is the
repetition, not the single job.

The `push` trigger is commented out until a run reports `complete=true`.
The weekly cron and workflow_dispatch still resume the build, which is
all it needs: indexbuild picks up from its checkpoint, so nothing already
imported is re-fetched.

Restoring the two commented lines is the entire revert, and the comment
beside them says so. NOTES.md carries the incident, including the two
things worth changing regardless: a destructive repair running inside
`database.NewDB` has to ask which binary it is in, and the only copy of a
205 GB derived asset is a single Docker volume with no snapshot.
2026-08-17 13:29:58 -04:00
yonluandClaude Opus 5 6bf832a4ba docs: record what credits are, and what the repair must never touch
Two mechanisms shipped today whose invariants are not visible from the
code, and one of them has already cost a rebuild.

Credits: why join phrases are assembly instructions rather than
disassembly ones, why credited_name is stored per row instead of joined
from artists, why the lookup is keyed on the recording MBID (and so
needed no local table), why an absent credit is cached as an answer,
and why the decomposition comes from a third dump at all — the
canonical dump has no join phrases and the JSON dumps overlap a real
library by zero rows. The measurements that justify the feature are
here too, including the correction that the "3 of 2,823" figure behind
plan 013 measured our own writer rather than any library.

The stale-shape repair gains the paragraph it should have shipped with:
retiring a Cache table is a build-tag decision, because the app
downloads its catalog and cmd/indexbuild derives it. Written as what
happened rather than as advice, since it dropped the real CI catalog on
its first run and the shape mismatch it found was there by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-17 13:13:05 -04:00
yonluandClaude Opus 5 4f8257ef72 fix(database): never retire the catalog the index build derives
Search index maintenance / maintain-index (push) Canceled after 0s
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m30s
The stale-shape repair dropped the CI catalog on its first run:

    retiring a table ... table=explore_index
      reason="column entity_type is TEXT, schema declares INTEGER"
    index maintenance mode=build reason="no completed import yet"

The mismatch was real and the drop was correct by the app's rule: a
client's catalog is *downloaded*, so a wrong shape costs a minute of
re-fetching the artifact, while keeping it costs every Explore read.

It is the wrong rule for one database. cmd/indexbuild's catalog is not
downloaded, it is what the artifact is cut from — the only way back is
the ~205 GB dump stream the /cache volume exists to avoid. And that
database is deliberately kept in the older encoding, which
`fix(indexexport): read an index older than the binary` exists to
tolerate, so the shape does not match by design and would have been
dropped on every run.

retireLibraryTables, right beside it, never touches the catalog for
exactly this reason. The repair reached past that protection because it
runs inside database.NewDB, which cmd/indexbuild also calls.

So the policy is a build tag, which is how this project already tells
the index tools apart (runtime_indexbuild.go, servicestartup.go,
dumpbuild_stub.go): Cache tables are rebuilt in the app and never in
cmd/indexbuild. Owned and Derived are still repaired in both — that is
the half this database can safely discard, and retireLibraryTables
already discards it.

The residual trade is deliberate: a future explore_index column will
now fail the index job loudly on applySchema rather than silently
costing it a 205 GB rebuild. A human should decide that one.

TestTheCatalogSurvivesAStaleShape is the accident, symptom first, with
the shape the real database is in — every current column, ids and
entity type still text. It fails with "the catalog was retired" when
the policy is flipped back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-17 12:24:49 -04:00
yonlu b505959934 Merge remote-tracking branch 'origin/main' into wails-v3
Build & publish Arch package / arch-package (push) Successful in 2m32s
CI / check (push) Successful in 3m10s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 1h31m25s
2026-08-17 11:38:49 -04:00
logan d0250a2133 docs: confirm the phone track list on the phone
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 7s
CI / check (push) Successful in 2m26s
CI / e2e (push) Successful in 6m12s
Build & publish the Android APK / apk (push) Successful in 1m46s
Sync Homebrew formula / sync-formula (push) Successful in 7s
The arrangement and the width fix, measured on the device with the build
installed rather than at the same viewport in a browser: `24px 304px
80px`, 52px rows, no header, the title untruncated, no overflow. Same
numbers both places, which is why both were measured.
2026-08-17 10:51:23 -04:00
logan de2b324e20 feat(explore): refuse 0.6 GB on someone's mobile data
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m30s
Plan 016 B4. The catalog artifact is about 0.6 GB and the app fetched it
with no awareness of the connection: on a desktop that is a minute of
bandwidth, on a phone it can be a month's allowance. It is now skipped on
a cellular connection unless `AllowMeteredCatalogDownload` is on, with
the toggle in Settings' Search Index section, where the text explaining
what the catalog is already lives.

The file layout is dictated by the cgo rule rather than by taste.
`explore` is imported by `cmd/indexbuild`, which builds with
CGO_ENABLED=0 and must not link Wails, so `netpolicy.go` holds the policy
and the JSON parsing -- tested on every platform -- and the single
platform call is a closure injected from `app.go`, which already names
`application` legitimately.

Three rules in it are load-bearing. An unknown answer is not a metered
one: only mobile answers at all, and treating silence as metered would
have disabled the download for every desktop user in the world. Cellular
is the only signal available, because the runtime reports
`wifi|cellular|ethernet|none` and no metered flag -- so a metered Wi-Fi
cannot be detected and is not refused, which is documented rather than
implied. And the gate runs before the first status write, so declining is
a no-op instead of a job in the indicator and an error tier to dismiss.

Two corrections to the plan while implementing it: the portable API is
`application.Mobile.NetworkJSON()`, not `application.Android`'s, which
exists only under the `android` build tag; and the permission is read at
the moment a download would start, so enabling it takes effect on the
next attempt rather than the next launch.
2026-08-17 10:48:00 -04:00
logan 2c78b58207 feat(ui): the track list a phone can read
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m26s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 6m15s
B2 phase 4, and the last of it. Measured on the device: at 424 CSS px
the four configured columns fit the row *exactly* -- `--grid-cols` came
out `24px 102px 101px 101px 80px` -- and not one of them fit its
content, with "Duration" too narrow for its own header. The columns were
never too wide; there were too many of them.

So a phone draws `titleArtist` (the title with the artist under it,
across the row's whole width) plus the duration, and drops the column
headers and the resize handles, which are a click-to-sort and a drag
with no touch equivalent. It is a **column set, not a second row
template**: the row, its delegated events, the selection semantics, the
playing marker and the virtualizer never learn anything changed, because
from their side only the number of columns did.

Three rules come with it. The row height is in two places
(`PHONE_ROW_HEIGHT` and the CSS rule) and must agree, since the
virtualizer positions rows from that number and a taller row overlaps
its neighbour. What is drawn and what can be sorted are different
questions, so the sort list is built from `configuredColumns` -- a phone
has no headers either, and building it from the drawn columns would
leave it able to sort by title and duration alone. And a phone's column
widths are neither loaded nor saved.

That third rule is the bug the device found with the arrangement already
passing five component tests and five e2e specs at the phone's own
viewport. `loadColumnWidths` is keyed by column *id* and fills a gap
with `MIN_COLUMN_WIDTH`, so the stacked column -- which nothing can ever
have saved a width for -- came out at 148px beside a duration column of
236. The mirror image was worse and unreachable from a phone at all:
saving would have written those widths back under the same ids,
replacing the width the user dragged on a desktop. The specs asserted
shape, and the fault depended on what `localStorage` held for a
different column set; the unit test now carries that map as a fixture.

Verified: 809 component tests, 112 e2e specs, and on the phone at
424x439 -- `24px 304px 80px`, 52px rows, no truncation, no overflow.
One full e2e run of three saw an unrelated autotag keypress spec flake
and pass on retry.
2026-08-17 10:36:29 -04:00
logan a9852c18a0 docs: the device answered both open questions, and neither as expected
Build & publish Arch package / arch-package (push) Successful in 2m39s
CI / check (push) Successful in 2m39s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m57s
Both faults reported from the phone are now measured rather than
inferred, with the installed build and current main compared on the same
device.

"The controls are off screen" was literal and already fixed: the
installed build predates B2 phase 2, so its player bar still carried the
seek bar and volume at 424px and the transport ran past the right edge.
Current main measures no horizontal overflow and the controls at 200..380
inside 424, on the phone's own engine.

"No icons" was my own screenshot: taken six seconds after a cold start,
before the icon fetches landed. On the settled app every icon paints, and
the earlier black `fill` was the svg root rather than the path that
carries `fill="currentColor"`. Two conclusions from one misread node,
both corrected.

Chrome 113's missing Popover API does not break the menus, which was the
standing worry: a long-press opens the real panel with seven items,
positioned and painted -- so long-press is now verified on hardware over
a 1,744-track library, not just in a browser at a phone-shaped viewport.

What the device does add is a measurement for phase 4: the track list's
columns fit the host exactly and are simply too many for 424px.
2026-08-17 10:15:01 -04:00
yonluandClaude Opus 5 409bfd5e89 test(download): wait for the work, not for the state that precedes it
TestManagerEndToEndAutoPick waits for StateComplete and then asserts
that staging was released and the library was rescanned. Those happen
*after* the state is recorded: manager.go sets StateComplete, then
satisfies the request, then releases staging, then scans. So waiting on
the state is not waiting on either assertion, and on a loaded machine
the worker is descheduled in between and the test reads the world one
step too early:

    manager_test.go:209: staging not released: 1 dirs remain
    manager_test.go:218: library scans = 0, want 1

It passed alone every time and failed three times under a full-suite
run, which is the signature of a test race rather than a broken
manager — nothing here is wrong except what the test chose to wait on.
It blocks pushes, since the pre-push hook is exactly the loaded run.

It polls for the side effects now, through the waitFor this package
already has and already uses for the same reason one file over
(service_test.go waits for a request to become satisfied after the same
StateComplete).

Not reproduced on demand: eight spinners and -count=5 did not provoke
it with or without the fix, so this rests on the ordering being plain
in the code rather than on a red-to-green demonstration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-17 09:22:51 -04:00
yonluandClaude Opus 5 0eeef6048e feat(frontend): credit the artists on the full-screen now playing too
The phone shell's now-playing view landed on main while the credit
rendering was being written, so it arrived with the one call site that
still showed a multi-artist credit as a single link with the other
artists as punctuation inside it.

It is the same fix as the other ten: render from the parts, fall back
to the single link when there are fewer than two. The subscription is
what makes it show up at all — credits arrive after the track does, so
the name already on screen has to be re-rendered when they land.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-17 08:34:27 -04:00
yonlu 4fc0cdeab7 Merge remote-tracking branch 'origin/main' into wails-v3 2026-08-17 08:29:21 -04:00
yonluandClaude Opus 5 eb059a3d71 fix(database): retire a table whose shape the schema moved past
`applySchema` is CREATE ... IF NOT EXISTS and there is no migration
chain, so a *changed* table never migrates: the statement silently
no-ops against the old shape. Two plans had already landed on that, and
neither showed up in a test because a fresh install is perfectly
healthy.

- 014 added `total_tracks` to explore_index and to `indexRowFields`,
  the projection every explore read uses, so every search, browse,
  artist page and album page failed with "no such column: total_tracks"
  on any database that already had a catalog.
- 013 reshaped audio_files, so applySchema could not run at all and the
  app did not open.

staleshape.go runs before applySchema and drops what disagrees, so the
create is a create. It parses sql/schemas/ for the expectation rather
than writing the column list down a second time, and it notices a
changed *type* as well as a missing column — 013 moved mbid TEXT to
BLOB, which no ALTER could express and which SQLite will not coerce, so
a query against 16 raw bytes returns no rows rather than an error.

Only Authored tables are exempt. Cache is rebuildable by definition,
Owned is what a rescan rebuilds (plan 013's stated "delete and
rescan"), and a table the schema no longer describes at all goes too --
013 left seven behind plus schema_migrations.

Three things in it are load-bearing, and each was a bug first:

- The parser read `UNIQUE(mbid)` as a column, which made a healthy
  catalog look stale. That would have retired it on every launch and
  cost every user an artifact download per start.
- The drops are one transaction with defer_foreign_keys. Those legacy
  tables reference each other, so any order fails on whichever goes
  first; turning foreign keys off instead would suppress
  playlist_tracks.audio_file_id's ON DELETE SET NULL and leave entries
  pointing at ids a rescan reissues to *different songs*. Nulled
  entries are empty; stale ones are wrong, and wrong quietly.
- The order is sorted, so a failure reproduces. Map order is random,
  and the foreign-key bug passed its own regression test on two runs in
  three until the order was fixed.

Verified against a real pre-013 install: it opens, its 22 playlists
survive, 1,887 linked playlist entries become 0 rather than dangling,
and the legacy tables are swept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-17 08:27:05 -04:00
yonluandClaude Opus 5 dcabec8b1d feat(frontend): render a multi-artist credit as one link per artist
Every artist name in the app went through `artistLink(name, mbid)`, so
a track credited to several artists rendered one link and the rest as
punctuation — "2Pac feat. Snoop Dogg" linked 2Pac and left Snoop Dogg
as text inside it.

`creditLink(parts, fallbackName, fallbackMbid)` renders the credit from
its parts: one link per credited artist, join phrases as plain text
between them. The link boundaries are known by construction, which is
the point — locating a name inside the stored credit string would
reintroduce the mismatch the catalog exists to avoid, since that string
may come from the file's tags while the parts come from MusicBrainz and
the two disagree for ~1 in 3 multi-artist credits.

Fewer than two parts falls through to the previous behaviour exactly,
so a single-artist credit, a file with no recording MBID and a catalog
that has not answered yet all render as they did before. Nothing tries
to split the fallback string: "Simon & Garfunkel" is one artist, which
is why primaryArtist() does not split on "&" either.

The lookup is keyed on the recording MBID, which both sides already
carry — a catalog row has one and so does a local file — so one binding
serves Explore and the library's own lists, and no local table is
needed for this.

credit-store.ts, and three things in it are load-bearing:

- A miss is cached as an empty array. The backend returns nothing for a
  single-artist credit, which is ~87% of tracks, and caching only the
  hits would re-request the rest on every render forever.
- request() is per-row and coalesces into one call per frame. A
  virtualized list cannot hand over "the whole list": 50,000 rows would
  be 100 queries for the ~30 on screen.
- It is an LRU with a counted retainedChars probe, because a cache that
  grows with use is a leak with a schedule.

The virtualized lists push requestUpdate() into the virtualizer rather
than only the host, since its rows come from its own properties — a
host update alone would leave them exactly as they were. now-playing
marks its geometry dirty instead, because the marquee measures the text
it is about to scroll.

track-list keeps the single link while a search term is active: the
highlight spans are computed against the flat credit string, and
mapping them onto decomposed parts is a different problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-17 08:26:34 -04:00
yonluandClaude Opus 5 b3737d30af feat(explore): carry multi-artist credits in the catalog
A track credited to more than one artist has exactly one navigable
artist in this app and the rest are punctuation. `primaryArtist()`
string-parses the credit, strips a " feat. " clause and discards the
guest; it deliberately does not split on "&", "with" or "," because
those live inside real artist names.

Measured on a real 26,069-file library plus an 80+80 MusicBrainz
sample: 13% of recordings are multi-artist upstream, while only 0.86%
of files carry any structured multi-artist tag — mp3 carries zero
files with multiple MUSICBRAINZ_ARTISTID across 19,840. Of 1,286 files
saying "feat.", 90% have nothing structured behind it, and a sample of
80 such files was multi-artist in MB 80 times out of 80.

CLAUDE.md justified plan 013's removal of the credit tables with "3
credits of 2,823 listed more than one artist". That measured our own
*writer* — cachedLinkArtist was called once per credit, so a
collaboration could never have been recorded. Dropping the join table
was still right on cost; the evidence for "multi-artist is rare" was
not.

A credit is ordered parts and the credit string is derived from them,
so join phrases are assembly instructions, not disassembly ones.
Nothing here reconstructs a credit by searching a name inside a credit
string: the stored text may come from tags while the parts come from
the catalog, and those disagree for ~1 in 3 multi-artist credits.

Where it comes from, after two dead ends: the canonical dump CI
already streams has no join phrases and no as-credited names, and the
JSON dumps cover 153,691 recordings of ~35M with *zero* overlap
against a real library. So mbdump.tar.bz2 — 7.1 GB, ~13.7 min in
pure-Go bzip2, whose members are alphabetical, which is what lets one
pass resolve an entity's credit without buffering 35M recordings.

- artist_credit_part / artist_credit_ref, multi-artist credits only:
  a single-artist credit is already explore_index's own artist_name.
- Column layouts verified against the real 20260815 export;
  ErrDumpShape makes a wrong guess a failed build, not a wrong catalog.
- The pass runs on every mode, not just a build. The job picks its mode
  from the index's own state, and a complete import means "refresh",
  which never enters the importer — so credits could otherwise only
  arrive via a rebuild that re-downloads ~205 GB. It reports whether it
  populated anything, which is what flips `changed` and republishes.
- The importer asks whether an artifact carries the tables, on the
  writer where `core` is attached, so the artifact already published
  still imports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-17 08:25:36 -04:00
logan 0bfa2136be feat(dev): ask the phone instead of looking at it
Build & publish Arch package / arch-package (push) Successful in 2m29s
CI / check (push) Successful in 2m26s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m53s
The device tier could only take a screenshot and read what Go chose to
log, and a screenshot cannot tell a dropped CSS declaration from a
missing asset. This adds the third thing: the page's own answer, from
the engine that is really rendering it.

`make android-screenshot` grabs the screen, `make android-inspect`
forwards the WebView's devtools socket, and `make android-eval EXPR=...`
evaluates in the real page.

Four details are load-bearing. Only a `debuggable` build opens that
socket, so the debug build type takes `applicationIdSuffix ".dev"` and
installs *beside* the release app -- the two carry different signing
certificates, and Android's only remedy for a changed certificate is an
uninstall, which takes the user's library with it. Playwright cannot
drive a WebView (`connectOverCDP` calls `Browser.setDownloadBehavior`,
which it answers "Browser context management is not supported"), so the
eval is raw CDP over Node's built-in WebSocket. The socket name carries
the pid, so it is resolved per launch rather than written down. And
`exec-out`, not `shell`, for the screenshot: a pty translates LF and
corrupts the PNG.

What it immediately established is why it was worth having. The phone
renders in Chrome 113 at 424x439 CSS px -- two years behind every
browser the other tiers use, with no Popover API and no relaxed CSS
nesting -- so a spec passing at that viewport says nothing about the
device, and two conclusions drawn from version numbers alone were wrong.
Both are corrected in NOTES.md and the plan.
2026-08-17 05:13:21 -04:00
logan b1cdef8769 docs: record what a phone said that no tier could
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 5s
CI / e2e (push) Successful in 6m10s
CI / check (push) Successful in 3m28s
The first device run of the published APK, and the first runtime
evidence any of the Android work has ever had -- A4 shipped entirely
reasoned from source.

It confirms A4 whole: playback survives the screen locking, and the
transport notification appears with cover art, which settles four
open questions at once (the service starts, the permission was granted
and the notification is visible, the lock screen picks up the session,
and art decoded from a MANAGE_EXTERNAL_STORAGE path by a service is
readable -- the one nobody could argue from documentation).

It also found the two faults fixed in the preceding commits, and the
lesson worth keeping is why *those two*: both are things the platform
adds rather than things the app draws. So the skill's Android tier now
says to ask a device about system bars, the back gesture, focus and
audio interruptions, permissions and the keyboard -- and not about
layout, which the other five tiers already cover.
2026-08-17 02:02:10 -04:00
logan d661836347 fix(android): keep the app out from under the system bars
Reported from the first device run: the playback controls are off
screen. `targetSdk 35` is Android 15, which lays every app out
edge-to-edge and ignores the deprecated `statusBarColor` and
`navigationBarColor` the scaffold's theme still sets -- so a
`match_parent` WebView draws the page's bottom band, which on a phone
is the transport *and* the tab bar, underneath the gesture bar.

`applyWindowInsets()` pads the container by
`systemBars | displayCutout | ime` and returns the insets rather than
consuming them, so the WebView is laid out inside them. The keyboard is
in the mask because a search box the keyboard covers is the same bug
one surface over.

The window background goes black to match the app's own default ramp:
that padding is what shows through, and a band of the scaffold's
blue-grey above and below reads as the app failing to fill the screen.

No tier we have can see this class of fault -- a browser viewport has
no system bars, so `phone-shell.spec.ts` at 390x844 renders a shell
that fits at the moment the device is clipping it. Verified only as far
as the APK building; the insets need the next build on a phone.
2026-08-17 02:02:10 -04:00
logan 28eecf0a97 fix(ui): the Android back button had nowhere to go
Reported from the first device run: back does not navigate back in the
app. The scaffold's `MainActivity.onBackPressed` asks
`webView.canGoBack()` and finishes the activity otherwise -- and this
app had never touched `history`, so that was false at every depth and
back quit from anywhere.

The fix is here rather than in Java, because the mechanism the scaffold
already uses is the one we were failing to feed: a navigation is a
history entry now, and `popstate` replays it. Nothing on the Android
side changes, and the behaviour becomes assertable in a browser with
`page.goBack()` instead of only on a phone.

The entry keeps the same URL -- the app has no routes, and a path a
reload cannot resolve is worse than none -- and carries the destination
in its state.

Two rules keep the stacks from disagreeing. The first navigation
*replaces* the launch entry rather than pushing one, or every launch
costs a back press before the app will close. And the in-app back
buttons go through `history.back()` rather than popping a stack of
their own: `navStack` is deleted, not kept alongside, because two
stacks is precisely how a detail view's own button and the phone's
gesture come to disagree about how far one press goes. The third spec
pins that invariant.
2026-08-17 02:01:57 -04:00
logan e8690476bd feat(ui): long-press opens the menus a right-click opens
Every context menu in the app opens from a `contextmenu` event, bound
three different ways across six components -- delegated on a
virtualizer, per row, per card. A phone has no right-click, so a phone
reached none of them (plan 016 B2 phase 3).

This is one document-capture listener installed once from `index.ts`,
not six components' worth of touch handling: a touch that holds still
for 500ms dispatches a synthetic `contextmenu` at the touch point, and
every existing handler runs unchanged. A seam no component has to opt
into is one no future component can forget.

Four details are load-bearing, each a way the obvious version fails.
The target is `composedPath()[0]`, not `elementFromPoint`, which stops
at the outermost shadow host -- every menu here is bound inside one, so
a host-targeted event reaches a delegated listener and no per-row one.
A browser that fires its own long-press `contextmenu` (Chromium does;
WebKit and the Android WebView vary) wins, and ours is told from theirs
by identity rather than `isTrusted`: `isTrusted` works in the app and
is untestable, which would leave the suppression path as the one thing
with no coverage. And the click ending the gesture is swallowed, keyed
on the gesture rather than a time window, or the first tap on the menu
it just opened is eaten too.

The e2e spec presses `.track-row`, not `[role="row"]`: the column
header is a row too, and it is the first one -- a press on it is
correctly ignored, which reads exactly like the gesture not working.
2026-08-17 02:01:46 -04:00
logan 7e0be8fa30 fix(indexexport): read an index older than the binary
Build & publish Arch package / arch-package (push) Successful in 2m24s
CI / check (push) Successful in 2m29s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 6m38s
`maintain-index` failed with

    indexexport: copy rows: SQL logic error: no such column: total_tracks

three minutes into the one job that owns the ~205 GB checkpoint and
publishes the catalog every user downloads.

The cause is the exception that keeps that checkpoint alive. The job's
/cache is a real YJ_HOME that survives between runs, so its
explore_index is classified Cache and is deliberately *not* dropped and
recreated by cmd/indexbuild's schema repair -- which means a column
added to the schema afterwards is absent from it. total_tracks arrived
with the album-completeness work; the exporter selected it regardless.

The fix is the rule the importing side already follows.
artifactHasTotals exists because "adding a column to the importer's
SELECT is how you break every artifact already published"; the mirror
image, reading an index older than the binary, had no such guard.
sourceColumns asks pragma_table_info and selects a literal 0 when the
column is absent -- which is what that column already means by "the
catalog does not say", and what the app renders as unknown rather than
as incomplete. The artifact keeps every column, so an importer needs no
second shape.

The test reproduces the failure symptom first: with the fix removed it
fails with the CI message verbatim. Its own first version proved
nothing, though, and that is worth the comment it now carries --
`strings.Replace(catalogColumns, "total_tracks, ", …)` matches nothing,
because the list is formatted across lines and the name is followed by
a newline, so the "old" index was built with every current column.
2026-08-17 00:39:19 -04:00
logan 1b05dde382 feat(ui): the full-screen now playing a phone needs
CI / check (push) Successful in 2m25s
Search index maintenance / maintain-index (push) Failing after 2m53s
Build & publish Arch package / arch-package (push) Successful in 2m27s
CI / e2e (push) Successful in 5m55s
Plan 016 B2, phase 2. Phase 1 took the seek bar and the volume out of
the phone's bottom bar -- 4px of height is not a thumb target, and a
phone's volume belongs to its hardware keys -- and promised them a
full-screen view. This is it, reached from a button over the mini
player's cover art.

**It composes the transport rather than reimplementing it.** The same
`seek-bar`, `player-controls` and `volume-control` the desktop bar
uses; a phone layout that copies them is a second transport to fix
every bug in, and the seek bar in particular carries interpolation
rules that took a plan of their own to get right. The seek bar
thickens its own track below the breakpoint, in its own stylesheet,
because the track size lives on a wa-slider inside its shadow root
where a custom property from the host cannot reach.

**It is a detail view, not a primary one.** It is somewhere you go and
come back from, so index.ts pushes the current view and Back pops it --
which is also why it is not a fifth tab: a tab you cannot leave by
pressing it again is not a tab.

Two things came from reading a screenshot rather than from a failing
test, and both were invisible to assertions that were individually
correct.

**The mini player was still under the full-screen view**, repeating it
in 4em of an 844px phone. index.css hides the bottom bar while
`#main-content[data-active-view="now-playing"]`, through `:has()`
rather than a class toggled from index.ts, because the active view is
already published as an attribute. That takes the queue button with it,
so the view carries its own.

**And phase 1's shell rules had never applied.** A media query adds no
specificity, and the phone block sat above the plain rules it meant to
override, so at 390px the header kept its 2em gutters (32px), its 16px
gap and its 24px title, and the bottom bar kept a fixed 320px first
column. Nothing failed: the shell fits because of `min-width: 0` and
each component's own media query, which live in their own stylesheets
and have no later rule to lose to -- so what was dead was exactly the
cosmetic half no assertion looks at. The phone rules are one section at
the end of the file now, and it says why it is last. Measured after:
12px, 8px, 17.6px, `154px 187px 33px`.
2026-08-17 00:22:58 -04:00
logan 29299d17da fix(dev): run the local e2e tier against the app CI runs
Build & publish Arch package / arch-package (push) Successful in 2m26s
CI / check (push) Successful in 2m30s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 6m5s
Two specs failed locally and passed in CI, which is the least useful
direction for a disagreement to point.

**`dev-headless.sh` was the only launcher not stubbing out the
catalog.** `seed-sandbox.sh` and `ci.yml` both send
`YJ_CORE_INDEX_URL` to a dead address; the dev launcher did not, so the
app downloaded and built the real ~1M-row Explore catalog into the
run's YJ_HOME and every local `make e2e` after that ran against a world
CI never sees. Found by reading the failure screenshot: the spec had
searched Explore for its fixture album and the page was full of real
ones. It defaults to the dead address now and takes an explicit one for
exploring by hand.

**And the shared backend carries spec state between runs.**
`explore-shelves` staged its catalog only `IfEmpty`, so one album row
left behind by `requested-badge` satisfied that gate: the shelves were
drawn from a single foreign row and the artist card the spec clicks did
not exist. It failed on the *second* local run and passed on the first,
and never in CI, where every run gets a fresh home.

"Is the catalog empty" was the wrong question and "are my rows there"
is the right one, so staging is unconditional (INSERT OR IGNORE keyed
on the MBID) and the assertion moved from *this insert wrote a row* to
*every fixture row is present*. That is both idempotent and stronger:
an MBID that fails CHECK(length(mbid) = 16) is silently dropped by OR
IGNORE, which the old per-insert count caught only on a cold catalog
and the new one catches always.

Verified by running the whole suite twice against one app: 97/3 before,
100 passed both times after.
2026-08-16 23:47:24 -04:00
logan 57fbbdf0d2 feat(ui): a shell a phone can be held in
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m40s
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column,
`bottom-nav` becomes the primary navigation, and the shell fits the
viewport instead of scrolling sideways out of it.

600 rather than the sidebar's own 900, because 900 is a laptop and the
answer there is a narrower sidebar, which is still a sidebar. Under 600
there is no room for one at all: 360px of viewport over a 200px nav is
not a layout.

**The tab bar is four destinations and a way to everything else.**
Three to five is where touch targets stop being thumb-sized -- eleven
over 360px is 32px each -- so the four are the ones plan 016's subset
says a phone is for, and "More" opens the *existing* `app-sidebar` in a
drawer rather than listing the destinations a second time. Two lists is
two places to add the next view to.

That reuse has a cost this found the hard way: a shared component
brings its `data-testid`s with it, so rendering the drawer's sidebar
unconditionally put a second `nav-home` (and ten siblings) in the DOM
and **failed 30 existing specs** with "resolved to 2 elements" -- on a
desktop viewport, where this element is `display: none` and the drawer
can never open. It renders only while the drawer is open, and the
component test asserts the absence, because the failure is invisible
from inside the component and lands in files nobody touched.

**What made the shell overflow was minimums, not padding.** Measured at
360px: the body was 652px wide, because a `min-width` in a flex row is
a hard floor and a grid item's implicit minimum is its content. So
`min-width: 0` on the boxes between the viewport and the content, and
each component stands its own non-essential parts down in its *own*
stylesheet -- search-bar's 200px floor, job-indicator's label (the
visible one; the live region that announces it is untouched),
audio-player's seek bar and volume. A media query inside a shadow root
is answered by the viewport, so this is the component saying what it
drops rather than the shell reaching in.

Volume goes because the hardware keys own it on a phone, which is the
same reason mediacontrols' Android handler implements no volume
callback. Seeking goes because 4px is not a thumb target; it belongs to
the full-screen now-playing view, which is the next phase.

An existing spec therefore asserts the opposite of what it did:
layout-overflow's 320px case used to require that the 464px behind
`overflow: hidden` could be *scrolled to*, which was the remedy
available while the shell had one layout. It reflows now -- 320px in a
320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for.
2026-08-16 23:19:26 -04:00
logan df2e9ea777 docs: record what the Android work established and disproved
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 6m5s
Section A of plan 016 is closed and B1 is decided, so the three tenses
move together: CLAUDE.md for what mediacontrols now is, the skill for
what to run, NOTES.md for what was measured and when.

The entry worth reading is the one that disproves a claim written here
earlier in the same session. Dropping x86_64 was expected to make
make android-install fail with INSTALL_FAILED_NO_MATCHING_ABIS.
Measured, it installs and launches: Google's google_apis x86_64 images
carry arm64 translation (abilist = x86_64,arm64-v8a), so the loader
maps lib/arm64/libwails.so and runs it. It dies before any of our code
with SIGILL, and the disassembly names the reason exactly --
`mrs x0, ID_AA64ISAR0_EL1`, Go's internal/cpu reading the arm64 feature
register at runtime init, which the translator does not implement. So
no Go binary starts under it, and that is not a property of this app.

Which closes the last plausible shortcut. There are now three distinct
ways this app fails on an x86_64 Android -- seccomp on the x86_64
build, an unimplemented system register on the translated arm64 one,
and a real device still unverified -- and none of them is a bug in it.
A phone remains the only verification path.

Plan 016 also carries the B2 scope, now decided rather than
recommended: option 1's data model with option 2's surface. The phone
gets home, library browse, now-playing-as-a-view, the queue, search and
playlists; it does not get autotag, downloads, Explore or the 93-control
Settings page, and each of those has a reason written beside it. One
rule for the work: no view forks, because a phone template that copies
a view's is two templates to fix every bug in.
2026-08-16 22:26:39 -04:00
logan c99c8efa11 ci(android): tell a wrong password apart from a wrong keystore
The v1.5.0 run reported that the keystore did not open, and the
diagnostics could not say why. They now clear the two causes that look
identical to a wrong password.

**A password pasted with its shell quotes** is two characters longer
than the password and nothing in keytool's error says so. The step
retries with the surrounding quotes stripped and, if *that* opens the
keystore, says exactly that. It does not strip them and carry on: a
password may legitimately contain a quote, so this reports a diagnosis
rather than guessing at a fix.

**A password that is right for a different keystore** is the other one,
and it is the one currently in play -- the secret decodes to a valid
2280-byte PKCS12 and the password is the length the owner expects, which
leaves "is this the keystore I have locally?" as the open question. The
step prints the decoded file's sha256 so that is answerable by
comparing one line against sha256sum. Hashing a certificate store gives
nothing away.
2026-08-16 22:26:29 -04:00
logan 904786b941 fix(dev): the Android harness did not parse, and then chose any device
Two bugs, and the first had made every make android-* target dead since
the commit that introduced it.

**The script did not parse at all.** A case pattern read
`*signatures do not match*)`, and `do` is a reserved word: bash rejects
the *whole file*, so android-emulator, android-install, android-smoke
and android-logs all died with "line 190: syntax error near unexpected
token `do'" -- a message that points at a line nobody had reason to
suspect, in a file that had been working. Quoting the inner words fixes
it. A shell script only ever run by hand can carry a syntax error
indefinitely; nothing in the pre-commit hooks runs bash -n.

**A bare adb addresses whatever is attached.** With a second emulator
present -- another project's, or this one's own corpse left `offline` by
a previous run -- every adb call fails with "more than one device", and
cmd_install reported that as "no device - run 'make android-emulator'
first" *directly after* that had printed "waiting for boot ok". Which
is the harness's own house rule broken: a failure that names the wrong
cause is worse than one that names none.

pick_device resolves ANDROID_SERIAL from ro.boot.qemu.avd_name before
any device command. The AVD name is the identity because serials are
assigned in boot order and change between runs; a caller's own
ANDROID_SERIAL wins, and a single device that is not ours is taken as
the target, since that is a phone and a phone is what this tier
actually wants. Verified with both emulators running.
2026-08-16 22:26:21 -04:00
logan b6651310ea build(android): drop the x86_64 ABI, which no Android can run
The fat APK's second half was 31 MB that cannot execute on any Android
device. modernc.org/libc's Xlstat64 issues a raw lstat syscall on
linux/amd64, and Android's seccomp policy forbids it because bionic
never issues it, so the process takes SIGSYS the first time anything
touches the database -- which for this app is startup. That is every
x86_64 Android, x86 Chromebooks included, not merely the emulator.
arm64 is structurally unaffected: the architecture has no lstat syscall
at all, so modernc routes through fstatat.

27,059,130 bytes to 15,898,465, and one lib/ entry.

Three places had to agree, and the third is what would have made this a
silent no-op: abiFilters (what Gradle packages), android:package rather
than package:fat (what Go *compiles* -- otherwise the library is still
built and then discarded), and the native-code assertion in CI. That
assertion is anchored, `native-code: 'arm64-v8a'$`, because without the
anchor it also matches the fat APK's line and would pass on exactly the
thing it exists to catch. Checked against a real artifact.

Adding the ABI back, if modernc ever fixes Xlstat64, is those same
three edits.
2026-08-16 22:26:11 -04:00
logan da38b865fc feat(android): playback that survives the screen locking
An app that plays audio becomes a music player at the point where the
screen can lock, a call can interrupt, and the headphones can come out.
None of that existed: the foreground service was typed for media but
had no MediaSession, no transport notification and no audio focus, so
oto would happily keep writing to a stream nobody could hear.

The apparent blocker is that Wails' androidBridge* helpers are
unexported, so Go cannot call arbitrary Java. It does not need to.
StartForegroundService(json) *is* exported, and build/android/ is our
tree, so widening the JSON WailsBridge already accepts is a local edit;
coming back, WailsBridge.emitEvent lands on the application event bus,
which Go subscribes to with app.Event.On. One document out, one command
event back, and no new JNI. No new Gradle dependency either: minSdk is
21, which is exactly when android.media.session.MediaSession and
Notification.MediaStyle arrived, so androidx.media buys two
Build.VERSION branches' worth of nothing.

Four things in it are load-bearing.

**A duck is not a volume change.** Player.SetDuck holds the attenuation
as an offset and re-applies the user's level through setVolumeLocked,
so it cannot accumulate across repeated ducks and getUserVolume -- which
feeds the event, the persisted state and every relative change -- still
reports what the user chose. Writing through to the volume would let
one notification tone permanently turn the music down.

**The duck path is pre-Oreo only.** From API 26 the framework ducks the
app itself and sends no CAN_DUCK focus change; asking to be told
instead (setWillPauseWhenDucked) would mean pausing for every
notification tone, and doing both would attenuate twice.

**An unchanged payload is not an event**, the rule emitStatus already
states one package over: every push crosses JNI and re-delivers an
Intent, and the player pushes state on several paths that can agree.

**After the first start, an update is startService.** From Android 12 a
background app may not *start* a foreground service but may keep
feeding one it already has, which is every track change with the screen
off. Relatedly, every path through onStartCommand calls startForeground
-- one that returns without it is killed.

The contract with Java lives in androidpayload.go *without* the android
build tag, and is tested. Everything left in android.go is untested by
construction: make lint and make test are three tag sets on
linux/amd64, so the only thing that compiles it is the cross-compiler
in make android, and the only thing that can run it is a phone.

None of the behaviour above has been observed on a device. The APK
builds and both halves compile; that is the whole of what is verified.
2026-08-16 22:26:03 -04:00
logan ced537ecf2 docs: record which Android blockers are now cleared
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m57s
CI / check (push) Successful in 2m37s
2026-08-16 17:18:22 -04:00
logan e14a34fccf fix(android): let the app reach the user's music
Three of plan 016's four blockers. Each is a different reason the app
could not work at all on a phone.

**It had no permission to read anything.** The generated manifest asked
for INTERNET, VIBRATE, biometrics, location and a camera, and nothing
whatever about storage -- so at targetSdk 35 the app could see its own
private directory and no music. It now declares READ_MEDIA_AUDIO, the
two capped legacy storage permissions, and MANAGE_EXTERNAL_STORAGE.

That last one is deliberate and is the load-bearing choice. This app is
a library manager: audio_files.file_path is the primary key of
ownership, the scanner walks a directory the user chose, and tagwriter
rewrites files in place. MediaStore offers no stable directory to walk
and no in-place write, so scoped storage is not "more work" here, it is
a different application. MANAGE_EXTERNAL_STORAGE is Play-restricted,
which is acceptable only because this ships as an APK through the
package registry -- if it ever targets Play, that line is what has to
go, and plan 016 says what replaces it.

It is granted on a Settings screen rather than in a dialog, so it
cannot be requested with requestPermissions(). MainActivity opens that
screen on every cold start until access exists -- there is no degraded
mode worth offering -- and re-checks in onResume, because the way back
from another task is a resume, emitting android:storageAccess so the
frontend can react.

**The first-run flow could not complete.** All three call sites asked
for a folder through the Wails dialog, which returns an error on
Android: SAF yields tree URIs and this app is keyed on paths. So the
app browses the filesystem itself, which it can now do. ListDirectories
lists directories only (the thing being chosen is a library root),
skips what it cannot stat rather than failing the listing (Android's
storage root holds directories no app may enter), follows symlinks
(os.DirEntry reports the link, so a symlinked music folder would
silently vanish), and hides dotted entries.

utils/pick-directory.ts is the one place that chooses between the two,
so the three call sites changed by one line each. **Which platform is
asked of the backend**, not of System.IsAndroid(): the dialog is
backend code, so the backend is what knows whether it can open one; it
answers for iOS at the same time; and it keeps the fallback testable
through the ordinary transport fake rather than a module mock of the
Wails runtime, whose platform helpers read build constants.

**And MPRIS was compiled into the Android build**, because android
implies the linux build tag, so it went looking for a session bus that
does not exist. mpris_linux.go is `linux && !android` now and the stub
covers Android, which means no lock-screen transport there yet -- a
missing feature rather than a broken one, and the remaining blocker.

The foreground service is typed mediaPlayback rather than the
scaffold's dataSync, with the matching permission, so playback can
survive the screen locking once there is a MediaSession to drive it.
The type in the manifest and the one passed to startForeground must
agree or startForeground throws.
2026-08-16 17:18:03 -04:00
logan 78576b8da9 docs: assess what Android parity would take
Build & publish Arch package / arch-package (push) Successful in 2m40s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Canceled after 3s
CI / check (push) Successful in 2m18s
Plan 015 shipped a pipeline; this is what stands between that and an
app worth installing. Verified against the source and the generated
manifest rather than guessed.

Four blockers, and none of them is porting work. The manifest requests
no storage or media permission at all, so the app can read no music --
and READ_MEDIA_AUDIO would not be enough, because it grants access
through MediaStore while this app's whole model is absolute paths:
audio_files.file_path is the primary key of ownership and every
GetFilePathsBy... query exists to hand paths to the player. The
first-run wizard calls DirectoryPicker, which Wails documents as
returning an error on Android, and the wizard intercepts pointer events
until a library exists, so the app is inert rather than merely empty.
mpris_linux.go is compiled in, because android implies linux. And the
scaffold's foreground service is typed dataSync rather than
mediaPlayback, with no MediaSession and no audio focus, so playback
dies at screen lock and there are no lock-screen controls.

They are all the same question: is the Android app a librarian or a
player? The desktop app is a librarian -- it scans folders, dedupes
covers, rewrites tags on disk -- and that model rests on owning a
filesystem, which is exactly what Android declines to give. So the plan
argues that parity is the wrong target and lays out three coherent
products instead, recommending a MediaStore-backed player.

Four things are worth doing whatever is decided, and the highest
information-per-minute one needs no code: run the published APK on a
real phone. Nothing in sections A or B has been observed on Android,
because the x86_64 emulator cannot run the app and emulator 37 refuses
arm64 images on an x86_64 host.
2026-08-16 17:02:53 -04:00
logan 01706c6053 ci(android): say why the keystore did not open
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m41s
"the keystore did not open — is ANDROID_KEYSTORE_PASSWORD right?" is a
guess, and there are three quite different reasons behind it. The step
distinguishes them now.

**A secret pasted into a web form very often carries a trailing
newline**, and a password is compared byte for byte, so the run failed
with a password that was correct. Reproduced exactly: keytool rejects
`Correct123\n` against a keystore whose password is `Correct123`. CR
and LF are stripped from the password, the alias and the key password
now, and the step says when that mattered.

**A wrong alias failed a minute later, inside Gradle.** It defaults to
`yellowjacket`, so any keystore created with another alias got there.
The alias is checked up front and the failure lists the aliases the
keystore actually holds.

**And a truncated or mis-pasted base64 is a different problem from a
bad password**, so the artifact is described before it is opened: size
and its first four bytes, named as PKCS12 or legacy JKS, with a warning
when the header is neither. A truncation shows up as 300 bytes against
2564.

Verified against real keystores for all five cases: correct, trailing
newline, wrong password, wrong alias, truncated base64.

Decode and build are one step now. Splitting them would mean either
handing the password to a later step through $GITHUB_ENV -- where the
env dump is only masked for values that are verbatim a secret, so a
trimmed one could print in clear -- or repeating the trimming in both.
The failure message also prints the password's length, which is the
one thing that distinguishes "wrong value" from "invisible whitespace",
and only on failure.
2026-08-16 17:00:36 -04:00
logan f7dc76c955 docs(android): an arm64 image will not run on an x86_64 host
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m42s
CI / check (push) Successful in 2m22s
Sync Homebrew formula / sync-formula (push) Successful in 6s
Build & publish the Android APK / apk (push) Failing after 50s
Emulator 37 refuses cross-architecture emulation outright -- "Avd's CPU
Architecture 'arm64' is not supported by the QEMU2 emulator on x86_64
host" -- and there is no flag for it. Google dropped it.

That matters because the previous commit's finding points at arm64 as
the ABI that works, so the obvious next move is to boot an arm64 AVD,
and the obvious next move costs a 3.8 GB download before it fails.
Written down so the next session does not spend it.

The consequence is stated rather than hidden: the claim that arm64
avoids the seccomp trap rests on reading modernc's two code paths, not
on having run it. Verifying it needs an arm64 host, a physical device
or adb connect.
2026-08-16 16:27:52 -04:00
logan ed975019dc fix(dev): the smoke target died silently on a genuinely dead app
Two harness bugs and the finding that exposed them.

**`pidof` exits 1 when it finds nothing**, and under `set -e` a failing
command substitution killed the script before it could print anything
-- rc=1, no output. That was invisible for as long as the app
crash-*looped*, because there is always some pid in that state. It
appeared the moment the app died for good and ActivityManager stopped
respawning it, which is precisely the run you most want output from.

**And an install failure said nothing useful.** Both ways it fails are
about identity rather than the build: INSTALL_FAILED_VERSION_DOWNGRADE
when a bare `make android` (versionCode 1) meets something a versioned
build left behind, and a signature mismatch when a debug-signed local
build meets a release-signed one. Both were hit in one session, and
both are fixed by uninstalling. The target says so now instead of
leaving someone to read the constant name.

The finding: with the startup bug fixed the app reaches the database
and takes SIGSYS on the x86_64 emulator, because modernc.org/libc's
Xlstat64 issues a raw lstat syscall on linux/amd64 and Android's
seccomp filter forbids it -- bionic never issues it. arm64 has no lstat
syscall at all, so ccgo_linux_arm64.go routes Xlstat through fstatat
and is structurally unaffected; Go's own syscall package already used
fstatat on both.

So the default emulator cannot verify this app, and the skill says so
rather than letting the next session read a tombstone as a regression.
2026-08-16 16:25:33 -04:00
logan 0c7f34ab90 fix(android): give the app a home directory so it starts
backend/system resolves config and data from $HOME or the OS
equivalent, and Android has neither: buildUserDirPath switches on
runtime.GOOS with cases for darwin, linux and windows and a default
returning errUnsupportedOS. So NewYellowJacketApp failed and main()
called os.Exit(1) about six milliseconds after the JNI bridge came up.

That failure is invisible in all three places anyone would look. There
is no panic, no AndroidRuntime stack and no tombstone, because os.Exit
is not a crash; Go's stdout does not reach logcat, so the slog line
naming the error is discarded; and ActivityManager respawns the process
fast enough that pidof always answers, so a crash-looping app looks
alive.

main() now sets the override before anything asks for a path.
application.Mobile.StoragePath() is the platform's own answer --
getFilesDir() on Android, Application Support on iOS -- and returns ""
on desktop, where UseHomeOverride is a no-op, so this needs no build
tag and changes nothing off mobile. resolveUserDirPath already honours
YJ_HOME on every OS, so there was a seam for it.

The knowledge stays in main(): backend/system gains no import of the
Wails application package, for the same reason backend/events is split
by the indexbuild tag.

UseHomeOverride's two rules are tested because nothing else would
notice them breaking. An empty base does nothing, which is exactly the
desktop case. And an override already set wins, so YJ_HOME still
relocates a sandbox on the one platform that would otherwise decide for
itself.

This is not the end of the port. The app now reaches the database and
takes SIGSYS on the x86_64 emulator -- modernc.org/libc issues a raw
lstat syscall on linux/amd64 and Android's seccomp forbids it. arm64,
which is what ships to phones, has no lstat syscall at all and routes
through fstatat, so it is structurally unaffected. See NOTES.md.
2026-08-16 16:25:20 -04:00
logan a7a33527c4 docs: record what the Android work established and disproved
Build & publish Arch package / arch-package (push) Successful in 2m26s
CI / e2e (push) Successful in 5m51s
CI / check (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 6s
CLAUDE.md said `wails3 task common:update:build-assets` regenerates
build/ios/ and build/android/. It does not: in beta.8 that command
extracts only updatable_build_assets, which is darwin/ios/linux/windows,
and the android tree comes from `generate build-assets`. It also said
nfpm's homepage and license are left alone by the refresh -- a comment
in that file says the same -- and a refresh reset them to wails.io and
MIT. Both corrected, and the CI section now describes five workflows.

NOTES.md gains the measurements: what cross-compiles and what does not,
the emulator environment, the Wails Android documentation's own two
errors, and the one line that stops the app at runtime --
buildUserDirPath switches on runtime.GOOS and Android takes the default
branch returning errUnsupportedOS, so main() calls os.Exit(1) six
milliseconds after the JNI bridge comes up.

The fix is a documented, build-tag-free API:
application.Mobile.StoragePath() returns the app's private files
directory and returns "" on desktop, and resolveUserDirPath already
lets YJ_HOME override the path on every OS. Deliberately not taken here
-- plan 015 is a pipeline, not a port, and the larger question it does
not answer is that open-directory dialogs return an error on Android
while this app's entire first run is "choose your music folder".
2026-08-16 15:31:18 -04:00
logan 0c6ca72cf1 ci(android): publish a signed APK on every version tag
Builds the fat APK and puts it in Gitea's *generic* package registry,
which unlike the repository is readable without credentials -- the
reason an Obtainium client can poll a plain URL with no token and no
public mirror of the source. A versioned copy for history, a fixed
`latest` URL to watch.

**Its own workflow, not a job in ci.yml.** That workflow runs on every
branch push and is the one that gates; this takes tens of minutes on a
cold cache and the runner has capacity 1, so hanging it off the gate
would put every push behind an SDK download.

**Keyed on the tag.** The ljos pipeline this is modelled on computes a
version in CI and cuts the release itself, then gates its Android job
on needs.release.outputs.version with an always() whose absence
silently kills the manual path. This repo has no release automation --
tags are pushed by hand and homebrew-formula.yml already keys on v* --
so the tag is the version and none of that machinery, or its failure
modes, is needed.

**No continue-on-error**, which that pipeline does carry: there the
Android job shares a workflow with a server deploy that must never go
red over a phone build. Here it is standalone and can neither delay nor
redden anything, so a release step that fails silently would be
strictly worse than one that fails visibly.

Four gates before anything is published, each checked against a real
APK: a non-empty artifact, both ABIs present, a versionCode equal to
the one derived from the tag, and -- verified by pointing it at a
deliberately debug-signed build, which it refused -- **not signed with
the debug key**. Android refuses to update an app whose signing
certificate changed and the only remedy is an uninstall that takes the
user's library with it, so the job also refuses to *build* without the
keystore secret rather than falling through to Gradle's debug default.

The keystore is opened with `keytool -list` before Gradle runs, because
Gradle only notices a bad password at :app:validateSigningRelease, a
minute of build time in, and reports it as a missing file. And nothing
pipes into `head`: under pipefail it exits after one line, the producer
takes SIGPIPE and the step fails with 141 having already printed a
perfectly good APK.

Two secrets, not four. keytool has produced PKCS12 by default since
JDK 9 regardless of the .jks extension, and PKCS12 cannot hold a key
password distinct from the store password -- given one it says so and
ignores it. So ANDROID_KEY_PASSWORD defaults to the store password and
the alias to a documented default.

The Wails CLI needs no caching hack here: it is a vendored `go tool`
and the runner already bind-mounts GOCACHE for every job, so it is warm
from ci.yml's own bindings-check. A fourth cache volume for
GRADLE_USER_HOME saves ~700MB a run.
2026-08-16 15:31:18 -04:00
logan 68468e5378 feat(dev): an Android failure looks exactly like a success
The APK installs and launches. It also dies six milliseconds later, and
finding that out cost a cycle for three reasons that have nothing to do
with the bug itself:

**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go
to /dev/null, so every slog line -- including the one naming the error
the app is about to exit on -- is discarded. `setprop
log.redirect-stdio true` does not help: that redirects the Java
runtime's System.out, and our code is a c-shared native library.

**os.Exit leaves no evidence.** No panic, no AndroidRuntime stack,
nothing in /data/tombstones, nothing in `logcat -b crash` or dropbox.
All three places anyone would look are empty, and the one signal that
is present -- "Zygote: exited due to signal 9" -- reads as "the system
killed it" and sends you after the low-memory killer.

**ActivityManager restarts it faster than you can observe.** pidof
always answers and `am start` always reports Status: ok, so a
crash-looping app looks alive. "Did it start" is the wrong question;
`make android-smoke` asks whether it is the *same pid* N seconds later,
and prints the filtered logcat plus how to read it when it is not.

The tell, once known: "I/WailsBridge: Wails bridge initialized"
followed immediately by a new pid doing the same thing.

scripts/android-emulator.sh follows dev-headless.sh's shape --
background start, saved-PID stop, filtered log tail, never pkill -f.
Two scaffold tasks are deliberately not wrapped: `android:logs` greps
logcat for (Wails|yellowjacket), which catches the WailsBridge tag but
misses the app's own process tag (app.yellowjacket is lowercase) and
misses ActivityManager's "has died" line, which is the one that says it
crashed; and `ensure-emulator` boots whatever `-list-avds | tail -1`
returns, with no pidfile and no boot wait, so it cannot be sequenced.

One environment note that is not obvious on Arch: Gradle needs a
platform and /opt/android-sdk has none, so ANDROID_SDK defaults to
~/Android/Sdk while ANDROID_NDK points at /opt/android-ndk. Two SDKs,
one for each half of the build.
2026-08-16 15:31:18 -04:00
logan 6fbb62730d fix(android): build a release APK that is releasable
Three edits to the scaffold, each of which the generated tree gets
wrong for a shipped app.

**The phone ABI got a debug library.** Upstream's `build` task forwards
ARCH to compile:go:shared but not PRODUCTION, so the arm64 leg
recomputed BUILD_FLAGS against an unset variable and took the debug
branch -- while amd64, which package:fat calls directly with
PRODUCTION: "true", was correct. A release APK therefore shipped a 40MB
unstripped debug library for the only ABI a release is for, beside a
31MB production one for the emulator. 34MB APK before, 27MB after.

**The APK could be installed once and never updated.** Android orders
releases by versionCode and refuses anything not greater than what is
installed; the scaffold hardcodes 1, so the first install would have
been the last and the only way out is an uninstall, which takes the
user's library with it. It comes from YJ_VERSION_CODE now, which CI
derives from the tag (1.3.1 -> 10301, monotonic while minor and patch
stay under 100), with a default that keeps a local build working.

Integer.parseInt, not `(...) as Integer`: Groovy binds the call
parentheses to versionCode before the cast, so the latter reads as
`versionCode("1") as Integer` -- it sets a String, then casts the
setter's null return, and Gradle fails the whole project with "Value is
null" pointing at that line.

**And it identified itself as com.wails.app.** applicationId is
app.yellowjacket now, matching build/config.yml's productIdentifier,
and the label is YellowJacket rather than "Wails App".

Two things follow from that rename and both bite:

The identity is declared twice. applicationId is what Gradle installs;
APP_ID in build/android/Taskfile.yml is what every adb-driven task
uninstalls, launches and filters, and nothing enforces agreement.
ANDROID.md says to set APP_ID in build/config.yml -- that does nothing
in beta.8, checked both ways: `wails3 task` builds its var set from CLI
KEY=VALUE arguments and the Taskfile tree and never reads config.yml,
and even when set it feeds only those adb commands, never Gradle.

And `namespace` deliberately stays com.wails.app, because that is the
Java package MainActivity and WailsBridge live in and renaming it means
renaming their source. So the launcher activity is
app.yellowjacket/com.wails.app.MainActivity, and the short
`.MainActivity` form resolves the dot against the applicationId and
fails with a class-not-found that reads like a broken build.
2026-08-16 15:30:35 -04:00
logan 48b37f6301 build(android): carry the Wails Android scaffolding verbatim
Plan 015 phase 0 established that this app cross-compiles for Android
with no source changes at all. A CGO_ENABLED=0 probe of the whole tree
for android/arm64 fails on exactly two packages -- ebitengine/oto/v3
and wails/v3/pkg/application -- and both fail only because their
Android implementation is cgo, which is what the NDK supplies. Notably
modernc.org/sqlite, the entire database layer and the thing most likely
to have no Android target, is clean. The fat APK (arm64-v8a + x86_64)
builds in about 25 seconds.

So build/android/ stops being ignored. This commit is the tree exactly
as `wails3 generate build-assets` emits it, so that the next commit is
a readable diff of what we changed and a future refresh has something
to compare against.

Two things about how it is carried:

`wails3 update build-assets` does NOT generate it, contrary to what
CLAUDE.md has claimed since the v3 migration. In beta.8 that command
extracts only internal/commands/updatable_build_assets, which is
darwin/ios/linux/windows; the android tree comes from `generate
build-assets`, which rewrites the whole of build/. It was generated
once into a scratch directory and copied across, so from here it is
committed and hand-edited like source. Only its output is ignored --
jniLibs (~60MB of per-ABI c-shared libraries), gen/, overlay.json and
Gradle's own directories.

And it brings one Go file into ./... -- scripts/deps/install_deps.go,
the interactive SDK installer behind `task android:install:deps`, which
trips 24 of our strict linters. golangci excludes the directory rather
than reformatting upstream's file, which the next refresh would undo
and which would make the diff against upstream unreadable. This repo
uses `make android-setup` instead.
2026-08-16 15:30:13 -04:00
logan 66182f82cd fix(indexbuild): repair the one database a squash cannot reach
Build & publish Arch package / arch-package (push) Successful in 2m34s
CI / check (push) Successful in 2m36s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m32s
The index job's /cache volume is a real YJ_HOME that outlives every
run, so plan 013's reshaped audio_files met a database still in the
old shape: `CREATE INDEX ... album_id` against a table without that
column, on every launch. "Delete and rescan" is the squash's answer
and is free everywhere except here, where half the file is the catalog
and deleting it costs ~205GB of downloading.

indexbuild now drops every table datamap does not classify as Cache
before the schema is applied. Nothing scans, plays or authors in that
database, so its non-catalog half is empty by construction and a shape
the schema stopped describing is pure liability; the catalog is never
touched.

TestRetireLibraryTables reproduces the failure symptom-first: build
the real schema, put audio_files back the way the volume had it,
assert the open fails, then assert the repair makes it open with the
catalog row still there.
2026-08-16 15:09:09 -04:00
logan 18aba34c08 test(e2e): a track plays the list it is in, not a queue of one
Build & publish Arch package / arch-package (push) Successful in 2m31s
CI / check (push) Successful in 2m46s
Search index maintenance / maintain-index (push) Failing after 5s
CI / e2e (push) Successful in 6m30s
e7748f1 made double-click and single-row Play queue the list as
displayed with startIndex on that row; this frozen spec still asserted
a queue of one, and was the only failure in both the chromium and
webkit runs on main.

It asserts the new contract instead: more than one track queued,
currentIndex on the row that was activated, and the panel showing that
queue rather than some other one.
2026-08-16 14:51:07 -04:00
logan b98840ee37 fix(build): keep the index tools free of the Wails application
The v3 migration put application.Get() in backend/events and a
ServiceStartup hook in backend/explore, both of which cmd/indexbuild
reaches. v3's application package is GTK/WebKit bindings on Linux, so
the index-artifact job — a plain golang container with CGO_ENABLED=0,
on the stated grounds that neither command imports the app — stopped
compiling with "undefined: pointer". That job owns the ~205 GB dump
checkpoint, so it is the worst place to learn this.

Both are behind the indexbuild tag now: the one app.Event.Emit lives in
runtime_wails.go, runtime_indexbuild.go answers ErrNoRuntime (what the
app itself returns before Run, so Deliver's callers need no second
path), and explore's ServiceStartup moves to its own tagged file.

TestIndexToolsDoNotImportWails walks `go list -deps -tags indexbuild`
so the claim the workflow makes is checked rather than assumed.
2026-08-16 14:51:01 -04:00
yonluandClaude Opus 5 dd17a4d8eb Merge origin/main into wails-v3
Build & publish Arch package / arch-package (push) Successful in 2m39s
Search index maintenance / maintain-index (push) Failing after 23s
CI / e2e (push) Failing after 6m17s
CI / check (push) Successful in 2m37s
21 conflicts, all from the same cause: three features were developed on
both lines and this branch's copies are the ones adapted to v3's
bindings and to the file-shaped schema. Resolutions:

- `frontend/wailsjs/` stays deleted — v2's generated bindings, replaced
  by `frontend/bindings/`.
- remove-from-library, `library-status.ts`, the requested-badge spec and
  its component test: took this branch's copies, which differ from
  main's only in calling `pruneEmptyEntities`/`CountAudioFiles`,
  importing `@go/download/models.js`, and staging a real UUID for the
  catalog's `CHECK(length(mbid) = 16)`.
- `GetFilePathsByRecordingMBIDsByLibrary` dropped: it joined
  `recordings`, which no longer exists, and `library_id = 0` answers
  both scoped and unscoped now. `GetAudioFilesByPaths` was already here.
- The album page, the artist page and the library badge kept this
  branch's versions, which supersede main's: ownership asked once from
  the files, the partial-completeness ring, and the request action.
- Docs: no migration chain (013) over main's two-file column rule and
  its pre-1.0 squashing note, both of which 013 retired. Kept main's
  `CreateSmartPlaylist` read-pool example, which is a real second
  instance of that bug.

Verified on the merge result, not on either parent: lint clean in all
three build configurations, `make test` green in all three, 776 Vitest
tests, `tsc --noEmit`, bindings-check and skill-check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-16 14:09:12 -04:00
yonluandClaude Opus 5 e7748f1fd5 feat(database): shape the library like files, and shrink the catalog
CI / check (push) Successful in 3m7s
CI / e2e (push) Canceled after 1m45s
Plans 013 and 014, the album page that prompted them, and the smaller
fixes they turned up. Changelog, largest first.

## The local library is shaped like files, not like MusicBrainz

`audio_files` carries its own tags and points at `albums` and
`artists`; `file_genres` is the one real many-to-many. `recordings`,
`release_group_recordings`, `artist_credit`, `artist_credit_artist`,
`recording_genres`, `release_groups` and `release_to_rg` are gone from
the local side, and with them a six-way join in every read, a
`MIN(release_group_id)` subquery in eleven queries and a
first-credited-artist subquery in nine. Measured on a real 25,966-file
library, every many-to-many that model expressed was 1:1 in the data.

- Ownership is a file. `GetFilePathsByRecordingMBIDs`,
  `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
  `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812
  orphaned recordings, 216 release groups and 260 artists that library
  carried are now structurally impossible.
- One projection: every track query selects from the `track_metadata`
  view, one row type, one mapper. Nine hand-rolled copies had drifted
  far enough to report different years on different screens.
- `library_id = 0` means every library, so each list query exists once
  instead of scoped and unscoped with a branch at every call site.
- No migration chain. `sql/schemas/` is the one description of the
  shape; `sql/migrations/`, `applyMigrations` and `schema_migrations`
  are squashed away, along with the drift between them that had sqlc
  generating against a stale schema.
- `database.InsertTestTrack` is the one test seeder; twenty test files
  had been assembling the old FK chain each in its own order.

## The catalog stores its ids as bytes

`explore_index`'s three 36-char MBID columns and its entity-type text
are 16 raw bytes and a small integer. The table and its six indexes go
780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh
install is ~0.6 GB rather than ~1.0 GB.

- `backend/explore/mbid.go` is the only place the encoding is known;
  everything above it speaks dashed strings.
- `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert
  rather than silently returning no rows, since SQLite does not coerce
  between TEXT and BLOB.
- The importer asks the artifact what encoding it carries and converts
  on the way in, so the artifact already published keeps working and no
  format bump is needed.
- `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column
  list, and `TestStoredEncodingRoundTrips` sweeps every read path.

## An album page that says how much of the album is yours

- One question, asked once: is there a file. `filePaths` is filled by a
  single batched lookup when the tracklist settles, and the badge, the
  Play count, the dimmed rows and every menu item read it — replacing
  four claims of decreasing confidence that could show a green tick on
  an album whose every action did nothing.
- Play, Play 7 of 12, or no play button at all.
- `total_tracks` on `explore_index` (~2 bytes over 400,677 release
  groups) and on `audio_files` from tags that have always carried it:
  a complete MBID-matched album now makes no catalog call at all, where
  it used to spend the most expensive request the app makes.
- A merged cluster shows the running order the most releases agree on,
  and the version list marks the release you own rather than standing a
  synthetic entry in for it.
- `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed
  one by a 12-second timer.
- Rows not in the library are dimmed in place (with `aria-disabled`)
  instead of the owned ones wearing a green tick and a legend.

## Caches and cover art get ceilings

- Only the three tiers of a cover are stored; the full-resolution copy
  nothing rendered was 1,134 MB of a 1.4 GB covers directory.
- One artist portrait is downloaded and the rest are remembered as
  URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads.
- `browsedArtBudget` and `httpCacheBudget` bound what an age cannot:
  the same install held art for 5,770 artists in a 1,301-artist
  library.
- `OrphanedArtistImagesJob` joined a bare MBID onto a sharded
  directory, so it deleted the rows that were the only record of the
  files it left behind. `explore.ArtistImageDir` is that layout's one
  definition now.

## The autotag queue asks whether there is work

`tagging_items` was a row per album folder, not a queue, and no query
read the `tag_status` column that held the answer. The four queue
queries ask the files, which matters most where it is least visible:
`startPrefetch` was scoring every album in a tagged library against
MusicBrainz.

## Phantom playlist tracks resolve in place

An M3U8 imported before its files leaves phantom rows; they now match
by path and fall back to position, keep their place in the playlist
when resolved, and pair best-first so two phantoms cannot claim the
same file.

## Playing a track plays the list it is in

Double-click, and Play on a single row's menu, queue the list as
displayed with `startIndex` on that row — the album page and the track
list used to queue one track and discard the album around it. A
multi-row selection still plays exactly itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-16 13:58:15 -04:00
yonluandClaude Opus 5 1128881e8d docs(wails): move the prose onto v3 and record Phase 7
CI / check (push) Successful in 4m49s
CI / e2e (push) Successful in 6m8s
CLAUDE.md gains a Packaging section for the four Taskfile facts the
recipes just needed — wails3 on PATH by bare name, no -ldflags on
`wails3 build`, bin/ not build/bin/, and bundling as its own step —
plus how build/'s platform metadata generates from build/config.yml and
what that refresh overwrites.

Its lifecycle, bindings, harness, events and CI sections were still
describing v2. The events one matters most: the rule to emit through
events.Emit survives, but its justification is now the weaker one, and
saying so is the point of the migration. v2's runtime.EventsEmit
log.Fatalf'd on any context not carrying the runtime; v3's emit takes
no context at all, so what is left to pin is that one emit path is what
lets emitStatus drop an unchanged payload for every caller at once.

README told a contributor to `go install wails/v2/cmd/wails` and
apt-get libgtk-3-dev/libwebkit2gtk-4.1-dev; the CLI is vendored and the
stack is GTK4 + WebKitGTK 6.0. Two comments claiming Xvfb and one
claiming frontend/wailsjs go with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 23:09:36 -04:00
yonluandClaude Opus 5 cad3d1339b fix(packaging): put the release recipes on v3's build
Neither packaging/arch/PKGBUILD nor the Homebrew formula had been run
since Phase 1, and both were still calling v2's CLI: `wails3 build`
takes -tags, -obfuscated and -garbleargs and nothing else, so
`-clean -trimpath -ldflags` fails at the flag parser. Both also
installed from build/bin/, which is v2's output path — v3 writes to
bin/, and build/ is tracked build assets now.

Three more things the tree needs that neither recipe had. The tasks
invoke `wails3` by bare name, so scripts/toolbin has to be on PATH or
the build dies at its first sub-task. `wails3 build` has no -ldflags at
all, and build:native computes BUILD_FLAGS in its own vars: so a CLI
variable cannot override it — LDFLAGS_EXTRA is appended inside the
production -ldflags string instead, on linux and darwin alike, empty by
default so make build-dev/build-prod are unchanged. And bundling is a
separate step from building: `task build` produces a bare binary on
both platforms, so the formula's macOS path runs `task package`.

The build assets were the scaffold's, not this app's. Info.plist named
CFBundleExecutable `yjref` and com.example.yjref, nfpm packaged
./bin/yjref, the .desktop template said "A yjref application" — an .app
built from that plist would not have launched. They generate from
build/config.yml, whose info block had never been filled from
wails.json either; `wails3 task common:update:build-assets` is the fix.
nfpm's homepage and license are not derived from it and are set by
hand, which is noted in place, and the refresh regenerates build/ios
and build/android, which this repo does not carry.

arch-package.yml's pacman list moves to webkitgtk-6.0/gtk4 to match the
PKGBUILD's depends(): makepkg installs nothing itself, so a mismatch
fails at link time rather than at check time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 23:09:24 -04:00