Kit Scrobbler is an Apple-first Safari extension for scrobbling music to Last.fm (and other
services). It is a higher-level project built around the Web Scrobbler codebase, which is vendored
as a read-only git submodule at src/web-scrobbler/.
We are not trying to be a generic cross-platform scrobbler. We optimise for one thing: the best possible scrobbling experience in Safari on macOS.
-
Never edit anything inside
src/web-scrobbler/. It is an upstream dependency. Read it, import it, learn from it — never modify it, and never add tracked files to it. All of Kit Scrobbler's own code lives outside that directory.bun run guardenforces this and runs on every commit. -
Bun is the build tool.
Bun.build,Bun.serve, Bun scripts and Bun plugins do the work. Upstream's own Vite/esbuild toolchain is used only to produce the loadable Safari web extension. Do not add webpack/rollup/vite to our own pipeline. -
Apple experience only. macOS Safari is the only target. No cross-platform fallbacks, no lowest-common-denominator UI. Use the newest web platform features Safari ships — CSS Nesting,
light-dark(),:has(),@starting-style, the Popover API, native form controls (including<input type="checkbox" switch>). Ship modern CSS as-is. No preprocessor, no transpilation, no polyfills for our own UI. -
Kit Scrobbler owns its UI. Our HTML/CSS/TS replaces upstream's popup and options pages. Everything else — background script, content scripts, the ~370 connectors, and all scrobbling logic — comes from
@upstreamuntouched.
app/ Kit Scrobbler's own source (our code — edit freely)
popup/ Safari toolbar popup (now-playing, edit view, state gallery)
options/ Settings page
shared/ Design tokens, base components, upstream bridges
vendor/ Generated Phosphor webfont (gitignored; scripts/phosphor.ts)
scripts/ Bun build pipeline
plugins/ Bun.build plugins (upstream alias, #v-ifdef)
src/web-scrobbler/ READ-ONLY git submodule (upstream Web Scrobbler)
build/preview/ The loadable extension (gitignored)
build/dev/ Dev-server output (gitignored)
dist/ App Store artifacts (gitignored)
Potential confusion to avoid: upstream's own source lives at src/web-scrobbler/src/. Our source is
app/. The src/ at our repo root contains only the submodule.
bun run buildproduces the loadable extension atbuild/previewand works anywhere Bun does, including this Linux DevContainer — we no longer use upstream's native image tooling (see below).bun run bundlearchives and exports the App Store build, so it needs macOS + Xcode and a signing identity.
@kit/*→app/*— our own code.@upstream/*→src/web-scrobbler/*— the submodule (e.g.import type { ManagerTab } from '@upstream/src/core/storage/wrapper').@/*→ upstream's internal alias for its ownsrc/. It is not ours and is resolved by our Bun plugin (scripts/plugins/upstream-alias.ts) only for files inside the submodule. Never write@/...inapp/.
Import from upstream; never re-declare it. Types come straight from the submodule
(import type { ManagerTab } from "@upstream/src/core/storage/wrapper"). Runtime code is reached
through app/shared/upstream.ts, which lazily imports upstream modules. The only reason anything is
imported dynamically is that webextension-polyfill throws at module-evaluation time outside an
extension — the lazy loader keeps bun run dev alive and returns null (preview) there. Do not copy
upstream logic, constants, or shapes into app/; unused code is tree-shaken at build.
Type-checking upstream source needs the submodule's dependencies; bun install's postinstall installs
them with bun i (scripts/setup.ts), and the build ensures them too. tsconfig.json also includes
the submodule's ambient declarations (src/web-scrobbler/src/**/*.d.ts).
bun run build assembles the loadable extension in build/preview:
scripts/upstream.tsdrives upstream's three Vite builds directly (background, content, popup + options) withVITE_PROD/VITE_SAFARIset — including its connector and icon steps — and copies the result out of the submodule.scripts/build-ui.tsrunsBun.buildoverapp/**and writes our popup + options to the exact paths the generated manifest points at (src/ui/popup/index.html,src/ui/options/index.html). This is how we replace the UI without touching upstream. Only TypeScript goes through Bun.build. CSS is copied verbatim, because Bun's CSS pipeline rewriteslight-dark()and flattens CSS Nesting — the exact modern CSS we insist on shipping as-is. The HTML is copied with just its entry script rewritten (./popup.ts→./popup.js); the source points at the TypeScript entry so Bun's dev server can bundle it.scripts/background.tsbundles Kit's own background script tobackground/kit.js; the manifest patch lists it ahead of upstream's background so its toolbar-icon hook is installed before the first update.- The generated
manifest.jsonis patched in place: name, version, Kit's SVG icons, andcontent_scripts.matchesnarrowed from upstream's<all_urls>. scripts/assets.tsstages the artwork upstream's build no longer produces — Kit'sapp/icons/*.svg— and copies only the upstream images the shipped scripts actually fetch (read out of their bundles), dropping upstream's own UI chunks and the rest ofimg/main.scripts/action-icons.tsthen renders the toolbar set, and the in-page info box mark, from those SVGs.
Content-script scope. Upstream matches <all_urls>, so its content script runs on every page.
app/shared/connector-matches.ts narrows that to the apex domain of every host the connectors declare
(*://*.spotify.com/*, …) — for context, 372 connectors collapse to ~510 patterns from 560 hosts.
Only http/https schemes are ever emitted. Three things to know:
- Connector patterns are written for upstream's own matcher, not the match-pattern grammar. Host
wildcards in the middle, port wildcards and TLD wildcards (
music.amazon.*) cannot be expressed, so they are dropped and reported — today that leavesamazonandamazon-alexawith no pattern. - Self-hosted servers (Plex, Synology, Nextcloud, …) match any host by path; those are kept as
path-limited
*host patterns, which is as narrow as they can get. - Because the manifest no longer matches everything, a user's custom URL patterns could not inject
the content script — upstream relied on
<all_urls>for that. The options page therefore no longer offers them at all. Widening this needs dynamic content-script registration, which is not implemented.
The build prints this report on every run, including any connector reachable over plain http only.
The options page reads the same connectorMatches() result, so it flags what the manifest does:
unreachable connectors render disabled with a link to the issue tracker, and partially covered ones
carry a note.
Icons. We do not render upstream's icon set. upstream-driver.ts drops two of its Vite plugins —
generate-icons (native canvas, renders src/icons/{main,monochrome} into icon_main_* and per-mode
action_* files) and minify-images (imagemin binaries) — and stubs canvas via a module hook
(canvas-stub-hooks.mjs), because merely importing the Vite configs would otherwise load it.
Kit's own artwork lives in app/icons as SVG, staged into build/preview/icons by assets.ts. The
manifest names a single SVG logo for icons (Apple's sample extension does the same) and the resting
toolbar icon for action.default_icon. Upstream's controller also hard-codes icons/icon_main_48.png for
the in-page info box, so action-icons.ts renders that filename from Kit's logo — no upstream artwork
reaches the build.
Upstream's action.ts drives the toolbar from the controller mode, asking for
icons/action_<mode>_<size>_<theme>.png on every update — files that no longer exist.
app/shared/action-icon.ts maps those modes onto the SVG set in app/icons: one file per mode, named for
the mode upstream asks for, with playing keeping the recording mark. scripts/action-icons.ts renders each
one to the PNG names Safari asks for (Bun.Image cannot decode SVG, so a Bun.WebView draws it into a
canvas page-side — that works on the macOS WebKit backend as well as Chrome), and app/background/kit.ts
(listed first in background.scripts) wraps browser.action.setIcon to draw it. Reading upstream's decision
rather than re-deriving it keeps us in step with the controller without touching the submodule, and no
icon_main_*.png set needs to be generated. README-TOOLBAR-ICONS.md documents what each state means.
build/preview is a plain, complete web extension folder — point Safari's Developer tab at it with
Add Temporary Extension to run it, exactly like loading an unpacked extension in Firefox/Chromium.
bun run dev (bun --hot scripts/dev.ts) is different in kind: it serves the pages straight from
app/ through Bun's development server, so edits hot-reload in the browser and there is no
hand-rolled reload client. Bun bundles those pages itself, so it also touches the CSS (it adds
--buncss-* fallback variables; light-dark() itself is preserved, so the theme toggle still works).
build/preview/src/ui/popup/dev.html is a state gallery: popup/index.html?state=<name> forces a
specific popup state (fixtures live in app/popup/dev-states.ts) and the gallery lays them all out
side by side. bun run dev serves the same page at http://localhost:3000/popup/dev.html. The gallery
and its fixtures are compiled out of bun run bundle.
bun run bundle takes that same bundle, stages it inside the submodule where upstream's Xcode project
expects it, runs xcodebuild archive, and exports for the App Store. Configure the host app's bundle
identifiers, team and signing in the Xcode project (they cannot be set per-target from the CLI).
Pushing a v* tag runs .github/workflows/release.yml. It builds the release variant on Linux — the
runner image's Chrome renders the toolbar icons — zips build/preview to KitScrobbler-<tag>.zip, keeps
that zip as a run artifact, and opens a draft GitHub release attached to it whose notes list every commit
since the previous v* tag, with the short ref linked to the commit. Nothing is published until someone
reviews that draft and publishes it. That build is bun run build:release, which is bun run build without
the popup state gallery.
- Conditional compilation directives. Upstream uses
vite-plugin-conditional-compiler:// #v-ifdef VITE_SAFARI…// #v-endif(also#v-ifdef-not,#v-else) across ~12 files, includingsrc/core/storage/wrapper.ts. Without processing, both branches survive and the code silently misbehaves. Ourscripts/plugins/v-ifdef.tshandles these for any upstream file we bundle. When we import an upstream module, assume it may contain directives. - Connectors are loaded at runtime via
await import(browser.runtime.getURL(\connectors/${...}`))and are compiled by a *separate* esbuild pass intoconnectors/*.js`. Never let a bundler try to statically resolve that import or inline the connectors directory. - The manifest is generated, not a static file, and uses
_localesmessage placeholders. Patch only the build output. - Never hand-edit
build/preview. It is regenerated; make changes inapp/orscripts/.
- Design tokens live in
app/shared/theme.cssas CSS custom properties, driven bycolor-scheme: light darkand thelight-dark()function. Never hard-code a colour in a component. - Compose from the primitives in
app/shared/base.css(inset grouped lists, buttons, native switches). - Typography uses the system font stack / Apple text styles. Spacing follows a 4/8 pt grid; corners are 10–12px; separators are hairlines.
- Favour native controls — real
<input type="checkbox" switch>,<select>,<input type="range">withaccent-color. Do not recreate iOS/macOS controls with custom markup if a native one exists. - The accent colour is the system accent (
AccentColor) wherever possible. - Icons come from Phosphor through
app/shared/icons.ts(icon(name)plus theICONS/SERVICE_ICONSmaps). Do not hand-roll SVG paths: upstream's icon pipeline needs native canvas/image tooling, whereas Phosphor is a webfont with no build step.
Use Conventional Commits (matches upstream's AGENTS.md).
Keep the submodule pointer update as its own commit when you bump it.
