diff --git a/iosApp/Tests/PairingCoordinatorTests.swift b/iosApp/Tests/PairingCoordinatorTests.swift index 8f2eb2d0f..20fa95079 100644 --- a/iosApp/Tests/PairingCoordinatorTests.swift +++ b/iosApp/Tests/PairingCoordinatorTests.swift @@ -234,7 +234,8 @@ final class CompanionPairingCoordinatorTests: XCTestCase { private func makeCoordinator( channel: FakePairingChannel, api: FakePairingAPI, - servers: [ServerEntry] + servers: [ServerEntry], + endpoints: [ServerEndpoint]? = nil ) -> CompanionPairingCoordinator { let coordinator = CompanionPairingCoordinator( channel: channel, @@ -243,12 +244,58 @@ final class CompanionPairingCoordinatorTests: XCTestCase { api: api, deviceModel: "iPhone", availableServers: { servers }, - accessToken: { _ in "token" } + accessToken: { _ in "token" }, + serverEndpoints: { _, _ in endpoints } ) coordinator.start() return coordinator } + /// A server with a verified identity is pushed with that identity and + /// the deployment's other addresses, and a typed TV failure reaches the + /// summary. A server without one is pushed the legacy way. + func testPushCarriesIdentityAndEndpointsAndSummarisesTypedFailure() async { + let channel = FakePairingChannel() + let api = FakePairingAPI() + let identified = ServerEntry( + id: "a", url: "https://a.example", fetchedName: "Home", profileId: nil, + lastUsedAt: Date(), verifiedServerId: "S" + ) + let legacy = entry("b", name: "Legacy") + let endpoints = [ServerEndpoint(url: "https://public.example", kind: .public)] + let coordinator = makeCoordinator(channel: channel, api: api, servers: [identified, legacy], endpoints: endpoints) + + channel.deliver(.hello(tvName: "Living Room", tvDeviceId: "tv", state: .setup, supportedVersions: [1])) + await expectEventually("picker") { + if case .pickServers = coordinator.state { return true } + return false + } + await coordinator.pushSelected([identified, legacy]) + await expectEventually("first push") { + channel.sent.contains { + if case .pushServer("https://a.example", "Home", "S", endpoints) = $0 { return true } + return false + } + } + channel.deliver(.serverResult(serverURL: "https://a.example", status: .failed, error: "unreachable")) + await expectEventually("legacy push") { + channel.sent.contains { + if case .pushServer("https://b.example", "Legacy", nil, nil) = $0 { return true } + return false + } + } + channel.deliver(.serverResult(serverURL: "https://b.example", status: .failed, error: nil)) + await expectEventually("summary") { + if case .finished(let ok, let bad) = coordinator.state { + return ok.isEmpty && bad.map(\.code) == [.unreachable, .authFailed] + } + return false + } + if case .finished(_, let bad) = coordinator.state { + XCTAssertTrue(bad[0].summary.contains("couldn't reach")) + } + } + private func hello() -> PairingMessage { .hello(tvName: "Living Room", tvDeviceId: "tv-1", state: .setup, supportedVersions: [PairingProtocol.version]) } @@ -327,12 +374,12 @@ final class CompanionPairingCoordinatorTests: XCTestCase { // Second server: the TV shows ZZZZ but the server says ABCD — splice. await expectEventually("second push") { - channel.sent.contains { if case .pushServer(let url, _) = $0 { return url == servers[1].url } else { return false } } + channel.sent.contains { if case .pushServer(let url, _, _, _) = $0 { return url == servers[1].url } else { return false } } } channel.deliver(.deviceStarted(serverURL: servers[1].url, userCode: "USER-2", matchCode: "ZZZZ")) await expectEventually("summary") { - if case .finished(let ok, let bad) = coordinator.state { return ok == ["Home"] && bad == ["Remote"] } + if case .finished(let ok, let bad) = coordinator.state { return ok == ["Home"] && bad.map(\.name) == ["Remote"] } return false } XCTAssertEqual(api.approvedCodes, ["USER-1"], "the spliced server must never be approved") @@ -377,7 +424,7 @@ final class CompanionPairingCoordinatorTests: XCTestCase { await coordinator.pushSelected(servers) channel.deliver(.deviceStarted(serverURL: servers[0].url, userCode: "USER-1", matchCode: "ABCD")) await expectEventually("zero-success summary") { - if case .finished(let ok, let bad) = coordinator.state { return ok.isEmpty && bad == ["Home"] } + if case .finished(let ok, let bad) = coordinator.state { return ok.isEmpty && bad.map(\.name) == ["Home"] } return false } XCTAssertTrue(api.approvedCodes.isEmpty) @@ -395,6 +442,7 @@ final class ReceiverPairingCoordinatorTests: XCTestCase { private final class PersistRecorder: @unchecked Sendable { var persisted: [Persisted] = [] + var verifiedIds: [String?] = [] } @MainActor @@ -420,11 +468,238 @@ final class ReceiverPairingCoordinatorTests: XCTestCase { } } - private func makeCoordinator(api: FakePairingAPI, recorder: PersistRecorder) -> ReceiverPairingCoordinator { - ReceiverPairingCoordinator(api: api) { url, _, access, _ in - recorder.persisted.append(Persisted(url: url, access: access)) + private func makeCoordinator( + api: FakePairingAPI, + recorder: PersistRecorder, + identities: [String: ServerIdentityProbeResult] = [:], + probeLog: ProbeLog? = nil + ) -> ReceiverPairingCoordinator { + ReceiverPairingCoordinator( + api: api, + identityProbe: { url in + probeLog?.record(url) + return identities[ServerRegistry.normalize(url: url)] ?? .unreachable + } + ) { pairing in + recorder.persisted.append(Persisted(url: pairing.url, access: pairing.accessToken)) + recorder.verifiedIds.append(pairing.verifiedServerId) + return true + } + } + + private final class ProbeLog: @unchecked Sendable { + private let lock = NSLock() + private var urls: [String] = [] + func record(_ url: String) { lock.withLock { urls.append(url) } } + var probed: [String] { lock.withLock { urls } } + } + + private static let identity = "96c1bd08-b839-4d47-980e-57d4e7a44cfa" + private static let pushedURL = "https://silo.overlay.example" + private static let publicURL = "https://silo.example" + private static let endpoints = [ + ServerEndpoint(url: publicURL, kind: .public), + ServerEndpoint(url: pushedURL, kind: .provider, provider: "tailscale", displayName: "Tailscale"), + ] + + private func pushWithIdentity() -> PairingMessage { + .pushServer(serverURL: Self.pushedURL, serverName: "Home", serverIdentity: Self.identity, endpoints: Self.endpoints) + } + + /// The pushed (plugin) address is unreachable from the TV, and the public + /// address answers with the same identity: the TV must OFFER it, name + /// the provider in its help, and only switch when the user chooses. The + /// frames back to the phone keep naming the pushed address; the TV + /// persists the address that worked, with the verified identity. + func testUnreachablePushOffersVerifiedAlternateAndPersistsWorkingAddress() async { + let channel = FakePairingChannel() + let api = FakePairingAPI() + api.pollResponse = approvedPoll + let recorder = PersistRecorder() + let probes = ProbeLog() + let coordinator = makeCoordinator( + api: api, recorder: recorder, + identities: [Self.publicURL: .identity(Self.identity)], + probeLog: probes + ) + let runTask = Task { await coordinator.run(session: channel, stream: channel.stream) } + + channel.deliver(pushWithIdentity()) + await expectEventually("consent prompt") { + if case .consentRequested(let name) = coordinator.state { return name == "Home" } + return false + } + coordinator.allowPendingServer() + await expectEventually("unreachable with alternate") { + if case .unreachable(let name, let help, let alternate) = coordinator.state { + return name == "Home" && help.contains("Tailscale") && alternate?.url == Self.publicURL + } + return false + } + XCTAssertTrue(api.startedServers.isEmpty, "no device login until the user chooses an address") + XCTAssertEqual(probes.probed, [Self.pushedURL, Self.publicURL]) + + coordinator.useAlternateAddress() + await expectEventually("signed in") { + if case .signedIn(let count) = coordinator.state { return count == 1 } + return false + } + XCTAssertEqual(api.startedServers, [Self.publicURL]) + XCTAssertEqual(recorder.persisted.map(\.url), [Self.publicURL]) + XCTAssertEqual(recorder.verifiedIds, [Self.identity]) + let started = channel.sent.compactMap { message -> String? in + if case .deviceStarted(let url, _, _) = message { return url } else { return nil } + } + let results = channel.sent.compactMap { message -> String? in + if case .serverResult(let url, .signedIn, _) = message { return url } else { return nil } + } + XCTAssertEqual(started, [Self.pushedURL], "deviceStarted echoes the pushed address") + XCTAssertEqual(results, [Self.pushedURL], "serverResult echoes the pushed address") + + channel.deliver(.done) + await runTask.value + } + + /// Retry after the user set the provider up: the pushed address is + /// probed again and, once it answers with the right identity, used. + func testRetryAfterProviderSetupUsesThePushedAddress() async { + let channel = FakePairingChannel() + let api = FakePairingAPI() + api.pollResponse = approvedPoll + let recorder = PersistRecorder() + let answers = ProbeAnswers([.unreachable, .identity(Self.identity)]) + let coordinator = ReceiverPairingCoordinator( + api: api, + identityProbe: { _ in answers.next() } + ) { pairing in + recorder.persisted.append(Persisted(url: pairing.url, access: pairing.accessToken)) return true } + let runTask = Task { await coordinator.run(session: channel, stream: channel.stream) } + + channel.deliver(.pushServer(serverURL: Self.pushedURL, serverName: "Home", serverIdentity: Self.identity, endpoints: nil)) + await expectEventually("consent prompt") { + if case .consentRequested = coordinator.state { return true } + return false + } + coordinator.allowPendingServer() + await expectEventually("unreachable without alternate") { + if case .unreachable(_, _, let alternate) = coordinator.state { return alternate == nil } + return false + } + coordinator.retryPushedAddress() + await expectEventually("signed in") { + if case .signedIn = coordinator.state { return true } + return false + } + XCTAssertEqual(api.startedServers, [Self.pushedURL]) + XCTAssertEqual(recorder.persisted.map(\.url), [Self.pushedURL]) + channel.deliver(.done) + await runTask.value + } + + private final class ProbeAnswers: @unchecked Sendable { + private let lock = NSLock() + private var queue: [ServerIdentityProbeResult] + init(_ queue: [ServerIdentityProbeResult]) { self.queue = queue } + func next() -> ServerIdentityProbeResult { + lock.withLock { queue.isEmpty ? .unreachable : queue.removeFirst() } + } + } + + /// An address that answers as a different deployment is refused, never + /// used, and the phone learns why. + func testIdentityMismatchFailsWithoutDeviceLogin() async { + let channel = FakePairingChannel() + let api = FakePairingAPI() + api.pollResponse = approvedPoll + let recorder = PersistRecorder() + let coordinator = makeCoordinator( + api: api, recorder: recorder, + identities: [Self.pushedURL: .identity("someone-else")] + ) + let runTask = Task { await coordinator.run(session: channel, stream: channel.stream) } + + channel.deliver(pushWithIdentity()) + await expectEventually("consent prompt") { + if case .consentRequested = coordinator.state { return true } + return false + } + coordinator.allowPendingServer() + await expectEventually("identity mismatch failure") { + if case .failed("Home", .identityMismatch, _) = coordinator.state { return true } + return false + } + XCTAssertTrue(api.startedServers.isEmpty) + XCTAssertTrue(recorder.persisted.isEmpty) + XCTAssertTrue(channel.sent.contains { + if case .serverResult(Self.pushedURL, .failed, "identity_mismatch") = $0 { return true } + return false + }) + channel.deliver(.done) + await runTask.value + } + + /// Cancelling from the unreachable screen ends the attempt cleanly, with + /// nothing persisted and the receiver back at idle. + func testCancelWhileUnreachableReturnsToIdle() async { + let channel = FakePairingChannel() + let api = FakePairingAPI() + let recorder = PersistRecorder() + let coordinator = makeCoordinator(api: api, recorder: recorder) + let runTask = Task { await coordinator.run(session: channel, stream: channel.stream) } + + channel.deliver(pushWithIdentity()) + await expectEventually("consent prompt") { + if case .consentRequested = coordinator.state { return true } + return false + } + coordinator.allowPendingServer() + await expectEventually("unreachable") { + if case .unreachable = coordinator.state { return true } + return false + } + await coordinator.cancel() + await runTask.value + guard case .idle = coordinator.state else { + return XCTFail("expected idle, got \(coordinator.state)") + } + XCTAssertTrue(api.startedServers.isEmpty) + XCTAssertTrue(recorder.persisted.isEmpty) + } + + /// A push from an older phone carries no identity: the pushed address is + /// used exactly, no probe runs, and a denied approval reports `denied`. + func testLegacyPushSkipsProbingAndReportsTypedFailure() async { + let channel = FakePairingChannel() + let api = FakePairingAPI() + api.pollResponse = DeviceLoginPollResponse( + status: "denied", pollAfter: nil, accessToken: nil, refreshToken: nil, expiresIn: nil, user: nil + ) + let recorder = PersistRecorder() + let probes = ProbeLog() + let coordinator = makeCoordinator(api: api, recorder: recorder, probeLog: probes) + let runTask = Task { await coordinator.run(session: channel, stream: channel.stream) } + + channel.deliver(.pushServer(serverURL: "https://home.example", serverName: "Home")) + await expectEventually("consent prompt") { + if case .consentRequested = coordinator.state { return true } + return false + } + coordinator.allowPendingServer() + await expectEventually("denied failure") { + if case .failed("Home", .denied, nil) = coordinator.state { return true } + return false + } + XCTAssertTrue(probes.probed.isEmpty) + XCTAssertEqual(api.startedServers, ["https://home.example"]) + XCTAssertEqual(recorder.verifiedIds, []) + XCTAssertTrue(channel.sent.contains { + if case .serverResult("https://home.example", .failed, "denied") = $0 { return true } + return false + }) + channel.deliver(.done) + await runTask.value } /// The consent gate: nothing touches the pushed URL until the TV user @@ -531,7 +806,7 @@ final class ReceiverPairingCoordinatorTests: XCTestCase { } coordinator.allowPendingServer() await expectEventually("missing request failure") { - if case .failed(let name) = coordinator.state { return name == "Home" } + if case .failed(let name, _, _) = coordinator.state { return name == "Home" } return false } @@ -576,9 +851,9 @@ final class ReceiverPairingCoordinatorTests: XCTestCase { api.pollResponse = approvedPoll let recorder = PersistRecorder() let gate = PersistGate() - let coordinator = ReceiverPairingCoordinator(api: api) { url, _, access, _ in + let coordinator = ReceiverPairingCoordinator(api: api) { pairing in await gate.wait() - recorder.persisted.append(Persisted(url: url, access: access)) + recorder.persisted.append(Persisted(url: pairing.url, access: pairing.accessToken)) return true } let runTask = Task { await coordinator.run(session: channel, stream: channel.stream) } @@ -618,9 +893,9 @@ final class ReceiverPairingCoordinatorTests: XCTestCase { api.pollResponse = approvedPoll let recorder = PersistRecorder() let gate = PersistGate() - let coordinator = ReceiverPairingCoordinator(api: api) { url, _, access, _ in + let coordinator = ReceiverPairingCoordinator(api: api) { pairing in await gate.wait() - recorder.persisted.append(Persisted(url: url, access: access)) + recorder.persisted.append(Persisted(url: pairing.url, access: pairing.accessToken)) return true } let runTask = Task { await coordinator.run(session: channel, stream: channel.stream) } @@ -662,7 +937,7 @@ final class ReceiverPairingCoordinatorTests: XCTestCase { } coordinator.allowPendingServer() await expectEventually("failed state") { - if case .failed(let name) = coordinator.state { return name == "Home" } + if case .failed(let name, _, _) = coordinator.state { return name == "Home" } return false } channel.deliver(.done) @@ -747,11 +1022,11 @@ final class ReceiverPairingCoordinatorTests: XCTestCase { let api = FakePairingAPI() api.pollResponse = approvedPoll let recorder = PersistRecorder() - let coordinator = ReceiverPairingCoordinator(api: api) { url, _, access, _ in + let coordinator = ReceiverPairingCoordinator(api: api) { pairing in guard let lease = await http.beginIdentityTransition() else { return false } - recorder.persisted.append(Persisted(url: url, access: access)) + recorder.persisted.append(Persisted(url: pairing.url, access: pairing.accessToken)) await http.endIdentityTransition(lease) return true } diff --git a/iosApp/Tests/PairingProtocolTests.swift b/iosApp/Tests/PairingProtocolTests.swift index 3a2e2986d..4b53c8a58 100644 --- a/iosApp/Tests/PairingProtocolTests.swift +++ b/iosApp/Tests/PairingProtocolTests.swift @@ -16,6 +16,15 @@ final class PairingProtocolTests: XCTestCase { .hello(tvName: "Living Room", tvDeviceId: "ABC-123", state: .setup, supportedVersions: [1]), .pushServer(serverURL: "https://media.example.com", serverName: "Home"), .pushServer(serverURL: "https://media.example.com", serverName: nil), + .pushServer( + serverURL: "https://media.example.com", serverName: "Home", + serverIdentity: "96c1bd08-b839-4d47-980e-57d4e7a44cfa", + endpoints: [ + ServerEndpoint(url: "https://media.example.com", kind: .public), + ServerEndpoint(url: "https://media.overlay.example", kind: .provider, provider: "tailscale", displayName: "Tailscale"), + ] + ), + .serverResult(serverURL: "https://media.example.com", status: .failed, error: PairingFailureCode.unreachable.rawValue), .deviceStarted(serverURL: "https://media.example.com", userCode: "WXYZ-12", matchCode: "brave-otter"), .serverResult(serverURL: "https://media.example.com", status: .signedIn, error: nil), .serverResult(serverURL: "https://media.example.com", status: .failed, error: "timeout"), @@ -34,6 +43,42 @@ final class PairingProtocolTests: XCTestCase { XCTAssertTrue(json["v"] as? Int == PairingProtocol.version, "missing/incorrect version") } + /// Protocol stays v1: the identity fields are additive and optional, so a + /// legacy push decodes and a legacy peer sees no new required key. + func testPushServerIdentityFieldsAreOptionalOnTheWire() throws { + let legacy = Data(#"{"type":"pushServer","v":1,"serverURL":"https://media.example.com","serverName":"Home"}"#.utf8) + guard case let .pushServer(url, name, identity, endpoints) = try decoder.decode(PairingMessage.self, from: legacy) else { + return XCTFail("expected pushServer") + } + XCTAssertEqual(url, "https://media.example.com") + XCTAssertEqual(name, "Home") + XCTAssertNil(identity) + XCTAssertNil(endpoints) + + let data = try encoder.encode(PairingMessage.pushServer(serverURL: "https://media.example.com", serverName: nil)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertNil(json["serverIdentity"]) + XCTAssertNil(json["endpoints"]) + XCTAssertEqual(json["v"] as? Int, 1) + + let full = try encoder.encode(PairingMessage.pushServer( + serverURL: "https://media.example.com", serverName: "Home", serverIdentity: "S", + endpoints: [ServerEndpoint(url: "https://media.overlay.example/", kind: .provider, provider: "tailscale", displayName: "Tailscale")] + )) + let fullJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: full) as? [String: Any]) + let endpoint = try XCTUnwrap((fullJSON["endpoints"] as? [[String: Any]])?.first) + XCTAssertEqual(endpoint["url"] as? String, "https://media.overlay.example") + XCTAssertEqual(endpoint["kind"] as? String, "provider") + XCTAssertEqual(endpoint["displayName"] as? String, "Tailscale") + } + + func testFailureCodesReadUnknownAsGenericFailure() { + XCTAssertEqual(PairingFailureCode(wire: "unreachable"), .unreachable) + XCTAssertEqual(PairingFailureCode(wire: "identity_mismatch"), .identityMismatch) + XCTAssertEqual(PairingFailureCode(wire: nil), .authFailed) + XCTAssertEqual(PairingFailureCode(wire: "something_new"), .authFailed) + } + func testUnknownTypeFailsToDecode() { let data = #"{"type":"bogus","v":1}"#.data(using: .utf8)! var threw = false diff --git a/iosApp/Tests/RemotePlaybackRoutingTests.swift b/iosApp/Tests/RemotePlaybackRoutingTests.swift new file mode 100644 index 000000000..b04064da4 --- /dev/null +++ b/iosApp/Tests/RemotePlaybackRoutingTests.swift @@ -0,0 +1,180 @@ +#if os(iOS) +import XCTest +@testable import Silo + +/// Every streaming play on iOS funnels through `AppRouter.presentPlayer`, +/// where one interceptor decides between the engaged TV and the local +/// player. These guard the funnel: an engaged TV takes the request and the +/// local cover never appears; with no TV the cover appears as before; a +/// second Play during the decision cannot slip past it; offline plays +/// prompt instead of silently starting a second player. +@MainActor +final class RemotePlaybackRoutingTests: XCTestCase { + private func expectEventually(_ label: String, timeout: TimeInterval = 3, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return } + try? await Task.sleep(for: .milliseconds(10)) + } + XCTFail("timed out waiting for: \(label)") + } + + func testEngagedTVTakesTheRequestAndNoLocalPlayerAppears() async { + let router = AppRouter() + var received: [SiloControlPlaybackRequest] = [] + router.remotePlaybackInterceptor = { request in + received.append(request) + return true + } + + router.presentPlayer(contentId: "c1", fileId: 7, startFromBeginning: false, resumePosition: 120) + + await expectEventually("interceptor called") { received.count == 1 } + try? await Task.sleep(for: .milliseconds(50)) + XCTAssertNil(router.presentedPlayer) + XCTAssertEqual(received.first?.contentId, "c1") + XCTAssertEqual(received.first?.fileId, 7) + XCTAssertEqual(received.first?.resumePosition, 120) + } + + func testNoEngagedTVOpensTheLocalPlayer() async { + let router = AppRouter() + router.remotePlaybackInterceptor = { _ in false } + + router.presentPlayer(contentId: "c1") + + await expectEventually("local player presented") { router.presentedPlayer?.contentId == "c1" } + } + + func testWithoutAnInterceptorTheLocalPlayerOpensSynchronously() { + let router = AppRouter() + router.presentPlayer(contentId: "c1") + XCTAssertEqual(router.presentedPlayer?.contentId, "c1") + } + + func testSecondPlayDuringTheDecisionIsDropped() async { + let router = AppRouter() + var calls = 0 + let gate = AsyncGate() + router.remotePlaybackInterceptor = { _ in + calls += 1 + await gate.wait() + return true + } + + router.presentPlayer(contentId: "first") + router.presentPlayer(contentId: "second") + await expectEventually("first decision started") { calls == 1 } + gate.open() + try? await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(calls, 1, "the second tap must not reach the interceptor or the local player") + XCTAssertNil(router.presentedPlayer) + } + + func testOfflinePlayPromptsWhileATVIsEngagedAndHonoursTheChoice() async { + let router = AppRouter() + var sentToTV: [SiloControlPlaybackRequest] = [] + router.isRemotePlaybackEngaged = { true } + router.remotePlaybackInterceptor = { request in + sentToTV.append(request) + return true + } + + router.presentOfflinePlayer(downloadId: "d1", contentId: "c1", resumePosition: 30) + XCTAssertNil(router.presentedPlayer) + XCTAssertEqual(router.pendingOfflinePlayChoice?.presentation.offlineDownloadId, "d1") + + router.confirmOfflinePlayHere() + XCTAssertEqual(router.presentedPlayer?.offlineDownloadId, "d1") + XCTAssertNil(router.pendingOfflinePlayChoice) + + router.presentedPlayer = nil + router.presentOfflinePlayer(downloadId: "d1", contentId: "c1", resumePosition: 30) + router.sendPendingOfflinePlayToTV() + await expectEventually("streamed to TV") { sentToTV.count == 1 } + XCTAssertEqual(sentToTV.first?.contentId, "c1") + XCTAssertEqual(sentToTV.first?.resumePosition, 30) + XCTAssertNil(router.presentedPlayer) + } + + func testOfflinePlayWithoutATVOpensLocallyWithoutPrompting() { + let router = AppRouter() + router.isRemotePlaybackEngaged = { false } + router.presentOfflinePlayer(downloadId: "d1", contentId: "c1") + XCTAssertNil(router.pendingOfflinePlayChoice) + XCTAssertEqual(router.presentedPlayer?.offlineDownloadId, "d1") + } + + func testPlayingADifferentTitleAsksBeforeReplacingWhatTheTVIsPlaying() async { + let router = AppRouter() + var sent: [String] = [] + router.remotePlaybackInterceptor = { request in sent.append(request.contentId); return true } + router.remotePlaybackCurrentTitle = { (title: "Oak Street", contentId: "oak", targetName: "Living Room") } + + router.presentPlayer(contentId: "miasma") + XCTAssertEqual(router.pendingReplaceRemotePlayback?.currentTitle, "Oak Street") + XCTAssertEqual(router.pendingReplaceRemotePlayback?.targetName, "Living Room") + XCTAssertNil(router.presentedPlayer, "the local player must never open behind the prompt") + XCTAssertTrue(sent.isEmpty) + + router.confirmReplaceRemotePlayback() + await expectEventually("sent after confirmation") { sent == ["miasma"] } + XCTAssertNil(router.pendingReplaceRemotePlayback) + } + + func testResumingTheSameTitleDoesNotAsk() async { + let router = AppRouter() + var sent: [String] = [] + router.remotePlaybackInterceptor = { request in sent.append(request.contentId); return true } + router.remotePlaybackCurrentTitle = { (title: "Oak Street", contentId: "oak", targetName: "Living Room") } + + router.presentPlayer(contentId: "oak", resumePosition: 90) + await expectEventually("sent without a prompt") { sent == ["oak"] } + XCTAssertNil(router.pendingReplaceRemotePlayback) + } + + func testIdleTVDoesNotAsk() async { + let router = AppRouter() + var sent: [String] = [] + router.remotePlaybackInterceptor = { request in sent.append(request.contentId); return true } + router.remotePlaybackCurrentTitle = { nil } + + router.presentPlayer(contentId: "miasma") + await expectEventually("sent without a prompt") { sent == ["miasma"] } + XCTAssertNil(router.pendingReplaceRemotePlayback) + } + + // MARK: - Engaged predicate + + /// The mode button, mini-bar, and routing all read one predicate. A + /// fresh client has nothing engaged, and the predicate is what the + /// legacy per-site `hasActiveSession` checks were replaced with. + func testFreshClientIsNotEngagedAndLaunchOnEngagedTVDeclines() async { + let client = SiloControlClient() + XCTAssertFalse(client.remotePlaybackEngaged) + let taken = await client.launchOnEngagedTV(SiloControlPlaybackRequest( + contentId: "c1", fileId: nil, audioTrackIndex: nil, subtitleTrackIndex: nil, + startFromBeginning: true, resumePosition: nil + )) + XCTAssertFalse(taken, "no TV engaged ⇒ the caller may play locally") + XCTAssertFalse(client.isShowingRemoteControl) + } +} + +@MainActor +private final class AsyncGate { + private var continuations: [CheckedContinuation] = [] + private var isOpen = false + func wait() async { + if isOpen { return } + await withCheckedContinuation { continuations.append($0) } + } + func open() { + isOpen = true + let waiting = continuations + continuations.removeAll() + waiting.forEach { $0.resume() } + } +} +#endif diff --git a/iosApp/Tests/ServerIdentityMatchingTests.swift b/iosApp/Tests/ServerIdentityMatchingTests.swift new file mode 100644 index 000000000..45a683664 --- /dev/null +++ b/iosApp/Tests/ServerIdentityMatchingTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import Silo + +/// Recognising one deployment across addresses (issue #341). Registry keys +/// stay URL-derived; the verified deployment identity only adds matches. +final class ServerIdentityMatchingTests: XCTestCase { + private let identity = "96c1bd08-b839-4d47-980e-57d4e7a44cfa" + + func testDifferentHostnamesMatchWhenVerifiedIdentitiesAgree() { + let phone = ServerRegistry.serverId(for: "https://silo-dev-1.bonobo-kitefin.ts.net") + let tv = ServerRegistry.serverId(for: "https://silo-dev.arkyncdn.net") + + XCTAssertFalse(ServerRegistry.serverIdsMatch(phone, tv), "URL rule alone cannot relate them") + XCTAssertTrue(ServerRegistry.serversMatch( + serverId: phone, verifiedServerId: identity, + serverId: tv, verifiedServerId: identity + )) + } + + func testMissingOrDifferentIdentityFallsBackToURLRule() { + let phone = ServerRegistry.serverId(for: "https://a.example") + let tv = ServerRegistry.serverId(for: "https://b.example") + + XCTAssertFalse(ServerRegistry.serversMatch( + serverId: phone, verifiedServerId: identity, serverId: tv, verifiedServerId: nil + )) + XCTAssertFalse(ServerRegistry.serversMatch( + serverId: phone, verifiedServerId: "", serverId: tv, verifiedServerId: "" + )) + XCTAssertFalse(ServerRegistry.serversMatch( + serverId: phone, verifiedServerId: identity, serverId: tv, verifiedServerId: "other" + )) + // Same origin still matches without any identity, as before. + XCTAssertTrue(ServerRegistry.serversMatch( + serverId: ServerRegistry.serverId(for: "https://A.example"), verifiedServerId: nil, + serverId: phone, verifiedServerId: nil + )) + } + + func testEntryPersistsVerifiedIdentityAndDecodesWithoutIt() throws { + let entry = ServerEntry( + id: "id", url: "https://a.example", fetchedName: "Home", + lastUsedAt: Date(timeIntervalSince1970: 1), verifiedServerId: identity + ) + let data = try JSONEncoder().encode(entry) + XCTAssertEqual(try JSONDecoder().decode(ServerEntry.self, from: data).verifiedServerId, identity) + + let legacy = Data(#"{"id":"id","url":"https://a.example","lastUsedAt":1}"#.utf8) + let decoded = try JSONDecoder().decode(ServerEntry.self, from: legacy) + XCTAssertNil(decoded.verifiedServerId) + XCTAssertEqual(decoded.url, "https://a.example") + } + + func testRegistryKeepsIdentityWhenAnUpdateOmitsIt() throws { + let name = "ServerIdentityMatchingTests.\(UUID().uuidString)" + let suite = try XCTUnwrap(UserDefaults(suiteName: name)) + addTeardownBlock { UserDefaults().removePersistentDomain(forName: name) } + let defaults = SharedDefaults(suite: suite, standard: suite) + defaults.set(true, forKey: "continuumServerRegistry.migrated.v1") + let registry = ServerRegistry( + defaults: defaults, + keychain: SharedKeychain(service: name, accessGroup: nil), + launchPreferences: ProfileLaunchPreferences(defaults: defaults), + persistenceOverride: { _, _ in true } + ) + let first = ServerEntry(id: "id", url: "https://a.example", fetchedName: nil, lastUsedAt: Date()) + XCTAssertNotNil(registry.addOrUpdate(first)) + XCTAssertTrue(registry.updateVerifiedServerId(for: "id", verifiedServerId: identity)) + XCTAssertFalse(registry.updateVerifiedServerId(for: "id", verifiedServerId: " "), "blank never replaces a known identity") + + let renamed = ServerEntry(id: "id", url: "https://a.example", fetchedName: "Home", lastUsedAt: Date()) + XCTAssertEqual(registry.addOrUpdate(renamed)?.verifiedServerId, identity) + XCTAssertEqual(registry.entry(verifiedServerId: identity)?.id, "id") + XCTAssertNil(registry.entry(verifiedServerId: "other")) + } + + func testConnectionsDocumentOffersOnlyAddressesWithURLs() throws { + let json = #""" + {"revision":"r","state":"available","allowed":true,"server_id":"S", + "current":{"kind":"default"}, + "endpoints":[ + {"kind":"public","url":"https://silo.example/"}, + {"kind":"provider","provider":"down","display_name":"Down Overlay","state":"unavailable"}, + {"kind":"provider","url":"https://silo.overlay.example","provider":"tailscale","display_name":"Tailscale","state":"connected"}, + {"kind":"public","url":"https://silo.example"} + ]} + """# + let document = try HTTPClient.makeJSONDecoder().decode(ServerConnectionsDocument.self, from: Data(json.utf8)) + XCTAssertTrue(document.isAvailable) + XCTAssertEqual(document.usableEndpoints, [ + ServerEndpoint(url: "https://silo.example", kind: .public), + ServerEndpoint(url: "https://silo.overlay.example", kind: .provider, provider: "tailscale", displayName: "Tailscale"), + ]) + } + + func testProviderHelpNamesTheProviderWithoutClaimingItIsMissing() { + let endpoint = ServerEndpoint(url: "https://silo.overlay.example", kind: .provider, provider: "tailscale", displayName: "Tailscale") + let help = endpoint.unreachableHelp(serverName: "Home") + XCTAssertTrue(help.contains("through Tailscale")) + XCTAssertTrue(help.contains("Install or open the Tailscale app")) + XCTAssertFalse(help.lowercased().contains("not installed")) + XCTAssertFalse(help.contains("overlay.example"), "provider name comes from the manifest, not the host") + } + + func testCandidateOrderPrefersSavedThenOfferedThenEndpointsWithoutDuplicates() { + let urls = RemotePlaybackCandidatePolicy.candidateURLs( + savedURL: "https://silo.example/", + offeredURL: "https://silo.overlay.example", + endpoints: [ + ServerEndpoint(url: "https://silo.example", kind: .public), + ServerEndpoint(url: "https://silo.overlay.example/", kind: .provider, provider: "tailscale", displayName: "Tailscale"), + ServerEndpoint(url: "https://other.overlay.example", kind: .provider, provider: "zerotier", displayName: "ZeroTier"), + ] + ) + XCTAssertEqual(urls, [ + "https://silo.example", + "https://silo.overlay.example", + "https://other.overlay.example", + ]) + XCTAssertEqual( + RemotePlaybackCandidatePolicy.candidateURLs(savedURL: nil, offeredURL: "https://a.example", endpoints: nil), + ["https://a.example"] + ) + } +} diff --git a/iosApp/Tests/ServerIdentityResolverTests.swift b/iosApp/Tests/ServerIdentityResolverTests.swift index 3afdd5bed..bbd68c148 100644 --- a/iosApp/Tests/ServerIdentityResolverTests.swift +++ b/iosApp/Tests/ServerIdentityResolverTests.swift @@ -124,6 +124,64 @@ final class ServerIdentityResolverTests: XCTestCase { await TokenStore.shared.switchActiveServer(serverId: previousTokenServerId) } + // MARK: - Deployment identity + + func testProbeReportsIdentityAtAnyAddress() async { + stub.configure([ + "/api/v2/system/identity": (200, #"{"server_id":"96c1bd08-b839-4d47-980e-57d4e7a44cfa"}"#), + ]) + + let result = await resolver().probeIdentity(serverURL: "https://silo.overlay.example/") + + XCTAssertEqual(result, .identity("96c1bd08-b839-4d47-980e-57d4e7a44cfa")) + let fetched = await resolver().fetchServerIdentity(serverURL: "https://silo.overlay.example") + XCTAssertEqual(fetched, "96c1bd08-b839-4d47-980e-57d4e7a44cfa") + } + + func testLegacy404IsReachableButUnsupported() async { + stub.handler.reset() + stub.handler.route(StubURLProtocol.path("/api/v2/system/identity")) { _ in + .text("404 page not found\n", status: 404, contentType: "text/plain") + } + + let result = await resolver().probeIdentity(serverURL: "https://silo.example") + + XCTAssertEqual(result, .unsupportedServer) + } + + func testProxy404AndTransportFailuresAreUnreachable() async { + stub.configure([ + "/api/v2/system/identity": (404, "nope"), + ]) + let proxy = await resolver().probeIdentity(serverURL: "https://silo.example") + XCTAssertEqual(proxy, .unreachable) + + stub.handler.reset() + stub.handler.route(StubURLProtocol.any) { _ in throw URLError(.cannotConnectToHost) } + let transport = await resolver().probeIdentity(serverURL: "https://silo.example") + XCTAssertEqual(transport, .unreachable) + let fetched = await resolver().fetchServerIdentity(serverURL: "https://silo.example") + XCTAssertNil(fetched) + } + + func testConnectionsUsesTheSuppliedBearerAndRequiresAvailability() async { + stub.configure([ + "/api/v2/system/connections": (200, #"{"revision":"r","state":"available","allowed":true,"server_id":"S","current":{"kind":"provider","provider":"tailscale"},"endpoints":[{"kind":"public","url":"https://silo.example"}]}"#), + ]) + + let document = await resolver().fetchConnections(serverURL: "https://silo.example", bearer: "TOKEN") + + XCTAssertEqual(document?.serverId, "S") + XCTAssertEqual(document?.current?.provider, "tailscale") + XCTAssertEqual(stub.handler.requests.first?.header("Authorization"), "Bearer TOKEN") + + stub.configure([ + "/api/v2/system/connections": (200, #"{"state":"not_configured","allowed":true,"server_id":"S","endpoints":[]}"#), + ]) + let unavailable = await resolver().fetchConnections(serverURL: "https://silo.example", bearer: "TOKEN") + XCTAssertNil(unavailable) + } + private func resolver() -> ServerIdentityResolver { ServerIdentityResolver( httpClient: HTTPClient(session: stub.handler.makeSession()) diff --git a/iosApp/Tests/SiloControlTests.swift b/iosApp/Tests/SiloControlTests.swift index 66df1f381..b49a22af5 100644 --- a/iosApp/Tests/SiloControlTests.swift +++ b/iosApp/Tests/SiloControlTests.swift @@ -72,6 +72,41 @@ final class SiloControlTests: XCTestCase { XCTAssertNil(offer.profileName) } + func testHandoffOfferCarriesIdentityAndEndpointsOptionally() throws { + let offer = SiloControlHandoffOffer( + requestId: "request-1", + serverId: "server-1", + serverURL: "https://silo.overlay.example", + serverName: "Home", + profileId: "profile-1", + profileName: "Alex", + serverIdentity: "96c1bd08-b839-4d47-980e-57d4e7a44cfa", + serverEndpoints: [ + ServerEndpoint(url: "https://silo.example", kind: .public), + ServerEndpoint(url: "https://silo.overlay.example", kind: .provider, provider: "tailscale", displayName: "Tailscale"), + ] + ) + XCTAssertEqual(try roundTrip(.handoffOffer(offer)), .handoffOffer(offer)) + + let hello = SiloControlHello( + role: .tv, deviceName: "TV", deviceId: "tv-1", serverId: "server-1", serverName: "Home", + supportedVersions: [1, 2], serverIdentity: "96c1bd08-b839-4d47-980e-57d4e7a44cfa" + ) + XCTAssertEqual(try roundTrip(.hello(hello)), .hello(hello)) + + // The keys stay off the wire when unset, so a v2 peer that predates + // them never sees an unexpected value. + let legacyOffer = SiloControlHandoffOffer( + requestId: "r", serverId: "s", serverURL: "https://silo.example", + serverName: nil, profileId: "p", profileName: nil + ) + let data = try JSONEncoder().encode(SiloControlMessage.handoffOffer(legacyOffer)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let body = try XCTUnwrap(json["handoffOffer"] as? [String: Any]) + XCTAssertNil(body["serverIdentity"]) + XCTAssertNil(body["serverEndpoints"]) + } + func testServerIdentityMatchesURLCapitalizationAcrossDevices() { let phone = ServerRegistry.serverId(for: "https://Media.Example.test") let tv = ServerRegistry.serverId(for: "HTTPS://media.example.test/") diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 1a2a26608..1d50ec4dc 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -64,6 +64,57 @@ struct ContentView: View { .environment(audioStore) #if os(iOS) .environment(siloControl) + .onAppear { + // One routing decision for every streaming play on iOS: an + // engaged TV (including one mid-reconnect) takes the request; + // otherwise the local player opens as before. + router.remotePlaybackInterceptor = { [siloControl] request in + await siloControl.launchOnEngagedTV(request) + } + router.isRemotePlaybackEngaged = { [siloControl] in siloControl.remotePlaybackEngaged } + router.remotePlaybackCurrentTitle = { [siloControl] in + guard siloControl.remotePlaybackEngaged, + let state = siloControl.state, + let contentId = state.contentId, !contentId.isEmpty else { return nil } + return ( + title: state.title, + contentId: contentId, + targetName: siloControl.activeTarget?.name ?? siloControl.lastTarget?.name ?? "the TV" + ) + } + } + .confirmationDialog( + "Replace what's playing?", + isPresented: Binding( + get: { router.pendingReplaceRemotePlayback != nil }, + set: { if !$0 { router.pendingReplaceRemotePlayback = nil } } + ), + titleVisibility: .visible, + presenting: router.pendingReplaceRemotePlayback + ) { choice in + Button("Play on \(choice.targetName)") { router.confirmReplaceRemotePlayback() } + Button("Cancel", role: .cancel) { router.pendingReplaceRemotePlayback = nil } + } message: { choice in + Text("\(choice.targetName) is playing \(choice.currentTitle). Playing this will stop it.") + } + .confirmationDialog( + "A TV is connected", + isPresented: Binding( + get: { router.pendingOfflinePlayChoice != nil }, + set: { if !$0 { router.pendingOfflinePlayChoice = nil } } + ), + titleVisibility: .visible + ) { + Button("Play on \(siloControl.activeTarget?.name ?? siloControl.lastTarget?.name ?? "TV")") { + router.sendPendingOfflinePlayToTV() + } + Button("Play on this \(UIDevice.current.model)") { + router.confirmOfflinePlayHere() + } + Button("Cancel", role: .cancel) { router.pendingOfflinePlayChoice = nil } + } message: { + Text("Downloads only play on this device. The TV can stream the same title from your server.") + } #endif .environmentObject(overlayPrefs) .preferredColorScheme(.dark) diff --git a/iosApp/iosApp/Control/SiloControlProtocol.swift b/iosApp/iosApp/Control/SiloControlProtocol.swift index 23b9fb931..caf967423 100644 --- a/iosApp/iosApp/Control/SiloControlProtocol.swift +++ b/iosApp/iosApp/Control/SiloControlProtocol.swift @@ -22,6 +22,28 @@ struct SiloControlHello: Codable, Equatable, Sendable { let serverId: String? let serverName: String? let supportedVersions: [Int] + /// The deployment identity behind `serverId` when the peer has learned + /// it. Lets a phone and a TV on different addresses of one server + /// recognise each other. Optional on the wire: older peers omit it. + var serverIdentity: String? = nil + + init( + role: SiloControlPeerRole, + deviceName: String, + deviceId: String, + serverId: String?, + serverName: String?, + supportedVersions: [Int], + serverIdentity: String? = nil + ) { + self.role = role + self.deviceName = deviceName + self.deviceId = deviceId + self.serverId = serverId + self.serverName = serverName + self.supportedVersions = supportedVersions + self.serverIdentity = serverIdentity + } } struct SiloControlPlaybackRequest: Codable, Equatable, Sendable { @@ -40,12 +62,44 @@ struct SiloControlLaunchRequest: Codable, Equatable, Sendable { struct SiloControlHandoffOffer: Codable, Equatable, Sendable { let requestId: String + /// The phone's registry key for the server. Echoed back in + /// `handoff_ready` and used for the launch request. let serverId: String + /// The phone's own address for the server. The TV treats it as one + /// candidate among `serverEndpoints` when `serverIdentity` is present. let serverURL: String let serverName: String? let profileId: String /// Display-only label for the verified profile ID. Older peers omit it. let profileName: String? + /// The deployment identity the phone verified at `serverURL`. When + /// present the TV may reach the server through any candidate address + /// that reports the same identity. Absent from older phones, in which + /// case the TV must use `serverURL` exactly. + var serverIdentity: String? = nil + /// Other addresses the deployment offers, from its connections document. + /// Public first, then providers. Reachability is the TV's to test. + var serverEndpoints: [ServerEndpoint]? = nil + + init( + requestId: String, + serverId: String, + serverURL: String, + serverName: String?, + profileId: String, + profileName: String?, + serverIdentity: String? = nil, + serverEndpoints: [ServerEndpoint]? = nil + ) { + self.requestId = requestId + self.serverId = serverId + self.serverURL = serverURL + self.serverName = serverName + self.profileId = profileId + self.profileName = profileName + self.serverIdentity = serverIdentity + self.serverEndpoints = serverEndpoints + } } struct SiloControlHandoffChallenge: Codable, Equatable, Sendable { diff --git a/iosApp/iosApp/Control/iOS/NowPlayingShelf.swift b/iosApp/iosApp/Control/iOS/NowPlayingShelf.swift index 1a4e4a970..7349916a4 100644 --- a/iosApp/iosApp/Control/iOS/NowPlayingShelf.swift +++ b/iosApp/iosApp/Control/iOS/NowPlayingShelf.swift @@ -25,7 +25,7 @@ struct NowPlayingShelf: View { /// tabViewBottomAccessory there made it re-insert (with a system slide-in) /// every time the sheet was swiped away. static func controlBarVisible(_ control: SiloControlClient) -> Bool { - (control.hasActiveSession && !control.isAutoResuming) || control.isReconnecting + control.remotePlaybackEngaged } #else static func hasActiveAccessory(audio: AudioPlaybackStore) -> Bool { diff --git a/iosApp/iosApp/Control/iOS/SiloControlBrowser.swift b/iosApp/iosApp/Control/iOS/SiloControlBrowser.swift index 5d3c6fb73..0e5cee37e 100644 --- a/iosApp/iosApp/Control/iOS/SiloControlBrowser.swift +++ b/iosApp/iosApp/Control/iOS/SiloControlBrowser.swift @@ -12,10 +12,25 @@ struct SiloControlTarget: Identifiable, Equatable { /// The TV's advertised "currently playing" flag (Bonjour TXT `playing`). /// False for TVs running an older build that doesn't advertise it. var isPlaying: Bool = false + /// The deployment identity behind the TV's server (Bonjour TXT + /// `serverIdentity`). Nil for older TVs or servers. + var serverIdentity: String? = nil static func == (lhs: SiloControlTarget, rhs: SiloControlTarget) -> Bool { lhs.id == rhs.id && lhs.isPlaying == rhs.isPlaying && lhs.serverId == rhs.serverId && lhs.protocolVersion == rhs.protocolVersion + && lhs.serverIdentity == rhs.serverIdentity + } + + /// Whether this TV is signed in to the phone's active server, by + /// registry origin or by verified deployment identity. + @MainActor + var targetsActiveServer: Bool { + let active = ServerRegistry.shared.activeServer + return ServerRegistry.serversMatch( + serverId: serverId, verifiedServerId: serverIdentity, + serverId: active?.id, verifiedServerId: active?.verifiedServerId + ) } } @@ -63,7 +78,8 @@ final class SiloControlBrowser { serverId: serverId, serverName: txt["serverName"], protocolVersion: Int(txt["v"] ?? "1") ?? 1, - isPlaying: txt["playing"] == "1" + isPlaying: txt["playing"] == "1", + serverIdentity: ServerIdentity.usable(txt["serverIdentity"]) ) } } diff --git a/iosApp/iosApp/Control/iOS/SiloControlClient.swift b/iosApp/iosApp/Control/iOS/SiloControlClient.swift index 93fe67fe6..e96207211 100644 --- a/iosApp/iosApp/Control/iOS/SiloControlClient.swift +++ b/iosApp/iosApp/Control/iOS/SiloControlClient.swift @@ -19,6 +19,7 @@ private struct PersistedControlTarget: Codable { let name: String let serverId: String let serverName: String? + var serverIdentity: String? = nil } private enum SiloControlHandoffError: LocalizedError { @@ -121,17 +122,56 @@ final class SiloControlClient { session != nil && activeTarget != nil } + /// The one predicate for "the user has a TV engaged", read by the mode + /// button, the mini-bar, and playback routing alike so they never + /// disagree. True through an in-flight reconnect (the user still + /// considers the TV theirs; a Play then waits for the link instead of + /// starting on the phone) and false during a silent, still-unconfirmed + /// auto-resume probe (no UI is showing, so nothing may silently cast). + var remotePlaybackEngaged: Bool { + (hasActiveSession && !isAutoResuming) || isReconnecting + } + + /// How long a Play tapped during a reconnect waits for the link before + /// giving up and reporting the failure in the remote cover. + private static let launchReconnectWait: Duration = .seconds(45) + + /// Launches on the engaged TV, waiting out an in-flight reconnect first. + /// Returns false when no TV is engaged, so the caller may play locally. + /// Never falls through to local playback on its own: once the user has a + /// TV engaged, a failed launch is reported on the remote cover instead. + @discardableResult + func launchOnEngagedTV(_ request: SiloControlPlaybackRequest) async -> Bool { + guard remotePlaybackEngaged else { return false } + if isReconnecting { + isShowingRemoteControl = true + let deadline = ContinuousClock.now + Self.launchReconnectWait + while isReconnecting, ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(100)) + } + guard hasActiveSession else { + if errorMessage == nil { + errorMessage = "Couldn't reconnect to \(lastTarget?.name ?? "the TV"). Choose a TV to keep playing there, or turn off control mode to play here." + } + isShowingRemoteControl = true + return true + } + } + await launch(request) + return true + } + @discardableResult func connect( to target: SiloControlTarget, origin: SiloControlConnectOrigin = .user, allowCrossServer: Bool = false ) async -> Bool { - guard let activeServerId = ServerRegistry.shared.activeServerId else { + guard ServerRegistry.shared.activeServer != nil else { errorMessage = "Choose a server before controlling a TV." return false } - let targetsActiveServer = ServerRegistry.serverIdsMatch(target.serverId, activeServerId) + let targetsActiveServer = target.targetsActiveServer guard targetsActiveServer || (allowCrossServer && target.protocolVersion >= 2) else { errorMessage = "That TV is connected to a different server." return false @@ -276,16 +316,35 @@ final class SiloControlClient { handoffReady = nil handoffCancellation = nil + // The deployment's other addresses let a TV that cannot reach the + // phone's URL (a network-plugin origin, say) still prepare the + // profile at the address it can reach. Best effort: without them the + // TV falls back to `serverURL` exactly, as before. + let endpoints = await Self.offeredEndpoints(for: server) + try ensureActiveIdentity(serverId: server.id, profileId: profileId) try await session.send(.handoffOffer(SiloControlHandoffOffer( requestId: requestId, serverId: server.id, serverURL: server.url, serverName: server.displayName, profileId: profileId, - profileName: profileName + profileName: profileName, + serverIdentity: server.verifiedServerId, + serverEndpoints: endpoints ))) - let challenge = try await waitForHandoffChallenge(requestId: requestId) + // A TV that still holds this phone's profile answers `handoff_ready` + // (reused) with no challenge at all. Waiting for a challenge there + // timed the launch out, so every second title sent to a TV failed. + let challenge: SiloControlHandoffChallenge + switch try await waitForHandoffChallengeOrReady(requestId: requestId) { + case .ready(let ready): + try ensureActiveIdentity(serverId: server.id, profileId: profileId) + resetPendingHandoff() + return ready + case .challenge(let issued): + challenge = issued + } do { try ensureActiveIdentity(serverId: server.id, profileId: profileId) @@ -328,13 +387,25 @@ final class SiloControlClient { return nil } - private func waitForHandoffChallenge(requestId: String) async throws -> SiloControlHandoffChallenge { - for _ in 0..<200 { + private enum HandoffFirstReply { + case challenge(SiloControlHandoffChallenge) + case ready(SiloControlHandoffReady) + } + + /// The TV's first reply to an offer: a challenge to approve, or, when it + /// already holds this phone's profile, a ready frame straight away. + /// Identity probing on the TV can precede the challenge, so this waits + /// longer than the old challenge-only wait did. + private func waitForHandoffChallengeOrReady(requestId: String) async throws -> HandoffFirstReply { + for _ in 0..<600 { if let cancellation = handoffCancellation, cancellation.requestId == requestId { throw SiloControlHandoffError.cancelled(cancellation.message ?? "The TV cancelled profile setup.") } + if let ready = handoffReady, ready.requestId == requestId { + return .ready(ready) + } if let challenge = handoffChallenge, challenge.requestId == requestId { - return challenge + return .challenge(challenge) } try await Task.sleep(for: .milliseconds(50)) } @@ -354,6 +425,22 @@ final class SiloControlClient { throw SiloControlHandoffError.timedOut } + /// The addresses the server offers besides the phone's own, from its + /// connections document. Empty (nil) when the server predates the + /// contract, the phone has no identity for it, or the read fails. + private static func offeredEndpoints(for server: ServerEntry) async -> [ServerEndpoint]? { + guard server.verifiedServerId != nil, + let token = await TokenStore.shared.getAccessToken(for: server.id), !token.isEmpty, + let document = await ServerIdentityResolver().fetchConnections( + serverURL: server.url, bearer: token + ), + document.serverId == server.verifiedServerId else { + return nil + } + let endpoints = document.usableEndpoints + return endpoints.isEmpty ? nil : endpoints + } + private func ensureActiveIdentity(serverId: String, profileId: String) throws { guard ServerRegistry.shared.activeServerId == serverId, ServerRegistry.shared.activeProfileId == profileId else { @@ -370,7 +457,8 @@ final class SiloControlClient { serverId: server.id, serverName: server.displayName, protocolVersion: target.protocolVersion, - isPlaying: true + isPlaying: true, + serverIdentity: server.verifiedServerId ) activeTarget = effective lastTarget = effective @@ -537,9 +625,11 @@ final class SiloControlClient { !isReconnecting, autoResumeTask == nil, let persisted = Self.loadPersistedTarget(), - ServerRegistry.serverIdsMatch( - persisted.serverId, - ServerRegistry.shared.activeServerId + ServerRegistry.serversMatch( + serverId: persisted.serverId, + verifiedServerId: persisted.serverIdentity, + serverId: ServerRegistry.shared.activeServerId, + verifiedServerId: ServerRegistry.shared.activeServer?.verifiedServerId ) else { return } @@ -856,7 +946,8 @@ final class SiloControlClient { id: target.id, name: target.name, serverId: target.serverId, - serverName: target.serverName + serverName: target.serverName, + serverIdentity: target.serverIdentity ) guard let data = try? JSONEncoder().encode(value) else { return } UserDefaults.standard.set(data, forKey: Self.persistedTargetKey) @@ -880,7 +971,8 @@ final class SiloControlClient { deviceId: device.id, serverId: server?.id, serverName: server?.displayName, - supportedVersions: SiloControlProtocol.supportedVersions + supportedVersions: SiloControlProtocol.supportedVersions, + serverIdentity: server?.verifiedServerId )) } diff --git a/iosApp/iosApp/Control/iOS/SiloControlMiniBar.swift b/iosApp/iosApp/Control/iOS/SiloControlMiniBar.swift index 2cee6f880..d1b27ea8f 100644 --- a/iosApp/iosApp/Control/iOS/SiloControlMiniBar.swift +++ b/iosApp/iosApp/Control/iOS/SiloControlMiniBar.swift @@ -17,7 +17,7 @@ struct SiloControlMiniBar: View { /// and pop back. Stays visible under the full remote sheet so dismissing /// the sheet doesn't re-insert the accessory with a second animation. private var isVisible: Bool { - (controller.hasActiveSession && !controller.isAutoResuming) || controller.isReconnecting + controller.remotePlaybackEngaged } private var targetName: String { diff --git a/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift b/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift index 48ca2a8d1..c80ec78ad 100644 --- a/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift +++ b/iosApp/iosApp/Control/iOS/SiloControlModeButton.swift @@ -6,7 +6,7 @@ struct SiloControlModeButton: View { let onChooseTarget: () -> Void var body: some View { - if controller.hasActiveSession { + if controller.remotePlaybackEngaged { Menu { Button { controller.showRemoteControl() } label: { Label("Remote Control", systemImage: "slider.horizontal.3") diff --git a/iosApp/iosApp/Control/iOS/SiloControlRemoteView.swift b/iosApp/iosApp/Control/iOS/SiloControlRemoteView.swift index e14d96d94..de18f6500 100644 --- a/iosApp/iosApp/Control/iOS/SiloControlRemoteView.swift +++ b/iosApp/iosApp/Control/iOS/SiloControlRemoteView.swift @@ -221,6 +221,7 @@ private struct RemoteNowPlayingContent: View { let onSetMuted: (Bool) -> Void @State private var scrubPreview: Double? + @State private var scrubSettleTask: Task? private let speedOptions: [Double] = [0.75, 1.0, 1.25, 1.5, 2.0] private let subtitleDelayOptions = [-2_000, -1_500, -1_000, -500, -250, 0, 250, 500, 1_000, 1_500, 2_000] @@ -304,9 +305,7 @@ private struct RemoteNowPlayingContent: View { value: Binding(get: { live }, set: { scrubPreview = $0 }), in: 0...max(state.duration, 1), onEditingChanged: { editing in - guard !editing, let scrubPreview else { return } - onSeek(scrubPreview) - self.scrubPreview = nil + if !editing { commitScrub() } } ) .tint(Color.siloOnSurface) @@ -323,6 +322,37 @@ private struct RemoteNowPlayingContent: View { .foregroundStyle(Color.siloSecondaryText) } } + // The slider's end-of-edit callback is not guaranteed: a tap that + // lands elsewhere while the finger is still down (play/pause, the + // mini-bar) can swallow the touch-up, and SwiftUI then never reports + // `editing == false`. The preview would stay pinned and every later + // drag would be ignored as "still editing". So the value stream is + // the commit signal too: once it goes quiet the seek is sent, and the + // slider is released whether or not the callback ever comes. + .onChange(of: scrubPreview) { _, value in + guard value != nil else { return } + scrubSettleTask?.cancel() + scrubSettleTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + commitScrub() + } + } + .onChange(of: state.contentId) { _, _ in + scrubSettleTask?.cancel() + scrubPreview = nil + } + } + + /// Sends the scrubbed position once and releases the slider. Safe to + /// call from both the settle timer and the end-of-edit callback: the + /// preview is cleared before sending so the second caller finds nothing. + private func commitScrub() { + scrubSettleTask?.cancel() + scrubSettleTask = nil + guard let target = scrubPreview else { return } + scrubPreview = nil + onSeek(target) } private func remainingLabel(live: Double) -> String { diff --git a/iosApp/iosApp/Control/iOS/SiloControlTargetPickerView.swift b/iosApp/iosApp/Control/iOS/SiloControlTargetPickerView.swift index a24ee74c9..b7b28bee8 100644 --- a/iosApp/iosApp/Control/iOS/SiloControlTargetPickerView.swift +++ b/iosApp/iosApp/Control/iOS/SiloControlTargetPickerView.swift @@ -87,10 +87,7 @@ struct SiloControlTargetPickerView: View { .font(.subheadline) .foregroundStyle(Color.siloPrimary) } else if let serverName = target.serverName { - Text(ServerRegistry.serverIdsMatch( - target.serverId, - ServerRegistry.shared.activeServerId - ) + Text(target.targetsActiveServer ? serverName : "Will temporarily use your server") .font(.subheadline) @@ -115,10 +112,8 @@ struct SiloControlTargetPickerView: View { private var displayedTargets: [SiloControlTarget] { guard request == nil else { return browser.found } - guard let activeServerId = ServerRegistry.shared.activeServerId else { return [] } - return browser.found.filter { - ServerRegistry.serverIdsMatch($0.serverId, activeServerId) - } + guard ServerRegistry.shared.activeServer != nil else { return [] } + return browser.found.filter(\.targetsActiveServer) } } diff --git a/iosApp/iosApp/Control/tvOS/RemotePlaybackIdentityManager.swift b/iosApp/iosApp/Control/tvOS/RemotePlaybackIdentityManager.swift index f626b59a5..4bd0b8895 100644 --- a/iosApp/iosApp/Control/tvOS/RemotePlaybackIdentityManager.swift +++ b/iosApp/iosApp/Control/tvOS/RemotePlaybackIdentityManager.swift @@ -26,6 +26,30 @@ enum RemotePlaybackIdentityEndPolicy { } } +/// Pure candidate-address policy for a handoff that carries a deployment +/// identity. Kept outside the tvOS conditional so it can be regression-tested +/// from the iOS test bundle. +enum RemotePlaybackCandidatePolicy { + /// The addresses to try, in order, de-duplicated on the normalized URL: + /// the TV's own saved address for the same deployment (already known to + /// work from here), the phone's address, then the deployment's public + /// address and connected providers in the server's order. + static func candidateURLs( + savedURL: String?, + offeredURL: String, + endpoints: [ServerEndpoint]? + ) -> [String] { + var ordered: [String] = [] + if let savedURL { ordered.append(savedURL) } + ordered.append(offeredURL) + ordered.append(contentsOf: (endpoints ?? []).map(\.url)) + var seen = Set() + return ordered + .map { ServerRegistry.normalize(url: $0) } + .filter { !$0.isEmpty && seen.insert($0).inserted } + } +} + #if os(tvOS) @MainActor final class RemotePlaybackIdentityManager { @@ -34,8 +58,11 @@ final class RemotePlaybackIdentityManager { struct ActiveIdentity: Equatable { let generationID: UUID let serverId: String + /// The address this TV reached the server at. May differ from the + /// phone's when the handoff carried a deployment identity. let serverURL: String let serverName: String? + let serverIdentity: String? let profileId: String let profileName: String? let controllerDeviceId: String @@ -50,6 +77,19 @@ final class RemotePlaybackIdentityManager { case denied case expired case invalidResponse + /// No candidate address answered from this TV. + case serverUnreachable(serverName: String?, help: String?) + /// A candidate answered with a different deployment identity. + case identityMismatch + + /// Wire reason for `handoff_cancel`, mirrored by Android. + var cancelReason: String { + switch self { + case .serverUnreachable: return "server_unreachable" + case .identityMismatch: return "identity_mismatch" + default: return "handoff_failed" + } + } var errorDescription: String? { switch self { @@ -63,12 +103,18 @@ final class RemotePlaybackIdentityManager { return "Profile handoff expired." case .invalidResponse: return "The server returned an invalid profile handoff." + case .serverUnreachable(let serverName, let help): + if let help { return help } + return "This Apple TV can't reach \(serverName ?? "the phone's server")." + case .identityMismatch: + return "The address the phone offered belongs to a different Silo server." } } } private(set) var activeIdentity: ActiveIdentity? private let api = PairingDeviceAPI() + private let identityResolver = ServerIdentityResolver() /// Set synchronously before a replacement begins global request /// cancellation. An older re-entrant `end` must not cancel or remove work /// after this generation has claimed the transition. @@ -84,9 +130,26 @@ final class RemotePlaybackIdentityManager { activeIdentity?.serverName ?? ServerRegistry.shared.activeServer?.displayName } + /// The deployment identity behind the effective server, when known. + var effectiveServerIdentity: String? { + activeIdentity?.serverIdentity ?? ServerRegistry.shared.activeServer?.verifiedServerId + } + + /// Whether a controller that names `serverId` / `serverIdentity` is on the + /// same deployment as this TV's effective server. + func controllerMatchesEffectiveServer(serverId: String?, serverIdentity: String?) -> Bool { + ServerRegistry.serversMatch( + serverId: serverId, verifiedServerId: serverIdentity, + serverId: effectiveServerId, verifiedServerId: effectiveServerIdentity + ) + } + func matches(_ offer: SiloControlHandoffOffer, controllerDeviceId: String) -> Bool { guard let activeIdentity else { return false } - return ServerRegistry.serverIdsMatch(activeIdentity.serverId, offer.serverId) + return ServerRegistry.serversMatch( + serverId: activeIdentity.serverId, verifiedServerId: activeIdentity.serverIdentity, + serverId: offer.serverId, verifiedServerId: offer.serverIdentity + ) && activeIdentity.profileId == offer.profileId && activeIdentity.controllerDeviceId == controllerDeviceId } @@ -97,10 +160,10 @@ final class RemotePlaybackIdentityManager { controllerDeviceName: String?, onChallenge: @escaping (SiloControlHandoffChallenge) async throws -> Void ) async throws -> SiloControlHandoffReady { - let normalizedURL = ServerRegistry.normalize(url: offer.serverURL) - guard !normalizedURL.isEmpty, + let offeredURL = ServerRegistry.normalize(url: offer.serverURL) + guard !offeredURL.isEmpty, !offer.profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - ServerRegistry.serverId(for: normalizedURL) == offer.serverId else { + ServerRegistry.serverId(for: offeredURL) == offer.serverId else { throw HandoffError.invalidOffer } @@ -115,6 +178,12 @@ final class RemotePlaybackIdentityManager { ) } + // With a deployment identity the phone's URL is one candidate among + // the deployment's addresses; the first that answers with the same + // identity from here is the one this TV can actually use. Without + // one (older phone) the offered URL is used exactly, as before. + let normalizedURL = try await resolveReachableURL(offer: offer, offeredURL: offeredURL) + let capability = try await api.remotePlaybackCapability(serverURL: normalizedURL) guard capability.remotePlaybackHandoff, capability.protocolVersions.contains(SiloControlProtocol.version) else { @@ -165,6 +234,7 @@ final class RemotePlaybackIdentityManager { expiresAt: expiresAt ), serverName: offer.serverName, + serverIdentity: offer.serverIdentity, profileName: offer.profileName, controllerDeviceName: controllerDeviceName ) else { @@ -188,6 +258,46 @@ final class RemotePlaybackIdentityManager { throw HandoffError.expired } + /// Picks the address this TV will use for the handoff. Only candidates + /// that report `offer.serverIdentity` qualify: an address that answers + /// with another identity is refused rather than skipped, because the + /// phone believes it belongs to this server. Reachability is decided + /// here, never by the server. + private func resolveReachableURL( + offer: SiloControlHandoffOffer, + offeredURL: String + ) async throws -> String { + guard let expectedIdentity = ServerIdentity.usable(offer.serverIdentity) else { + return offeredURL + } + let candidates = RemotePlaybackCandidatePolicy.candidateURLs( + savedURL: ServerRegistry.shared.entry(verifiedServerId: expectedIdentity)?.url, + offeredURL: offeredURL, + endpoints: offer.serverEndpoints + ) + for candidate in candidates { + try Task.checkCancellation() + switch await identityResolver.probeIdentity(serverURL: candidate) { + case .identity(let id) where id == expectedIdentity: + return candidate + case .identity: + throw HandoffError.identityMismatch + case .unsupportedServer: + // Reachable, but it cannot prove who it is; the phone's own + // address is still trusted the legacy way. + if candidate == offeredURL { return candidate } + case .unreachable: + continue + } + } + // The phone's address was unreachable: name the provider behind it + // when the deployment lists it, so the help says what to set up. + let help = offer.serverEndpoints? + .first { $0.kind == .provider && $0.url == offeredURL }? + .unreachableHelp(serverName: offer.serverName ?? "the phone's server") + throw HandoffError.serverUnreachable(serverName: offer.serverName, help: help) + } + @discardableResult func end( expectedGenerationID: UUID? = nil, @@ -257,6 +367,7 @@ final class RemotePlaybackIdentityManager { private func activate( _ scope: TemporaryAuthScope, serverName: String?, + serverIdentity: String?, profileName: String?, controllerDeviceName: String? ) async -> Bool { @@ -270,9 +381,10 @@ final class RemotePlaybackIdentityManager { } activationGenerationPending = generationID let previousIdentity = activeIdentity - let usesDifferentServer = !ServerRegistry.serverIdsMatch( - scope.serverId, - ServerRegistry.shared.activeServerId + let usesDifferentServer = !ServerRegistry.serversMatch( + serverId: scope.serverId, verifiedServerId: serverIdentity, + serverId: ServerRegistry.shared.activeServerId, + verifiedServerId: ServerRegistry.shared.activeServer?.verifiedServerId ) await HTTPClient.shared.cancelInFlightRequests() guard activationGenerationPending == generationID, @@ -316,6 +428,7 @@ final class RemotePlaybackIdentityManager { serverId: scope.serverId, serverURL: scope.serverURL, serverName: serverName, + serverIdentity: serverIdentity, profileId: scope.profileId, profileName: profileName, controllerDeviceId: scope.controllerDeviceId, diff --git a/iosApp/iosApp/Control/tvOS/TVControlReceiver.swift b/iosApp/iosApp/Control/tvOS/TVControlReceiver.swift index a2d379249..79a2ae53b 100644 --- a/iosApp/iosApp/Control/tvOS/TVControlReceiver.swift +++ b/iosApp/iosApp/Control/tvOS/TVControlReceiver.swift @@ -11,6 +11,7 @@ final class TVControlReceiver { private var listener: NWListener? private var advertisedServerId: String? private var advertisedServerName: String? + private var advertisedServerIdentity: String? /// Bumped whenever we intentionally cancel/replace the listener, so its /// state handler can tell a system-initiated failure (restart) from our /// own teardown (ignore). @@ -51,6 +52,7 @@ final class TVControlReceiver { private var remoteControllerName: String? private var remoteControllerDeviceId: String? private var remoteControllerServerId: String? + private var remoteControllerServerIdentity: String? private nonisolated static let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "org.siloserver.silo", category: "control.receiver" @@ -66,29 +68,37 @@ final class TVControlReceiver { } let serverId = RemotePlaybackIdentityManager.shared.effectiveServerId ?? server.id let serverName = RemotePlaybackIdentityManager.shared.effectiveServerName ?? server.displayName + let serverIdentity = RemotePlaybackIdentityManager.shared.effectiveServerIdentity if listener != nil, advertisedServerId == serverId, - advertisedServerName == serverName { + advertisedServerName == serverName, + advertisedServerIdentity == serverIdentity { return } stop() - startListener(serverId: serverId, serverName: serverName) + startListener(serverId: serverId, serverName: serverName, serverIdentity: serverIdentity) } - private func startListener(serverId: String, serverName: String) { + private func startListener(serverId: String, serverName: String, serverIdentity: String?) { listenerGeneration += 1 let generation = listenerGeneration let device = AppleDeviceIdentity.current - let txt = NWTXTRecord([ + var record: [String: String] = [ "v": String(SiloControlProtocol.version), "name": device.name, "id": device.id, "server": serverId, "serverName": serverName, "playing": isPlaybackAdvertised ? "1" : "0" - ]) + ] + // Lets a phone signed in at another address of the same deployment + // list this TV. Older phones ignore the key. + if let serverIdentity { + record["serverIdentity"] = serverIdentity + } + let txt = NWTXTRecord(record) do { let listener = try NWListener(using: SiloControlSession.tlsParameters()) @@ -123,6 +133,7 @@ final class TVControlReceiver { self.listener = listener advertisedServerId = serverId advertisedServerName = serverName + advertisedServerIdentity = serverIdentity } catch { Self.logger.error("failed to start control listener: \(String(describing: error), privacy: .public)") } @@ -132,6 +143,7 @@ final class TVControlReceiver { listener = nil advertisedServerId = nil advertisedServerName = nil + advertisedServerIdentity = nil Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(2)) guard let self, self.listener == nil, let router = self.router else { return } @@ -154,7 +166,11 @@ final class TVControlReceiver { listenerGeneration += 1 listener?.cancel() listener = nil - startListener(serverId: serverId, serverName: serverName) + startListener( + serverId: serverId, + serverName: serverName, + serverIdentity: RemotePlaybackIdentityManager.shared.effectiveServerIdentity + ) } func stop() { @@ -163,6 +179,7 @@ final class TVControlReceiver { listener = nil advertisedServerId = nil advertisedServerName = nil + advertisedServerIdentity = nil closeActiveSession(sendClose: false) } @@ -255,6 +272,7 @@ final class TVControlReceiver { remoteControllerName = nil remoteControllerDeviceId = nil remoteControllerServerId = nil + remoteControllerServerIdentity = nil refreshStandbyState() let stream = await session.open() startReadLoop(stream: stream, connectionId: connectionId) @@ -318,9 +336,10 @@ final class TVControlReceiver { remoteControllerName = hello.deviceName remoteControllerDeviceId = hello.deviceId remoteControllerServerId = serverId - if ServerRegistry.serverIdsMatch( - serverId, - RemotePlaybackIdentityManager.shared.effectiveServerId + remoteControllerServerIdentity = ServerIdentity.usable(hello.serverIdentity) + if RemotePlaybackIdentityManager.shared.controllerMatchesEffectiveServer( + serverId: serverId, + serverIdentity: remoteControllerServerIdentity ) { isAuthorized = true refreshStandbyState() @@ -433,7 +452,8 @@ final class TVControlReceiver { guard self.activeConnectionId == connectionId else { return } self.sendHandoffCancel( offer.requestId, - reason: "handoff_failed", + reason: (error as? RemotePlaybackIdentityManager.HandoffError)?.cancelReason + ?? "handoff_failed", message: error.localizedDescription ) self.pendingHandoffRequestId = nil @@ -475,9 +495,14 @@ final class TVControlReceiver { } private func handleLaunch(_ launch: SiloControlLaunchRequest) { - guard ServerRegistry.serverIdsMatch( - launch.serverId, - RemotePlaybackIdentityManager.shared.effectiveServerId + // The launch names the phone's registry key; the identity learned + // from its hello lets a same-deployment phone on another address + // launch without a handoff having renamed the effective server. + let launchIdentity = launch.serverId == remoteControllerServerId + ? remoteControllerServerIdentity : nil + guard RemotePlaybackIdentityManager.shared.controllerMatchesEffectiveServer( + serverId: launch.serverId, + serverIdentity: launchIdentity ) else { sendError(code: "server_mismatch", message: "This Apple TV is connected to a different Silo server.") return @@ -485,7 +510,15 @@ final class TVControlReceiver { let playback = launch.playback standbyState = nil - pendingPlayerHandoffGeneration = RemotePlaybackIdentityManager.shared.activeIdentity?.generationID + let generation = RemotePlaybackIdentityManager.shared.activeIdentity?.generationID + // Replacing a title: the outgoing player still owns this generation + // and would end the temporary identity on its way out, just as the + // incoming player starts loading under it. Hand the generation to + // the new player instead; the old one tears down without ending it. + if playerViewModel != nil, playerHandoffGeneration == generation { + playerHandoffGeneration = nil + } + pendingPlayerHandoffGeneration = generation router?.presentPlayer( contentId: playback.contentId, fileId: playback.fileId, @@ -536,6 +569,7 @@ final class TVControlReceiver { remoteLaunchReady = false remoteControllerDeviceId = nil remoteControllerServerId = nil + remoteControllerServerIdentity = nil standbyState = nil } @@ -558,6 +592,7 @@ final class TVControlReceiver { remoteLaunchReady = false remoteControllerDeviceId = nil remoteControllerServerId = nil + remoteControllerServerIdentity = nil standbyState = nil guard let session else { @@ -606,9 +641,9 @@ final class TVControlReceiver { private func reconcileAuthorizationAfterRestore() { remoteLaunchReady = false - isAuthorized = ServerRegistry.serverIdsMatch( - remoteControllerServerId, - RemotePlaybackIdentityManager.shared.effectiveServerId + isAuthorized = RemotePlaybackIdentityManager.shared.controllerMatchesEffectiveServer( + serverId: remoteControllerServerId, + serverIdentity: remoteControllerServerIdentity ) if isAuthorized { sendState() @@ -712,7 +747,8 @@ final class TVControlReceiver { deviceId: device.id, serverId: RemotePlaybackIdentityManager.shared.effectiveServerId, serverName: RemotePlaybackIdentityManager.shared.effectiveServerName, - supportedVersions: SiloControlProtocol.supportedVersions + supportedVersions: SiloControlProtocol.supportedVersions, + serverIdentity: RemotePlaybackIdentityManager.shared.effectiveServerIdentity )) } diff --git a/iosApp/iosApp/Navigation/AppRouter.swift b/iosApp/iosApp/Navigation/AppRouter.swift index 1e8ea8fe7..d1e7d0445 100644 --- a/iosApp/iosApp/Navigation/AppRouter.swift +++ b/iosApp/iosApp/Navigation/AppRouter.swift @@ -210,6 +210,71 @@ class AppRouter { var presentedPlayer: PlayerPresentation? + #if os(iOS) + /// Where a streaming play request should go. Installed by the root view + /// with the SiloControl client so every local-play entry point (detail + /// page, home rail badge, deep links, restored alerts) routes through one + /// decision instead of each call site re-checking the remote session. + /// Returns true when the request was taken by an engaged TV. + var remotePlaybackInterceptor: ((SiloControlPlaybackRequest) async -> Bool)? + /// Whether a TV is engaged right now, for sites that must not open the + /// local player at all (a PiP restore) rather than route a request. + var isRemotePlaybackEngaged: (() -> Bool)? + + /// True while the interceptor is deciding; a second Play in that window + /// must not slip past it and open the local player. + private var isRoutingRemotePlayback = false + + /// An offline play requested while a TV is engaged. A download can only + /// play on the phone, so instead of silently starting a second player the + /// root view asks: play here, or send the streamed version to the TV. + struct OfflinePlayChoice: Identifiable, Equatable { + let id = UUID() + let presentation: PlayerPresentation + let request: SiloControlPlaybackRequest + } + var pendingOfflinePlayChoice: OfflinePlayChoice? + + /// A play requested while the engaged TV is already playing a different + /// title. Replacing what someone may be watching deserves a confirmation, + /// so the root view asks before the request goes to the TV. + struct ReplaceRemotePlaybackChoice: Identifiable, Equatable { + let id = UUID() + let request: SiloControlPlaybackRequest + let currentTitle: String + let targetName: String + } + var pendingReplaceRemotePlayback: ReplaceRemotePlaybackChoice? + + /// Installed by the root view: the title the engaged TV is playing right + /// now, or nil when it is idle, so the router knows whether a play + /// would replace something. + var remotePlaybackCurrentTitle: (() -> (title: String, contentId: String?, targetName: String)?)? + + func confirmReplaceRemotePlayback() { + guard let choice = pendingReplaceRemotePlayback else { return } + pendingReplaceRemotePlayback = nil + guard let remotePlaybackInterceptor else { return } + Task { @MainActor in _ = await remotePlaybackInterceptor(choice.request) } + } + + /// User chose the phone for a pending offline play. + func confirmOfflinePlayHere() { + guard let choice = pendingOfflinePlayChoice else { return } + pendingOfflinePlayChoice = nil + presentedPlayer = choice.presentation + } + + /// User chose the TV for a pending offline play: the streamed version + /// goes through the same interceptor as any other play. + func sendPendingOfflinePlayToTV() { + guard let choice = pendingOfflinePlayChoice else { return } + pendingOfflinePlayChoice = nil + guard let remotePlaybackInterceptor else { return } + Task { @MainActor in _ = await remotePlaybackInterceptor(choice.request) } + } + #endif + // MARK: - Tab Selection /// One-shot tab-switch request, consumed (and cleared) by `MainTabView`, @@ -286,6 +351,38 @@ class AppRouter { ) #if os(iOS) presentation.detailPresentationID = presentedItemDetail?.id + if let remotePlaybackInterceptor { + // Decide the destination before touching `presentedPlayer`, so + // an engaged TV never sees the local cover flash. The request + // mirrors the values the local player would have used. + let request = SiloControlPlaybackRequest( + contentId: contentId, + fileId: fileId, + audioTrackIndex: audioTrackIndex, + subtitleTrackIndex: subtitleTrackIndex, + startFromBeginning: startFromBeginning, + resumePosition: resumePosition + ) + guard !isRoutingRemotePlayback else { return } + // The TV is mid-title and this is a different one: ask first. + // Same title (a Resume of what is already on) goes straight through. + if let now = remotePlaybackCurrentTitle?(), + now.contentId != contentId { + pendingReplaceRemotePlayback = ReplaceRemotePlaybackChoice( + request: request, + currentTitle: now.title, + targetName: now.targetName + ) + return + } + isRoutingRemotePlayback = true + Task { @MainActor in + defer { isRoutingRemotePlayback = false } + if await remotePlaybackInterceptor(request) { return } + presentedPlayer = presentation + } + return + } #endif presentedPlayer = presentation #endif @@ -331,6 +428,22 @@ class AppRouter { #if os(iOS) presentation.detailPresentationID = presentedItemDetail?.id #endif + #if os(iOS) + if isRemotePlaybackEngaged?() == true { + pendingOfflinePlayChoice = OfflinePlayChoice( + presentation: presentation, + request: SiloControlPlaybackRequest( + contentId: contentId, + fileId: nil, + audioTrackIndex: nil, + subtitleTrackIndex: nil, + startFromBeginning: startFromBeginning, + resumePosition: resumePosition + ) + ) + return + } + #endif presentedPlayer = presentation #endif } diff --git a/iosApp/iosApp/Networking/HTTPClient.swift b/iosApp/iosApp/Networking/HTTPClient.swift index 681e77059..8f71fd1a1 100644 --- a/iosApp/iosApp/Networking/HTTPClient.swift +++ b/iosApp/iosApp/Networking/HTTPClient.swift @@ -254,16 +254,62 @@ actor HTTPClient { serverURL: String, path: String, quietStatuses: Set = [], - diagnosticPath: String? = nil + diagnosticPath: String? = nil, + timeout: TimeInterval? = nil + ) async throws -> T { + try await getByExplicitURL( + serverURL: serverURL, + path: path, + bearer: nil, + quietStatuses: quietStatuses, + diagnosticPath: diagnosticPath, + timeout: timeout + ) + } + + /// Read from an explicit server URL with an explicit bearer, outside the + /// active credential slot. No refresh, no retry, no routing change: used + /// for reading another saved server's documents (for example its + /// connection list) while a different server stays active. + func getWithBearer( + serverURL: String, + path: String, + bearer: String, + quietStatuses: Set = [], + timeout: TimeInterval? = nil + ) async throws -> T { + try await getByExplicitURL( + serverURL: serverURL, + path: path, + bearer: bearer, + quietStatuses: quietStatuses, + diagnosticPath: nil, + timeout: timeout + ) + } + + private func getByExplicitURL( + serverURL: String, + path: String, + bearer: String?, + quietStatuses: Set, + diagnosticPath: String?, + timeout: TimeInterval? ) async throws -> T { let dispatchRevision = try captureRequestDispatchRevision() - let request = try buildRequest( + var request = try buildRequest( serverUrl: ServerRegistry.normalize(url: serverURL), method: "GET", path: path, query: [:], body: Optional.none ) + if let bearer { + request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization") + } + if let timeout { + request.timeoutInterval = timeout + } let (data, response) = try await perform( request: request, dispatchRevision: dispatchRevision, diff --git a/iosApp/iosApp/Networking/ServerIdentity.swift b/iosApp/iosApp/Networking/ServerIdentity.swift new file mode 100644 index 000000000..b5683e331 --- /dev/null +++ b/iosApp/iosApp/Networking/ServerIdentity.swift @@ -0,0 +1,228 @@ +import Foundation + +/// The deployment identity contract (`docs/architecture/server-identity.md` +/// in silo-server): `GET /api/v2/system/identity` is public and answers one +/// stable `server_id` at every address of a deployment; +/// `GET /api/v2/system/connections` is authenticated and lists the addresses +/// the deployment offers. The ID is self-asserted, so it only decides which +/// addresses are worth trying and how saved servers are grouped. Device-login +/// approval remains the proof that two addresses share one backend. +enum ServerIdentity { + static let identityPath = "/api/v2/system/identity" + static let connectionsPath = "/api/v2/system/connections" + + /// Probe timeout. A TV probing several candidate addresses in sequence + /// must fail fast on the ones it cannot reach. + static let probeTimeout: TimeInterval = 8 + + static func usable(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { return nil } + return trimmed + } +} + +/// `GET /api/v2/system/identity`. +struct ServerIdentityDocument: Decodable, Hashable, Sendable { + let serverId: String +} + +/// One address a deployment offers. Shared by the connections document, the +/// SiloControl handoff offer, and the companion-pairing push, so the same +/// value crosses every wire unchanged. +struct ServerEndpoint: Codable, Hashable, Sendable { + enum Kind: String, Codable, Sendable { + case `public` + case provider + } + + let url: String + let kind: Kind + /// Provider slug for `kind == .provider`, e.g. `tailscale`. + let provider: String? + /// Provider display name from its manifest, used for setup help on the + /// device that cannot reach the address. Never derived from the hostname. + let displayName: String? + + init(url: String, kind: Kind, provider: String? = nil, displayName: String? = nil) { + self.url = ServerRegistry.normalize(url: url) + self.kind = kind + self.provider = provider + self.displayName = displayName + } + + private enum CodingKeys: String, CodingKey { + case url, kind, provider, displayName + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let rawKind = try c.decode(String.self, forKey: .kind) + self.init( + url: try c.decode(String.self, forKey: .url), + kind: Kind(rawValue: rawKind) ?? .public, + provider: try c.decodeIfPresent(String.self, forKey: .provider), + displayName: try c.decodeIfPresent(String.self, forKey: .displayName) + ) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(url, forKey: .url) + try c.encode(kind, forKey: .kind) + try c.encodeIfPresent(provider, forKey: .provider) + try c.encodeIfPresent(displayName, forKey: .displayName) + } + + /// Help text for a device that cannot reach this address. Provider names + /// come from the manifest display name; nothing is inferred from the host. + /// The wording never claims the provider app is missing: a failed request + /// proves only that this device could not reach the address. + func unreachableHelp(serverName: String) -> String { + switch kind { + case .provider: + let name = ServerIdentity.usable(displayName) ?? "its network provider" + return "This device can't reach \(serverName) through \(name). " + + "Install or open the \(name) app on this device and make sure it has access to the server's network, then try again." + case .public: + return "This device can't reach \(serverName) at \(url). Check the network connection and try again." + } + } +} + +/// `GET /api/v2/system/connections` (account-authenticated). +struct ServerConnectionsDocument: Decodable, Hashable, Sendable { + struct AccessPath: Decodable, Hashable, Sendable { + let kind: String + let provider: String? + } + + struct Endpoint: Decodable, Hashable, Sendable { + let kind: String + let url: String? + let provider: String? + let displayName: String? + let state: String? + + /// Only a connected provider carries a URL; a public endpoint always + /// does. Anything without one cannot be offered to another device. + var usable: ServerEndpoint? { + guard let url = ServerIdentity.usable(url) else { return nil } + switch kind { + case "public": + return ServerEndpoint(url: url, kind: .public) + case "provider": + return ServerEndpoint(url: url, kind: .provider, provider: provider, displayName: displayName) + default: + return nil + } + } + } + + let revision: String? + let state: String? + let allowed: Bool? + let serverId: String + let current: AccessPath? + let endpoints: [Endpoint] + + var isAvailable: Bool { state == "available" && allowed != false } + + /// Addresses another device may try, in the server's order: public first, + /// then providers in slug order. De-duplicated on the normalized URL. + var usableEndpoints: [ServerEndpoint] { + var seen = Set() + return endpoints.compactMap(\.usable).filter { seen.insert($0.url).inserted } + } +} + +enum ServerIdentityProbeResult: Equatable, Sendable { + /// The address answered the identity operation. + case identity(String) + /// The address answered, but it is a server that predates the identity + /// contract. Reachable, unknown identity. + case unsupportedServer + /// The address could not be reached, or did not answer like a Silo server. + case unreachable +} + +/// Resolves deployment identities by explicit URL, never through the active +/// credential slot, so a probe of a candidate address cannot disturb the +/// device's own session. +struct ServerIdentityResolver { + private let httpClient: HTTPClient + + init(httpClient: HTTPClient = .shared) { + self.httpClient = httpClient + } + + /// The display name a server advertises. Branding owns the native + /// product identity; health remains a compatibility fallback when + /// branding is blank or the endpoint returns 404. Other failures leave the + /// previously stored identity unchanged. + func fetchServerName(serverURL: String) async -> String? { + do { + let branding: ServerBrandingStatus = try await httpClient.getUnauthenticated( + serverURL: serverURL, + path: "/api/v1/theme/branding", + quietStatuses: [404] + ) + if let name = ServerIdentity.usable(branding.serverName) { + return name + } + } catch HTTPError.http(let statusCode, _) where statusCode == 404 { + // Older servers do not expose native branding. + } catch { + return nil + } + + if let health: HealthStatus = try? await httpClient.getUnauthenticated( + serverURL: serverURL, + path: "/api/v1/health" + ) { + return ServerIdentity.usable(health.serverName) + } + return nil + } + + /// The deployment identity at `serverURL`, or nil when the address is + /// unreachable or the server predates the contract. + func fetchServerIdentity(serverURL: String) async -> String? { + if case .identity(let id) = await probeIdentity(serverURL: serverURL) { return id } + return nil + } + + /// Classifies one address for candidate selection: which identity it + /// reports, that it is an older server, or that it cannot be reached. + func probeIdentity(serverURL: String) async -> ServerIdentityProbeResult { + do { + let document: ServerIdentityDocument = try await httpClient.getUnauthenticated( + serverURL: serverURL, + path: ServerIdentity.identityPath, + quietStatuses: [404], + timeout: ServerIdentity.probeTimeout + ) + guard let id = ServerIdentity.usable(document.serverId) else { return .unreachable } + return .identity(id) + } catch HTTPError.http(let statusCode, let body) where statusCode == 404 { + return APIv2Probe.isLegacyNotFound(body: body) ? .unsupportedServer : .unreachable + } catch { + return .unreachable + } + } + + /// The connections document for a saved server, read with that server's + /// own bearer. Best effort: a nil result means the feature is unavailable + /// and callers fall back to offering the saved URL alone. + func fetchConnections(serverURL: String, bearer: String) async -> ServerConnectionsDocument? { + guard let document: ServerConnectionsDocument = try? await httpClient.getWithBearer( + serverURL: serverURL, + path: ServerIdentity.connectionsPath, + bearer: bearer, + timeout: ServerIdentity.probeTimeout + ), document.isAvailable else { + return nil + } + return document + } +} diff --git a/iosApp/iosApp/Networking/ServerIdentityResolver.swift b/iosApp/iosApp/Networking/ServerIdentityResolver.swift deleted file mode 100644 index 09ad3653e..000000000 --- a/iosApp/iosApp/Networking/ServerIdentityResolver.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -/// Resolves the native display name advertised by a Silo server. -/// -/// Branding owns the native product identity. Health remains a compatibility -/// fallback when branding is blank or the endpoint returns 404, indicating a -/// server that predates the public branding endpoint. Other failures leave the -/// previously stored identity unchanged. -struct ServerIdentityResolver { - private let httpClient: HTTPClient - - init(httpClient: HTTPClient = .shared) { - self.httpClient = httpClient - } - - func fetchServerName(serverURL: String) async -> String? { - do { - let branding: ServerBrandingStatus = try await httpClient.getUnauthenticated( - serverURL: serverURL, - path: "/api/v1/theme/branding", - quietStatuses: [404] - ) - if let name = Self.usableName(branding.serverName) { - return name - } - } catch HTTPError.http(let statusCode, _) where statusCode == 404 { - // Older servers do not expose native branding. - } catch { - return nil - } - - if let health: HealthStatus = try? await httpClient.getUnauthenticated( - serverURL: serverURL, - path: "/api/v1/health" - ) { - return Self.usableName(health.serverName) - } - return nil - } - - private static func usableName(_ value: String?) -> String? { - guard let name = value?.trimmingCharacters(in: .whitespacesAndNewlines), - !name.isEmpty else { - return nil - } - return name - } -} diff --git a/iosApp/iosApp/Networking/ServerRegistry.swift b/iosApp/iosApp/Networking/ServerRegistry.swift index 9a6a928b6..8da2fc206 100644 --- a/iosApp/iosApp/Networking/ServerRegistry.swift +++ b/iosApp/iosApp/Networking/ServerRegistry.swift @@ -22,6 +22,13 @@ struct ServerEntry: Codable, Identifiable, Equatable, Hashable { /// list; not part of identity. var lastUsedAt: Date + /// The deployment identity the server reported from + /// `GET /api/v2/system/identity` at this URL. It lets one deployment be + /// recognised across public, LAN, and network-plugin addresses without + /// changing `id`, which still keys credentials and settings. Self-asserted: + /// it groups and matches servers but never authorizes anything by itself. + var verifiedServerId: String? + /// Display label for lists/menus. Server-advertised name → URL. var displayName: String { if let name = fetchedName, !name.isEmpty { return name } @@ -38,13 +45,15 @@ struct ServerEntry: Codable, Identifiable, Equatable, Hashable { url: String, fetchedName: String?, profileId: String? = nil, - lastUsedAt: Date + lastUsedAt: Date, + verifiedServerId: String? = nil ) { self.id = id self.url = url self.fetchedName = fetchedName self.lastUsedAt = lastUsedAt self.legacyProfileId = profileId + self.verifiedServerId = ServerIdentity.usable(verifiedServerId) } private enum CodingKeys: String, CodingKey { @@ -53,6 +62,7 @@ struct ServerEntry: Codable, Identifiable, Equatable, Hashable { case fetchedName case profileId case lastUsedAt + case verifiedServerId } init(from decoder: Decoder) throws { @@ -62,6 +72,9 @@ struct ServerEntry: Codable, Identifiable, Equatable, Hashable { fetchedName = try container.decodeIfPresent(String.self, forKey: .fetchedName) lastUsedAt = try container.decode(Date.self, forKey: .lastUsedAt) legacyProfileId = try container.decodeIfPresent(String.self, forKey: .profileId) + verifiedServerId = ServerIdentity.usable( + try container.decodeIfPresent(String.self, forKey: .verifiedServerId) + ) } func encode(to encoder: Encoder) throws { @@ -75,6 +88,7 @@ struct ServerEntry: Codable, Identifiable, Equatable, Hashable { // profile identity. try container.encodeIfPresent(legacyProfileId, forKey: .profileId) try container.encode(lastUsedAt, forKey: .lastUsedAt) + try container.encodeIfPresent(verifiedServerId, forKey: .verifiedServerId) } } @@ -226,6 +240,9 @@ final class ServerRegistry { if merged.fetchedName == nil || merged.fetchedName?.isEmpty == true { merged.fetchedName = existing.fetchedName } + if merged.verifiedServerId == nil { + merged.verifiedServerId = existing.verifiedServerId + } } let isExistingEntry = self.entries.contains(where: { $0.id == entry.id }) if let idx = self.entries.firstIndex(where: { $0.id == entry.id }) { @@ -274,6 +291,32 @@ final class ServerRegistry { return true } + /// Records the deployment identity a server reported at its registry URL. + /// A blank value is ignored: losing the identity is never an improvement + /// over a stale one, and both only affect matching, never authorization. + @discardableResult + func updateVerifiedServerId(for serverId: String, verifiedServerId: String?) -> Bool { + guard let idx = entries.firstIndex(where: { $0.id == serverId }), + let identity = ServerIdentity.usable(verifiedServerId) else { return false } + guard entries[idx].verifiedServerId != identity else { return true } + let previousEntries = entries + entries[idx].verifiedServerId = identity + guard persist() else { + entries = previousEntries + _ = persist() + return false + } + return true + } + + /// The saved server that belongs to the deployment `verifiedServerId`, + /// if any. Prefers the active server when it qualifies. + func entry(verifiedServerId: String?) -> ServerEntry? { + guard let identity = ServerIdentity.usable(verifiedServerId) else { return nil } + if let active = activeServer, active.verifiedServerId == identity { return active } + return sortedEntries.first { $0.verifiedServerId == identity } + } + // MARK: - Server switching /// Activate a server. Updates the active ID, mirrors the URL, clears the @@ -768,6 +811,21 @@ final class ServerRegistry { return lhsCanonical == rhsCanonical } + /// Whether two servers are one deployment. Registry IDs still match by + /// origin; additionally, two verified deployment identities that are equal + /// recognise one server across different addresses (public URL, LAN, or a + /// network plugin origin). Used for visibility and grouping only: it never + /// merges credentials and never authorizes a handoff by itself. + static func serversMatch( + serverId lhsId: String?, verifiedServerId lhsIdentity: String?, + serverId rhsId: String?, verifiedServerId rhsIdentity: String? + ) -> Bool { + if serverIdsMatch(lhsId, rhsId) { return true } + guard let lhs = ServerIdentity.usable(lhsIdentity), + let rhs = ServerIdentity.usable(rhsIdentity) else { return false } + return lhs == rhs + } + private static func canonicalComparisonURL(for url: String) -> String? { guard var components = URLComponents(string: normalize(url: url)), let scheme = components.scheme?.lowercased(), diff --git a/iosApp/iosApp/Pairing/Companion/CompanionPairingCard.swift b/iosApp/iosApp/Pairing/Companion/CompanionPairingCard.swift index 4167a85e5..1aa4eb98a 100644 --- a/iosApp/iosApp/Pairing/Companion/CompanionPairingCard.swift +++ b/iosApp/iosApp/Pairing/Companion/CompanionPairingCard.swift @@ -189,7 +189,7 @@ struct CompanionPairingCard: View { } } - private func finished(signedIn: [String], failed: [String]) -> some View { + private func finished(signedIn: [String], failed: [CompanionPairingCoordinator.FailedServer]) -> some View { VStack(spacing: 0) { Image(systemName: signedIn.isEmpty ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") .font(.system(size: 44)) @@ -198,8 +198,8 @@ struct CompanionPairingCard: View { Text(signedIn.isEmpty ? "Setup didn’t finish" : "Set up \(signedIn.joined(separator: ", "))") .font(.siloHeadline) .multilineTextAlignment(.center) - if !failed.isEmpty { - Text("Couldn’t sign in to \(failed.joined(separator: ", ")).") + ForEach(failed, id: \.name) { failure in + Text(failure.summary) .font(.siloCaption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) diff --git a/iosApp/iosApp/Pairing/Companion/CompanionPairingCoordinator.swift b/iosApp/iosApp/Pairing/Companion/CompanionPairingCoordinator.swift index d5c9c04d7..4f080b7bc 100644 --- a/iosApp/iosApp/Pairing/Companion/CompanionPairingCoordinator.swift +++ b/iosApp/iosApp/Pairing/Companion/CompanionPairingCoordinator.swift @@ -28,10 +28,34 @@ final class CompanionPairingCoordinator { case confirmMatch(tvName: String, serverName: String, matchCode: String) /// Pushing/approving the remaining servers after confirmation. case working(progress: String) - case finished(signedIn: [String], failed: [String]) + /// `failed` pairs each server that did not sign in with the reason + /// the TV reported, already phrased for the user. + case finished(signedIn: [String], failed: [FailedServer]) case error(String) } + struct FailedServer: Equatable, Sendable { + let name: String + let code: PairingFailureCode + + /// What to tell the phone user. The TV shows the detailed recovery + /// (provider setup, alternate address); the phone summarises it. + var summary: String { + switch code { + case .unreachable: + return "\(name): the TV couldn't reach this address. Follow the steps on the TV, or set the TV up with the server's public address." + case .identityMismatch: + return "\(name): the address answered as a different server." + case .denied: + return "\(name): the sign-in was declined." + case .expired: + return "\(name): the code expired before it was approved." + case .authFailed: + return "\(name): the TV couldn't finish signing in." + } + } + } + enum Timeouts { /// Connect + TLS + the TV's `hello`. Generous enough for peer-to-peer /// Wi-Fi bring-up, short enough that a vanished TV isn't a trap. @@ -40,6 +64,13 @@ final class CompanionPairingCoordinator { /// request on their screen — leave time to find the remote. static let firstDeviceStarted: Duration = .seconds(90) static let deviceStarted: Duration = .seconds(30) + /// When the push offered alternate addresses, a newer TV may probe + /// each one and then ask its user whether to use the one that + /// answered, so `deviceStarted` can legitimately take longer. The + /// protocol has no progress frame an older TV would tolerate, so the + /// phone waits longer instead. + static let firstDeviceStartedWithEndpoints: Duration = .seconds(240) + static let deviceStartedWithEndpoints: Duration = .seconds(180) static let serverResult: Duration = .seconds(30) } @@ -52,6 +83,10 @@ final class CompanionPairingCoordinator { private let deviceModel: String private let availableServers: @MainActor () async -> [ServerEntry] private let accessToken: @MainActor (String) async -> String? + /// The addresses a server offers besides the phone's own, or nil when the + /// server predates the contract or the read fails. Best effort: the push + /// then carries the phone's address alone, as before. + private let serverEndpoints: @MainActor (ServerEntry, _ bearer: String) async -> [ServerEndpoint]? private var tvName: String private var queue: [ServerEntry] = [] @@ -59,7 +94,7 @@ final class CompanionPairingCoordinator { private var isFirstPush = true private var pendingUserCode: String? private var signedIn: [String] = [] - private var failed: [String] = [] + private var failed: [FailedServer] = [] private var runTask: Task? private var watchdog: Task? /// Set once the flow reaches a deliberate end (summary, error, or a @@ -75,7 +110,8 @@ final class CompanionPairingCoordinator { api: any PairingDeviceAuthorizing = PairingDeviceAPI(), deviceModel: String = UIDevice.current.model, availableServers: @escaping @MainActor () async -> [ServerEntry] = CompanionPairingCoordinator.serversWithTokens, - accessToken: @escaping @MainActor (String) async -> String? = { await TokenStore.shared.getAccessToken(for: $0) } + accessToken: @escaping @MainActor (String) async -> String? = { await TokenStore.shared.getAccessToken(for: $0) }, + serverEndpoints: @escaping @MainActor (ServerEntry, String) async -> [ServerEndpoint]? = CompanionPairingCoordinator.offeredEndpoints ) { self.channel = channel self.stream = stream @@ -84,6 +120,7 @@ final class CompanionPairingCoordinator { self.deviceModel = deviceModel self.availableServers = availableServers self.accessToken = accessToken + self.serverEndpoints = serverEndpoints } /// Open the transport for a discovered TV and start its coordinator. @@ -147,9 +184,9 @@ final class CompanionPairingCoordinator { case let .deviceStarted(_, userCode, matchCode): disarmWatchdog() await handleDeviceStarted(userCode: userCode, channelCode: matchCode) - case let .serverResult(_, status, _): + case let .serverResult(_, status, error): disarmWatchdog() - recordResult(signedInOK: status == .signedIn) + recordResult(signedInOK: status == .signedIn, code: PairingFailureCode(wire: error)) await pushNext() case .cancel: await conclude(.error("Setup was cancelled on \(tvName)."), goodbye: nil) @@ -242,18 +279,32 @@ final class CompanionPairingCoordinator { } else { state = .working(progress: "Setting up \(server.displayName)…") } + var endpoints: [ServerEndpoint]? + if server.verifiedServerId != nil, + let token = await accessToken(server.id), !token.isEmpty { + endpoints = await serverEndpoints(server, token) + } + guard !concluded, queue.first?.id == server.id else { return } // Arm BEFORE the suspending send: the stream reader keeps running // while `send` is suspended, so a fast TV's `deviceStarted` could // otherwise land (and disarm nothing) before this task resumed and // armed a stale watchdog over the confirm screen. + let offersAlternates = !(endpoints ?? []).isEmpty armWatchdog( - firstPush ? Timeouts.firstDeviceStarted : Timeouts.deviceStarted, + firstPush + ? (offersAlternates ? Timeouts.firstDeviceStartedWithEndpoints : Timeouts.firstDeviceStarted) + : (offersAlternates ? Timeouts.deviceStartedWithEndpoints : Timeouts.deviceStarted), firstPush ? "\(tvName) didn’t respond. Make sure you allowed the request on the TV, then try again." : "\(tvName) stopped responding." ) do { - try await channel.send(.pushServer(serverURL: server.url, serverName: server.displayName)) + try await channel.send(.pushServer( + serverURL: server.url, + serverName: server.displayName, + serverIdentity: server.verifiedServerId, + endpoints: endpoints + )) } catch { await conclude(.error("Connection to \(tvName) was lost."), goodbye: nil) } @@ -279,12 +330,12 @@ final class CompanionPairingCoordinator { } } - private func recordResult(signedInOK: Bool) { + private func recordResult(signedInOK: Bool, code: PairingFailureCode = .authFailed) { guard let server = queue.first else { return } if signedInOK { signedIn.append(server.displayName) } else { - failed.append(server.displayName) + failed.append(FailedServer(name: server.displayName, code: code)) } queue.removeFirst() } @@ -293,7 +344,7 @@ final class CompanionPairingCoordinator { /// codes couldn't be bound). Move on; the TV abandons its in-flight /// attempt as soon as the next `pushServer` arrives. private func failCurrentAndAdvance(_ server: ServerEntry) async { - failed.append(server.displayName) + failed.append(FailedServer(name: server.displayName, code: .authFailed)) if !queue.isEmpty { queue.removeFirst() } await pushNext() } @@ -345,6 +396,19 @@ final class CompanionPairingCoordinator { return result } + /// The other addresses `server` offers, from its connections document, + /// read with that server's own token. Only used when the document + /// confirms the identity the phone already verified for the server. + static func offeredEndpoints(for server: ServerEntry, bearer: String) async -> [ServerEndpoint]? { + guard let document = await ServerIdentityResolver().fetchConnections( + serverURL: server.url, bearer: bearer + ), document.serverId == server.verifiedServerId else { + return nil + } + let endpoints = document.usableEndpoints + return endpoints.isEmpty ? nil : endpoints + } + /// Whether this device has anything to hand off — gates the discovery /// card so a signed-out phone is never invited into a dead-end flow. static func hasServerWithToken() async -> Bool { diff --git a/iosApp/iosApp/Pairing/PairingProtocol.swift b/iosApp/iosApp/Pairing/PairingProtocol.swift index 0cf83e5d8..77a38693c 100644 --- a/iosApp/iosApp/Pairing/PairingProtocol.swift +++ b/iosApp/iosApp/Pairing/PairingProtocol.swift @@ -29,13 +29,20 @@ enum PairingReceiverState: String, Codable, Equatable { enum PairingMessage: Equatable { /// TV → phone, first message after the connection opens. case hello(tvName: String, tvDeviceId: String, state: PairingReceiverState, supportedVersions: [Int]) - /// phone → TV, one per chosen server. - case pushServer(serverURL: String, serverName: String?) + /// phone → TV, one per chosen server. `serverIdentity` and `endpoints` + /// are optional additions (protocol still v1): the deployment identity + /// the phone verified at `serverURL`, and the other addresses the + /// deployment offers, so a TV that cannot reach the phone's address can + /// verify and use one it can. Older peers omit and ignore them. + case pushServer(serverURL: String, serverName: String?, serverIdentity: String? = nil, endpoints: [ServerEndpoint]? = nil) /// TV → phone, after the TV called device/start for a pushed server. /// `matchCode` is advisory display only; the phone re-fetches the /// authoritative match code from the server via lookup before approving. case deviceStarted(serverURL: String, userCode: String, matchCode: String) - /// TV → phone, terminal per-server outcome. + /// TV → phone, terminal per-server outcome. `serverURL` always echoes + /// the pushed URL, even when the TV signed in at another address, so a + /// phone that keys on it keeps working. `error` is a + /// ``PairingFailureCode`` raw value on failure. case serverResult(serverURL: String, status: PairingServerStatus, error: String?) /// phone → TV, no more servers; finish. case done @@ -48,11 +55,33 @@ enum PairingServerStatus: String, Codable, Equatable { case failed } +/// Why a pushed server failed on the TV. Carried in `serverResult.error` +/// so the phone can say what to fix. Older TVs send only `auth_failed`, and +/// an unknown code from a newer TV reads as that generic failure. +enum PairingFailureCode: String, Codable, Equatable, Sendable { + /// Device authorization could not be started or completed; the legacy + /// catch-all. + case authFailed = "auth_failed" + /// The phone's user declined the request, or the server refused it. + case denied + /// The device code expired before approval, or was already used. + case expired + /// The TV could not reach the pushed address, and no offered + /// alternative worked or was accepted. + case unreachable + /// An address answered with a different deployment identity. + case identityMismatch = "identity_mismatch" + + init(wire: String?) { + self = wire.flatMap(PairingFailureCode.init(rawValue:)) ?? .authFailed + } +} + extension PairingMessage: Codable { private enum CodingKeys: String, CodingKey { case type, v case tvName, tvDeviceId, state, supportedVersions - case serverURL, serverName + case serverURL, serverName, serverIdentity, endpoints case userCode, matchCode case status, error case reason @@ -72,10 +101,12 @@ extension PairingMessage: Codable { try c.encode(tvDeviceId, forKey: .tvDeviceId) try c.encode(state, forKey: .state) try c.encode(supportedVersions, forKey: .supportedVersions) - case let .pushServer(serverURL, serverName): + case let .pushServer(serverURL, serverName, serverIdentity, endpoints): try c.encode(Kind.pushServer, forKey: .type) try c.encode(serverURL, forKey: .serverURL) try c.encodeIfPresent(serverName, forKey: .serverName) + try c.encodeIfPresent(serverIdentity, forKey: .serverIdentity) + try c.encodeIfPresent(endpoints, forKey: .endpoints) case let .deviceStarted(serverURL, userCode, matchCode): try c.encode(Kind.deviceStarted, forKey: .type) try c.encode(serverURL, forKey: .serverURL) @@ -108,7 +139,9 @@ extension PairingMessage: Codable { case .pushServer: self = .pushServer( serverURL: try c.decode(String.self, forKey: .serverURL), - serverName: try c.decodeIfPresent(String.self, forKey: .serverName) + serverName: try c.decodeIfPresent(String.self, forKey: .serverName), + serverIdentity: try c.decodeIfPresent(String.self, forKey: .serverIdentity), + endpoints: try c.decodeIfPresent([ServerEndpoint].self, forKey: .endpoints) ) case .deviceStarted: self = .deviceStarted( diff --git a/iosApp/iosApp/Pairing/Receiver/ReceiverPairingCoordinator.swift b/iosApp/iosApp/Pairing/Receiver/ReceiverPairingCoordinator.swift index 68e23c67d..47b38f199 100644 --- a/iosApp/iosApp/Pairing/Receiver/ReceiverPairingCoordinator.swift +++ b/iosApp/iosApp/Pairing/Receiver/ReceiverPairingCoordinator.swift @@ -37,10 +37,62 @@ final class ReceiverPairingCoordinator { case signedIn(serverCount: Int) /// Terminal success; every signed-in server, named for the summary. case completed(serverNames: [String]) + /// Checking which of the server's addresses this TV can reach, and + /// starting device authorization there. + case reaching(serverName: String) + /// The pushed address did not answer from this TV. `help` names the + /// network provider behind it when the server listed one; `alternate` + /// is a verified address of the same server the user may choose + /// instead. Nothing switches without that choice. + case unreachable(serverName: String, help: String, alternate: ServerEndpoint?) /// Terminal failure for the last attempted server. Kept on screen /// (never clobbered back to idle by the phone's `done`/EOF) so the - /// user sees what happened; "Try again" returns to idle. - case failed(String) + /// user sees what happened; "Try again" returns to idle. `help` is + /// the recovery text for the failure, when there is a specific one. + case failed(serverName: String, code: PairingFailureCode, help: String?) + } + + /// One server pushed by the phone, with the identity and alternate + /// addresses it offered (absent from older phones). + struct PushedServer: Equatable, Sendable { + let serverURL: String + let serverName: String? + let serverIdentity: String? + let endpoints: [ServerEndpoint] + + var displayName: String { serverName ?? ServerRegistry.normalize(url: serverURL) } + + /// The provider entry behind the pushed address, when the server + /// listed it: this is what makes the unreachable copy name the + /// provider to set up rather than guessing from the hostname. + var pushedProvider: ServerEndpoint? { + let pushed = ServerRegistry.normalize(url: serverURL) + return endpoints.first { $0.kind == .provider && $0.url == pushed } + } + + func unreachableHelp() -> String { + if let provider = pushedProvider { + return provider.unreachableHelp(serverName: displayName) + } + return "This Apple TV can't reach \(displayName) at \(ServerRegistry.normalize(url: serverURL)). Check its network connection and try again." + } + } + + /// What the receiver commits once a poll returns tokens. + struct PersistedPairing: Equatable, Sendable { + /// The address that worked from this TV. It may differ from the + /// pushed one; the phone's saved address is never changed. + let url: String + let fetchedName: String? + let verifiedServerId: String? + let accessToken: String + let refreshToken: String + } + + private enum AlternateChoice: Sendable { + case useAlternate(ServerEndpoint) + case retry + case cancelled } /// How long a connected phone may sit completely silent (no message, no @@ -52,10 +104,13 @@ final class ReceiverPairingCoordinator { private(set) var state: State = .idle private let api: any PairingDeviceAuthorizing - private let persist: @MainActor (_ url: String, _ fetchedName: String?, _ access: String, _ refresh: String) async -> Bool + private let identityProbe: @Sendable (_ serverURL: String) async -> ServerIdentityProbeResult + private let persist: @MainActor (PersistedPairing) async -> Bool private var signedInNames: [String] = [] private var consented = false - private var pendingPush: (serverURL: String, serverName: String?)? + private var pendingPush: PushedServer? + /// The TV user's pending choice while `state` is `.unreachable`. + private var alternateDecision: CheckedContinuation? /// The session currently being driven, so `cancel()`/consent can reach it. private var activeSession: (any PairingChannel)? /// The in-flight start+poll for the current server. Run as a separate @@ -70,9 +125,13 @@ final class ReceiverPairingCoordinator { init( api: any PairingDeviceAuthorizing = PairingDeviceAPI(), - persist: @escaping @MainActor (String, String?, String, String) async -> Bool = ReceiverPairingCoordinator.persistServer + identityProbe: @escaping @Sendable (String) async -> ServerIdentityProbeResult = { url in + await ServerIdentityResolver().probeIdentity(serverURL: url) + }, + persist: @escaping @MainActor (PersistedPairing) async -> Bool = ReceiverPairingCoordinator.persistServer ) { self.api = api + self.identityProbe = identityProbe self.persist = persist } @@ -101,18 +160,24 @@ final class ReceiverPairingCoordinator { guard !isCancelling else { continue } armIdleTimer(session) switch message { - case let .pushServer(serverURL, serverName): + case let .pushServer(serverURL, serverName, serverIdentity, endpoints): // The protocol is one-server-at-a-time: a new push while // one is in flight means the phone gave up on the // previous server — supersede it, don't ignore the push. pollTask?.cancel() await pollTask?.value guard !isCancelling else { return } + let push = PushedServer( + serverURL: serverURL, + serverName: serverName, + serverIdentity: ServerIdentity.usable(serverIdentity), + endpoints: endpoints ?? [] + ) if consented { - beginAttempt(serverURL: serverURL, serverName: serverName, session: session) + beginAttempt(push, session: session) } else { - pendingPush = (serverURL, serverName) - state = .consentRequested(serverName: serverName ?? ServerRegistry.normalize(url: serverURL)) + pendingPush = push + state = .consentRequested(serverName: push.displayName) } case .done: // An in-flight server has no committed result; abandon it. @@ -178,7 +243,40 @@ final class ReceiverPairingCoordinator { guard case .consentRequested = state, let push = pendingPush, let session = activeSession else { return } consented = true pendingPush = nil - beginAttempt(serverURL: push.serverURL, serverName: push.serverName, session: session) + beginAttempt(push, session: session) + } + + // MARK: - Unreachable address + + /// User chose the verified alternate address shown on the unreachable + /// screen. Device authorization runs there; the address that works is + /// what this TV saves. + func useAlternateAddress() { + guard case let .unreachable(_, _, alternate) = state, let alternate else { return } + resumeAlternateDecision(.useAlternate(alternate)) + } + + /// User set up the network provider (or fixed the connection) and wants + /// the pushed address tried again. + func retryPushedAddress() { + guard case .unreachable = state else { return } + resumeAlternateDecision(.retry) + } + + private func resumeAlternateDecision(_ choice: AlternateChoice) { + guard let decision = alternateDecision else { return } + alternateDecision = nil + decision.resume(returning: choice) + } + + private func awaitAlternateDecision() async -> AlternateChoice { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + alternateDecision = continuation + } + } onCancel: { + Task { @MainActor [weak self] in self?.resumeAlternateDecision(.cancelled) } + } } /// User declined the pending server — end the session; the phone is told @@ -245,7 +343,7 @@ final class ReceiverPairingCoordinator { // MARK: - Per-server attempt - private func beginAttempt(serverURL: String, serverName: String?, session: any PairingChannel) { + private func beginAttempt(_ push: PushedServer, session: any PairingChannel) { // "Automatic" only once a sign-in has been COMMITTED: the phone // auto-approves only after its user confirmed a match code, and the // first confirmed approval is what produces the first success. A @@ -255,7 +353,7 @@ final class ReceiverPairingCoordinator { let automatic = !signedInNames.isEmpty idleTask?.cancel() pollTask = Task { [weak self] in - await self?.handlePushServer(serverURL: serverURL, serverName: serverName, session: session, automatic: automatic) + await self?.handlePushServer(push, session: session, automatic: automatic) self?.attemptEnded(session) } } @@ -265,15 +363,29 @@ final class ReceiverPairingCoordinator { armIdleTimer(session) } - private func handlePushServer(serverURL: String, serverName: String?, session: any PairingChannel, automatic: Bool) async { - let normalized = ServerRegistry.normalize(url: serverURL) - let displayName = serverName ?? normalized + private func handlePushServer(_ push: PushedServer, session: any PairingChannel, automatic: Bool) async { + // Every frame back to the phone names the PUSHED address, whatever + // address this TV ends up using: phones key their per-server state + // on the URL they sent. + let pushedURL = ServerRegistry.normalize(url: push.serverURL) + let displayName = push.displayName let device = AppleDeviceIdentity.current do { + // 0. Decide which address to sign in at. Legacy pushes (no + // identity) use the pushed address exactly, as before. + state = .reaching(serverName: displayName) + let loginURL = try await resolveLoginURL(push, pushedURL: pushedURL) + // 1. Start device auth against the PENDING candidate (not persisted). - let started = try await api.start(serverURL: normalized, deviceName: device.name, devicePlatform: device.platform) + let started: DeviceLoginStartResponse + do { + started = try await api.start(serverURL: loginURL, deviceName: device.name, devicePlatform: device.platform) + } catch { + try Task.checkCancellation() + throw Self.isTransportFailure(error) ? AttemptFailure.unreachable : error + } state = .awaitingApproval(serverName: displayName, matchCode: started.matchCode, automatic: automatic) - try await session.send(.deviceStarted(serverURL: normalized, userCode: started.userCode, matchCode: started.matchCode)) + try await session.send(.deviceStarted(serverURL: pushedURL, userCode: started.userCode, matchCode: started.matchCode)) // 2. Poll until approved or the device code expires. let deadline = Date().addingTimeInterval(TimeInterval(started.expiresIn)) @@ -282,11 +394,11 @@ final class ReceiverPairingCoordinator { try Task.checkCancellation() // abort promptly on peer cancel / drop let poll: DeviceLoginPollResponse do { - poll = try await api.poll(serverURL: normalized, deviceCode: started.deviceCode) + poll = try await api.poll(serverURL: loginURL, deviceCode: started.deviceCode) } catch { try Task.checkCancellation() if case PairingDeviceAPI.APIError.http(404) = error { - throw error // the server has expired and removed this request + throw AttemptFailure.expired // the server has expired and removed this request } // Match the ordinary device-login flow and Android TV: // a deploy, proxy hiccup, or brief network loss must not @@ -301,7 +413,13 @@ final class ReceiverPairingCoordinator { guard let access = poll.accessToken, let refresh = poll.refreshToken else { throw PairingDeviceAPI.APIError.decode } - guard await persist(normalized, serverName, access, refresh) else { + guard await persist(PersistedPairing( + url: loginURL, + fetchedName: push.serverName, + verifiedServerId: push.serverIdentity, + accessToken: access, + refreshToken: refresh + )) else { return } signedInNames.append(displayName) @@ -310,16 +428,18 @@ final class ReceiverPairingCoordinator { // confirmation frame must not repaint a real sign-in as a // failure. If the send is lost the phone may undercount, // but EOF-after-success still completes on both ends. - await session.queue(.serverResult(serverURL: normalized, status: .signedIn, error: nil)) + await session.queue(.serverResult(serverURL: pushedURL, status: .signedIn, error: nil)) return - case "denied", "expired", "consumed": - throw PairingDeviceAPI.APIError.http(409) + case "denied": + throw AttemptFailure.denied + case "expired", "consumed": + throw AttemptFailure.expired default: // "pending" pollInterval = max(1, poll.pollAfter ?? pollInterval) try await Task.sleep(for: .seconds(pollInterval)) } } - throw PairingDeviceAPI.APIError.http(408) // local timeout + throw AttemptFailure.expired // local timeout } catch { // Persist-on-success: nothing was written, so nothing to roll back. if Task.isCancelled { @@ -329,15 +449,106 @@ final class ReceiverPairingCoordinator { return } Self.logger.error("server pairing failed: \(String(describing: error), privacy: .private)") - state = .failed(displayName) - try? await session.send(.serverResult(serverURL: normalized, status: .failed, error: "auth_failed")) + let code = (error as? AttemptFailure)?.code ?? .authFailed + state = .failed( + serverName: displayName, + code: code, + help: code == .unreachable ? push.unreachableHelp() : nil + ) + try? await session.send(.serverResult(serverURL: pushedURL, status: .failed, error: code.rawValue)) + } + } + + private enum AttemptFailure: Error { + case unreachable + case identityMismatch + case denied + case expired + + var code: PairingFailureCode { + switch self { + case .unreachable: return .unreachable + case .identityMismatch: return .identityMismatch + case .denied: return .denied + case .expired: return .expired + } + } + } + + private static func isTransportFailure(_ error: Error) -> Bool { + if error is URLError { return true } + if case PairingDeviceAPI.APIError.http(let status) = error, status < 0 { return true } + return false + } + + /// The address to run device authorization at. + /// + /// With an identity, the pushed address must answer with that identity + /// from this TV. If it does not answer at all, the server's other + /// addresses are checked for the same identity and the first match is + /// OFFERED, never taken: the user sees why the pushed address failed + /// (usually a network provider to set up on the TV) and chooses between + /// the alternate and a retry. An address answering with a different + /// identity is never used, whether pushed or alternate. + private func resolveLoginURL(_ push: PushedServer, pushedURL: String) async throws -> String { + guard let expected = push.serverIdentity else { return pushedURL } + while true { + try Task.checkCancellation() + switch await identityProbe(pushedURL) { + case .identity(let id) where id == expected: + return pushedURL + case .identity, .unsupportedServer: + // The phone verified this identity at this very address; a + // different answer from here is not the server it meant. + throw AttemptFailure.identityMismatch + case .unreachable: + break + } + + let alternate = await firstReachableAlternate(push, pushedURL: pushedURL, expected: expected) + try Task.checkCancellation() + state = .unreachable(serverName: push.displayName, help: push.unreachableHelp(), alternate: alternate) + switch await awaitAlternateDecision() { + case .useAlternate(let endpoint): + state = .reaching(serverName: push.displayName) + return endpoint.url + case .retry: + state = .reaching(serverName: push.displayName) + continue + case .cancelled: + throw CancellationError() + } + } + } + + private func firstReachableAlternate( + _ push: PushedServer, + pushedURL: String, + expected: String + ) async -> ServerEndpoint? { + for endpoint in push.endpoints where endpoint.url != pushedURL { + if Task.isCancelled { return nil } + if case .identity(let id) = await identityProbe(endpoint.url), id == expected { + return endpoint + } } + return nil } /// Commit the now-trusted server + tokens. Runs only after a successful poll. - static func persistServer(url: String, fetchedName: String?, access: String, refresh: String) async -> Bool { + static func persistServer(_ pairing: PersistedPairing) async -> Bool { + let url = pairing.url + let access = pairing.accessToken + let refresh = pairing.refreshToken let id = ServerRegistry.serverId(for: url) - let entry = ServerEntry(id: id, url: url, fetchedName: fetchedName, profileId: nil, lastUsedAt: Date()) + let entry = ServerEntry( + id: id, + url: url, + fetchedName: pairing.fetchedName, + profileId: nil, + lastUsedAt: Date(), + verifiedServerId: pairing.verifiedServerId + ) // Device authorization can replace the account for an already-saved // server URL. Preserve its name, but never carry the previous account's // profile selection across that credential boundary. diff --git a/iosApp/iosApp/Pairing/Receiver/TVPairingReceiverView.swift b/iosApp/iosApp/Pairing/Receiver/TVPairingReceiverView.swift index 126b411e5..88009e2bf 100644 --- a/iosApp/iosApp/Pairing/Receiver/TVPairingReceiverView.swift +++ b/iosApp/iosApp/Pairing/Receiver/TVPairingReceiverView.swift @@ -14,7 +14,7 @@ struct TVPairingReceiverView: View { private static let successDwell: Duration = .seconds(1.8) @FocusState private var focused: Control? - private enum Control: Hashable { case primary, secondary } + private enum Control: Hashable { case primary, secondary, tertiary } var body: some View { VStack(spacing: 28) { @@ -32,8 +32,12 @@ struct TVPairingReceiverView: View { signedIn(count: count) case let .completed(serverNames): completed(serverNames: serverNames) - case let .failed(name): - failed(name: name) + case let .reaching(serverName): + reaching(serverName: serverName) + case let .unreachable(serverName, help, alternate): + unreachable(serverName: serverName, help: help, alternate: alternate) + case let .failed(name, code, help): + failed(name: name, code: code, help: help) } } .frame(maxWidth: 880) @@ -201,9 +205,77 @@ struct TVPairingReceiverView: View { } } + // MARK: - Reaching (checking which address answers) + + private func reaching(serverName: String) -> some View { + VStack(spacing: 26) { + AuroraEyebrow(text: "Step 01 — Connect", centered: true) + Text("Connecting to \(serverName)") + .font(.siloTitle) + .foregroundStyle(Color.auroraInk) + WaitingDots() + Text("Checking which address this Apple TV can reach.") + .font(.siloBody) + .foregroundStyle(Color.auroraInkSecondary) + .frame(maxWidth: 720) + cancelButton(title: "Cancel") + .padding(.top, 8) + } + } + + // MARK: - Unreachable (provider help + explicit alternate) + + /// The pushed address did not answer. The user chooses: set the provider + /// up and retry, or use the verified alternate address when the server + /// offers one. Nothing switches on its own. + private func unreachable(serverName: String, help: String, alternate: ServerEndpoint?) -> some View { + VStack(spacing: 22) { + AuroraEyebrow(text: "Step 01 — Connect", centered: true) + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 60)) + .foregroundStyle(Color.auroraAccent) + Text("Can’t reach \(serverName)") + .font(.siloTitle) + .foregroundStyle(Color.auroraInk) + Text(help) + .font(.siloBody) + .foregroundStyle(Color.auroraInkSecondary) + .frame(maxWidth: 760) + + if let alternate { + Button { coordinator.useAlternateAddress() } label: { + Text("Use \(Self.hostLabel(alternate.url))") + } + .buttonStyle(AuroraPrimaryButtonStyle()) + .focused($focused, equals: .primary) + .frame(width: 520) + .padding(.top, 8) + Button { coordinator.retryPushedAddress() } label: { Text("Try again") } + .buttonStyle(AuroraGhostButtonStyle()) + .focused($focused, equals: .secondary) + } else { + Button { coordinator.retryPushedAddress() } label: { Text("Try again") } + .buttonStyle(AuroraPrimaryButtonStyle()) + .focused($focused, equals: .primary) + .frame(width: 360) + .padding(.top, 8) + } + Button { cancel() } label: { Text("Cancel") } + .buttonStyle(AuroraGhostButtonStyle()) + .focused($focused, equals: .tertiary) + } + .defaultFocus($focused, .primary) + } + + /// The host of an address, for a button label. Falls back to the + /// address itself when it does not parse. + private static func hostLabel(_ url: String) -> String { + URLComponents(string: url)?.host ?? url + } + // MARK: - Failed (actionable retry) - private func failed(name: String) -> some View { + private func failed(name: String, code: PairingFailureCode, help: String?) -> some View { VStack(spacing: 22) { AuroraEyebrow(text: "Step 01 — Connect", centered: true) Image(systemName: "exclamationmark.triangle.fill") @@ -212,7 +284,7 @@ struct TVPairingReceiverView: View { Text("Setup didn’t finish") .font(.siloTitle) .foregroundStyle(Color.auroraInk) - Text("Something went wrong signing in to \(name). Try again from your iPhone, or add your server manually.") + Text(Self.failureText(name: name, code: code, help: help)) .font(.siloBody) .foregroundStyle(Color.auroraInkSecondary) .frame(maxWidth: 720) @@ -226,6 +298,22 @@ struct TVPairingReceiverView: View { .defaultFocus($focused, .primary) } + private static func failureText(name: String, code: PairingFailureCode, help: String?) -> String { + switch code { + case .unreachable: + return (help ?? "This Apple TV can’t reach \(name).") + + " You can also add the server manually with its public address." + case .identityMismatch: + return "The address your iPhone sent for \(name) answered as a different server. Check the server address on your iPhone, or add your server manually." + case .denied: + return "The sign-in to \(name) was declined. Try again from your iPhone." + case .expired: + return "The code for \(name) expired before it was approved. Try again from your iPhone." + case .authFailed: + return "Something went wrong signing in to \(name). Try again from your iPhone, or add your server manually." + } + } + // MARK: - Shared pieces private var successMark: some View { diff --git a/iosApp/iosApp/Screens/Auth/AuthService.swift b/iosApp/iosApp/Screens/Auth/AuthService.swift index 891143b42..8a7af7255 100644 --- a/iosApp/iosApp/Screens/Auth/AuthService.swift +++ b/iosApp/iosApp/Screens/Auth/AuthService.swift @@ -130,6 +130,10 @@ final class AuthService: @unchecked Sendable { // exposing a global A/B routing mixture to unrelated requests. let fetchedName = await serverIdentityResolver.fetchServerName(serverURL: normalized) try Task.checkCancellation() + // Best effort: an older server has no identity and the entry simply + // stays unmatched across addresses until it is upgraded. + let verifiedServerId = await serverIdentityResolver.fetchServerIdentity(serverURL: normalized) + try Task.checkCancellation() // Commit only after the candidate proves it can serve setup status. let status: SetupStatus = try await httpClient.getUnauthenticated( @@ -144,7 +148,8 @@ final class AuthService: @unchecked Sendable { url: normalized, fetchedName: fetchedName, profileId: nil, - lastUsedAt: Date() + lastUsedAt: Date(), + verifiedServerId: verifiedServerId ) guard serverRegistry.addOrUpdate(entry) != nil else { throw ServerRegistryError.persistenceFailed @@ -174,6 +179,8 @@ final class AuthService: @unchecked Sendable { // unreachable->reachable recovery all come through here. await recordContractVerdict(serverId: serverId, serverURL: server.url) guard serverRegistry.activeServerId == serverId else { return } + await refreshVerifiedServerId(for: server) + guard serverRegistry.activeServerId == serverId else { return } guard let name = await serverIdentityResolver.fetchServerName(serverURL: server.url), serverRegistry.activeServerId == serverId else { return @@ -181,6 +188,17 @@ final class AuthService: @unchecked Sendable { serverRegistry.updateFetchedName(for: serverId, fetchedName: name) } + /// Learns (or re-learns) the deployment identity behind a saved server so + /// SiloRemote and companion pairing can recognise it at other addresses. + /// Servers added before the identity contract pick it up here on their + /// next activation or foreground refresh. + func refreshVerifiedServerId(for server: ServerEntry) async { + guard let identity = await serverIdentityResolver.fetchServerIdentity(serverURL: server.url) else { + return + } + serverRegistry.updateVerifiedServerId(for: server.id, verifiedServerId: identity) + } + /// Validate a Keychain-restored account without changing the remembered /// server entry. Temporary failures return `indeterminate`; only the /// existing HTTP refresh policy may invalidate a terminally rejected diff --git a/iosApp/iosApp/Screens/Auth/ServerSetupViewModel.swift b/iosApp/iosApp/Screens/Auth/ServerSetupViewModel.swift index 786804a24..4fde8dae8 100644 --- a/iosApp/iosApp/Screens/Auth/ServerSetupViewModel.swift +++ b/iosApp/iosApp/Screens/Auth/ServerSetupViewModel.swift @@ -60,6 +60,12 @@ class ServerSetupViewModel { attempted.append(candidate) do { let status = try await auth.checkServer(url: candidate) + // This view is also pushed onto the login stack (Change + // Server, then Add Server) while `authState` is already + // `needsLogin`. Setting the same state is a no-op there, so + // the stack must be reset explicitly or the setup screen + // stays put after a successful connect. + router.popToRoot() router.authState = .needsLogin if status.needsSetup { router.navigate(to: .serverNeedsSetup) diff --git a/iosApp/iosApp/Screens/Detail/ItemDetailView.swift b/iosApp/iosApp/Screens/Detail/ItemDetailView.swift index 2891a8cc6..7b2051e70 100644 --- a/iosApp/iosApp/Screens/Detail/ItemDetailView.swift +++ b/iosApp/iosApp/Screens/Detail/ItemDetailView.swift @@ -465,7 +465,7 @@ private struct ItemDetailPhoneContent: View { router.itemDetailPath.removeLast() } }, - trailingSystemName: siloControl.hasActiveSession + trailingSystemName: siloControl.remotePlaybackEngaged ? "appletvremote.gen4.fill" : "appletvremote.gen4", onTrailingTap: handleRemoteControlTap @@ -489,7 +489,7 @@ private struct ItemDetailPhoneContent: View { private func handleRemoteControlTap() { if let detail = viewModel.detail, isDirectlyPlayable(detail) { playOnTV(currentControlRequest(for: detail)) - } else if siloControl.hasActiveSession || siloControl.isReconnecting { + } else if siloControl.remotePlaybackEngaged { isShowingRemoteControl = true } else { isShowingControlPicker = true @@ -536,9 +536,9 @@ private struct ItemDetailPhoneContent: View { } private func playOnTV(_ request: SiloControlPlaybackRequest) { - if siloControl.hasActiveSession { - // Already connected ⇒ cast this item now. - Task { await siloControl.launch(request) } + if siloControl.remotePlaybackEngaged { + // Already engaged (or reconnecting) ⇒ cast this item now. + Task { await siloControl.launchOnEngagedTV(request) } } else { // No session ⇒ pick a TV, then cast-and-play in one step. controlRequestBox = ControlRequestBox(request) @@ -816,16 +816,10 @@ private struct ItemDetailPhoneContent: View { /// play affordance on the page. private func playExtra(contentId: String) { #if os(iOS) - if siloControl.hasActiveSession { - let request = SiloControlPlaybackRequest( - contentId: contentId, - fileId: nil, - audioTrackIndex: nil, - subtitleTrackIndex: nil, - startFromBeginning: true, - resumePosition: nil - ) - Task { await siloControl.launch(request) } + // An engaged TV takes the request through the router's interceptor + // (see `AppRouter.presentPlayer`); nothing here decides destination. + if siloControl.remotePlaybackEngaged { + router.presentPlayer(contentId: contentId, startFromBeginning: true, resumePosition: nil) return } #endif @@ -1123,8 +1117,12 @@ private struct ItemDetailPhoneContent: View { resumePosition: Double? ) { #if os(iOS) - if siloControl.hasActiveSession { - let request = SiloControlPlaybackRequest( + // An engaged TV takes the request through the router's interceptor + // (see `AppRouter.presentPlayer`). Skipping the local-copy choice and + // the reachability alert is deliberate: a TV can't read the phone's + // download, and the TV reaches the server on its own link. + if siloControl.remotePlaybackEngaged { + presentStreamingPlayer( contentId: contentId, fileId: fileId, audioTrackIndex: audioTrackIndex, @@ -1132,9 +1130,6 @@ private struct ItemDetailPhoneContent: View { startFromBeginning: startFromBeginning, resumePosition: resumePosition ) - Task { - await siloControl.launch(request) - } return } #endif diff --git a/iosApp/iosApp/Screens/Player/iOS/PlayerPresentationRestoration.swift b/iosApp/iosApp/Screens/Player/iOS/PlayerPresentationRestoration.swift index d97a75100..7f18cfa16 100644 --- a/iosApp/iosApp/Screens/Player/iOS/PlayerPresentationRestoration.swift +++ b/iosApp/iosApp/Screens/Player/iOS/PlayerPresentationRestoration.swift @@ -48,6 +48,12 @@ enum PlayerPresentationRestoration { logger.error("No player presentation owner available for a PiP restore") return false } + // The user engaged a TV while PiP was up (or the session reconnected + // underneath it): the phone player must not come back over it. + if presenter.isRemotePlaybackEngaged?() == true { + logger.info("Skipping PiP restore: a TV is engaged") + return false + } pendingAdoption = (viewModel, payload.contentId) // A fresh identity is what makes `fullScreenCover(item:)` re-present // even if the router is still holding the outgoing payload.