From 3e8183336864a767d8ded818d8ad11d90741b965 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 29 Jun 2026 14:20:41 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20applenugs://=20deep-link=20handler=20(s?= =?UTF-8?q?how/track/video=20=E2=86=92=20navigate=20+=20play)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the app side of the goose-almanac deep-link contract: applenugs://show/?artist=Goose[&venue=][&song=&set=&pos=][&media=audio|video] - DeepLink.swift: pure, unit-tested parser + DeepLinkMatch fuzzy matching (normalize / venue / best-track-index), plus zero-pad date normalization. - DeepLinkRouter.swift: resolves artist+date to a nugs container and navigates + starts playback; track links start at the matched track, show links play from the top, video opens the auto-playing video detail. Audio resolution pages the artist's shows (bounded) and falls back to catalog search; video pages the per-artist list (no search exists for video). - AppModel: serialized deep-link channel (receiveDeepLink/handleDeepLink) so two links — or a link racing the post-login drain — can't interleave their navigate+play steps; pending-link slot replayed after login/bootstrap. - AppleNugsApp: .onOpenURL wired non-destructively (existing .frame / UITest modifiers untouched). RootView: drains the pending link once .loggedIn. - project.yml: registers the `applenugs` scheme only (not `nugsnet`, which stays OAuth-only) and adds a standalone host-free AppleNugsTests unit-test target (DeepLink.swift compiled in directly, no app launch on test runs). Review corrections folded in: venue mismatch falls through to search instead of guessing; fetch-before-navigate; best-track-index prefers the longest contained title for segue links; date comparison is zero-pad tolerant. 19 unit tests; clean build under SWIFT_STRICT_CONCURRENCY=complete. Co-Authored-By: Claude Opus 4.8 --- AppleNugs/App/AppModel.swift | 35 ++++++++ AppleNugs/App/AppleNugsApp.swift | 7 ++ AppleNugs/Core/DeepLink.swift | 108 +++++++++++++++++++++++ AppleNugs/Core/DeepLinkRouter.swift | 129 ++++++++++++++++++++++++++++ AppleNugs/Views/RootView.swift | 10 +++ AppleNugsTests/DeepLinkTests.swift | 120 ++++++++++++++++++++++++++ project.yml | 32 +++++++ 7 files changed, 441 insertions(+) create mode 100644 AppleNugs/Core/DeepLink.swift create mode 100644 AppleNugs/Core/DeepLinkRouter.swift create mode 100644 AppleNugsTests/DeepLinkTests.swift diff --git a/AppleNugs/App/AppModel.swift b/AppleNugs/App/AppModel.swift index 23e717f..6fbad68 100644 --- a/AppleNugs/App/AppModel.swift +++ b/AppleNugs/App/AppModel.swift @@ -24,6 +24,18 @@ final class AppModel { private(set) var isLoggingIn = false var loginError: String? + /// A deep link received while logged out / still booting — replayed once + /// logged in (drained by RootView's main layout). Single slot: if two links + /// arrive before login the most recent wins (newest user intent). See + /// `receiveDeepLink` / `handleDeepLink` and DeepLinkRouter. + var pendingDeepLink: DeepLink? + + /// Serializes deep-link handling. handle() has many awaits (catalog fetches), + /// each releasing the MainActor; without serialization two links — or a link + /// racing the post-login drain — could interleave their navigate+play steps + /// and leave the nav stack and the player queue showing different shows. + private var deepLinkTask: Task? + private var cachedArtists: [ArtistEntry]? var isLoggedIn: Bool { @@ -139,4 +151,27 @@ final class AppModel { cachedArtists = parsed return parsed } + + // MARK: - deep links + + /// Entry point from `.onOpenURL`: act now if logged in, else stash for the + /// post-login drain (RootView's main layout). + func receiveDeepLink(_ link: DeepLink, ui: UIState) { + guard isLoggedIn else { pendingDeepLink = link; return } + handleDeepLink(link, ui: ui) + } + + /// Run the router for one link, chained after any in-flight link so the + /// previous link's navigation + playback finish before the next begins. + /// Re-checks login at the last moment (a logout can land between receipt and + /// execution) and re-stashes rather than running against a logged-out client. + func handleDeepLink(_ link: DeepLink, ui: UIState) { + let previous = deepLinkTask + deepLinkTask = Task { [weak self] in + await previous?.value + guard let self else { return } + guard self.isLoggedIn else { self.pendingDeepLink = link; return } + await DeepLinkRouter.handle(link, app: self, ui: ui) + } + } } diff --git a/AppleNugs/App/AppleNugsApp.swift b/AppleNugs/App/AppleNugsApp.swift index c8809a6..4976317 100644 --- a/AppleNugs/App/AppleNugsApp.swift +++ b/AppleNugs/App/AppleNugsApp.swift @@ -48,6 +48,13 @@ struct AppleNugsApp: App { // RootView — it sets NSWindow.minSize directly so it correctly // accounts for the inspector column (250pt) when it is open. .frame(minHeight: 600) + // Deep links (applenugs://show/…). Parse here; AppModel decides + // whether to act now (serialized) or stash for RootView to replay + // after login/bootstrap completes. + .onOpenURL { url in + guard let link = DeepLink.parse(url) else { return } + app.receiveDeepLink(link, ui: ui) + } #if DEBUG .modifier(UITestWindowSize()) #endif diff --git a/AppleNugs/Core/DeepLink.swift b/AppleNugs/Core/DeepLink.swift new file mode 100644 index 0000000..d3d3eb5 --- /dev/null +++ b/AppleNugs/Core/DeepLink.swift @@ -0,0 +1,108 @@ +import Foundation + +/// A parsed `applenugs://` deep link. Pure value type — no I/O — so it unit-tests +/// cleanly. Contract lives in goose-almanac `docs/integrations/applenugs-deeplink.md`: +/// applenugs://show/?artist=Goose[&venue=][&song=&set=&pos=][&media=audio|video] +struct DeepLink: Equatable { + enum Media: String { case audio, video } + + var date: String // "yyyy-MM-dd" — the join key + var artist: String // e.g. "Goose" + var venue: String? // tie-break for two-show days + var song: String? // track title to start at (track-level link) + var setNumber: String? // elgoose setNumber ("1","2","e"…) — tie-break only + var position: Int? // elgoose position — tie-break only + var media: Media + + /// Parse `applenugs://show/?artist=…&venue=…&song=…&set=…&pos=…&media=…`. + /// Returns nil for anything that isn't a well-formed `show` link. + static func parse(_ url: URL) -> DeepLink? { + guard url.scheme == "applenugs", + let comps = URLComponents(url: url, resolvingAgainstBaseURL: false), + comps.host == "show" + else { return nil } + + // host is "show"; the date is the first path segment (path == "/"). + guard let date = comps.path.split(separator: "/").first.map(String.init), + !date.isEmpty + else { return nil } + + // URLComponents percent-decodes queryItems for us. (The Almanac emits %20, + // never +, precisely because URLComponents does NOT turn + into a space.) + // uniquingKeysWith avoids a trap-on-duplicate-key crash from malformed links. + let items = Dictionary( + (comps.queryItems ?? []).map { ($0.name, $0.value ?? "") }, + uniquingKeysWith: { first, _ in first }) + + guard let artist = items["artist"], !artist.isEmpty else { return nil } + + func nonEmpty(_ key: String) -> String? { + items[key].flatMap { $0.isEmpty ? nil : $0 } + } + + return DeepLink( + date: DeepLinkMatch.normalizedISODate(date), // pad so "2024-4-20" matches catalog dateText + artist: artist, + venue: nonEmpty("venue"), + song: nonEmpty("song"), + setNumber: nonEmpty("set"), + position: items["pos"].flatMap { Int($0) }, + media: items["media"].flatMap(Media.init(rawValue:)) ?? .audio) + } +} + +/// Pure, app-type-free fuzzy matching for the deep-link router. Kept separate +/// from `DeepLinkRouter` (which needs AppModel/Catalog) so this logic compiles +/// into the host-free logic-test bundle and unit-tests cleanly. +enum DeepLinkMatch { + + /// Lowercased, diacritic-folded, punctuation-stripped, whitespace-trimmed. + /// Makes title/venue comparison robust across elgoose vs nugs spellings. + static func normalize(_ s: String) -> String { + s.lowercased() + .folding(options: .diacriticInsensitive, locale: nil) + .filter { $0.isLetter || $0.isNumber || $0 == " " } + .trimmingCharacters(in: .whitespaces) + } + + /// Bidirectional normalized containment. A venue hint like "Salt Shed" should + /// match "The Salt Shed, Chicago" and vice-versa. nil left side never matches. + static func venueMatches(_ a: String?, _ b: String) -> Bool { + guard let a else { return false } + let na = normalize(a), nb = normalize(b) + guard !na.isEmpty, !nb.isEmpty else { return false } + return na.contains(nb) || nb.contains(na) + } + + /// Title match within ONE show's track list — a small candidate set, so + /// normalized matching is safe. Titles are in performance order, so the + /// returned index lines up with the playback queue. elgoose set/pos are + /// tie-breakers only; titles win. Precedence (most→least confident): + /// 1. exact normalized equality + /// 2. a title that CONTAINS the song (e.g. "Hot Tea" → "Hot Tea Reprise") + /// 3. the song CONTAINS a title (segue link "Madhuvan > Hot Tea" → track + /// "Madhuvan"): pick the LONGEST contained title — the most specific — + /// rather than the first, so an incidental earlier short track doesn't win. + static func bestTrackIndex(matching song: String, inTitles titles: [String]) -> Int? { + let target = normalize(song) + guard !target.isEmpty else { return nil } + let norm = titles.map(normalize) + if let i = norm.firstIndex(of: target) { return i } + if let i = norm.firstIndex(where: { !$0.isEmpty && $0.contains(target) }) { return i } + return norm.enumerated() + .filter { $0.element.count >= 4 && target.contains($0.element) } + .max(by: { $0.element.count < $1.element.count })? + .offset + } + + /// Zero-pad a `yyyy-M-d` date to `yyyy-MM-dd` so a non-padded but otherwise + /// valid inbound date still matches the catalog's always-padded dateText. + /// Returns the input unchanged if it isn't three numeric components. + static func normalizedISODate(_ s: String) -> String { + let parts = s.split(separator: "-") + guard parts.count == 3, + let y = Int(parts[0]), let m = Int(parts[1]), let d = Int(parts[2]) + else { return s } + return String(format: "%04d-%02d-%02d", y, m, d) + } +} diff --git a/AppleNugs/Core/DeepLinkRouter.swift b/AppleNugs/Core/DeepLinkRouter.swift new file mode 100644 index 0000000..d1a9583 --- /dev/null +++ b/AppleNugs/Core/DeepLinkRouter.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Resolves a parsed `DeepLink` to a nugs container and navigates + starts +/// playback, reusing the existing client/catalog/player/navigation surface. +/// For a track link it starts at that track; for a show link it plays from the +/// top; for a video it opens the video detail (which drives its own playback). +@MainActor +enum DeepLinkRouter { + + /// Entry point. Resolve + act; surface failures as a toast rather than + /// throwing into the UI. The user arrived from a "Listen"/"Watch" link, so + /// the show is navigated to AND playback starts. + static func handle(_ link: DeepLink, app: AppModel, ui: UIState) async { + do { + switch link.media { + case .audio: try await openAudio(link, app: app, ui: ui) + case .video: try await openVideo(link, app: app, ui: ui) + } + } catch { + ui.showToast("Couldn't open that show on nugs") + } + } + + // MARK: - audio + + private static func openAudio(_ link: DeepLink, app: AppModel, ui: UIState) async throws { + guard let containerId = try await resolveContainerId(link, app: app) else { + ui.showToast("That show isn't on nugs") + return + } + // Do the fallible fetch + queue build BEFORE touching navigation, so a + // failure leaves the UI untouched (just the catch's toast) rather than + // stranding the user on an empty album detail. + let album = Catalog.album(from: try await app.client.album(id: containerId), id: containerId) + let queue = album.tracks.map { + QueueTrack(trackId: $0.id, title: $0.title, artist: album.artistName, + show: album.title, artworkPath: album.imagePath, showId: album.id) + } + ui.open(.album(id: containerId, title: nil)) // now navigate… + guard !queue.isEmpty else { return } + // …and start playback (the user came from a "Listen" link). + if let song = link.song, + let idx = DeepLinkMatch.bestTrackIndex(matching: song, inTitles: album.tracks.map(\.title)) { + app.player.play(queue, startAt: idx) // jump to the linked performance + } else { + app.player.play(queue) // play the whole show + } + } + + /// Find the live container for artist+date (venue tie-breaks), paging the + /// artist's shows like the video path, then falling back to catalog search. + private static func resolveContainerId(_ link: DeepLink, app: AppModel) async throws -> String? { + guard let artistId = try await artistId(matching: link.artist, app: app) else { + return try await searchContainerId(link, app: app) + } + if let id = try await pagedContainerId(artistId: artistId, link: link, app: app) { return id } + return try await searchContainerId(link, app: app) + } + + /// Page through the artist's shows (bounded) for a live container matching the + /// date. The UI only ever loads the first page, so a deep link to an older show + /// would otherwise miss; mirror openVideo's bounded paging. + private static func pagedContainerId(artistId: String, link: DeepLink, app: AppModel) async throws -> String? { + let pageSize = 100 + let maxPages = 10 // safety cap (≤1000 shows) + var offset = 1 + for _ in 0.. String? { + let model = Catalog.search(from: try await app.client.search("\(link.artist) \(link.date)")) + for section in model.sections { + for item in section.items { + guard case .container(let id) = item.kind, item.dateText == link.date else { continue } + if let v = link.venue, !DeepLinkMatch.venueMatches(item.venue, v) { continue } + return id + } + } + return nil + } + + // MARK: - video + + /// Per-artist video list isn't searchable (catalog.search has no video kind) + /// and the UI only ever loads its first page, so a deep link to an older show + /// would miss. Page through (bounded) until the date is found or the list ends. + private static func openVideo(_ link: DeepLink, app: AppModel, ui: UIState) async throws { + guard let artistId = try await artistId(matching: link.artist, app: app) else { + ui.showToast("That show isn't on nugs"); return + } + let pageSize = 100 + let maxPages = 10 // safety cap (≤1000 videos) + var offset = 1 + for _ in 0.. String? { + try await app.artists().first { $0.name.caseInsensitiveCompare(name) == .orderedSame }?.id + } + + /// Date already filtered. With a venue hint, REQUIRE a venue match — return + /// nil on mismatch so the caller keeps paging / falls back to search rather + /// than silently autoplaying the wrong show on a two-show day. Without a hint, + /// take the first same-day show. (Matches searchContainerId's venue semantics.) + private static func pick(_ cs: [ContainerSummary], venue: String?) -> ContainerSummary? { + guard let venue else { return cs.first } + return cs.first { DeepLinkMatch.venueMatches($0.venue, venue) } + } +} diff --git a/AppleNugs/Views/RootView.swift b/AppleNugs/Views/RootView.swift index af1354b..b2c30a4 100644 --- a/AppleNugs/Views/RootView.swift +++ b/AppleNugs/Views/RootView.swift @@ -140,6 +140,16 @@ struct RootView: View { // the inspector's 250pt minimum, so the window could be dragged narrower // than sidebar+detail+inspector, clipping the sidebar off the left edge. .background(WindowMinSizeUpdater(inspectorOpen: ui.inspectorOpen)) + // Replay a deep link that arrived before login/bootstrap finished. This + // layout only exists once `.loggedIn`, so the task fires exactly when the + // app is ready. Goes through the serialized channel so it can't interleave + // with a link that arrives at almost the same moment. + .task(id: app.isLoggedIn) { + if app.isLoggedIn, let link = app.pendingDeepLink { + app.pendingDeepLink = nil + app.handleDeepLink(link, ui: ui) + } + } } @ViewBuilder diff --git a/AppleNugsTests/DeepLinkTests.swift b/AppleNugsTests/DeepLinkTests.swift new file mode 100644 index 0000000..4131350 --- /dev/null +++ b/AppleNugsTests/DeepLinkTests.swift @@ -0,0 +1,120 @@ +import XCTest + +// NOTE: DeepLink.swift is compiled directly into this (host-free) logic-test +// bundle — see project.yml — so DeepLink / DeepLinkMatch are same-module types +// here, referenced without importing the app module. + +final class DeepLinkParseTests: XCTestCase { + + func testShowAudioDefaults() { + let link = DeepLink.parse(URL(string: "applenugs://show/2024-04-20?artist=Goose")!) + XCTAssertEqual(link, DeepLink(date: "2024-04-20", artist: "Goose", venue: nil, + song: nil, setNumber: nil, position: nil, media: .audio)) + } + + func testTrackWithVenueAndPercentDecoding() { + let link = DeepLink.parse(URL(string: + "applenugs://show/2024-04-20?artist=Goose&song=Hot%20Tea&set=1&pos=2&venue=The%20Salt%20Shed")!) + XCTAssertEqual(link?.song, "Hot Tea") // %20 → space + XCTAssertEqual(link?.venue, "The Salt Shed") + XCTAssertEqual(link?.setNumber, "1") + XCTAssertEqual(link?.position, 2) + XCTAssertEqual(link?.media, .audio) + } + + func testVideoMedia() { + let link = DeepLink.parse(URL(string: "applenugs://show/2026-05-30?artist=Goose&media=video")!) + XCTAssertEqual(link?.media, .video) + } + + func testUnknownMediaFallsBackToAudio() { + let link = DeepLink.parse(URL(string: "applenugs://show/2024-04-20?artist=Goose&media=hologram")!) + XCTAssertEqual(link?.media, .audio) + } + + func testRejectsWrongScheme() { + XCTAssertNil(DeepLink.parse(URL(string: "nugsnet://show/2024-04-20?artist=Goose")!)) + XCTAssertNil(DeepLink.parse(URL(string: "https://show/2024-04-20?artist=Goose")!)) + } + + func testRejectsWrongHost() { + XCTAssertNil(DeepLink.parse(URL(string: "applenugs://artist/2024-04-20?artist=Goose")!)) + } + + func testRejectsMissingDate() { + XCTAssertNil(DeepLink.parse(URL(string: "applenugs://show?artist=Goose")!)) + XCTAssertNil(DeepLink.parse(URL(string: "applenugs://show/?artist=Goose")!)) + } + + func testRejectsMissingOrEmptyArtist() { + XCTAssertNil(DeepLink.parse(URL(string: "applenugs://show/2024-04-20")!)) + XCTAssertNil(DeepLink.parse(URL(string: "applenugs://show/2024-04-20?artist=")!)) + } + + func testEmptyOptionalsBecomeNil() { + let link = DeepLink.parse(URL(string: + "applenugs://show/2024-04-20?artist=Goose&venue=&song=&set=")!) + XCTAssertNil(link?.venue) + XCTAssertNil(link?.song) + XCTAssertNil(link?.setNumber) + } + + func testNonNumericPositionIsNil() { + let link = DeepLink.parse(URL(string: "applenugs://show/2024-04-20?artist=Goose&pos=abc")!) + XCTAssertNil(link?.position) + } + + func testNonPaddedDateIsZeroPadded() { + // The contract emits zero-padded dates, but a non-padded but otherwise + // valid date must still match the catalog's padded dateText. + let link = DeepLink.parse(URL(string: "applenugs://show/2024-4-20?artist=Goose")!) + XCTAssertEqual(link?.date, "2024-04-20") + } +} + +final class DeepLinkMatchTests: XCTestCase { + + func testNormalizeLowercasesStripsPunctuationAndDiacritics() { + XCTAssertEqual(DeepLinkMatch.normalize(" Hót-Tea! (Reprise) "), "hottea reprise") + XCTAssertEqual(DeepLinkMatch.normalize("Madhuvan"), "madhuvan") + } + + func testVenueMatchesIsBidirectionalContains() { + XCTAssertTrue(DeepLinkMatch.venueMatches("The Salt Shed", "Salt Shed")) + XCTAssertTrue(DeepLinkMatch.venueMatches("Salt Shed", "The Salt Shed, Chicago")) + XCTAssertFalse(DeepLinkMatch.venueMatches("Red Rocks", "The Salt Shed")) + } + + func testVenueMatchesNilIsFalse() { + XCTAssertFalse(DeepLinkMatch.venueMatches(nil, "Salt Shed")) + } + + func testBestTrackIndexPrefersExactNormalizedMatch() { + let titles = ["Intro", "Hot Tea", "Hot Tea Reprise"] + XCTAssertEqual(DeepLinkMatch.bestTrackIndex(matching: "hot tea", inTitles: titles), 1) + } + + func testBestTrackIndexFallsBackToContains() { + let titles = ["Intro", "Arcadia", "Madhuvan > Hot Tea"] + XCTAssertEqual(DeepLinkMatch.bestTrackIndex(matching: "Hot Tea", inTitles: titles), 2) + } + + func testBestTrackIndexReturnsNilWhenNoMatch() { + let titles = ["Intro", "Arcadia"] + XCTAssertNil(DeepLinkMatch.bestTrackIndex(matching: "Bouncing Around the Room", inTitles: titles)) + } + + func testBestTrackIndexSeguePrefersLongestContainedTitle() { + // A segue link "Madhuvan > Hot Tea" where the show split it into separate + // tracks: the most specific contained title ("Madhuvan") should win, NOT + // the earlier-listed standalone "Hot Tea". + let titles = ["Intro", "Hot Tea", "Drive", "Madhuvan", "Arcadia"] + XCTAssertEqual(DeepLinkMatch.bestTrackIndex(matching: "Madhuvan > Hot Tea", inTitles: titles), 3) + } + + func testBestTrackIndexStillPrefersWholeTitleContainingSong() { + // Forward containment (title ⊇ song) must still win over the segue rule. + let titles = ["Intro", "Arcadia", "Madhuvan > Hot Tea"] + XCTAssertEqual(DeepLinkMatch.bestTrackIndex(matching: "Hot Tea", inTitles: titles), 2) + } +} diff --git a/project.yml b/project.yml index a7798ab..f8b9921 100644 --- a/project.yml +++ b/project.yml @@ -32,6 +32,7 @@ targets: - AppleNugs scheme: testTargets: + - AppleNugsTests - AppleNugsUITests gatherCoverageData: false dependencies: @@ -53,6 +54,14 @@ targets: path: AppleNugs/Info.plist properties: CFBundleDisplayName: AppleNugs + # Deep-link scheme. `applenugs://` only — the OAuth `nugsnet` scheme is + # deliberately left unregistered (see NugsConstants) to avoid clashing + # with the official nugs.net app. Handles + # applenugs://show/?artist=…&song=…&venue=…&media=audio|video + CFBundleURLTypes: + - CFBundleURLName: com.timvbs.applenugs.deeplink + CFBundleURLSchemes: + - applenugs CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION) LSApplicationCategoryType: public.app-category.music @@ -89,6 +98,29 @@ targets: CODE_SIGN_IDENTITY: "-" DEVELOPMENT_TEAM: "" + # Unit tests (logic only). Deliberately a STANDALONE test bundle: no + # `dependencies: [target: AppleNugs]`, so XcodeGen sets no TEST_HOST and the + # tests run without launching (and code-signing) the host app — which would + # otherwise trigger AppModel.bootstrap()/Keychain access on every test run. + # The pure, app-type-free logic under test (DeepLink + DeepLinkMatch) is + # compiled directly into this bundle via the explicit source-file entry below. + AppleNugsTests: + type: bundle.unit-test + platform: macOS + sources: + - AppleNugsTests + - path: AppleNugs/Core/DeepLink.swift + # Compiled into the test bundle too (not @testable-imported) so the + # logic tests stay host-free. DeepLink.swift is pure Foundation. + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.timvbs.applenugs.tests + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "-" + DEVELOPMENT_TEAM: "" + ENABLE_HARDENED_RUNTIME: NO + # UI tests. `bundle.ui-testing` + a dependency on AppleNugs makes XcodeGen set # TEST_TARGET_NAME=AppleNugs (the app is the test host). The app is driven into # a logged-in stub state via the `-UITEST` launch argument (see AppModel).