ci / bun-nix (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust-arm64 (pull_request) Successful in 1m32s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m36s
The reserved punktfunk://browse/<host-ref> route now routes on Apple: it
drives the same libraryTarget every internal surface writes, so the link
lands in whichever presentation the current mode owns — the gamepad
console's in-place library screen, the touch cover, the macOS sheet, or
tvOS's cover. Connect's posture minus the connect: a pin conflict
refuses, a live session is never preempted, an unsaved host gets a
notice (the library rides the paired mTLS identity, so there is nothing
to browse before the host is saved). browse ignores launch=/profile= —
nothing streams until a title is picked, and that connect resolves its
own profile.
On top of the route, the two new front doors:
- OpenLibraryIntent ("Open Game Library") beside Connect/Wake/End in
Shortcuts/Siri/Spotlight, host-parameterized like the others and
round-tripping through the URL — one router, no second path.
- A configurable library widget (kind "PunktfunkLibrary",
AppIntentConfiguration over HostEntity — the configuration the
HostEntity doc comment anticipated): pick a host, tap into its
library. Unconfigured it follows the most recent host; a configured
host that was removed shows the empty state rather than silently
following a different host. Same .never timeline + HostStore push as
the hosts widget, now reloading both kinds.
DeepLink.browse(host:) is the one emitter both doors share, covered by
a round-trip test beside connect's; the parse side was already in the
grammar and the vector file. Docs updated (clients, game-library,
profiles-and-links).
476 lines
22 KiB
Swift
476 lines
22 KiB
Swift
// The `punktfunk://` URL grammar — the single builder/parser shared by the widget (which emits
|
|
// links via `widgetURL`/`Link`), the App Intents (which round-trip through it rather than opening
|
|
// a second connect path) and the app (`ContentView.onOpenURL`, which routes them into the existing
|
|
// connect path). Keeping every side on one type means the wire format can't drift.
|
|
//
|
|
// punktfunk://connect/<host-ref>[?fp=<64-hex>][&host=<addr[:port]>][&launch=<id>]
|
|
// [&profile=<ref>][&name=<label>]
|
|
//
|
|
// The invariant the grammar exists to keep: **a URL may only ever do what a click on an existing
|
|
// card could do, minus trust decisions.** So it carries *references* to things that already exist
|
|
// on this device — a host record, a settings profile, a library id — and never values: no
|
|
// resolution, no bitrate, no codec. A web page must not be able to shape a session beyond picking
|
|
// among the user's own configurations. `pair` is deliberately not a route and never will be;
|
|
// pairing stays an interactive ceremony.
|
|
//
|
|
// `pf://` parses as an alias so a hand-typed or legacy link still works, but nothing ever *emits*
|
|
// or registers it (design/client-deep-links.md §2).
|
|
//
|
|
// This is a port of `crates/pf-client-core/src/deeplink.rs`, and the two are held together by
|
|
// `clients/shared/deeplink-vectors.json` — 44 cases including every refusal code, run verbatim by
|
|
// both test suites. Change one parser and the other's suite fails, which is the point: three
|
|
// parsers that drift are three different security postures.
|
|
|
|
import Foundation
|
|
|
|
/// Hostile-input caps (design §8). The total is generous for a real link and small enough that a
|
|
/// pasted megabyte never reaches the decoder.
|
|
public enum DeepLinkLimits {
|
|
public static let url = 2048
|
|
public static let hostRef = 128
|
|
public static let launch = 128
|
|
public static let profile = 64
|
|
public static let name = 64
|
|
}
|
|
|
|
/// The default native port, as everywhere else in the clients.
|
|
public let punktfunkDefaultPort: UInt16 = 9777
|
|
|
|
/// What the URL asks for. `wake`/`browse` are reserved in the grammar and parse today; a front-end
|
|
/// that hasn't implemented them refuses with a notice rather than silently connecting — the
|
|
/// grammar is the contract, per-platform support is not.
|
|
public enum DeepLinkRoute: String, Equatable, Sendable {
|
|
case connect, wake, browse
|
|
}
|
|
|
|
/// An `addr[:port]` pair carried by a link (the `host=` recovery parameter, or an address-shaped
|
|
/// reference).
|
|
public struct DeepLinkAddress: Equatable, Sendable {
|
|
public var address: String
|
|
public var port: UInt16
|
|
|
|
public init(_ address: String, _ port: UInt16) {
|
|
self.address = address
|
|
self.port = port
|
|
}
|
|
}
|
|
|
|
/// Why a URL was rejected. The `code` strings are the cross-language contract (the vector file
|
|
/// names them) — Rust and Kotlin report the same code for the same input.
|
|
public enum DeepLinkError: Error, Equatable, Sendable {
|
|
/// Not a `punktfunk://` (or `pf://`) URL at all — the caller should ignore it, not warn.
|
|
case notOurScheme
|
|
case tooLong
|
|
/// A route this grammar doesn't define.
|
|
case unknownRoute(String)
|
|
/// `punktfunk://pair/…` — pairing is an interactive ceremony, never a link.
|
|
case pairRefused
|
|
case missingHostRef
|
|
/// A `%` escape that isn't two hex digits, or a decode that isn't UTF-8.
|
|
case badEscape
|
|
/// A control character survived decoding — no legitimate field contains one.
|
|
case controlChar
|
|
/// A parameter past its cap; carries the parameter name.
|
|
case paramTooLong(String)
|
|
/// `fp=` that isn't 64 hex characters.
|
|
case badFingerprint
|
|
/// `host=` that isn't `addr[:port]` with a parsable port.
|
|
case badHostParam
|
|
/// `launch=` outside the printable, shell-safe id charset the host and Decky agree on.
|
|
case badLaunchID
|
|
|
|
/// The stable code shared with the Rust/Kotlin ports and the vector file.
|
|
public var code: String {
|
|
switch self {
|
|
case .notOurScheme: return "not-our-scheme"
|
|
case .tooLong: return "too-long"
|
|
case .unknownRoute: return "unknown-route"
|
|
case .pairRefused: return "pair-refused"
|
|
case .missingHostRef: return "missing-host-ref"
|
|
case .badEscape: return "bad-escape"
|
|
case .controlChar: return "control-char"
|
|
case .paramTooLong: return "param-too-long"
|
|
case .badFingerprint: return "bad-fingerprint"
|
|
case .badHostParam: return "bad-host-param"
|
|
case .badLaunchID: return "bad-launch-id"
|
|
}
|
|
}
|
|
|
|
/// A sentence for the notice a refusing front-end shows. Deliberately names the failing
|
|
/// reference: a shortcut that can't honor what it asked for says so instead of streaming with
|
|
/// the wrong settings.
|
|
public var message: String {
|
|
switch self {
|
|
case .notOurScheme: return "That isn't a Punktfunk link."
|
|
case .tooLong: return "That link is too long to be genuine."
|
|
case .unknownRoute(let r): return "Punktfunk links can't do “\(r)”."
|
|
case .pairRefused:
|
|
return "Pairing can't be done from a link — pair the host in Punktfunk first."
|
|
case .missingHostRef: return "That link doesn't say which host to use."
|
|
case .badEscape, .controlChar: return "That link is malformed and was ignored."
|
|
case .paramTooLong(let p): return "That link's “\(p)” value is too long."
|
|
case .badFingerprint: return "That link's host fingerprint isn't a valid one."
|
|
case .badHostParam: return "That link's host address isn't valid."
|
|
case .badLaunchID: return "That link's game id isn't a valid one."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A parsed, validated link. Every field is already length- and charset-checked, so a consumer
|
|
/// never has to re-validate hostile input; what it still has to do is *resolve* — the references
|
|
/// may name things that don't exist here.
|
|
public struct DeepLink: Equatable, Sendable {
|
|
public var route: DeepLinkRoute = .connect
|
|
/// The host reference as written: a stable record id, a host name, or `addr[:port]`.
|
|
public var hostRef: String
|
|
/// Expected host certificate fingerprint, lowercase hex (64 chars).
|
|
public var fp: String?
|
|
/// Recovery address for a stable id that no longer resolves (store wiped, reinstall).
|
|
public var host: DeepLinkAddress?
|
|
/// A store-qualified library id (`steam:570`) for the host to launch on arrival.
|
|
public var launch: String?
|
|
/// A settings-profile reference (id, or a unique name) — one-off, never rebinding.
|
|
public var profile: String?
|
|
/// Display label for the unknown-host confirmation sheet (external emitters).
|
|
public var name: String?
|
|
|
|
public static let scheme = "punktfunk"
|
|
|
|
public init(
|
|
route: DeepLinkRoute = .connect, hostRef: String, fp: String? = nil,
|
|
host: DeepLinkAddress? = nil, launch: String? = nil, profile: String? = nil,
|
|
name: String? = nil
|
|
) {
|
|
self.route = route
|
|
self.hostRef = hostRef
|
|
self.fp = fp
|
|
self.host = host
|
|
self.launch = launch
|
|
self.profile = profile
|
|
self.name = name
|
|
}
|
|
|
|
// MARK: - Emission
|
|
|
|
/// The canonical URL for this link — always `punktfunk://`, never the `pf://` alias.
|
|
public var urlString: String {
|
|
var s = "punktfunk://\(route.rawValue)/\(Self.encode(hostRef))"
|
|
var separator = "?"
|
|
func push(_ key: String, _ value: String) {
|
|
s += separator + key + "=" + Self.encode(value)
|
|
separator = "&"
|
|
}
|
|
if let fp { push("fp", fp) }
|
|
if let host {
|
|
let text: String
|
|
if host.port == punktfunkDefaultPort {
|
|
text = host.address
|
|
} else if host.address.contains(":") {
|
|
text = "[\(host.address)]:\(host.port)" // literal IPv6 needs its brackets back
|
|
} else {
|
|
text = "\(host.address):\(host.port)"
|
|
}
|
|
push("host", text)
|
|
}
|
|
if let launch { push("launch", launch) }
|
|
if let profile { push("profile", profile) }
|
|
if let name { push("name", name) }
|
|
return s
|
|
}
|
|
|
|
/// Non-optional: every segment is percent-encoded on the way out, so the emitted string is
|
|
/// always a valid URL — force-unwrapping a value we fully control.
|
|
public var url: URL { URL(string: urlString)! }
|
|
|
|
/// A plain connect link for a saved host — the shape the widget and the Connect intent emit.
|
|
public static func connect(host: UUID, launchID: String? = nil, profile: String? = nil)
|
|
-> DeepLink {
|
|
DeepLink(
|
|
hostRef: host.uuidString,
|
|
launch: (launchID?.isEmpty ?? true) ? nil : launchID,
|
|
profile: (profile?.isEmpty ?? true) ? nil : profile)
|
|
}
|
|
|
|
/// A library link for a saved host — the shape the library widget and the Open Library intent
|
|
/// emit. Opens the host's game library without starting a session; a session begins only when
|
|
/// the user picks a title there, through the normal connect path.
|
|
public static func browse(host: UUID) -> DeepLink {
|
|
DeepLink(route: .browse, hostRef: host.uuidString)
|
|
}
|
|
|
|
/// The self-emitted form for a saved host: id first (address-independent), with the address
|
|
/// and pin alongside so the link degrades to a confirmation sheet instead of a dead click when
|
|
/// the record is gone ("Copy link", and any shortcut written from a card).
|
|
public static func forHost(
|
|
_ host: StoredHost, launch: String? = nil, profile: String? = nil
|
|
) -> DeepLink {
|
|
DeepLink(
|
|
hostRef: host.id.uuidString,
|
|
fp: host.pinnedSHA256.map(Self.hexLower),
|
|
host: DeepLinkAddress(host.address, host.port),
|
|
launch: (launch?.isEmpty ?? true) ? nil : launch,
|
|
profile: (profile?.isEmpty ?? true) ? nil : profile)
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
/// Parse an incoming URL. Everything hostile is rejected here, once, for every front-end.
|
|
public init(url: URL) throws {
|
|
self = try DeepLink.parse(url.absoluteString)
|
|
}
|
|
|
|
public static func parse(_ text: String) throws -> DeepLink {
|
|
guard text.utf8.count <= DeepLinkLimits.url else { throw DeepLinkError.tooLong }
|
|
guard let separator = text.range(of: "://") else { throw DeepLinkError.notOurScheme }
|
|
let scheme = text[text.startIndex..<separator.lowerBound].lowercased()
|
|
guard scheme == "punktfunk" || scheme == "pf" else { throw DeepLinkError.notOurScheme }
|
|
// A fragment is never part of this grammar; drop it rather than folding it into the last
|
|
// parameter (where it would smuggle unvalidated text past the caps).
|
|
let rest = String(text[separator.upperBound...].prefix { $0 != "#" })
|
|
let path: Substring
|
|
let query: Substring
|
|
if let q = rest.firstIndex(of: "?") {
|
|
path = rest[rest.startIndex..<q]
|
|
query = rest[rest.index(after: q)...]
|
|
} else {
|
|
path = rest[...]
|
|
query = ""
|
|
}
|
|
var trimmed = String(path)
|
|
while trimmed.hasSuffix("/") { trimmed.removeLast() }
|
|
|
|
let routeWord: String
|
|
let hostRefRaw: String
|
|
if let slash = trimmed.firstIndex(of: "/") {
|
|
routeWord = String(trimmed[trimmed.startIndex..<slash])
|
|
hostRefRaw = String(trimmed[trimmed.index(after: slash)...])
|
|
} else if isRouteWord(trimmed) {
|
|
// A single segment: a bare reference is unambiguous as long as it isn't one of the
|
|
// route words — those stay routes (with a missing reference), so `punktfunk://pair`
|
|
// refuses instead of hunting for a host called "pair".
|
|
routeWord = trimmed
|
|
hostRefRaw = ""
|
|
} else {
|
|
routeWord = "connect"
|
|
hostRefRaw = trimmed
|
|
}
|
|
let lowered = routeWord.lowercased()
|
|
if lowered == "pair" { throw DeepLinkError.pairRefused }
|
|
guard let route = DeepLinkRoute(rawValue: lowered) else {
|
|
throw DeepLinkError.unknownRoute(lowered)
|
|
}
|
|
|
|
let hostRef = try decode(hostRefRaw)
|
|
guard !hostRef.isEmpty else { throw DeepLinkError.missingHostRef }
|
|
guard hostRef.unicodeScalars.count <= DeepLinkLimits.hostRef else {
|
|
throw DeepLinkError.paramTooLong("host-ref")
|
|
}
|
|
|
|
var link = DeepLink(route: route, hostRef: hostRef)
|
|
for pair in query.split(separator: "&", omittingEmptySubsequences: true) {
|
|
let rawKey: Substring
|
|
let rawValue: Substring
|
|
if let eq = pair.firstIndex(of: "=") {
|
|
rawKey = pair[pair.startIndex..<eq]
|
|
rawValue = pair[pair.index(after: eq)...]
|
|
} else {
|
|
rawKey = pair
|
|
rawValue = ""
|
|
}
|
|
let key = try decode(String(rawKey)).lowercased()
|
|
let value = try decode(String(rawValue))
|
|
// `?launch=` with nothing after it is "not given", not an error.
|
|
if value.isEmpty { continue }
|
|
// First occurrence wins, and unknown keys are ignored: a newer emitter's parameter
|
|
// must not turn an otherwise valid link into a refusal, and appending a second `fp=`
|
|
// must not be able to override the first.
|
|
switch key {
|
|
case "fp" where link.fp == nil:
|
|
let fp = value.lowercased()
|
|
guard fp.count == 64, fp.allSatisfy(\.isHexDigit) else {
|
|
throw DeepLinkError.badFingerprint
|
|
}
|
|
link.fp = fp
|
|
case "host" where link.host == nil:
|
|
guard let parsed = parseAddressPort(value) else { throw DeepLinkError.badHostParam }
|
|
link.host = parsed
|
|
case "launch" where link.launch == nil:
|
|
guard value.utf8.count <= DeepLinkLimits.launch else {
|
|
throw DeepLinkError.paramTooLong("launch")
|
|
}
|
|
guard isSafeLaunchID(value) else { throw DeepLinkError.badLaunchID }
|
|
link.launch = value
|
|
case "profile" where link.profile == nil:
|
|
guard value.unicodeScalars.count <= DeepLinkLimits.profile else {
|
|
throw DeepLinkError.paramTooLong("profile")
|
|
}
|
|
link.profile = value
|
|
case "name" where link.name == nil:
|
|
guard value.unicodeScalars.count <= DeepLinkLimits.name else {
|
|
throw DeepLinkError.paramTooLong("name")
|
|
}
|
|
link.name = value
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
return link
|
|
}
|
|
|
|
// MARK: - Resolution
|
|
|
|
/// True when this link's `fp` contradicts what we have pinned for that host — the link is
|
|
/// stale or lying, and the only safe answer is a hard refusal (design §3.1).
|
|
public func pinConflict(with host: StoredHost) -> Bool {
|
|
guard let fp, let pin = host.pinnedSHA256 else { return false }
|
|
return fp.lowercased() != Self.hexLower(pin)
|
|
}
|
|
|
|
/// Resolve this link's host reference against the local store, in the documented order:
|
|
/// stable record id → unique case-insensitive name → `addr[:port]` literal. The `host=`
|
|
/// parameter is the recovery path — a self-emitted shortcut that outlived the record it was
|
|
/// written from still lands on the right box (degraded to the confirmation sheet).
|
|
public func resolveHost(in hosts: [StoredHost]) -> HostResolution {
|
|
let reference = hostRef.lowercased()
|
|
if let match = hosts.first(where: { $0.id.uuidString.lowercased() == reference }) {
|
|
return .known(match)
|
|
}
|
|
let byName = hosts.filter { !$0.name.isEmpty && $0.name.lowercased() == reference }
|
|
if byName.count == 1 { return .known(byName[0]) }
|
|
if byName.count > 1 { return .ambiguous }
|
|
// `addr[:port]` literal, then the `host=` recovery parameter — both matched the way every
|
|
// other per-host lookup in the client matches. The literal is only considered when the
|
|
// reference could BE an address: a stale record id must fall through to `host=` (or to a
|
|
// refusal), never be offered as a box to dial.
|
|
let literal = Self.looksLikeAddress(hostRef) ? Self.parseAddressPort(hostRef) : nil
|
|
for candidate in [literal, host].compactMap({ $0 }) {
|
|
if let match = hosts.first(where: {
|
|
$0.address == candidate.address && $0.port == candidate.port
|
|
}) {
|
|
return .known(match)
|
|
}
|
|
}
|
|
guard let target = literal ?? host else { return .unresolvable }
|
|
return .unknown(address: target.address, port: target.port, name: name, fp: fp)
|
|
}
|
|
|
|
/// What the local host store made of a link's references.
|
|
public enum HostResolution: Equatable, Sendable {
|
|
/// A record we already have (subject to `pinConflict`).
|
|
case known(StoredHost)
|
|
/// No record, but the link says where to dial: the confirmation sheet's input, from which
|
|
/// the normal pairing flow proceeds under the user's eyes. Never an auto-connect.
|
|
case unknown(address: String, port: UInt16, name: String?, fp: String?)
|
|
/// The name matched more than one saved host — refuse with a notice, never guess.
|
|
case ambiguous
|
|
/// A reference that resolves to nothing and carries no address to fall back on.
|
|
case unresolvable
|
|
}
|
|
|
|
// MARK: - Grammar helpers (ports of the Rust originals — keep them in step)
|
|
|
|
/// Could this reference be a network address (an IP literal or a host name) rather than a
|
|
/// record id or a display name? Only then may an unmatched reference become "an unknown host
|
|
/// at this address". A stable id that no longer resolves is NOT an address: offering to dial a
|
|
/// UUID as a hostname would turn a wiped store into a dead end instead of `host=` recovery.
|
|
static func looksLikeAddress(_ s: String) -> Bool {
|
|
let chars = Array(s)
|
|
let uuidShaped = chars.count == 36 && chars.enumerated().allSatisfy { i, c in
|
|
[8, 13, 18, 23].contains(i) ? c == "-" : c.isHexDigit
|
|
}
|
|
guard !uuidShaped, !s.isEmpty else { return false }
|
|
return s.allSatisfy { c in
|
|
c.isASCII && (c.isLetter || c.isNumber || ".-_:[]".contains(c))
|
|
}
|
|
}
|
|
|
|
/// The reserved first path segments — everything the grammar routes on, plus `pair`, which is
|
|
/// reserved precisely so it can be refused rather than mistaken for a host name.
|
|
private static func isRouteWord(_ s: String) -> Bool {
|
|
["connect", "wake", "browse", "pair"].contains(s.lowercased())
|
|
}
|
|
|
|
/// `addr`, `addr:port`, `[v6]`, `[v6]:port` — nil when the port isn't a number. A bare IPv6
|
|
/// literal (`::1`) keeps its colons and takes the default port; anything else splits at the
|
|
/// last colon, like every other host-parsing site in the clients.
|
|
static func parseAddressPort(_ s: String) -> DeepLinkAddress? {
|
|
guard !s.isEmpty else { return nil }
|
|
if s.hasPrefix("[") {
|
|
let body = s.dropFirst()
|
|
guard let close = body.firstIndex(of: "]") else { return nil }
|
|
let address = String(body[body.startIndex..<close])
|
|
guard !address.isEmpty else { return nil }
|
|
let tail = String(body[body.index(after: close)...])
|
|
if tail.isEmpty { return DeepLinkAddress(address, punktfunkDefaultPort) }
|
|
guard tail.hasPrefix(":"), let port = UInt16(tail.dropFirst()) else { return nil }
|
|
return DeepLinkAddress(address, port)
|
|
}
|
|
guard let colon = s.lastIndex(of: ":") else {
|
|
return DeepLinkAddress(s, punktfunkDefaultPort)
|
|
}
|
|
let head = String(s[s.startIndex..<colon])
|
|
// `::1` and friends: the head still has a colon, so this isn't a port separator.
|
|
if head.contains(":") { return DeepLinkAddress(s, punktfunkDefaultPort) }
|
|
guard !head.isEmpty, let port = UInt16(s[s.index(after: colon)...]) else { return nil }
|
|
return DeepLinkAddress(head, port)
|
|
}
|
|
|
|
/// The launch-id charset the whole product already agrees on: printable, non-space ASCII with
|
|
/// no shell metacharacters (Decky rides ids through Steam launch options as an env token, so a
|
|
/// quote or a backtick genuinely breaks something downstream). Validation only — the id is
|
|
/// opaque and the host matches it verbatim against its own library.
|
|
static func isSafeLaunchID(_ id: String) -> Bool {
|
|
// `"`, `'`, `\`, `$`, backtick — spelled as bytes because a Swift literal holding all five
|
|
// is its own little escaping puzzle, and this list must read exactly like the Rust one.
|
|
let forbidden: Set<UInt8> = [0x22, 0x27, 0x5C, 0x24, 0x60]
|
|
return !id.isEmpty && id.utf8.allSatisfy { (0x21...0x7e).contains($0) && !forbidden.contains($0) }
|
|
}
|
|
|
|
/// Strict percent-decoding: `%` must be followed by exactly two hex digits, the result must be
|
|
/// UTF-8, and no control character may survive. Lenient decoders are how `%00`, a stray `\n`
|
|
/// or a half-escape end up inside a filename or a log line.
|
|
static func decode(_ s: String) throws -> String {
|
|
let bytes = Array(s.utf8)
|
|
var out: [UInt8] = []
|
|
out.reserveCapacity(bytes.count)
|
|
var i = 0
|
|
while i < bytes.count {
|
|
guard bytes[i] == UInt8(ascii: "%") else {
|
|
out.append(bytes[i])
|
|
i += 1
|
|
continue
|
|
}
|
|
guard i + 2 < bytes.count,
|
|
let hi = Character(UnicodeScalar(bytes[i + 1])).hexDigitValue,
|
|
let lo = Character(UnicodeScalar(bytes[i + 2])).hexDigitValue
|
|
else { throw DeepLinkError.badEscape }
|
|
out.append(UInt8(hi << 4 | lo))
|
|
i += 3
|
|
}
|
|
guard let text = String(bytes: out, encoding: .utf8) else { throw DeepLinkError.badEscape }
|
|
if text.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) {
|
|
throw DeepLinkError.controlChar
|
|
}
|
|
return text
|
|
}
|
|
|
|
/// Percent-encode for emission: unreserved characters plus `:` (legal in a query value and
|
|
/// left alone by `URLComponents`, so the three emitters agree on `steam:570`).
|
|
static func encode(_ s: String) -> String {
|
|
var out = ""
|
|
for b in s.utf8 {
|
|
let c = Character(UnicodeScalar(b))
|
|
if c.isASCII, c.isLetter || c.isNumber || "-._~:".contains(c) {
|
|
out.append(c)
|
|
} else {
|
|
out += String(format: "%%%02X", b)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
static func hexLower(_ data: Data) -> String {
|
|
data.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
}
|