Merge wip into main: Homebrew tap + smartplaylist materialization
Build & publish Arch package / arch-package (push) Successful in 2m11s
Sync Homebrew formula / sync-formula (push) Successful in 5s

Adds the Homebrew tap formula and Gitea release-sync workflow, plus
smartplaylist materialize-on-creation, volume-control scroll/drag, and
smartplaylist cover-art batch loading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 15:04:27 -04:00
co-authored by Claude Opus 4.8
9 changed files with 608 additions and 61 deletions
+86
View File
@@ -0,0 +1,86 @@
name: Sync Homebrew formula
# On every version tag, recompute the release tarball checksum and push an
# updated Formula/yellowjacket.rb into the Homebrew tap repo. Keeping the tap
# in a separate repo (github.com/Shadow-Puppet/homebrew-yellowjacket) is what
# lets users install with a single command:
#
# brew install shadow-puppet/yellowjacket/yellowjacket
#
# (`shadow-puppet/yellowjacket` is shorthand for the homebrew-yellowjacket repo;
# brew auto-taps it, so no separate `brew tap` step is needed.)
on:
push:
tags:
- "v*"
jobs:
sync-formula:
runs-on: ubuntu-latest
env:
# GitHub PAT (or fine-grained token) with write access to the tap repo.
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
# Gitea source that serves the release tarball referenced by the formula.
SOURCE_TARBALL_BASE: https://git.ljones.me/yonlu/yellowjacket/archive
# separate GitHub tap repo the formula is published to.
TAP_REPO: Shadow-Puppet/homebrew-yellowjacket
steps:
- name: Check out source (for the canonical formula)
uses: actions/checkout@v4
- name: Compute version and tarball checksum
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}" # e.g. v1.3.0
VERSION="${TAG#v}" # e.g. 1.3.0
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
echo "Fetching ${TARBALL}"
# Retry briefly: the tag archive can lag a few seconds behind the push.
for attempt in 1 2 3 4 5; do
if curl -fSsL "$TARBALL" -o release.tar.gz; then
break
fi
echo "attempt ${attempt} failed, retrying..."
sleep 5
done
SHA256="$(sha256sum release.tar.gz | cut -d' ' -f1)"
echo "version=${VERSION} sha256=${SHA256}"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "SHA256=${SHA256}" >> "$GITHUB_ENV"
- name: Render the formula with the new version and checksum
run: |
set -euo pipefail
src="packaging/homebrew/Formula/yellowjacket.rb"
# Rewrite only the two managed lines; the interpolated url picks up the
# new version automatically.
sed -E \
-e "s|^ version \".*\"| version \"${VERSION}\"|" \
-e "s|^ sha256 \".*\"| sha256 \"${SHA256}\"|" \
"$src" > yellowjacket.rb
echo "----- rendered formula -----"
cat yellowjacket.rb
- name: Push to the Homebrew tap repo
run: |
set -euo pipefail
git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap
mkdir -p tap/Formula
cp yellowjacket.rb tap/Formula/yellowjacket.rb
cd tap
git config user.name "yellowjacket-ci"
git config user.email "yj@yellowjacket.app"
if git diff --quiet; then
echo "Formula already up to date; nothing to push."
exit 0
fi
git add Formula/yellowjacket.rb
git commit -m "yellowjacket ${VERSION}"
git push origin HEAD:main
+3
View File
@@ -202,6 +202,9 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
yj.playlist.EnsureDefaultPlaylist()
// Recover playlists that lost tracks from a pre-fix FullRescan.
go yj.playlist.RepopulateFromM3U()
// Backfill snapshots for smart playlists created before
// creation-time materialization existed.
go yj.playlist.MaterializeUnmaterializedSmartPlaylists()
// Initialize speaker hardware (player struct created in
// NewYellowJacketApp for Wails binding registration).
+74 -2
View File
@@ -2591,9 +2591,9 @@ func (s *Service) CreateSmartPlaylist(
)
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
_ = rows.Close()
return Summary{}, fmt.Errorf(
"failed to create smart playlist: %w",
errNoRowReturned,
@@ -2610,6 +2610,8 @@ func (s *Service) CreateSmartPlaylist(
if err := rows.Scan(
&id, &retName, &createdAt, &updatedAt,
); err != nil {
_ = rows.Close()
s.logger.Error(
"Failed to create smart playlist",
"name", trimmed, "err", err,
@@ -2620,11 +2622,26 @@ func (s *Service) CreateSmartPlaylist(
)
}
// Close before RefreshSmartPlaylist issues its own queries
// (MaxOpenConns=1 test DBs would deadlock).
_ = rows.Close()
s.logger.Info(
"Smart playlist created",
"id", id, "name", retName,
)
// Materialize the rule set once at creation so the playlist has a
// track snapshot immediately (track counts, instant open). A failed
// evaluation is non-fatal — the lazy path re-materializes on first
// open.
if err := s.RefreshSmartPlaylist(id); err != nil {
s.logger.Warn(
"Failed to materialize smart playlist at creation",
"id", id, "err", err,
)
}
summary := Summary{
ID: id,
Name: retName,
@@ -2821,6 +2838,61 @@ func (s *Service) GetSmartPlaylistTracks(
return s.GetPlaylistTracks(playlistID)
}
// MaterializeUnmaterializedSmartPlaylists evaluates and snapshots any
// smart playlist that has never been materialized (smart_snapshot_at
// IS NULL) — e.g. playlists created before creation-time
// materialization existed. It runs once at startup and is idempotent:
// once every smart playlist has a snapshot it becomes a no-op. Errors
// on individual playlists are logged and skipped so one bad rule set
// doesn't block the rest.
func (s *Service) MaterializeUnmaterializedSmartPlaylists() {
// SAFETY: Static SELECT for smart_snapshot_at column not yet in
// sqlc schema. No parameters.
rows, err := s.db.QueryContext(
`SELECT id FROM playlists
WHERE is_smart = 1 AND smart_snapshot_at IS NULL`,
)
if err != nil {
s.logger.Error(
"Failed to list unmaterialized smart playlists",
"err", err,
)
return
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
s.logger.Error(
"Failed to scan smart playlist id", "err", err,
)
continue
}
ids = append(ids, id)
}
// Close before RefreshSmartPlaylist issues its own queries
// (MaxOpenConns=1 test DBs would deadlock).
_ = rows.Close()
for _, id := range ids {
if err := s.RefreshSmartPlaylist(id); err != nil {
s.logger.Warn(
"Failed to materialize smart playlist snapshot",
"id", id, "err", err,
)
continue
}
}
}
// EvaluateSmartPlaylist loads the rule set for a smart playlist
// from the database and evaluates it against the track library,
// returning the matching tracks.
+174 -50
View File
@@ -555,11 +555,7 @@ const leanTrackQuery = `SELECT
af.bitrate,
af.file_size,
af.play_count,
COALESCE(af.last_played, '') AS last_played,
af.cover_art_path,
af.artist_mbid,
af.release_group_mbid,
af.recording_mbid
COALESCE(af.last_played, '') AS last_played
FROM (
SELECT
af.id,
@@ -591,15 +587,7 @@ FROM (
af.file_size,
af.library_id,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE((SELECT a.mbid
FROM artist_credit_artist aca
JOIN artists a ON a.id = aca.artist_id
WHERE aca.credit_id = ac.id
LIMIT 1), '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
af.last_played
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
@@ -610,7 +598,6 @@ FROM (
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
) af`
@@ -719,6 +706,38 @@ func Evaluate(
genreDuration := time.Since(genreStart)
// Batch-load cover art + MusicBrainz IDs for the matched rows only.
// These fields are presentation-only (track-row styling); keeping
// them out of the lean query avoids a per-row correlated subquery
// and cover-art join over the whole library before WHERE/LIMIT.
artStart := time.Now()
artworkByRecording, err := fetchArtwork(db, recordingIDs)
if err != nil {
return nil, err
}
for i, rid := range recordingIDs {
art, ok := artworkByRecording[rid]
if !ok {
continue
}
tracks[i].ArtistMBID = art.artistMBID
tracks[i].ReleaseGroupMBID = art.releaseGroupMBID
tracks[i].RecordingMBID = art.recordingMBID
if art.coverArtPath != "" {
urls := coverart.ResolveURLs(art.coverArtPath)
tracks[i].CoverArtPath = urls.Original
tracks[i].CoverArtSmall = urls.Small
tracks[i].CoverArtMedium = urls.Medium
tracks[i].CoverArtLarge = urls.Large
}
}
artDuration := time.Since(artStart)
// Apply genre-sort and deferred LIMIT in Go if needed.
if sortByGenre {
dir := 1
@@ -743,6 +762,7 @@ func Evaluate(
"tracks", len(tracks),
"main_ms", mainDuration.Milliseconds(),
"genres_ms", genreDuration.Milliseconds(),
"artwork_ms", artDuration.Milliseconds(),
"total_ms", time.Since(start).Milliseconds(),
)
@@ -778,11 +798,6 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
fileSize int64
playCount int64
lastPlayed string
coverArtPath string
artistMBID string
releaseGroupMBID string
recordingMBID string
)
if err := rows.Scan(
@@ -792,8 +807,6 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
&sampleRate, &bitDepth, &channels,
&bitrate, &fileSize,
&playCount, &lastPlayed,
&coverArtPath, &artistMBID,
&releaseGroupMBID, &recordingMBID,
); err != nil {
return nil, nil, fmt.Errorf(
"could not scan smart playlist row: %w", err,
@@ -801,34 +814,23 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
}
track := library.Track{
TrackName: title,
ArtistName: artistName,
TrackLength: strconv.FormatInt(lengthMs, 10),
FilePath: filePath,
TrackNumber: trackNumber.Int64,
DiscNumber: discNumber.Int64,
Album: album,
Year: year,
Composer: composer,
FileType: fileType,
SampleRate: sampleRate,
BitDepth: bitDepth,
Channels: channels,
Bitrate: bitrate,
FileSize: fileSize,
PlayCount: playCount,
LastPlayed: lastPlayed,
ArtistMBID: artistMBID,
ReleaseGroupMBID: releaseGroupMBID,
RecordingMBID: recordingMBID,
}
if coverArtPath != "" {
urls := coverart.ResolveURLs(coverArtPath)
track.CoverArtPath = urls.Original
track.CoverArtSmall = urls.Small
track.CoverArtMedium = urls.Medium
track.CoverArtLarge = urls.Large
TrackName: title,
ArtistName: artistName,
TrackLength: strconv.FormatInt(lengthMs, 10),
FilePath: filePath,
TrackNumber: trackNumber.Int64,
DiscNumber: discNumber.Int64,
Album: album,
Year: year,
Composer: composer,
FileType: fileType,
SampleRate: sampleRate,
BitDepth: bitDepth,
Channels: channels,
Bitrate: bitrate,
FileSize: fileSize,
PlayCount: playCount,
LastPlayed: lastPlayed,
}
tracks = append(tracks, track)
@@ -929,6 +931,128 @@ func fetchGenres(
return result, nil
}
// trackArtwork holds the presentation-only cover-art path and
// MusicBrainz identifiers attached to a matched track after the main
// filter query, keyed by recording_id.
type trackArtwork struct {
coverArtPath string
artistMBID string
releaseGroupMBID string
recordingMBID string
}
// fetchArtwork batch-loads cover-art paths and MusicBrainz IDs for the
// given recording_ids in a single IN-list query. These fields drive
// track-row styling only, so scoping them to the matched result set
// keeps the cost proportional to results rather than library size.
func fetchArtwork(
db *database.DB, ids []int64,
) (map[int64]trackArtwork, error) {
if len(ids) == 0 {
return nil, nil
}
// Deduplicate to keep the IN list minimal.
seen := make(map[int64]struct{}, len(ids))
unique := make([]int64, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
if len(unique) == 0 {
return nil, nil
}
placeholders := make([]string, len(unique))
for i := range unique {
placeholders[i] = "?"
}
inList := strings.Join(placeholders, ", ")
// A recording's artist credit can name several artists; the old
// correlated subquery picked one via LIMIT 1. GROUP BY r.id with
// MIN() reproduces a single stable value without multiplying rows.
// SAFETY: placeholders are static "?" tokens; every value is
// parameterized. The IN list is bound twice (subquery + outer).
query := `SELECT r.id,
COALESCE(MIN(ca.file_path), '') AS cover_art_path,
COALESCE(MIN(a.mbid), '') AS artist_mbid,
COALESCE(MIN(rg.mbid), '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM recordings r
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
WHERE recording_id IN (` + inList + `)
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE r.id IN (` + inList + `)
GROUP BY r.id`
args := make([]any, 0, len(unique)*2)
for range 2 {
for _, id := range unique {
args = append(args, id)
}
}
rows, err := db.QueryContext(query, args...)
if err != nil {
return nil, fmt.Errorf(
"smart playlist artwork fetch failed: %w", err,
)
}
defer func() { _ = rows.Close() }()
result := make(map[int64]trackArtwork, len(unique))
for rows.Next() {
var (
rid int64
art trackArtwork
)
if err := rows.Scan(
&rid, &art.coverArtPath, &art.artistMBID,
&art.releaseGroupMBID, &art.recordingMBID,
); err != nil {
return nil, fmt.Errorf(
"could not scan smart playlist artwork row: %w", err,
)
}
result[rid] = art
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"smart playlist artwork iteration error: %w", err,
)
}
return result, nil
}
// ParseRuleSet parses a JSON string into a validated RuleSet.
func ParseRuleSet(jsonStr string) (RuleSet, error) {
var rs RuleSet
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
)
@@ -812,6 +813,85 @@ func TestEvaluate_TextIs(t *testing.T) {
}
}
// TestEvaluate_ArtworkEnrichment verifies the presentation-only
// cover-art and MusicBrainz-ID fields are attached to matched tracks
// by the batched fetchArtwork pass (they are no longer part of the
// lean filter query).
func TestEvaluate_ArtworkEnrichment(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
// Minimal FK chain: cover_art → release_group(mbid) →
// release_group_recordings → recording(mbid) → audio_file, plus
// artist_credit → artist_credit_artist → artist(mbid).
exec := func(query string, args ...any) {
t.Helper()
if _, err := db.ExecContext(query, args...); err != nil {
t.Fatalf("seed %q: %v", query, err)
}
}
// file_types are pre-seeded by the schema (id 0 = .mp3).
exec("INSERT INTO cover_art (id, file_path, mime_type) " +
"VALUES (1, '/covers/abc123.jpg', 'image/jpeg')")
exec("INSERT INTO artists (id, name, mbid) " +
"VALUES (1, 'Queen', 'artist-mbid-1')")
exec("INSERT INTO artist_credit (id, text) VALUES (1, 'Queen')")
exec("INSERT INTO artist_credit_artist (credit_id, artist_id) " +
"VALUES (1, 1)")
exec("INSERT INTO release_groups (id, name, cover_art_id, mbid) " +
"VALUES (1, 'A Night at the Opera', 1, 'rg-mbid-1')")
exec("INSERT INTO recordings (id, name, artist_credit_id, mbid) " +
"VALUES (1, 'Bohemian Rhapsody', 1, 'rec-mbid-1')")
exec("INSERT INTO release_group_recordings " +
"(release_group_id, recording_id) VALUES (1, 1)")
exec("INSERT INTO audio_files (id, file_path, " +
"length_milliseconds, recording_id, file_type_id) " +
"VALUES (1, '/music/bohemian.mp3', 354000, 1, 0)")
tracks, err := Evaluate(db, RuleSet{
Rules: []Rule{
{Field: "artist", Operator: "is", Value: "Queen"},
},
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(tracks))
}
tr := tracks[0]
if tr.ArtistMBID != "artist-mbid-1" {
t.Errorf("ArtistMBID = %q, want artist-mbid-1", tr.ArtistMBID)
}
if tr.ReleaseGroupMBID != "rg-mbid-1" {
t.Errorf("ReleaseGroupMBID = %q, want rg-mbid-1",
tr.ReleaseGroupMBID)
}
if tr.RecordingMBID != "rec-mbid-1" {
t.Errorf("RecordingMBID = %q, want rec-mbid-1",
tr.RecordingMBID)
}
wantURLs := coverart.ResolveURLs("/covers/abc123.jpg")
if tr.CoverArtPath != wantURLs.Original {
t.Errorf("CoverArtPath = %q, want %q",
tr.CoverArtPath, wantURLs.Original)
}
if tr.CoverArtSmall != wantURLs.Small {
t.Errorf("CoverArtSmall = %q, want %q",
tr.CoverArtSmall, wantURLs.Small)
}
}
func TestEvaluate_TextContains(t *testing.T) {
t.Parallel()
@@ -6,14 +6,28 @@ import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.
import { PlayerController } from '@store/controllers/player-controller';
import { designTokens } from '../../../styles/tokens.css';
/** Volume change (0-100) applied per scroll-wheel tick. */
const WHEEL_STEP = 5;
/** Delay before a live volume change is pushed to the backend. */
const VOLUME_DEBOUNCE_MS = 60;
@customElement('volume-control')
export class VolumeControl extends LitElement {
private player = new PlayerController(this);
private boundHandleOutsideClick = this.handleOutsideClick.bind(this);
private volumeDebounceTimer?: ReturnType<typeof setTimeout>;
@state()
private showSlider = false;
// Locally-tracked volume while the user is actively dragging or scrolling.
// The store's volume only updates once the backend echoes VolumeChanged
// (which we debounce), so we track intent here for responsive UI and to let
// rapid events accumulate. Cleared once the store catches up.
@state()
private pendingVolume: number | null = null;
static override styles = [designTokens, css`
:host {
position: relative;
@@ -70,8 +84,12 @@ export class VolumeControl extends LitElement {
// DERIVED STATE
// ===================================================================
private get currentVolume(): number {
return this.pendingVolume ?? this.player.volume;
}
private get volumeIcon(): string {
const vol = this.player.volume;
const vol = this.currentVolume;
if (vol === 0) return 'volume-xmark';
if (vol <= 50) return 'volume-low';
@@ -86,6 +104,15 @@ export class VolumeControl extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleOutsideClick);
clearTimeout(this.volumeDebounceTimer);
}
override willUpdate() {
// Once the backend has echoed our pending change back through the store,
// drop the local override so external volume changes are reflected again.
if (this.pendingVolume !== null && this.player.volume === this.pendingVolume) {
this.pendingVolume = null;
}
}
// ===================================================================
@@ -113,21 +140,37 @@ export class VolumeControl extends LitElement {
}
private handleInput(e: Event) {
const value = (e.target as WaSlider).value;
this.player.setVolume(value);
this.changeVolume((e.target as WaSlider).value);
}
private handleWheel(e: WheelEvent) {
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
this.changeVolume(this.currentVolume + direction * WHEEL_STEP);
}
private handlePopupClick(e: Event) {
e.stopPropagation();
}
/** Update the UI immediately and push to the backend on a short debounce. */
private changeVolume(value: number) {
const clamped = Math.max(0, Math.min(100, Math.round(value)));
this.pendingVolume = clamped;
clearTimeout(this.volumeDebounceTimer);
this.volumeDebounceTimer = setTimeout(() => {
this.player.setVolume(clamped);
}, VOLUME_DEBOUNCE_MS);
}
// ===================================================================
// RENDER
// ===================================================================
override render() {
return html`
<button @click="${this.toggleSlider}">
<button @click="${this.toggleSlider}" @wheel="${this.handleWheel}">
<wa-icon name=${this.volumeIcon}></wa-icon>
</button>
${this.showSlider
@@ -137,8 +180,8 @@ export class VolumeControl extends LitElement {
orientation="vertical"
min="0"
max="100"
.value="${this.player.volume}"
@change="${this.handleInput}"
.value="${this.currentVolume}"
@input="${this.handleInput}"
></wa-slider>
</div>
`
@@ -1930,9 +1930,7 @@ export class PlaylistView extends LitElement {
index: number,
) {
const trackCount = entry.tracks.length;
const countLabel = entry.summary.IsSmart
? 'Smart'
: `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const isDragOver =
this.dragOverPlaylistIndex === index;
@@ -0,0 +1,65 @@
# typed: false
# frozen_string_literal: true
# YellowJacket — cross-platform desktop music player built with Wails (Go + Lit).
#
# This formula builds from source. The Wails toolchain (`go tool wails`) resolves
# from the tool directives in go.mod, and Wails drives the frontend install/build
# itself (pnpm), so only the Go toolchain, Node, and pnpm are needed at build time.
#
# This file is the canonical source. On each tagged release, CI computes the
# tarball checksum and syncs an updated copy into the homebrew-yellowjacket tap
# repo (see .gitea/workflows/homebrew-formula.yml). The `version`/`sha256` lines
# below are what CI rewrites — keep them on their own lines.
class Yellowjacket < Formula
desc "Cross-platform desktop music player — local library, MusicBrainz explore & auto-tag"
homepage "https://git.ljones.me/yonlu/yellowjacket"
version "1.3.0"
url "https://git.ljones.me/yonlu/yellowjacket/archive/v#{version}.tar.gz"
sha256 "11929d9a7a32839f86213502b698a02376b38f0838fa20610408f062423899e5"
license :cannot_represent # custom license — see repository
head "https://git.ljones.me/yonlu/yellowjacket.git", branch: "main"
depends_on "go" => :build
depends_on "node" => :build
depends_on "pnpm" => :build
# Wails targets macOS and Linux. On Linux, Homebrew builds against the system
# WebKitGTK/GTK stack, which must be present (webkit2gtk-4.1, gtk3, alsa-lib).
on_linux do
depends_on "pkg-config" => :build
end
def install
ENV["CGO_ENABLED"] = "1"
# Keep Go resolving modules from the network into its sandboxed cache.
ENV["GOFLAGS"] = "-mod=mod"
commit = build.head? ? "HEAD" : "v#{version}"
ldflags = "-s -w -X 'main.version=v#{version}' -X 'main.commit=#{commit}'"
system "go", "generate", "./..."
system "go", "tool", "wails", "build",
"-tags", "webkit2_41",
"-clean", "-trimpath",
"-ldflags", ldflags
# Wails emits a .app bundle on macOS and a bare ELF binary on Linux.
if OS.mac?
prefix.install "build/bin/YellowJacket.app"
bin.write_exec_script "#{prefix}/YellowJacket.app/Contents/MacOS/YellowJacket"
else
bin.install "build/bin/yellowjacket"
end
end
test do
# The GUI binary has no headless mode; assert it was built and is runnable.
if OS.mac?
assert_predicate prefix/"YellowJacket.app/Contents/MacOS/YellowJacket", :executable?
else
assert_predicate bin/"yellowjacket", :executable?
end
end
end
+76
View File
@@ -0,0 +1,76 @@
# YellowJacket Homebrew formula
The formula builds YellowJacket from source on macOS and Linuxbrew, mirroring
the Arch `PKGBUILD`: the Wails toolchain resolves from `go.mod`'s tool
directives and drives the frontend build itself, so the only build inputs are
Go, Node, and pnpm.
```
packaging/homebrew/
└── Formula/
└── yellowjacket.rb ← canonical source; CI syncs it to the tap repo
```
## Installing
```bash
brew install shadow-puppet/yellowjacket/yellowjacket
```
`shadow-puppet/yellowjacket` is Homebrew shorthand for the tap repo
`github.com/Shadow-Puppet/homebrew-yellowjacket`. Brew auto-taps it, so there's
no separate `brew tap` step. To build the tip of `main` instead of the latest
release, add `--HEAD`.
## How publishing works
This directory holds the **canonical** formula. The tap users install from lives
in a **separate** repo — `homebrew-yellowjacket` — because Homebrew only
discovers formulae from a repo whose name starts with `homebrew-`, with the
formula at a top-level `Formula/`. Keeping it separate is also why nothing has
to live in this repo's root.
On every version tag (`v*`), `.gitea/workflows/homebrew-formula.yml`:
1. downloads the GitHub release tarball for that tag,
2. computes its `sha256`,
3. rewrites the `version` and `sha256` lines in the formula, and
4. commits the result to `homebrew-yellowjacket`'s `Formula/yellowjacket.rb`.
So a normal release needs **no manual formula edits** — tag, and the tap updates
itself. (This is the Homebrew equivalent of the Arch package's publish workflow.)
### About the `sha256`
Homebrew re-downloads the source tarball on each install and refuses to build
unless its checksum matches `sha256` — integrity/tamper detection. The committed
value here is a `REPLACE_WITH_...` placeholder on purpose; the real checksum is
computed and injected by CI at release time, so it never has to be maintained by
hand. (The Arch `PKGBUILD` sidesteps this with `SKIP` because it clones over git
rather than downloading a tarball.)
## One-time setup
1. **Create the tap repo:** `Shadow-Puppet/homebrew-yellowjacket` on GitHub,
with a `main` branch. It can start empty — the first tagged release seeds
`Formula/yellowjacket.rb`.
2. **Add a CI secret:** `HOMEBREW_TAP_TOKEN` — a GitHub token with write access
to that repo (a fine-grained PAT scoped to `homebrew-yellowjacket`, Contents:
read/write, is enough).
That's it. The source repo must be public (or the tap private with an
authenticated `brew install`) for brew to fetch the release tarball.
## Notes
- **License**: declared as `license :cannot_represent` (custom license). Replace
with the correct SPDX identifier once the license is finalized.
- **macOS vs Linux**: `wails build` produces a `YellowJacket.app` bundle on
macOS (installed under the Cellar with an `exec` shim in `bin`) and a bare
`yellowjacket` binary on Linux (installed to `bin`).
- **Linuxbrew**: building on Linux additionally needs the system WebKitGTK/GTK
stack (`webkit2gtk-4.1`, `gtk3`, `alsa-lib`) — OS packages, not Homebrew deps.
macOS needs only the Xcode Command Line Tools.
- **Cask alternative**: if you later ship prebuilt macOS `.dmg`/`.zip` artifacts,
a Homebrew *cask* pointing at those installs faster than this source build.
This formula is the source-build path.