Commit d94d769c authored by Kourser's avatar Kourser
Browse files

feat: podcast links, OPML files and Handoff

Three ways into the app from outside it.

Podcast links. The app registers podcast://, pcast://, itpc:// and
feed://, the schemes "subscribe" buttons use. A link opens the podcast's
page when it is already subscribed, a preview to subscribe from
otherwise: a link tapped on a web page should not subscribe to anything
by itself. PodcastLink turns either spelling — the scheme standing in
for http(s), or put in front of a complete address — into a feed URL.

OPML files. The app declares the OPML type and offers itself under
"Ouvrir avec" in Files and the Finder; the file goes through the same
import as the file picker and drag and drop.

Handoff. HandoffCoordinator advertises the episode in the player as a
user activity, refreshed on play, pause and every ten seconds of
listening. Libraries do not share identifiers, so ListeningHandoff names
the episode by its feed, its guid and its media address — compared
without its query, where hosts append tokens that change on every
fetch. The receiving device plays it from the position handed over,
opens the podcast when it does not have the episode yet, or previews
the feed when it is not subscribed.

All three may be what launches the app, so they wait for the library to
load before deciding what they point to. On macOS the main window takes
them instead of each one opening a new window.

Verified on an iOS 27 simulator, app terminated beforehand: a feed://
link to a subscribed podcast opens its page, a podcast:// link to an
unknown feed opens the preview, and "Ouvrir avec › Skingomz" on an .opml
in Files imports it. 14 tests cover the link parsing and the Handoff
payload and matching. Not run: Handoff itself, which needs two devices
on one iCloud account, and everything on macOS — it builds, but the Mac
was locked, its keychain with it, and the app blocks on the keychain at
launch.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 881407d5
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -20,6 +20,13 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
  (format moyen) y ajoute les prochains épisodes de la file d'attente, chacun à
  un toucher de la lecture. Une commande « Lecture Skingomz » s'ajoute au
  centre de contrôle, à l'écran verrouillé ou au bouton Action.
- **Liens de podcast** : un lien « s'abonner » d'un site web (`podcast://`,
  `pcast://`, `itpc://`, `feed://`) s'ouvre dans Skingomz — sur la fiche du
  podcast s'il est déjà suivi, sinon sur un aperçu d'où s'abonner.
- **Ouvrir un fichier OPML** : depuis Fichiers, Mail ou le Finder, « Ouvrir
  avec › Skingomz » importe les abonnements du fichier.
- **Handoff** : l'épisode en cours sur un appareil se reprend sur un autre, au
  même endroit, depuis le Dock du Mac ou le sélecteur d'apps de l'iPhone.
- **Vitesse, direct et chapitres sur l'écran verrouillé** : la vitesse de
  lecture se règle depuis l'écran verrouillé et CarPlay ; une radio y apparaît
  en direct, sans barre de progression ni boutons de saut qui ne faisaient
+61 −0
Original line number Diff line number Diff line
@@ -11,6 +11,67 @@
	<array>
		<string>eu.cythin.skingomz.refresh</string>
	</array>
	<key>CFBundleURLTypes</key>
	<array>
		<dict>
			<key>CFBundleURLName</key>
			<string>eu.cythin.skingomz.podcast-link</string>
			<key>CFBundleTypeRole</key>
			<string>Viewer</string>
			<key>CFBundleURLSchemes</key>
			<array>
				<string>podcast</string>
				<string>pcast</string>
				<string>itpc</string>
				<string>feed</string>
			</array>
		</dict>
	</array>
	<key>CFBundleDocumentTypes</key>
	<array>
		<dict>
			<key>CFBundleTypeName</key>
			<string>OPML</string>
			<key>CFBundleTypeRole</key>
			<string>Viewer</string>
			<key>LSHandlerRank</key>
			<string>Alternate</string>
			<key>LSItemContentTypes</key>
			<array>
				<string>org.opml.opml</string>
			</array>
		</dict>
	</array>
	<key>UTImportedTypeDeclarations</key>
	<array>
		<dict>
			<key>UTTypeIdentifier</key>
			<string>org.opml.opml</string>
			<key>UTTypeDescription</key>
			<string>OPML</string>
			<key>UTTypeConformsTo</key>
			<array>
				<string>public.xml</string>
			</array>
			<key>UTTypeTagSpecification</key>
			<dict>
				<key>public.filename-extension</key>
				<array>
					<string>opml</string>
				</array>
				<key>public.mime-type</key>
				<array>
					<string>text/x-opml</string>
				</array>
			</dict>
		</dict>
	</array>
	<key>LSSupportsOpeningDocumentsInPlace</key>
	<true/>
	<key>NSUserActivityTypes</key>
	<array>
		<string>eu.cythin.skingomz.listening</string>
	</array>
	<key>ITSAppUsesNonExemptEncryption</key>
	<false/>
	<key>NSAppTransportSecurity</key>
+32 −0
Original line number Diff line number Diff line
import Foundation

/// The "subscribe" links websites and other apps hand over: a feed address
/// under a scheme of its own, so that the system opens a podcast app rather
/// than a browser.
public enum PodcastLink {
    /// `podcast` and `pcast` come from Apple Podcasts, `itpc` from iTunes,
    /// `feed` from the feed readers of the 2000s. All four mean the same thing.
    public static let schemes = ["podcast", "pcast", "itpc", "feed"]

