ios: add share extension to queue and play video links

Embed an iOS and Catalyst share extension using existing discovery and player APIs. Share saved players through an App Group snapshot, preserve the queue, and verify advancing playback before reporting success.

Cover URL provider transport, settings migration, discovery addresses, duplicate handling, cancellation, and partial enqueue/play outcomes with focused tests. Document authentication and signing limits.
This commit is contained in:
2026-10-20 22:08:12 -07:00
parent ec99065cdf
commit 8723955705
26 changed files with 2104 additions and 241 deletions
@@ -5,7 +5,6 @@
// Created by James Magahern on 6/10/25.
//
import Network
import SwiftUI
struct AddServerView: View
@@ -60,14 +59,18 @@ struct AddServerView: View
// Discovered
Section(.discovered) {
if model.discoveredServers.isEmpty {
if let message = model.resolutionError ?? model.discovery.errorMessage {
Text(message)
.foregroundStyle(.red)
}
if model.discovery.servers.isEmpty {
HStack {
ProgressView()
.progressViewStyle(.circular)
Text(.findingServers)
}
} else {
List(model.discoveredServers) { (server: DiscoveredEndpoint) in
List(model.discovery.servers) { (server: DiscoveredEndpoint) in
Button {
resolveEndpoint(server)
} label: {
@@ -90,23 +93,30 @@ struct AddServerView: View
}
.task {
model.startDiscovery()
model.discovery.start()
}
.onDisappear { model.discovery.stop() }
}
private func resolveEndpoint(_ endpoint: DiscoveredEndpoint) {
Task {
model.resolvingServers.insert(endpoint)
let server = try await endpoint.resolve()
onAddServer(server)
model.resolvingServers.remove(endpoint)
model.resolutionError = nil
defer { model.resolvingServers.remove(endpoint) }
do {
let server = try await endpoint.resolve()
onAddServer(server)
} catch is CancellationError {
// The view or its caller no longer needs this result.
} catch {
model.resolutionError = error.localizedDescription
}
}
}
// MARK: - Types
@MainActor
@Observable
class ViewModel
{
@@ -114,10 +124,9 @@ struct AddServerView: View
var validationURL: String = ""
var validationState: ValidationState = .empty
var discoveredServers: [DiscoveredEndpoint] = []
let discovery = ServerDiscovery()
var resolvingServers = Set<DiscoveredEndpoint>()
private let browser = NWBrowser(for: .bonjour(type: "_queuecube._tcp.", domain: "local."), using: .tcp)
var resolutionError: String?
private var validationTimer: Timer? = nil
@@ -125,21 +134,6 @@ struct AddServerView: View
observeForValidation()
}
public func startDiscovery() {
browser.browseResultsChangedHandler = { [weak self] results, changes in
guard let self else { return }
self.discoveredServers = results.map { DiscoveredEndpoint(result: $0) }
}
browser.stateUpdateHandler = { state in
if case .failed(let error) = state {
print("Discovery error: \(error)")
}
}
browser.start(queue: .global(qos: .userInitiated))
}
private func observeForValidation() {
withObservationTracking {
_ = serverURL
@@ -156,7 +150,9 @@ struct AddServerView: View
self.validationURL = self.serverURL
self.validationTimer?.invalidate()
self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
self?.validateSettings()
Task { @MainActor [weak self] in
self?.validateSettings()
}
}
}
@@ -204,77 +200,3 @@ struct AddServerView: View
}
}
}
struct DiscoveredEndpoint: Identifiable, Hashable
{
let endpoint: NWEndpoint
let serviceName: String
var displayName: String {
serviceName.queueCubeServiceName
}
var id: String { serviceName }
init(result: NWBrowser.Result) {
self.endpoint = result.endpoint
switch result.endpoint {
case .service(name: let name, type: _, domain: _, interface: _):
self.serviceName = name
default:
self.serviceName = "(Unknown)"
break
}
}
func resolve() async throws -> Server {
return try await withCheckedThrowingContinuation { continuation in
let connection = NWConnection(to: endpoint, using: .tcp)
connection.stateUpdateHandler = { state in
switch state {
case .preparing: break
case .ready:
// xxx: is this really the right way to do this? Maybe we should not try to turn this into a URL.
if case .hostPort(host: let host, port: let port) = connection.currentPath?.remoteEndpoint {
let address = switch host {
case .name(let string, _): string
case .ipv4(let iPv4Address): iPv4Address.debugDescription
case .ipv6(let iPv6Address): iPv6Address.debugDescription
default: "unknown"
}
if let server = Server(serviceName: serviceName, host: address, port: port.rawValue) {
continuation.resume(returning: server)
} else {
continuation.resume(throwing: Self.Error.urlError)
}
} else {
continuation.resume(throwing: Self.Error.endpointIncorrect)
}
connection.cancel()
case .cancelled:
// expected
break
case .failed(let error):
continuation.resume(throwing: error)
connection.cancel()
default:
break
}
}
connection.start(queue: .global(qos: .userInitiated))
}
}
// MARK: - Types
enum Error: Swift.Error
{
case cancelledConnection
case endpointIncorrect
case urlError
}
}