Skip to content

Yeti: honest identity, encrypted downloads, pill duck, and the rename - #6

Merged
tsvb merged 14 commits into
mainfrom
yeti
Nov 7, 2026
Merged

tsvb merged 14 commits into
mainfrom
yeti

Conversation

@tsvb

@tsvb tsvb commented Nov 7, 2026

Copy link
Copy Markdown
Owner

The app becomes Yeti — an unofficial player for nugs.net (named for the Goose song) and follows nugs.net's Terms of Use where it matters. Also includes the iPhone Duo pill-duck.

Compliance

  • Honest identity. Every request now says Yeti/<ver> (unofficial; +github; <OS>) with no faked Referer, instead of posing as the official Android app. A live probe on a real account showed nugs accepts it everywhere, including token refresh. The only borrowed piece left is the official OAuth client (documented in NOTICE; needs a nugs-issued client).
  • One stream request per track. Tier 7 (ALAC 16-bit, what playback already chose) first; other tiers one at a time only if it's empty — instead of four parallel probes.
  • Browser sign-in only. The in-app password screen is gone; the app never sees the password.
  • Encrypted downloads. Downloads are sealed .yeti files (chunked AES-GCM, per-install Keychain key), playable only inside the app; Reveal in Finder removed; logout deletes them; offline play needs a subscription confirmed active within 30 days.
  • Polite networking. 4 req/s token bucket, retry backoff, cached artist catalogs, capped download concurrency, lazy thumbnails.
  • Rename + disclaimers. "Yeti" everywhere users see it; "Unofficial. Not affiliated with nugs.net or Apple." on the login screen, in About and atop the README; downloader pointers and protocol notes removed. Bundle ids, data folders, Keychain, Sparkle feed and the repo are unchanged, so installs keep their data, login and updates. yeti:// added; applenugs:// still works.

Performance

Encrypted playback runs at plain-file memory (peak 134 MB → 19.5 MB per FLAC track, via isEntireLengthAvailableOnDemand), and the 4 Hz player tick no longer runs in the background. On a real iPhone Duo: 60.9 MB peak for the whole app over a locked-screen session, 0 background ticks.

iPhone Duo

Where the tab bar is vertical, the now-playing pill ducks out of the way on scroll and springs back.

Verification

  • Gate: both schemes warning-free under SWIFT_STRICT_CONCURRENCY=complete; AppleNugsTests 307 passing.
  • Real iPhone Duo (iOS 27.2): signed in under the honest identity, streaming, encrypted download + seek + gapless, memory, pill duck.
  • Not yet on hardware: the Mac migration of existing plaintext downloads, and logout wiping downloads.

Also adds docs/design/yeti-theme/, the song-inspired design that will become the app's only design.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN

Tim and others added 14 commits November 5, 2026 23:51
…and/tent

Records how it landed (PR #5, CI green on the xcode-27 runner,
fast-forward so every referenced hash is on main), that listening-stats
now needs a rebase, and the two queued features with their probe
verdicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
…b bar is vertical

Where iPhone Duo lays the tab bar out vertically, iOS's minimize-on-
scroll never folds the accessory, so the pill sat over the content.
The probe ruled out animating the system accessory: toggling isEnabled
pops or crossfades and reflows the content 38-56pt each time.

So where \.toolbarVerticalEdge is non-nil (iOS 27.1+), the accessory is
off and the same NowPlayingPill is mounted as our own glass overlay,
resting where the accessory did. Scrolling down sinks it with a squash
(easeIn 0.22s); scrolling up, reaching the top, or ~1s idle springs it
back with an overshoot. Reduce Motion fades; it never ducks while
VoiceOver runs, and a ducked pill isn't hit-testable or focusable.

- PillDuckPolicy (pure, 22 tests): 24pt of unbroken downward travel
  ducks, 12pt upward shows, the top 8pt and non-scrolling content
  always show; offsets clamp to the scrollable range so rubber-banding
  doesn't count. ScrollGeometry.containerSize means different things
  for ScrollView (insets removed) and List (insets included), so the
  sample uses visibleRect.