    /// The feed such a link designates, or `nil` for anything else.
    ///
    /// Two spellings are in use: the scheme standing in for `http(s)`
    /// (`feed://example.com/rss`), and the scheme put in front of a complete
    /// address (`feed:https://example.com/rss`). The first says nothing of the
    /// real scheme: `https` is assumed, as it is for an address typed by hand.
    public static func feedURL(from link: URL) -> URL? {
        guard let scheme = link.scheme?.lowercased(), schemes.contains(scheme) else { return nil }
        var rest = String(link.absoluteString.dropFirst(scheme.count + 1))
        while rest.hasPrefix("/") { rest.removeFirst() }
        // A browser normalising "feed://https://host" eats the second colon.
        for mangled in ["https//", "http//"] where rest.lowercased().hasPrefix(mangled) {
            rest = String(mangled.dropLast(2)) + "://" + String(rest.dropFirst(mangled.count))
        }
        let lowered = rest.lowercased()
        if !lowered.hasPrefix("http://") && !lowered.hasPrefix("https://") {
            rest = "https://" + rest
        }
        guard let url = URL(string: rest), let host = url.host, !host.isEmpty else { return nil }
        return url
    }
}
+69 −0
Original line number Diff line number Diff line
import Foundation
import PodcastModel

/// What one device tells another so it can carry on with the same episode
/// (Handoff). Devices do not share identifiers — each library assigns its
/// own — so an episode is named by what its feed says of it.
public struct ListeningHandoff: Equatable, Sendable {
    /// Declared in the app's `NSUserActivityTypes`.
    public static let activityType = "eu.cythin.skingomz.listening"

    public var feedURL: URL
    public var guid: String?
    public var enclosureURL: URL?
    public var position: TimeInterval

    public init(feedURL: URL, guid: String?, enclosureURL: URL?, position: TimeInterval) {
        self.feedURL = feedURL
        self.guid = guid
        self.enclosureURL = enclosureURL
        self.position = position
    }

    // MARK: NSUserActivity payload

    private enum Key {
        static let feed = "feed"
        static let guid = "guid"
        static let enclosure = "enclosure"
        static let position = "position"
    }

    /// Property-list values only: that is all an activity may carry.
    public var userInfo: [String: Any] {
        var info: [String: Any] = [Key.feed: feedURL.absoluteString, Key.position: position]
        info[Key.guid] = guid
        info[Key.enclosure] = enclosureURL?.absoluteString
        return info
    }

    public init?(userInfo: [AnyHashable: Any]?) {
        guard let userInfo,
              let feed = (userInfo[Key.feed] as? String).flatMap(URL.init(string:)) else { return nil }
        feedURL = feed
        guid = userInfo[Key.guid] as? String
        enclosureURL = (userInfo[Key.enclosure] as? String).flatMap(URL.init(string:))
        position = max(0, userInfo[Key.position] as? TimeInterval ?? 0)
    }

    // MARK: Matching

    /// The episode it designates among those of its feed. The `guid` decides
    /// when there is one; otherwise the media address, compared without its
    /// query — hosts append tokens there that change from one fetch to the
    /// next.
    public func episode(in episodes: [Episode]) -> Episode? {
        if let guid, let match = episodes.first(where: { $0.guid == guid }) {
            return match
        }
        guard let wanted = enclosureURL.map(Self.withoutQuery) else { return nil }
        return episodes.first { $0.enclosureURL.map(Self.withoutQuery) == wanted }
    }

    private static func withoutQuery(_ url: URL) -> String {
        var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
        components?.query = nil
        components?.fragment = nil
        return components?.string ?? url.absoluteString
    }
}
+42 −0
Original line number Diff line number Diff line
import Foundation
import Testing
@testable import DiscoveryKit

@Suite struct PodcastLinkTests {
    private func feed(_ link: String) -> String? {
        URL(string: link).flatMap(PodcastLink.feedURL(from:))?.absoluteString
    }

    @Test func schemeStandingInForHTTPBecomesHTTPS() {
        #expect(feed("feed://example.com/rss.xml") == "https://example.com/rss.xml")
        #expect(feed("podcast://feeds.example.com/show") == "https://feeds.example.com/show")
        #expect(feed("pcast://example.com/feed") == "https://example.com/feed")
        #expect(feed("itpc://example.com/feed?id=42") == "https://example.com/feed?id=42")
    }

    @Test func schemeInFrontOfACompleteAddressKeepsThatAddress() {
        #expect(feed("feed:https://example.com/rss") == "https://example.com/rss")
        #expect(feed("feed:http://example.com/rss") == "http://example.com/rss")
        #expect(feed("podcast://https://example.com/rss") == "https://example.com/rss")
    }

    @Test func colonEatenByABrowserIsRestored() {
        #expect(feed("feed://https//example.com/rss") == "https://example.com/rss")
        #expect(feed("itpc://http//example.com/rss") == "http://example.com/rss")
    }

    @Test func schemeIsCaseInsensitive() {
        #expect(feed("FEED://example.com/rss") == "https://example.com/rss")
    }

    @Test func otherLinksAreNotFeeds() {
        #expect(feed("https://example.com/rss") == nil)
        #expect(feed("mailto:someone@example.com") == nil)
        #expect(feed("file:///tmp/subscriptions.opml") == nil)
    }

    @Test func linkWithoutAHostIsRejected() {
        #expect(feed("feed://") == nil)
        #expect(feed("feed:") == nil)
    }
}
Loading