- One shared reporter on 9 scroll roots writes only state CHANGES. In
  overlay mode each scroll view gets a constant 38pt bottom
  safeAreaPadding (inset 72 = the accessory's), so ducking never
  reflows content; the toast clears the pill.
- Regular iPhones and horizontal-bar displays keep the system accessory
  and minimize behavior; iPhone 17 Pro / 26.5 Home is pixel-identical.

197 tests. Unverified: any real scroll (simctl can't scroll).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
The Apple Team ID, the notary profile name and a device UDID were in
the public repo's CLAUDE.md. They now live only in the maintainer's
local memory; the doc points there. (Older history keeps them — a
rewrite isn't worth it; none of them is a credential.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
Every header that identifies the app to nugs.net (the API and stream
resolver User-Agents and the CDN Referer) now comes from NugsIdentity.
`.current` is byte-for-byte today's identity (pinned by tests), so this
changes nothing in production; switching to an honest identity later
is a one-line change.

The DEBUG-only Network Probe (account menu) lets the maintainer measure,
with his real session, what nugs accepts before that switch: 16
sequential requests, 1s apart, only on Run — subscriptions and the
catalog API (api.aspx) with the current vs an honest
"Yeti/<ver> (unofficial; +github)" UA, the stream resolver per
platformID tier with each UA, and the CDN with/without the Referer.
"Copy results" yields a report with no tokens or stream URLs. Verified
absent from Release binaries (strings/nm). 231 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
…networking

Acting on the live probe (Tim's Duo, 2026-11-07): nugs accepted an
honest "Yeti/<ver> (unofficial; +github)" User-Agent on every surface
it tested, and the CDN served media without the faked Referer.

- Identity: every nugs request now identifies as Yeti; no Referer. The
  old Android strings survive only as the DEBUG probe's control, absent
  from Release (symbols + disassembly checked).
- Streams: tier 7 (ALAC 16-bit, what playback already chose) first;
  other tiers one at a time only if it has no usable stream. One
  resolve per track instead of four parallel; in-flight resolves are
  shared; per-tier 4h cache. Pure StreamTierPlan, 12 tests.
- Sign-in: the in-app email/password (ROPC) screen is gone; sign-in
  happens on nugs.net's own page, so the app never sees the password.
  Existing sessions keep working.
- Politeness: 4 req/s token bucket (burst 8) on API/resolver calls;
  playback retries back off 0.5/1/2s and skip failed tiers; artist
  catalogs cached 30 min with a 30-page cap; lazy video rails; at most
  2 download transfers per host, 300ms between resolves. 16 tests.

259 tests. Live-unverified: id.nugs.net (refresh) and catalog.nugs.net
with the honest UA; tier fallback; HLS-only download fall-through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
"AppleNugs" built its name from two companies' trademarks. The app is
now Yeti (named for the Goose song) everywhere a user sees it: menu bar
and About (CFBundleName), Home/Dock (CFBundleDisplayName), login screen,
Home eyebrow, the Tape Room cassette label, Help menu, Keychain label,
Credits, appcast title, and an iOS account-menu version line.

Kept on purpose, so existing installs lose nothing: bundle ids,
Application Support/AppleNugs, the Keychain service/account, the Sparkle
feed URL + EdDSA key (verify-sparkle-config passes), the repo, and the
.app file name for now. yeti:// is added; applenugs:// keeps working for
goosealmanac's links (10 new DeepLink tests, case-insensitive schemes).

Disclaimers: "Unofficial. Not affiliated with nugs.net or Apple." on the
login screen, in About and at the top of the README. NOTICE rewritten:
both trademarks, the Goose homage, honest "Yeti" identification, no DRM
circumvention, and why it still uses the official OAuth client. README
drops the downloader pointers and protocol notes, no longer claims the
CDN needs faked headers, describes browser-only sign-in, and fixes the
stale download link. 269 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
…tive subscription

Downloads used to be the raw CDN FLAC/ALAC files — unencrypted, with
Reveal in Finder on macOS, never expiring and surviving logout, so
content could outlive a cancelled subscription.

- At rest: each finished download is sealed into <uuid>.yeti — 64 KiB
  chunks, AES-GCM, nonce = per-file prefix + chunk counter, the whole
  header + chunk index authenticated (reorder/splice/truncate/edit all
  rejected). Per-install key in the Keychain (AfterFirstUnlock
  ThisDeviceOnly, never synced); no file fallback. The plaintext temp
  is deleted immediately. iOS: completeUntilFirstUserAuthentication so
  lock-screen and gapless playback keep working.
- Playback: yeti-local:// + an AVAssetResourceLoaderDelegate that
  decrypts only the chunks a request covers. Verified with generated
  FLAC and ALAC: duration matches, a mid-file range decodes to
  bit-identical PCM, an AVPlayerItem seeks and plays to the end.
- Migration: existing plaintext downloads are sealed in place on launch
  (write new → manifest → delete old; crash-safe); a show that can't be
  sealed is deleted, never left plain.
- Lifecycle: logout deletes every download and the key. Offline play
  needs a subscription confirmed active within 30 days; the stamp is
  renewed only by a FRESH subscription answer (re-fetched on each token
  refresh, one extra request), never by the sign-in snapshot, so a
  cancelled account can't keep its downloads by browsing.
- macOS: Reveal in Finder removed; downloads stay available.

302 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
…re rules

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
…ckground

Measured with a repeatable harness (scripts/sealed-footprint, two
10-minute tracks, play + park + seek + gapless cross), peak
phys_footprint in MB:

                    FLAC    ALAC
  plain file        19.3    18.6
  sealed, before   134.0   105.5
  sealed, after     19.5    20.8

- SealedAssetLoader sets isEntireLengthAvailableOnDemand, so
  AVFoundation reads <=64 KiB on demand instead of buffering the whole
  file. Bit-identical PCM re-verified (FLAC + ALAC, many requests); a
  test fails if the flag is removed. Bounded responses weren't needed.
  The old "3.0 s FLAC plays to 3.04 s" was AVPlayer's own item
  duration, not corruption.
- iOS background: the 0.25 s periodic observer is removed on
  background and restored (with one immediate tick) on foreground. A
  once-a-minute, player-clock observer saves the resume position (only
  if it moved >=5 s). Lock-screen Now Playing no longer needs the tick:
  elapsed time is read live, seeks push their target, duration is read
  at item-ready/gapless adoption, and out-of-app play/pause flips are
  pushed. Position is saved on pause and at queue end.
- Fixes a race: the play/pause observer usually lands before an
  interruption's .began, so a phone call would never resume music; a
  pause within the last 2 s now counts as "was playing".
- DEBUG memory readout in the Network Probe: footprint, kernel peak
  (lifetime high-water mark), jetsam headroom, background tick count.

Deferring the local preload was measured and skipped: AVQueuePlayer
already prepares a parked item ~6-9 s before the boundary; early vs
late peaks are within noise. 307 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
…lock

Memory comes first, sampled at copy time, and Copy works without a
network run — so a paste after a long background session carries
footprint, peak, headroom and background ticks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
…ssion

- docs/design/yeti-theme/: the song-inspired sixth theme ("Peg Hook":
  cover art in a toy blister on night blue, foil-pink accent only, sale-
  yellow stickers, one FoilSweep on a new track), a token sheet that maps
  onto Theme fields, a wiring list, and the three concepts it beat.
  Produced by a research → brief → 3 concepts → 2 judges → refine
  workflow; paraphrases the song, quotes no lyrics.
- CLAUDE.md: title/intro now say Yeti; a START HERE block lists the
  branches (pill-duck + yeti unpushed), the next steps in order, and the
  session gotchas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
@tsvb
tsvb merged commit e6ab835 into main Nov 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant