diff --git a/README.md b/README.md index db0ba0e..c5f3342 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ QueueCube is a web frontend for [mpv](https://github.com/mpv-player/mpv). It allows you to remotely manage mpv's playqueue as well as the ability to search YouTube via a locally running [Invidious](https://github.com/iv-org/invidious) instance. +## Sharing video links + +The iOS and Mac Catalyst app includes a QueueCube share extension. Share one +video link, choose a player, and tap **Queue & Play** to append and play it while +keeping the existing queue. See [share extension setup, API behavior, and validation](ios/SHARE_EXTENSION.md). + ## iOS TestFlight releases Gitea Actions builds and uploads the iOS app when a version tag is pushed. The @@ -21,7 +27,8 @@ Configure these Gitea Actions secrets before the first run: For local Fastlane use, copy `.env.example` to `.env` and fill in the same values. Run `bundle exec fastlane ios setup_signing` only when the match signing -assets need to be created or updated. `bundle exec fastlane ios build` creates +assets need to be created or updated. The app and share extension require separate +profiles with the shared App Group enabled; see the share extension guide. `bundle exec fastlane ios build` creates a signed IPA without uploading it, while `bundle exec fastlane ios beta` performs the release. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 1e36fee..ddac967 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -6,8 +6,10 @@ require "tempfile" default_platform(:ios) BUNDLE_IDENTIFIER = "net.buzzert.QueueCube" +SHARE_BUNDLE_IDENTIFIER = "net.buzzert.QueueCube.Share" DEVELOPMENT_TEAM = "DQQH5H6GBD" PROVISIONING_PROFILE_NAME = "QueueCube AppStore CI" +SHARE_PROVISIONING_PROFILE_NAME = "QueueCube Share AppStore CI" CI_KEYCHAIN_NAME = "queuecube_ci_keychain" CI_KEYCHAIN_PASSWORD = "queuecube-ci-keychain-password" CI_KEYCHAIN_DB_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}-db") @@ -108,6 +110,8 @@ def upload_export_options #{BUNDLE_IDENTIFIER} #{PROVISIONING_PROFILE_NAME} + #{SHARE_BUNDLE_IDENTIFIER} + #{SHARE_PROVISIONING_PROFILE_NAME} manageAppVersionAndBuildNumber @@ -156,18 +160,23 @@ platform :ios do end private_lane :sync_signing do |options| - match( - type: "appstore", - readonly: options.fetch(:readonly), - app_identifier: BUNDLE_IDENTIFIER, - team_id: DEVELOPMENT_TEAM, - profile_name: PROVISIONING_PROFILE_NAME, - git_url: ENV.fetch("MATCH_GIT_URL"), - git_branch: ENV.fetch("MATCH_GIT_BRANCH", "master"), - git_full_name: "QueueCube Release Bot", - git_user_email: "james.magahern@me.com", - api_key: options.fetch(:api_key) - ) + { + BUNDLE_IDENTIFIER => PROVISIONING_PROFILE_NAME, + SHARE_BUNDLE_IDENTIFIER => SHARE_PROVISIONING_PROFILE_NAME + }.each do |identifier, profile_name| + match( + type: "appstore", + readonly: options.fetch(:readonly), + app_identifier: identifier, + team_id: DEVELOPMENT_TEAM, + profile_name: profile_name, + git_url: ENV.fetch("MATCH_GIT_URL"), + git_branch: ENV.fetch("MATCH_GIT_BRANCH", "master"), + git_full_name: "QueueCube Release Bot", + git_user_email: "james.magahern@me.com", + api_key: options.fetch(:api_key) + ) + end end desc "Create or update match signing assets" @@ -200,12 +209,12 @@ platform :ios do "clean", "archive", "DEVELOPMENT_TEAM=#{DEVELOPMENT_TEAM}", - "PRODUCT_BUNDLE_IDENTIFIER=#{BUNDLE_IDENTIFIER}", "MARKETING_VERSION=#{version}", "CURRENT_PROJECT_VERSION=#{build}", "CODE_SIGN_STYLE=Manual", "CODE_SIGN_IDENTITY=Apple Distribution", - "PROVISIONING_PROFILE_SPECIFIER=#{PROVISIONING_PROFILE_NAME}" + "QUEUECUBE_APP_PROFILE=#{PROVISIONING_PROFILE_NAME}", + "QUEUECUBE_SHARE_PROFILE=#{SHARE_PROVISIONING_PROFILE_NAME}" )) end diff --git a/ios/CLAUDE.md b/ios/CLAUDE.md index 01ffb9d..ef364a6 100644 --- a/ios/CLAUDE.md +++ b/ios/CLAUDE.md @@ -13,6 +13,9 @@ QueueCube is a SwiftUI-based jukebox client application for iOS and macOS (via M - **API.swift**: Central networking layer that handles all communication with the jukebox server. Includes REST API methods for playback control, playlist management, and WebSocket events for real-time updates. - **ContentView.swift**: Main view controller containing the `MainViewModel` that coordinates between UI components and API calls. Handles WebSocket event processing and data flow. - **Server.swift**: Represents individual jukebox servers with support for both manual configuration and Bonjour service discovery. +- **ServerDiscovery.swift**: Shared Bonjour browsing and bounded, cancellation-safe endpoint resolution for the app and share extension. +- **RequestBuilder.swift**: Shared extension-safe HTTP builder and error handling. +- **SharePlayback.swift**: Append once, select the new queue item, unpause, and verify playback using existing endpoints. - **Settings.swift**: Manages multiple server configurations stored in UserDefaults with validation through `SettingsViewModel` that tests connectivity on URL changes. ### Data Flow @@ -64,7 +67,8 @@ The server API includes these endpoints: - `GET /playlist` - Current playlist items - `GET /favorites` - User favorites - `POST /play`, `/pause`, `/skip`, `/previous` - Playback controls -- `POST /playlist` - Add media URL to playlist +- `POST /playlist` - Append media URL (append-play starts only when idle) +- `POST /skip/{index}` - Select and play a zero-based playlist index; existing queue remains - `DELETE /playlist/{index}` - Remove playlist item - `POST /volume` - Set volume level - `WS /events` - WebSocket for real-time updates @@ -90,4 +94,10 @@ QueueCubeApp - **PlaylistView**: Scrollable list of queued media with reorder/delete actions, includes embedded NowPlayingView - **NowPlayingView**: Playback controls and current track display - **AddMediaBarView**: Input field for adding new media URLs to playlist -- **SettingsView**: Multi-server configuration with live validation and service discovery \ No newline at end of file +- **SettingsView**: Multi-server configuration with live validation and service discovery + +## Share extension + +`QueueCubeShare` is embedded in iOS and Mac Catalyst builds. It reads an App Group +settings snapshot and reuses the API/discovery code. See [SHARE_EXTENSION.md](SHARE_EXTENSION.md) +for authentication limitations, queue/play failure semantics, signing setup, and focused tests. diff --git a/ios/Package.swift b/ios/Package.swift new file mode 100644 index 0000000..1319604 --- /dev/null +++ b/ios/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// A small macOS test harness for the production networking and share logic. +// The app and extension continue to build through QueueCube.xcodeproj. +let package = Package( + name: "QueueCubeShareCore", + platforms: [.macOS(.v14)], + targets: [ + .target( + name: "QueueCubeShareCore", + path: "QueueCube/Backend", + exclude: ["Utilities.swift", "ServerDiscovery.swift"], + sources: [ + "API.swift", "RequestBuilder.swift", "Server.swift", "Settings.swift", + "SharedVideo.swift", "SharePlayback.swift", "SharedInput.swift" + ], + swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")] + ), + .testTarget(name: "QueueCubeShareCoreTests", dependencies: ["QueueCubeShareCore"]) + ] +) diff --git a/ios/QueueCube.xcodeproj/project.pbxproj b/ios/QueueCube.xcodeproj/project.pbxproj index b4c3988..52799d2 100644 --- a/ios/QueueCube.xcodeproj/project.pbxproj +++ b/ios/QueueCube.xcodeproj/project.pbxproj @@ -6,8 +6,53 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + CD2026102000000000000200 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000100 /* API.swift */; }; + CD2026102000000000000201 /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000101 /* RequestBuilder.swift */; }; + CD2026102000000000000202 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000102 /* Server.swift */; }; + CD2026102000000000000203 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000103 /* Settings.swift */; }; + CD2026102000000000000204 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000104 /* ServerDiscovery.swift */; }; + CD2026102000000000000205 /* SharedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000105 /* SharedVideo.swift */; }; + CD2026102000000000000206 /* SharePlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000106 /* SharePlayback.swift */; }; + CD2026102000000000000207 /* SharedInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000107 /* SharedInput.swift */; }; + CD2026102000000000000001 /* QueueCubeShare.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CD2026102000000000000002 /* QueueCubeShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + CD2026102000000000000003 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CD4E9B8F2D7691C20066FC17 /* Project object */; + proxyType = 1; + remoteGlobalIDString = CD2026102000000000000004; + remoteInfo = QueueCubeShare; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + CD2026102000000000000005 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + CD2026102000000000000001 /* QueueCubeShare.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ CD4E9B972D7691C20066FC17 /* QueueCube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QueueCube.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CD2026102000000000000002 /* QueueCubeShare.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QueueCubeShare.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + CD2026102000000000000100 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; + CD2026102000000000000101 /* RequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestBuilder.swift; sourceTree = ""; }; + CD2026102000000000000102 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; + CD2026102000000000000103 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; + CD2026102000000000000104 /* ServerDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; + CD2026102000000000000105 /* SharedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedVideo.swift; sourceTree = ""; }; + CD2026102000000000000106 /* SharePlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePlayback.swift; sourceTree = ""; }; + CD2026102000000000000107 /* SharedInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedInput.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -19,6 +64,14 @@ ); target = CD4E9B962D7691C20066FC17 /* QueueCube */; }; + CD2026102000000000000006 /* Exceptions for "QueueCubeShare" folder in "QueueCubeShare" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Entitlements.plist, + Info.plist, + ); + target = CD2026102000000000000004 /* QueueCubeShare */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -30,6 +83,14 @@ path = QueueCube; sourceTree = ""; }; + CD2026102000000000000007 /* QueueCubeShare */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + CD2026102000000000000006 /* Exceptions for "QueueCubeShare" folder in "QueueCubeShare" target */, + ); + path = QueueCubeShare; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -40,6 +101,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CD2026102000000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -47,6 +115,8 @@ isa = PBXGroup; children = ( CD4E9B992D7691C20066FC17 /* QueueCube */, + CD2026102000000000000007 /* QueueCubeShare */, + CD2026102000000000000009 /* Shared Extension Sources */, CD4E9B982D7691C20066FC17 /* Products */, ); sourceTree = ""; @@ -55,10 +125,27 @@ isa = PBXGroup; children = ( CD4E9B972D7691C20066FC17 /* QueueCube.app */, + CD2026102000000000000002 /* QueueCubeShare.appex */, ); name = Products; sourceTree = ""; }; + CD2026102000000000000009 /* Shared Extension Sources */ = { + isa = PBXGroup; + children = ( + CD2026102000000000000100 /* API.swift */, + CD2026102000000000000101 /* RequestBuilder.swift */, + CD2026102000000000000102 /* Server.swift */, + CD2026102000000000000103 /* Settings.swift */, + CD2026102000000000000104 /* ServerDiscovery.swift */, + CD2026102000000000000105 /* SharedVideo.swift */, + CD2026102000000000000106 /* SharePlayback.swift */, + CD2026102000000000000107 /* SharedInput.swift */, + ); + name = "Shared Extension Sources"; + path = QueueCube/Backend; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -69,10 +156,12 @@ CD4E9B932D7691C20066FC17 /* Sources */, CD4E9B942D7691C20066FC17 /* Frameworks */, CD4E9B952D7691C20066FC17 /* Resources */, + CD2026102000000000000005 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + CD202610200000000000000A /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( CD4E9B992D7691C20066FC17 /* QueueCube */, @@ -84,6 +173,28 @@ productReference = CD4E9B972D7691C20066FC17 /* QueueCube.app */; productType = "com.apple.product-type.application"; }; + CD2026102000000000000004 /* QueueCubeShare */ = { + isa = PBXNativeTarget; + buildConfigurationList = CD202610200000000000000B /* Build configuration list for PBXNativeTarget "QueueCubeShare" */; + buildPhases = ( + CD202610200000000000000C /* Sources */, + CD2026102000000000000008 /* Frameworks */, + CD202610200000000000000D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + CD2026102000000000000007 /* QueueCubeShare */, + ); + name = QueueCubeShare; + packageProductDependencies = ( + ); + productName = QueueCubeShare; + productReference = CD2026102000000000000002 /* QueueCubeShare.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -94,8 +205,17 @@ LastSwiftUpdateCheck = 1700; LastUpgradeCheck = 1700; TargetAttributes = { + CD2026102000000000000004 = { + CreatedOnToolsVersion = 26.5; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = {enabled = 1; }; + }; + }; CD4E9B962D7691C20066FC17 = { CreatedOnToolsVersion = 17.0; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = {enabled = 1; }; + }; }; }; }; @@ -114,6 +234,7 @@ projectRoot = ""; targets = ( CD4E9B962D7691C20066FC17 /* QueueCube */, + CD2026102000000000000004 /* QueueCubeShare */, ); }; /* End PBXProject section */ @@ -126,6 +247,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CD202610200000000000000D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -136,8 +264,31 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CD202610200000000000000C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CD2026102000000000000200 /* API.swift in Sources */, + CD2026102000000000000201 /* RequestBuilder.swift in Sources */, + CD2026102000000000000202 /* Server.swift in Sources */, + CD2026102000000000000203 /* Settings.swift in Sources */, + CD2026102000000000000204 /* ServerDiscovery.swift in Sources */, + CD2026102000000000000205 /* SharedVideo.swift in Sources */, + CD2026102000000000000206 /* SharePlayback.swift in Sources */, + CD2026102000000000000207 /* SharedInput.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + CD202610200000000000000A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CD2026102000000000000004 /* QueueCubeShare */; + targetProxy = CD2026102000000000000003 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ CD4E9BA02D7691C40066FC17 /* Debug */ = { isa = XCBuildConfiguration; @@ -286,6 +437,7 @@ ); MARKETING_VERSION = 1.5.2; PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube; + PROVISIONING_PROFILE_SPECIFIER = "$(QUEUECUBE_APP_PROFILE)"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; @@ -322,6 +474,7 @@ ); MARKETING_VERSION = 1.5.2; PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube; + PROVISIONING_PROFILE_SPECIFIER = "$(QUEUECUBE_APP_PROFILE)"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; @@ -332,6 +485,76 @@ }; name = Release; }; + CD202610200000000000000E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = QueueCubeShare/Entitlements.plist; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 13; + DEVELOPMENT_TEAM = DQQH5H6GBD; + ENABLE_APP_SANDBOX = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = QueueCubeShare/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = QueueCube; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.5.2; + PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube.Share; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(QUEUECUBE_SHARE_PROFILE)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,6"; + }; + name = Debug; + }; + CD202610200000000000000F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = QueueCubeShare/Entitlements.plist; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 13; + DEVELOPMENT_TEAM = DQQH5H6GBD; + ENABLE_APP_SANDBOX = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = QueueCubeShare/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = QueueCube; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.5.2; + PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube.Share; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(QUEUECUBE_SHARE_PROFILE)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,6"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -353,6 +576,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CD202610200000000000000B /* Build configuration list for PBXNativeTarget "QueueCubeShare" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CD202610200000000000000E /* Debug */, + CD202610200000000000000F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = CD4E9B8F2D7691C20066FC17 /* Project object */; diff --git a/ios/QueueCube/App/Entitlements.plist b/ios/QueueCube/App/Entitlements.plist index 0c67376..f1bd3e7 100644 --- a/ios/QueueCube/App/Entitlements.plist +++ b/ios/QueueCube/App/Entitlements.plist @@ -1,5 +1,10 @@ - + + com.apple.security.application-groups + + group.net.buzzert.QueueCube + + diff --git a/ios/QueueCube/Backend/API.swift b/ios/QueueCube/Backend/API.swift index ce250f0..51c13a5 100644 --- a/ios/QueueCube/Backend/API.swift +++ b/ios/QueueCube/Backend/API.swift @@ -70,11 +70,13 @@ struct NowPlayingInfo: Codable actor API { let baseURL: URL + private let requestTimeout: TimeInterval private var pingTask: Task<(), any Swift.Error>? = nil - init(baseURL: URL) { + init(baseURL: URL, requestTimeout: TimeInterval = 60) { self.baseURL = baseURL + self.requestTimeout = requestTimeout } public func fetchNowPlayingInfo() async throws -> NowPlayingInfo { @@ -260,7 +262,7 @@ actor API } private func request() -> RequestBuilder { - RequestBuilder(url: baseURL) + RequestBuilder(url: baseURL, timeout: requestTimeout) } // MARK: - Types diff --git a/ios/QueueCube/Backend/RequestBuilder.swift b/ios/QueueCube/Backend/RequestBuilder.swift new file mode 100644 index 0000000..d337e9a --- /dev/null +++ b/ios/QueueCube/Backend/RequestBuilder.swift @@ -0,0 +1,114 @@ +import Foundation + +struct RequestBuilder +{ + let url: URL + private var timeout: TimeInterval + private var httpMethod: HTTPMethod = .get + private var body: Data? = nil + + init(url: URL, timeout: TimeInterval = 60) { + self.url = url + self.timeout = timeout + } + + public func method(_ method: HTTPMethod) -> Self { + var copy = self + copy.httpMethod = method + return copy + } + + public func path(_ path: any StringProtocol) -> Self { + return RequestBuilder(url: self.url.appending(path: path), timeout: timeout) + } + + public func pathString(_ pathString: any StringProtocol) -> Self { + // xxx: should just fix DELETE /favorites/:filename: instead. + return RequestBuilder(url: URL(string: self.url.absoluteString + pathString)!, timeout: timeout) + } + + public func body(_ data: Codable) -> Self { + var copy = self + copy.body = try! JSONEncoder().encode(data) + return copy + } + + public func build() -> URLRequest { + var request = URLRequest(url: self.url, cachePolicy: .reloadIgnoringLocalCacheData) + request.timeoutInterval = timeout + request.httpMethod = self.httpMethod.rawValue + request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = body + } + + return request + } + + public func json() async throws -> T { + let data = try await responseData() + return try JSONDecoder().decode(T.self, from: data) + } + + public func post() async throws { + try await self.method(.post).execute() + } + + public func execute() async throws { + _ = try await responseData() + } + + private func responseData() async throws -> Data { + let (data, response) = try await URLSession.shared.data(for: build()) + guard let httpResponse = response as? HTTPURLResponse else { + throw RequestError.invalidResponse + } + + guard (200..<300).contains(httpResponse.statusCode) else { + let serverError = try? JSONDecoder().decode(ServerErrorResponse.self, from: data) + throw RequestError.httpError( + statusCode: httpResponse.statusCode, + message: serverError?.error + ) + } + + return data + } + + public func websocket() -> URL { + guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { fatalError() } + components.scheme = components.scheme == "https" ? "wss" : "ws" + components.host = components.host!.replacing(/\%(.*)$/, with: "") + return components.url! + } + + enum HTTPMethod: String { + case get = "GET" + case put = "PUT" + case post = "POST" + case delete = "DELETE" + } + + private struct ServerErrorResponse: Decodable { + let error: String? + } + + enum RequestError: Swift.Error, LocalizedError { + case invalidResponse + case httpError(statusCode: Int, message: String?) + + var errorDescription: String? { + switch self { + case .invalidResponse: + "The server returned an invalid response." + case .httpError(let statusCode, let message): + if let message, !message.isEmpty { + "Server error (\(statusCode)): \(message)" + } else { + "Server error (\(statusCode)): \(HTTPURLResponse.localizedString(forStatusCode: statusCode))" + } + } + } + } +} diff --git a/ios/QueueCube/Backend/Server.swift b/ios/QueueCube/Backend/Server.swift index 6260524..ddfbc3b 100644 --- a/ios/QueueCube/Backend/Server.swift +++ b/ios/QueueCube/Backend/Server.swift @@ -6,6 +6,7 @@ // import Foundation +import Network struct Server: Identifiable, Codable, Equatable { @@ -28,10 +29,22 @@ struct Server: Identifiable, Codable, Equatable init?(serviceName: String?, host: String, port: UInt16) { self.serviceName = serviceName - // Assumes this is the local service discovery path, which is http - // Bounjour gives us the interface sometimes, which we can handle, but need to percent encode. - let host = host.replacingOccurrences(of: "%", with: "%25") - guard let url = URL(string: "http://\(host):\(port)/api") else { + // Network.framework can append an interface to IPv4 and IPv6 addresses. + // URL hosts only support that scope on IPv6 literals. + var host = host + if let scope = host.firstIndex(of: "%"), + IPv4Address(String(host[.. Server { + try await EndpointResolver(endpoint: endpoint, serviceName: serviceName).resolve() + } + + enum Error: Swift.Error, LocalizedError { + case timedOut + case endpointIncorrect + case urlError + + var errorDescription: String? { + switch self { + case .timedOut: + "The player did not respond. Check that it is online and try again." + case .endpointIncorrect, .urlError: + "The player did not provide a usable network address." + } + } + } +} + +// Keep connection callbacks, cancellation, and timeout completion on one actor so +// a connection that becomes ready during cancellation never resumes twice. +@MainActor +private final class EndpointResolver +{ + private let connection: NWConnection + private let serviceName: String + private var continuation: CheckedContinuation? + private var timeoutTask: Task? + + init(endpoint: NWEndpoint, serviceName: String) { + connection = NWConnection(to: endpoint, using: .tcp) + self.serviceName = serviceName + } + + func resolve() async throws -> Server { + try Task.checkCancellation() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + connection.stateUpdateHandler = { [weak self] state in + Task { @MainActor in self?.handle(state) } + } + timeoutTask = Task { [weak self] in + do { + try await Task.sleep(for: .seconds(8)) + } catch { + return + } + self?.finish(.failure(DiscoveredEndpoint.Error.timedOut)) + } + connection.start(queue: .main) + } + } onCancel: { + Task { @MainActor in self.finish(.failure(CancellationError())) } + } + } + + private func handle(_ state: NWConnection.State) { + switch state { + case .ready: + guard case .hostPort(host: let host, port: let port) = connection.currentPath?.remoteEndpoint else { + finish(.failure(DiscoveredEndpoint.Error.endpointIncorrect)) + return + } + let address: String + switch host { + case .name(let name, _): address = name + case .ipv4(let ip): address = ip.debugDescription + case .ipv6(let ip): address = ip.debugDescription + @unknown default: + finish(.failure(DiscoveredEndpoint.Error.endpointIncorrect)) + return + } + guard let server = Server(serviceName: serviceName, host: address, port: port.rawValue) else { + finish(.failure(DiscoveredEndpoint.Error.urlError)) + return + } + finish(.success(server)) + case .failed(let error): + finish(.failure(error)) + case .cancelled: + finish(.failure(CancellationError())) + default: + break + } + } + + private func finish(_ result: Result) { + guard let continuation else { return } + self.continuation = nil + timeoutTask?.cancel() + timeoutTask = nil + connection.stateUpdateHandler = nil + connection.cancel() + continuation.resume(with: result) + } +} diff --git a/ios/QueueCube/Backend/Settings.swift b/ios/QueueCube/Backend/Settings.swift index 2e31a8b..e851d61 100644 --- a/ios/QueueCube/Backend/Settings.swift +++ b/ios/QueueCube/Backend/Settings.swift @@ -7,7 +7,7 @@ import Foundation -struct Settings +struct Settings: Codable { var selectedServer: Server? @@ -27,22 +27,44 @@ struct Settings var isConfigured: Bool { !configuredServers.isEmpty } - static func fromDefaults() -> Settings { - let defaults = UserDefaults.standard - return Settings( + static func fromDefaults(defaults: UserDefaults = .standard, + sharedDefaults: UserDefaults? = UserDefaults(suiteName: appGroupIdentifier)) -> Settings { + let settings = Settings( selectedServer: defaults[SelectedServerKey.self], configuredServers: defaults[ConfiguredServersKey.self] ) + settings.publishForSharing(defaults: sharedDefaults) + return settings } - func save() { - let defaults = UserDefaults.standard + func save(defaults: UserDefaults = .standard, + sharedDefaults: UserDefaults? = UserDefaults(suiteName: Self.appGroupIdentifier)) { defaults[ConfiguredServersKey.self] = configuredServers defaults[SelectedServerKey.self] = selectedServer + publishForSharing(defaults: sharedDefaults) postSettingsChanged() } + // The containing app remains the owner of settings. Publish one snapshot so + // the extension never observes a selected player from a different revision. + static let appGroupIdentifier = "group.net.buzzert.QueueCube" + private static let sharedSettingsKey = "shareSettings.v1" + + static func fromSharedDefaults(defaults: UserDefaults? = UserDefaults(suiteName: appGroupIdentifier)) -> Settings { + guard let defaults, + let data = defaults.data(forKey: sharedSettingsKey), + let settings = try? PropertyListDecoder().decode(Settings.self, from: data) + else { return Settings(selectedServer: nil, configuredServers: []) } + return settings + } + + private func publishForSharing(defaults: UserDefaults?) { + guard let defaults, + let data = try? PropertyListEncoder().encode(self) else { return } + defaults.set(data, forKey: Self.sharedSettingsKey) + } + func postSettingsChanged() { NotificationCenter.default.post(name: .settingsChanged, object: nil) } diff --git a/ios/QueueCube/Backend/SharePlayback.swift b/ios/QueueCube/Backend/SharePlayback.swift new file mode 100644 index 0000000..52cf805 --- /dev/null +++ b/ios/QueueCube/Backend/SharePlayback.swift @@ -0,0 +1,104 @@ +import Foundation + +protocol SharePlayerAPI: Sendable { + func fetchPlaylist() async throws -> [MediaItem] + func add(mediaURL: String) async throws + func skip(_ to: Int?) async throws + func play() async throws + func fetchNowPlayingInfo() async throws -> NowPlayingInfo +} + +extension API: SharePlayerAPI {} + +enum SharePlayback { + struct Failure: Swift.Error, LocalizedError { + let message: String + let canRetry: Bool + var errorDescription: String? { message } + } + + // Existing servers can acknowledge append before mpv has updated its queue. + // Poll reads only; never repeat a mutation after an ambiguous response. + static func enqueueAndPlay(_ url: URL, using api: any SharePlayerAPI, + attempts: Int = 80, + pollInterval: Duration = .milliseconds(250)) async throws { + let previousIDs: Set + do { + _ = try SharedVideo.validate(url) + previousIDs = Set(try await api.fetchPlaylist().map(\.id)) + try Task.checkCancellation() + } catch { + throw Failure(message: "Could not connect to the player. No link was added. Check that QueueCube has local network access and the player is online.", canRetry: true) + } + + var queued = false + let deadline = ContinuousClock.now + .seconds(20) + do { + try await api.add(mediaURL: url.absoluteString) + var addedID: Int? + for attempt in 0..= 0 { + if let previousPosition, position > previousPosition { return } + previousPosition = position + } else { + previousPosition = nil + } + if attempt + 1 < attempts { try await Task.sleep(for: pollInterval) } + } + throw VerificationError.playbackFailed + } catch { + let message = queued + ? "The link was queued, but playback could not be confirmed. Open QueueCube to check the player before sharing again." + : "The player did not confirm the request. The link may already be queued. Open QueueCube to check before sharing again." + throw Failure(message: message, canRetry: false) + } + } + + private enum VerificationError: Swift.Error { + case itemMissing, ambiguousItem, playbackFailed + } +} diff --git a/ios/QueueCube/Backend/SharedInput.swift b/ios/QueueCube/Backend/SharedInput.swift new file mode 100644 index 0000000..9123cb6 --- /dev/null +++ b/ios/QueueCube/Backend/SharedInput.swift @@ -0,0 +1,60 @@ +import Foundation +import UniformTypeIdentifiers + +enum SharedInput { + static func load(_ items: [Any]) async throws -> URL { + let extensionItems = items.compactMap { $0 as? NSExtensionItem } + let providers = extensionItems.flatMap { $0.attachments ?? [] } + // Safari and video apps often also provide a title as text. Prefer their + // explicit URL attachments, so a title isn't mistaken for another link. + let urlProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) } + var urls: [URL] = [] + if !urlProviders.isEmpty { + for provider in urlProviders { + let item = try await provider.loadItem(forTypeIdentifier: UTType.url.identifier) + try Task.checkCancellation() + if let url = item as? URL { urls.append(try SharedVideo.validate(url)) } + else if let text = (item as? String) ?? (item as? Data).flatMap(urlText) { + urls.append(try SharedVideo.extract(from: text)) + } + else { throw SharedVideo.Error.noLink } + } + } else { + for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + let item = try await provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) + try Task.checkCancellation() + let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } + if let text { + do { urls.append(try SharedVideo.extract(from: text)) } + catch SharedVideo.Error.noLink { continue } + } + } + if urls.isEmpty { + for item in extensionItems { + if let text = item.attributedContentText?.string { + do { urls.append(try SharedVideo.extract(from: text)) } + catch SharedVideo.Error.noLink { continue } + } + } + } + } + return try SharedVideo.single(urls) + } + + private static func urlText(_ data: Data) -> String? { + // Some binary plists also happen to be valid UTF-8. Recognize their + // header first so malformed structured data cannot become plain text. + if !data.starts(with: Data("bplist00".utf8)) { + return String(data: data, encoding: .utf8) + } + // UIKit can transport public.url as an NSURL property list: + // [relative string, base string, properties]. Accept only the observed + // absolute-URL form; never read a file or decode an arbitrary archive. + guard let parts = (try? PropertyListSerialization.propertyList(from: data, options: [], format: nil)) as? [Any], + parts.count == 3, + let text = parts[0] as? String, + let base = parts[1] as? String, base.isEmpty, + let properties = parts[2] as? [String: Any], properties.isEmpty else { return nil } + return text + } +} diff --git a/ios/QueueCube/Backend/SharedVideo.swift b/ios/QueueCube/Backend/SharedVideo.swift new file mode 100644 index 0000000..614700f --- /dev/null +++ b/ios/QueueCube/Backend/SharedVideo.swift @@ -0,0 +1,38 @@ +import Foundation + +enum SharedVideo { + enum Error: Swift.Error, LocalizedError { + case noLink + case multipleLinks + + var errorDescription: String? { + switch self { + case .noLink: "Share a single http or https video link with QueueCube." + case .multipleLinks: "Share one video link at a time." + } + } + } + + static func validate(_ url: URL) throws -> URL { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + ["http", "https"].contains(components.scheme?.lowercased() ?? ""), + let host = components.host, !host.isEmpty, + components.user == nil, components.password == nil else { throw Error.noLink } + return url + } + + static func extract(from text: String) throws -> URL { + let detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + let urls = detector.matches(in: text, range: NSRange(text.startIndex..., in: text)) + .compactMap { $0.url } + .compactMap { try? validate($0) } + return try single(urls) + } + + static func single(_ urls: [URL]) throws -> URL { + let unique = Set(urls) + guard unique.count <= 1 else { throw Error.multipleLinks } + guard let url = unique.first else { throw Error.noLink } + return try validate(url) + } +} diff --git a/ios/QueueCube/Backend/Utilities.swift b/ios/QueueCube/Backend/Utilities.swift index 6d0d49c..cfe7838 100644 --- a/ios/QueueCube/Backend/Utilities.swift +++ b/ios/QueueCube/Backend/Utilities.swift @@ -18,115 +18,6 @@ extension Optional struct UnwrapError: Swift.Error {} } -struct RequestBuilder -{ - let url: URL - private var httpMethod: HTTPMethod = .get - private var body: Data? = nil - - init(url: URL) { - self.url = url - } - - public func method(_ method: HTTPMethod) -> Self { - var copy = self - copy.httpMethod = method - return copy - } - - public func path(_ path: any StringProtocol) -> Self { - return RequestBuilder(url: self.url.appending(path: path)) - } - - public func pathString(_ pathString: any StringProtocol) -> Self { - // xxx: should just fix DELETE /favorites/:filename: instead. - return RequestBuilder(url: URL(string: self.url.absoluteString + pathString)!) - } - - public func body(_ data: Codable) -> Self { - var copy = self - copy.body = try! JSONEncoder().encode(data) - return copy - } - - public func build() -> URLRequest { - var request = URLRequest(url: self.url, cachePolicy: .reloadIgnoringLocalCacheData) - request.httpMethod = self.httpMethod.rawValue - request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") - if let body { - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = body - } - - return request - } - - public func json() async throws -> T { - let data = try await responseData() - return try JSONDecoder().decode(T.self, from: data) - } - - public func post() async throws { - try await self.method(.post).execute() - } - - public func execute() async throws { - _ = try await responseData() - } - - private func responseData() async throws -> Data { - let (data, response) = try await URLSession.shared.data(for: build()) - guard let httpResponse = response as? HTTPURLResponse else { - throw RequestError.invalidResponse - } - - guard (200..<300).contains(httpResponse.statusCode) else { - let serverError = try? JSONDecoder().decode(ServerErrorResponse.self, from: data) - throw RequestError.httpError( - statusCode: httpResponse.statusCode, - message: serverError?.error - ) - } - - return data - } - - public func websocket() -> URL { - guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { fatalError() } - components.scheme = components.scheme == "https" ? "wss" : "ws" - components.host = components.host!.replacing(/\%(.*)$/, with: "") - return components.url! - } - - enum HTTPMethod: String { - case get = "GET" - case put = "PUT" - case post = "POST" - case delete = "DELETE" - } - - private struct ServerErrorResponse: Decodable { - let error: String? - } - - enum RequestError: Swift.Error, LocalizedError { - case invalidResponse - case httpError(statusCode: Int, message: String?) - - var errorDescription: String? { - switch self { - case .invalidResponse: - "The server returned an invalid response." - case .httpError(let statusCode, let message): - if let message, !message.isEmpty { - "Server error (\(statusCode)): \(message)" - } else { - "Server error (\(statusCode)): \(HTTPURLResponse.localizedString(forStatusCode: statusCode))" - } - } - } - } -} extension Color { diff --git a/ios/QueueCube/Views/Settings View/AddServerView.swift b/ios/QueueCube/Views/Settings View/AddServerView.swift index bc861cf..e34f2d9 100644 --- a/ios/QueueCube/Views/Settings View/AddServerView.swift +++ b/ios/QueueCube/Views/Settings View/AddServerView.swift @@ -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() - - 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 - } -} diff --git a/ios/QueueCubeShare/Entitlements.plist b/ios/QueueCubeShare/Entitlements.plist new file mode 100644 index 0000000..f1bd3e7 --- /dev/null +++ b/ios/QueueCubeShare/Entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.net.buzzert.QueueCube + + + diff --git a/ios/QueueCubeShare/Info.plist b/ios/QueueCubeShare/Info.plist new file mode 100644 index 0000000..3428e9c --- /dev/null +++ b/ios/QueueCubeShare/Info.plist @@ -0,0 +1,38 @@ + + + + + ITSAppUsesNonExemptEncryption + + NSBonjourServices + + _queuecube._tcp. + + NSLocalNetworkUsageDescription + QueueCube discovers nearby players so you can choose where to play this video. + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsWebPageWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/ios/QueueCubeShare/ShareViewController.swift b/ios/QueueCubeShare/ShareViewController.swift new file mode 100644 index 0000000..7eb91b3 --- /dev/null +++ b/ios/QueueCubeShare/ShareViewController.swift @@ -0,0 +1,229 @@ +import SwiftUI +import UIKit + +final class ShareViewController: UIViewController { + private let model = ShareViewModel() + private var inputTask: Task? + private var playbackTask: Task? + + override func viewDidLoad() { + super.viewDidLoad() + preferredContentSize = CGSize(width: 480, height: 600) + let host = UIHostingController(rootView: ShareView(model: model, send: { [weak self] in + self?.send() + }, close: { [weak self] in + self?.close() + })) + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(host.view) + NSLayoutConstraint.activate([ + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + host.view.topAnchor.constraint(equalTo: view.topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + host.didMove(toParent: self) + model.discovery.start() + inputTask = Task { [weak self] in + guard let self else { return } + do { + model.url = try await SharedInput.load(extensionContext?.inputItems ?? []) + } catch { + guard !Task.isCancelled else { return } + model.message = error.localizedDescription + } + model.loading = false + } + } + + private func send() { + guard !model.sending, !model.finished, let url = model.url, + let server = model.selectedServer else { return } + model.sending = true + model.message = nil + model.discovery.stop() + playbackTask = Task { [weak self] in + guard let self else { return } + do { + try await SharePlayback.enqueueAndPlay(url, using: API(baseURL: server.baseURL, requestTimeout: 8)) + try Task.checkCancellation() + model.succeeded = true + model.finished = true + model.message = "Queued and playing on \(server.displayName)." + } catch let error as SharePlayback.Failure { + guard !Task.isCancelled else { return } + model.message = error.localizedDescription + model.finished = !error.canRetry + if error.canRetry { model.discovery.start() } + } catch { + guard !Task.isCancelled else { return } + model.message = "The request could not be confirmed. Check the player in QueueCube before sharing again." + model.finished = true + } + model.sending = false + } + } + + private func close() { + inputTask?.cancel() + playbackTask?.cancel() + model.stop() + if model.finished { + extensionContext?.completeRequest(returningItems: nil) + } else { + extensionContext?.cancelRequest(withError: NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + inputTask?.cancel() + playbackTask?.cancel() + model.stop() + } + + deinit { + inputTask?.cancel() + playbackTask?.cancel() + } +} + +@MainActor @Observable +private final class ShareViewModel { + let discovery = ServerDiscovery() + let configuredServers: [Server] + var selectedServer: Server? + var url: URL? + var loading = true + var sending = false + var finished = false + var succeeded = false + var resolving = false + var message: String? + private var resolutionTask: Task? + + init() { + let settings = Settings.fromSharedDefaults() + configuredServers = settings.configuredServers + selectedServer = settings.selectedServer.flatMap { selected in + settings.configuredServers.first { $0.id == selected.id } + } ?? settings.configuredServers.first + } + + func select(_ endpoint: DiscoveredEndpoint) { + resolutionTask?.cancel() + resolving = true + message = nil + resolutionTask = Task { + do { + let server = try await endpoint.resolve() + try Task.checkCancellation() + selectedServer = server + } catch { + if !Task.isCancelled { + message = "Could not connect to that player. Check its connection and try again." + } + } + if !Task.isCancelled { resolving = false } + } + } + + func stop() { + resolutionTask?.cancel() + discovery.stop() + } +} + +private struct ShareView: View { + @Bindable var model: ShareViewModel + let send: () -> Void + let close: () -> Void + + var body: some View { + NavigationStack { + Form { + Section("Video link") { + if model.loading { + ProgressView("Reading link…") + } else if let url = model.url { + Text(url.absoluteString) + .font(.callout) + .lineLimit(3) + } + } + if !model.finished { + Section("Choose player") { + if model.configuredServers.isEmpty { + Text("Open QueueCube once to share your saved players, or choose a nearby player below. Allow local network access in QueueCube to reach nearby players.") + .foregroundStyle(.secondary) + } + ForEach(model.configuredServers) { server in + Button { + model.selectedServer = server + } label: { + playerLabel(server.displayName, selected: model.selectedServer?.id == server.id) + } + .disabled(model.sending || model.resolving) + } + if let selected = model.selectedServer, + !model.configuredServers.contains(where: { $0.id == selected.id }) { + playerLabel(selected.displayName, selected: true) + } + } + if !model.sending { + Section("Nearby players") { + if model.discovery.servers.isEmpty { + Text(model.discovery.errorMessage ?? "Looking for QueueCube players on your local network…") + .foregroundStyle(.secondary) + } + ForEach(model.discovery.servers) { endpoint in + Button(endpoint.displayName) { model.select(endpoint) } + .disabled(model.resolving) + } + if model.resolving { ProgressView("Connecting…") } + } + } + } + if let message = model.message { + Section { + Label(message, systemImage: model.succeeded ? "checkmark.circle.fill" : "exclamationmark.circle") + .foregroundStyle(model.succeeded ? Color.green : Color.primary) + } + } + if !model.finished { + Section { + Button(action: send) { + HStack { + Spacer() + if model.sending { ProgressView() } + Text(model.sending ? "Queuing and starting playback…" : "Queue & Play") + Spacer() + } + } + .disabled(model.url == nil || model.selectedServer == nil || model.sending || model.resolving) + Text("Adds this link to the queue and plays it now. Existing queued items are kept.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("QueueCube") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(model.finished ? "Done" : "Cancel", action: close) + .disabled(model.sending) + } + } + } + } + + private func playerLabel(_ name: String, selected: Bool) -> some View { + HStack { + Label(name, systemImage: "hifispeaker.fill") + Spacer() + if selected { Image(systemName: "checkmark").accessibilityLabel("Selected") } + } + } +} diff --git a/ios/SHARE_EXTENSION.md b/ios/SHARE_EXTENSION.md new file mode 100644 index 0000000..9f49c6a --- /dev/null +++ b/ios/SHARE_EXTENSION.md @@ -0,0 +1,102 @@ +# Share a video to QueueCube + +Open QueueCube once after updating to make saved players available to the share +extension. In Safari or a video app, share one HTTP(S) link, choose QueueCube, +select a saved or nearby player, and tap **Queue & Play**. The link is appended +and played immediately; existing queue entries remain. A successful request +shows the selected player and a Done button. Video support depends on the +player's existing mpv/yt-dlp support. + +Nearby players use the same `_queuecube._tcp.` Bonjour discovery as Add Server. +Resolved IPv4 interface suffixes are removed; scoped IPv6 addresses remain valid. +Allow local network access in the containing app first. Players can also be +configured manually in QueueCube Settings. The extension handles URL attachments +(including UTF-8 data and the absolute-URL property-list form observed from +UIKit) and plain text containing one link. URL data keeps the same single-link +validation; property lists with a nonempty base or properties dictionary and +unrecognized binary forms are rejected. +Multiple different links, files, and links +with embedded user/password information are rejected. + +## Data and permissions + +The app and `net.buzzert.QueueCube.Share` extension share +`group.net.buzzert.QueueCube`. The app retains its original UserDefaults and +publishes a single versioned settings snapshot on read/save. The extension reads +that snapshot without changing the app's selected player or server list. No +queued link, credentials store, background transfer, or keychain group is added. +The extension has Bonjour/local network declarations and the same HTTP transport +compatibility as the existing app; Catalyst adds only app sandbox and outgoing +network access. + +The repository has no application authentication, login flow, or token store. +Requests reuse the existing URLSession client and configured server URLs. +Browser login cookies and credentials cached in another process are not shared. +Servers behind an authenticated reverse proxy may fail; this change does not +introduce a new authentication protocol or bypass access controls. + +## Existing API sequence and failure behavior + +All paths below are relative to the configured API base URL (usually `/api`). +No backend deployment is required. + +| Request | Purpose | +| --- | --- | +| `GET /playlist` | Snapshot existing mpv item IDs before append. | +| `POST /playlist` with `{"url":"https://…"}` | Append once using the existing append-play operation. Starts only if idle. | +| `GET /playlist` | Poll for exactly one new ID matching the URL, then obtain its current zero-based index. | +| `POST /skip/{index}` | Select the new entry while retaining the queue. | +| `POST /play` | Clear pause, including on an already paused player. | +| `GET /nowplaying` | Confirm the new ID is current, is not paused, has no reported playback error, and its playback position advances across reads. | + +The current server may acknowledge mutations before mpv finishes and may return +HTTP success even for an mpv failure. The extension verifies reads instead of +assuming a 2xx response means playback started. A selected but still loading +item does not count as success; playback position must advance for the intended +item. Verification polling checks a shared 20-second deadline between operations; each +HTTP request has an 8-second timeout. In-flight or sequential mutation requests +can extend completion beyond that window. Slow media may remain queued without +confirmed playback. This is a point-in-time +confirmation; media loading or decoding can fail later. + +If preflight fails, no link has been added and the user can retry. Once append +has been attempted, any failure is treated as potentially partially completed: +the UI says either the link may already be queued or it was queued but playback +was not confirmed. It disables repeat submission for that share session and +asks the user to check QueueCube before sharing again. It never clears the queue, +rolls back another client's changes, or automatically retries a mutation. + +Identical URLs already in the queue are distinguished using mpv IDs. Concurrent +identical new entries are ambiguous and are not selected. The existing skip API +uses an index, so another client can reorder the queue between lookup and skip; +verification catches a different resulting item, but cannot make these existing +endpoints atomic. No new backend contract is assumed. + +## Building and validation + +Build the QueueCube scheme to embed the extension on iOS and Mac Catalyst: + +```sh +xcodebuild -project ios/QueueCube.xcodeproj -scheme QueueCube -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/queuecube-build-simulator CODE_SIGNING_ALLOWED=NO build +xcodebuild -project ios/QueueCube.xcodeproj -scheme QueueCube -configuration Debug -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath /tmp/queuecube-build-catalyst CODE_SIGNING_ALLOWED=NO build +swift test --package-path ios --scratch-path /tmp/queuecube-share-tests +``` + +Unsigned builds validate compilation and embedding; they do not validate App +Group provisioning. For simulator UI tests of saved players, use normal local +ad-hoc signing (`CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM=`) +so Simulator registers the App Group container. This does not use an Apple +signing identity or provision a physical device. Before a future signed distribution, +register the extension ID and associate **both** app IDs with the App Group, +then update their profiles through the existing signing workflow. Fastlane maps +`QueueCube AppStore CI` and `QueueCube Share AppStore CI` separately and keeps +app/extension versions synchronized. Do not apply a single global bundle ID or +provisioning profile override to both targets. + +For manual UI validation, use a dedicated simulator or test installation: +configure a test player in QueueCube, share a URL from Safari, verify saved and +nearby player selection, Queue & Play success, cancellation, invalid text, and +an offline/partially failing player. Confirm the old queue is retained and the +new item is current and unpaused. On macOS also confirm QueueCube appears in +Share extensions and can be invoked from a host app. Record signed-device and +live-player gaps explicitly; unit mocks cannot establish those behaviors. diff --git a/ios/Tests/QueueCubeShareCoreTests/RequestBuilderTests.swift b/ios/Tests/QueueCubeShareCoreTests/RequestBuilderTests.swift new file mode 100644 index 0000000..db16abd --- /dev/null +++ b/ios/Tests/QueueCubeShareCoreTests/RequestBuilderTests.swift @@ -0,0 +1,113 @@ +import Foundation +import XCTest +@testable import QueueCubeShareCore + +final class RequestBuilderTests: XCTestCase { + override func setUp() { + super.setUp() + URLProtocol.registerClass(StubURLProtocol.self) + } + + override func tearDown() { + StubURLProtocol.setResponse(status: 200, body: Data()) + URLProtocol.unregisterClass(StubURLProtocol.self) + super.tearDown() + } + + func testConfiguredTimeoutAndBodySurviveEndpointBuilding() throws { + let baseURL = URL(string: "https://queuecube-test.invalid/api")! + let request = RequestBuilder(url: baseURL, timeout: 7) + .path("/playlist") + .body(["url": "https://video.example/watch?v=one"]) + .method(.post) + .build() + + XCTAssertEqual(request.url?.path, "/api/playlist") + XCTAssertEqual(request.timeoutInterval, 7) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(request.value(forHTTPHeaderField: "Cache-Control"), "no-cache") + XCTAssertEqual(try JSONDecoder().decode([String: String].self, from: XCTUnwrap(request.httpBody)), ["url": "https://video.example/watch?v=one"]) + XCTAssertEqual(RequestBuilder(url: baseURL, timeout: 7).pathString("/search?q=one").build().timeoutInterval, 7) + } + + func testPOSTRejectsAuthenticationFailureInsteadOfReportingSuccess() async throws { + StubURLProtocol.setResponse(status: 401, body: Data(#"{"error":"Authentication required"}"#.utf8)) + do { + try await RequestBuilder(url: URL(string: "https://queuecube-test.invalid/api/playlist")!).post() + XCTFail("A non-2xx mutation must fail") + } catch RequestBuilder.RequestError.httpError(let status, let message) { + XCTAssertEqual(status, 401) + XCTAssertEqual(message, "Authentication required") + } + } + + func testNonJSONServerFailureStillReportsItsStatus() async throws { + StubURLProtocol.setResponse(status: 502, body: Data("upstream unavailable".utf8)) + do { + try await RequestBuilder(url: URL(string: "https://queuecube-test.invalid/api/play")!).post() + XCTFail("A non-JSON server failure must fail") + } catch RequestBuilder.RequestError.httpError(let status, let message) { + XCTAssertEqual(status, 502) + XCTAssertNil(message) + } + } + + func testSuccessfulEmptyMutationResponseIsAccepted() async throws { + StubURLProtocol.setResponse(status: 204, body: Data()) + try await RequestBuilder(url: URL(string: "https://queuecube-test.invalid/api/play")!).post() + } + + func testAPIReadsExistingPlaylistEndpointAndDecodesIDs() async throws { + StubURLProtocol.setResponse(status: 200, body: Data(#"[{"id":42,"filename":"https://video.example/one"}]"#.utf8)) + let api = API(baseURL: URL(string: "https://queuecube-test.invalid/api")!, requestTimeout: 7) + let playlist = try await api.fetchPlaylist() + XCTAssertEqual(playlist.map(\.id), [42]) + let request = try XCTUnwrap(StubURLProtocol.lastRequest()) + XCTAssertEqual(request.url?.path, "/api/playlist") + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.timeoutInterval, 7) + } +} + +// Intercepts only this test's reserved host. No network or real player is used. +private final class StubURLProtocol: URLProtocol { + private static let lock = NSLock() + private static var status = 200 + private static var body = Data() + private static var request: URLRequest? + + static func setResponse(status: Int, body: Data) { + lock.lock() + defer { lock.unlock() } + Self.status = status + Self.body = body + Self.request = nil + } + + static func lastRequest() -> URLRequest? { + lock.lock() + defer { lock.unlock() } + return request + } + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.host == "queuecube-test.invalid" + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + let status = Self.status + let body = Self.body + Self.request = request + Self.lock.unlock() + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: [:])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/ios/Tests/QueueCubeShareCoreTests/ServerTests.swift b/ios/Tests/QueueCubeShareCoreTests/ServerTests.swift new file mode 100644 index 0000000..bff3578 --- /dev/null +++ b/ios/Tests/QueueCubeShareCoreTests/ServerTests.swift @@ -0,0 +1,47 @@ +import Foundation +import XCTest +@testable import QueueCubeShareCore + +final class ServerTests: XCTestCase { + func testScopedIPv4FromNetworkFrameworkUsesResolvableHost() throws { + let server = try XCTUnwrap(Server(serviceName: nil, host: "192.168.1.153%en1", port: 18731)) + XCTAssertEqual(server.baseURL.absoluteString, "http://192.168.1.153:18731/api") + XCTAssertEqual(server.baseURL.host, "192.168.1.153") + } + + func testUnscopedIPv4AndHostnameKeepHostAndPort() throws { + for host in ["192.168.1.153", "queuecube.local"] { + let server = try XCTUnwrap(Server(serviceName: nil, host: host, port: 3000)) + XCTAssertEqual(server.baseURL.absoluteString, "http://\(host):3000/api") + XCTAssertEqual(server.baseURL.host, host) + } + } + + func testIPv6LiteralsAreBracketedAndPreserveEncodedScope() throws { + let cases = [ + ("2001:db8::1", "http://[2001:db8::1]:3000/api"), + ("fe80::1%en1", "http://[fe80::1%25en1]:3000/api"), + ("[2001:db8::1]", "http://[2001:db8::1]:3000/api"), + ("[fe80::1%en1]", "http://[fe80::1%25en1]:3000/api") + ] + for (host, expected) in cases { + let server = try XCTUnwrap(Server(serviceName: nil, host: host, port: 3000)) + XCTAssertEqual(server.baseURL.absoluteString, expected) + } + } + + func testHostnameUsesURLHostEncoding() throws { + let server = try XCTUnwrap(Server(serviceName: nil, host: "café.local", port: 3000)) + XCTAssertEqual(server.baseURL.absoluteString, "http://xn--caf-dma.local:3000/api") + XCTAssertNil(Server(serviceName: nil, host: "queuecube.local/path", port: 3000)) + } + + func testPlayerDisplayNameUsesBonjourNameAndFallsBackToHost() throws { + let advertised = try XCTUnwrap(Server(serviceName: "QueueCube Media Server (Living Room)", host: "192.168.1.153%en1", port: 3000)) + XCTAssertEqual(advertised.displayName, "Living Room") + let custom = try XCTUnwrap(Server(serviceName: "Kitchen", host: "queuecube.local", port: 3000)) + XCTAssertEqual(custom.displayName, "Kitchen") + let unnamed = try XCTUnwrap(Server(serviceName: nil, host: "queuecube.local", port: 3000)) + XCTAssertEqual(unnamed.displayName, "queuecube.local") + } +} diff --git a/ios/Tests/QueueCubeShareCoreTests/SettingsTests.swift b/ios/Tests/QueueCubeShareCoreTests/SettingsTests.swift new file mode 100644 index 0000000..c20222f --- /dev/null +++ b/ios/Tests/QueueCubeShareCoreTests/SettingsTests.swift @@ -0,0 +1,79 @@ +import Foundation +import XCTest +@testable import QueueCubeShareCore + +final class SettingsTests: XCTestCase { + func testExistingSettingsArePublishedOnReadWithoutChangingLegacyStorage() throws { + try withDefaults { legacy, shared, legacyName, _ in + let first = Server(baseURL: URL(string: "http://living-room.invalid:8080/api")!) + let selected = Server(baseURL: URL(string: "https://bedroom.invalid/api")!) + let original = Settings(selectedServer: selected, configuredServers: [first, selected]) + original.save(defaults: legacy, sharedDefaults: nil) + let legacyBefore = legacy.persistentDomain(forName: legacyName)! as NSDictionary + + XCTAssertFalse(Settings.fromSharedDefaults(defaults: shared).isConfigured) + let loaded = Settings.fromDefaults(defaults: legacy, sharedDefaults: shared) + let snapshot = Settings.fromSharedDefaults(defaults: shared) + + XCTAssertEqual(loaded.configuredServers, original.configuredServers) + XCTAssertEqual(loaded.selectedServer, selected) + XCTAssertEqual(snapshot.configuredServers, original.configuredServers) + XCTAssertEqual(snapshot.selectedServer, selected) + XCTAssertEqual(legacy.persistentDomain(forName: legacyName)! as NSDictionary, legacyBefore) + } + } + + func testSavePublishesOneSnapshotAndExtensionReadsLeaveItUnchanged() throws { + try withDefaults { legacy, shared, _, sharedName in + let first = Server(baseURL: URL(string: "http://living-room.invalid:8080/api")!) + let second = Server(baseURL: URL(string: "http://bedroom.invalid:8080/api")!) + let settings = Settings(selectedServer: first, configuredServers: [first, second]) + settings.save(defaults: legacy, sharedDefaults: shared) + settings.selectedServer(second).save(defaults: legacy, sharedDefaults: shared) + let before = shared.persistentDomain(forName: sharedName)! + XCTAssertEqual(before.count, 1, "Player list and selection must be published atomically") + + let snapshot = Settings.fromSharedDefaults(defaults: shared) + + XCTAssertEqual(snapshot.selectedServer, second) + XCTAssertEqual(snapshot.configuredServers, [first, second]) + XCTAssertEqual(shared.persistentDomain(forName: sharedName)! as NSDictionary, before as NSDictionary) + } + } + + func testMissingOrInvalidSharedSnapshotIsAnUnconfiguredState() throws { + XCTAssertFalse(Settings.fromSharedDefaults(defaults: nil).isConfigured) + try withDefaults { _, shared, _, _ in + XCTAssertNil(Settings.fromSharedDefaults(defaults: shared).selectedServer) + shared.set(Data("not a property list".utf8), forKey: "shareSettings.v1") + let invalid = Settings.fromSharedDefaults(defaults: shared) + XCTAssertTrue(invalid.configuredServers.isEmpty) + XCTAssertNil(invalid.selectedServer) + } + } + + func testSelectionTracksFirstAddedAndRemovedConfiguredPlayers() { + let first = Server(baseURL: URL(string: "http://living-room.invalid/api")!) + let second = Server(baseURL: URL(string: "http://bedroom.invalid/api")!) + var settings = Settings(selectedServer: nil, configuredServers: []) + settings.configuredServers = [first, second] + XCTAssertEqual(settings.selectedServer, first) + settings.configuredServers = [second] + XCTAssertEqual(settings.selectedServer, second) + settings.configuredServers = [] + XCTAssertNil(settings.selectedServer) + } + + private func withDefaults(_ body: (UserDefaults, UserDefaults, String, String) throws -> Void) throws { + let prefix = "QueueCubeShareTests.\(UUID().uuidString)" + let legacyName = "\(prefix).legacy" + let sharedName = "\(prefix).shared" + let legacy = try XCTUnwrap(UserDefaults(suiteName: legacyName)) + let shared = try XCTUnwrap(UserDefaults(suiteName: sharedName)) + defer { + legacy.removePersistentDomain(forName: legacyName) + shared.removePersistentDomain(forName: sharedName) + } + try body(legacy, shared, legacyName, sharedName) + } +} diff --git a/ios/Tests/QueueCubeShareCoreTests/SharePlaybackTests.swift b/ios/Tests/QueueCubeShareCoreTests/SharePlaybackTests.swift new file mode 100644 index 0000000..390f66c --- /dev/null +++ b/ios/Tests/QueueCubeShareCoreTests/SharePlaybackTests.swift @@ -0,0 +1,369 @@ +import Foundation +import XCTest +@testable import QueueCubeShareCore + +final class SharePlaybackTests: XCTestCase { + private let url = URL(string: "https://video.example/watch?v=shared")! + + func testSelectsNewDuplicateByIDAtItsLatestQueueIndex() async throws { + let existing = item(10) + let other = item(11, filename: "https://video.example/other") + let appended = item(12) + let api = FakePlayer( + playlists: [.success([existing, other]), .success([existing, other, appended]), .success([other, appended, existing])], + nowPlaying: [ + .success(nowPlaying(appended, paused: true)), + .success(nowPlaying(appended, position: 0)), + .success(nowPlaying(appended, position: 0.25)) + ] + ) + + try await enqueue(using: api) + + let calls = await api.calls + XCTAssertEqual(calls.addedURLs, [url.absoluteString]) + XCTAssertEqual(calls.skippedIndices, [1]) + XCTAssertEqual(calls.playCount, 1) + XCTAssertEqual(calls.nowPlayingReads, 3, "A paused player or a single position sample cannot prove playback") + } + + func testEmptyQueueWaitsForAsynchronousAppendThenStartsPlayer() async throws { + let appended = item(42) + let api = FakePlayer( + playlists: [.success([]), .success([]), .success([]), .success([appended]), .success([appended])], + nowPlaying: [ + .success(nowPlaying(nil)), + .success(nowPlaying(appended, position: 0)), + .success(nowPlaying(appended, position: 0.25)) + ] + ) + + try await enqueue(using: api) + + let calls = await api.calls + XCTAssertEqual(calls.addedURLs.count, 1) + XCTAssertEqual(calls.skippedIndices, [0]) + XCTAssertEqual(calls.playCount, 1) + XCTAssertEqual(calls.playlistReads, 5) + } + + func testPreflightFailureAllowsRetryAndDoesNotMutatePlayer() async throws { + let api = FakePlayer(playlists: [.failure(.connection)], nowPlaying: []) + let failure = await failure(using: api) + XCTAssertTrue(failure.canRetry) + XCTAssertTrue(failure.message.contains("No link was added")) + let calls = await api.calls + XCTAssertTrue(calls.addedURLs.isEmpty) + XCTAssertTrue(calls.skippedIndices.isEmpty) + XCTAssertEqual(calls.playCount, 0) + } + + func testUncertainAppendResponseDoesNotRepeatMutationOrAllowRetry() async throws { + let api = FakePlayer(playlists: [.success([])], nowPlaying: [], addError: .connection) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + XCTAssertTrue(failure.message.contains("may already be queued")) + let calls = await api.calls + XCTAssertEqual(calls.addedURLs, [url.absoluteString]) + XCTAssertTrue(calls.skippedIndices.isEmpty) + XCTAssertEqual(calls.playCount, 0) + } + + func testAppendNeverAppearsDoesNotRepeatMutation() async throws { + let api = FakePlayer(playlists: [.success([])], nowPlaying: []) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + let calls = await api.calls + XCTAssertEqual(calls.addedURLs.count, 1) + XCTAssertTrue(calls.skippedIndices.isEmpty) + XCTAssertEqual(calls.playCount, 0) + } + + func testConcurrentIdenticalAppendsFailWithoutSelectingAnArbitraryItem() async throws { + let api = FakePlayer(playlists: [.success([]), .success([item(1), item(2)])], nowPlaying: []) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + let calls = await api.calls + XCTAssertEqual(calls.addedURLs.count, 1) + XCTAssertTrue(calls.skippedIndices.isEmpty) + XCTAssertEqual(calls.playCount, 0) + } + + func testDuplicateAppearingInFinalSnapshotDoesNotSelectAnArbitraryItem() async throws { + let api = FakePlayer( + playlists: [.success([]), .success([item(1)]), .success([item(1), item(2)])], + nowPlaying: [] + ) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + let calls = await api.calls + XCTAssertEqual(calls.addedURLs.count, 1) + XCTAssertTrue(calls.skippedIndices.isEmpty) + XCTAssertEqual(calls.playCount, 0) + } + + func testDifferentCandidateInFinalSnapshotDoesNotSelectAnotherClientsAppend() async throws { + let api = FakePlayer(playlists: [.success([]), .success([item(1)]), .success([item(2)])], nowPlaying: []) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + let calls = await api.calls + XCTAssertTrue(calls.skippedIndices.isEmpty) + XCTAssertEqual(calls.playCount, 0) + } + + func testAppendedItemRemovedBeforeSelectionDoesNotSkipAnotherItem() async throws { + let api = FakePlayer(playlists: [.success([]), .success([item(1)]), .success([])], nowPlaying: []) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + XCTAssertTrue(failure.message.contains("was queued")) + let calls = await api.calls + XCTAssertTrue(calls.skippedIndices.isEmpty) + XCTAssertEqual(calls.playCount, 0) + } + + func testPlayRequestFailureReportsPartialSuccessWithoutRetry() async throws { + let appended = item(1) + let api = FakePlayer(playlists: [.success([]), .success([appended])], nowPlaying: [], playError: .connection) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + XCTAssertTrue(failure.message.contains("was queued, but playback could not be confirmed")) + let calls = await api.calls + XCTAssertEqual(calls.addedURLs.count, 1) + XCTAssertEqual(calls.skippedIndices, [0]) + XCTAssertEqual(calls.playCount, 1) + } + + func testPlaybackErrorIsNotReportedAsSuccess() async throws { + let appended = item(1) + let errored = item(1, playbackError: "Unsupported video") + let api = FakePlayer( + playlists: [.success([]), .success([appended])], + nowPlaying: [.success(nowPlaying(errored))] + ) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + let calls = await api.calls + XCTAssertEqual(calls.nowPlayingReads, 1) + XCTAssertEqual(calls.addedURLs.count, 1) + } + + func testWrongPlayingItemAndPausedPlaybackDoNotReportSuccess() async throws { + for info in [nowPlaying(item(8)), nowPlaying(item(1), paused: true)] { + let api = FakePlayer(playlists: [.success([]), .success([item(1)])], nowPlaying: [.success(info)]) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + let calls = await api.calls + XCTAssertEqual(calls.nowPlayingReads, 4) + XCTAssertEqual(calls.playCount, 1) + } + } + + func testLoadingItemFollowedByLoadErrorDoesNotReportSuccess() async throws { + let appended = item(1) + let errored = item(1, playbackError: "Failed to load video") + let api = FakePlayer( + playlists: [.success([]), .success([appended])], + nowPlaying: [.success(nowPlaying(appended)), .success(nowPlaying(errored))] + ) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + let calls = await api.calls + XCTAssertEqual(calls.nowPlayingReads, 2) + XCTAssertEqual(calls.playCount, 1) + } + + func testLoadingItemFollowedByDifferentCurrentItemDoesNotReportSuccess() async throws { + let appended = item(1) + let api = FakePlayer( + playlists: [.success([]), .success([appended])], + nowPlaying: [ + .success(nowPlaying(appended)), + .success(nowPlaying(item(2), position: 0)), + .success(nowPlaying(item(2), position: 0.25)) + ] + ) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + let calls = await api.calls + XCTAssertEqual(calls.nowPlayingReads, 4) + } + + func testLoadingItemFollowedByMatchingPlaybackProgressSucceeds() async throws { + let appended = item(1) + let api = FakePlayer( + playlists: [.success([]), .success([appended])], + nowPlaying: [ + .success(nowPlaying(appended)), + .success(nowPlaying(appended, position: 0)), + .success(nowPlaying(appended, position: 0.25)) + ] + ) + try await enqueue(using: api) + let calls = await api.calls + XCTAssertEqual(calls.nowPlayingReads, 3) + XCTAssertEqual(calls.addedURLs.count, 1) + XCTAssertEqual(calls.playCount, 1) + } + + func testFrozenPlaybackPositionDoesNotReportSuccess() async throws { + let appended = item(1) + let api = FakePlayer( + playlists: [.success([]), .success([appended])], + nowPlaying: [.success(nowPlaying(appended, position: 0))] + ) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + let calls = await api.calls + XCTAssertEqual(calls.nowPlayingReads, 4) + } + + func testInvalidPositionCannotEstablishPlaybackProgress() async throws { + let appended = item(1) + for invalidPosition in [-1, Double.infinity, -Double.infinity, Double.nan] { + let api = FakePlayer( + playlists: [.success([]), .success([appended])], + nowPlaying: [ + .success(nowPlaying(appended, position: invalidPosition)), + .success(nowPlaying(appended, position: 1)) + ] + ) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + let calls = await api.calls + XCTAssertEqual(calls.nowPlayingReads, 4) + } + } + + func testInterruptedPlaybackObservationsResetProgressBaseline() async throws { + let appended = item(1) + let interruptions = [ + nowPlaying(appended), + nowPlaying(appended, paused: true, position: 0.25), + nowPlaying(item(2), position: 0.25) + ] + for interruption in interruptions { + let api = FakePlayer( + playlists: [.success([]), .success([appended])], + nowPlaying: [ + .success(nowPlaying(appended, position: 0)), + .success(interruption), + .success(nowPlaying(appended, position: 1)) + ] + ) + let failure = await failure(using: api) + XCTAssertFalse(failure.canRetry) + let calls = await api.calls + XCTAssertEqual(calls.nowPlayingReads, 4) + } + } + + func testCancellationBeforeAppendDoesNotMutateAndAllowsRetry() async throws { + let api = FakePlayer(playlists: [.success([])], nowPlaying: [], cancellation: .preflight) + let failure = await cancelledFailure(using: api) + XCTAssertTrue(failure.canRetry) + XCTAssertTrue(failure.message.contains("No link was added")) + let calls = await api.calls + XCTAssertTrue(calls.addedURLs.isEmpty) + XCTAssertTrue(calls.skippedIndices.isEmpty) + XCTAssertEqual(calls.playCount, 0) + } + + func testCancellationAfterAppendDoesNotRepeatMutationOrAllowRetry() async throws { + let api = FakePlayer(playlists: [.success([])], nowPlaying: [], cancellation: .afterAppend) + let failure = await cancelledFailure(using: api) + XCTAssertFalse(failure.canRetry) + XCTAssertTrue(failure.message.contains("may already be queued")) + let calls = await api.calls + XCTAssertEqual(calls.addedURLs, [url.absoluteString]) + XCTAssertTrue(calls.skippedIndices.isEmpty) + XCTAssertEqual(calls.playCount, 0) + } + + private func cancelledFailure(using api: FakePlayer) async -> SharePlayback.Failure { + // Cancel only this child operation, leaving XCTest's task unaffected. + await Task { await failure(using: api) }.value + } + + private func enqueue(using api: FakePlayer) async throws { + try await SharePlayback.enqueueAndPlay(url, using: api, attempts: 4, pollInterval: .zero) + } + + private func failure(using api: FakePlayer, file: StaticString = #filePath, line: UInt = #line) async -> SharePlayback.Failure { + do { + try await enqueue(using: api) + XCTFail("Expected playback to fail", file: file, line: line) + } catch let failure as SharePlayback.Failure { + return failure + } catch { + XCTFail("Unexpected error: \(error)", file: file, line: line) + } + return .init(message: "Test did not receive the expected failure", canRetry: true) + } + + private func item(_ id: Int, filename: String? = nil, playbackError: String? = nil) -> MediaItem { + MediaItem(filename: filename ?? url.absoluteString, title: nil, id: id, current: nil, playing: nil, metadata: nil, playbackError: playbackError) + } + + private func nowPlaying(_ item: MediaItem?, paused: Bool = false, position: Double? = nil) -> NowPlayingInfo { + NowPlayingInfo(playingItem: item, isPaused: paused, volume: 50, timePosition: position, duration: nil, seekable: nil) + } +} + +private enum TestError: Error { case connection } + +private actor FakePlayer: SharePlayerAPI { + enum Cancellation { case preflight, afterAppend } + + struct Calls { + var addedURLs: [String] = [] + var skippedIndices: [Int?] = [] + var playCount = 0 + var playlistReads = 0 + var nowPlayingReads = 0 + } + + private var playlists: [Result<[MediaItem], TestError>] + private var nowPlaying: [Result] + private let addError: TestError? + private let playError: TestError? + private let cancellation: Cancellation? + private(set) var calls = Calls() + + init(playlists: [Result<[MediaItem], TestError>], nowPlaying: [Result], addError: TestError? = nil, playError: TestError? = nil, cancellation: Cancellation? = nil) { + self.playlists = playlists + self.nowPlaying = nowPlaying + self.addError = addError + self.playError = playError + self.cancellation = cancellation + } + + func fetchPlaylist() async throws -> [MediaItem] { + calls.playlistReads += 1 + if cancellation == .preflight, calls.playlistReads == 1 { + withUnsafeCurrentTask { $0?.cancel() } + } + guard let next = playlists.first else { throw TestError.connection } + if playlists.count > 1 { playlists.removeFirst() } + return try next.get() + } + + func add(mediaURL: String) async throws { + calls.addedURLs.append(mediaURL) + if cancellation == .afterAppend { withUnsafeCurrentTask { $0?.cancel() } } + if let addError { throw addError } + } + + func skip(_ to: Int?) async throws { calls.skippedIndices.append(to) } + + func play() async throws { + calls.playCount += 1 + if let playError { throw playError } + } + + func fetchNowPlayingInfo() async throws -> NowPlayingInfo { + calls.nowPlayingReads += 1 + guard let next = nowPlaying.first else { throw TestError.connection } + if nowPlaying.count > 1 { nowPlaying.removeFirst() } + return try next.get() + } +} diff --git a/ios/Tests/QueueCubeShareCoreTests/SharedInputTests.swift b/ios/Tests/QueueCubeShareCoreTests/SharedInputTests.swift new file mode 100644 index 0000000..a684ab0 --- /dev/null +++ b/ios/Tests/QueueCubeShareCoreTests/SharedInputTests.swift @@ -0,0 +1,209 @@ +import Foundation +import UniformTypeIdentifiers +import XCTest +@testable import QueueCubeShareCore + +final class SharedInputTests: XCTestCase { + private let url = URL(string: "https://video.example/watch?v=shared")! + + func testURLDataRepresentationLoadsWithTitleOnlyCompanion() async throws { + let result = try await SharedInput.load([extensionItem([ + dataProvider(Data(url.absoluteString.utf8), type: .url), + textProvider("A video worth watching") + ])]) + XCTAssertEqual(result, url) + } + + func testURLObjectRepresentationLoads() async throws { + let provider = NSItemProvider(item: url as NSURL, typeIdentifier: UTType.url.identifier) + let result = try await SharedInput.load([extensionItem([provider])]) + XCTAssertEqual(result, url) + } + + func testModernNSURLObjectProviderRoundTrips() async throws { + let provider = NSItemProvider(object: url as NSURL) + let result = try await SharedInput.load([extensionItem([ + provider, textProvider("A video worth watching") + ])]) + XCTAssertEqual(result, url) + } + + func testTypedFileAndCredentialURLsDoNotFallBackToCompanionText() async throws { + for invalid in [ + URL(fileURLWithPath: "/tmp/video.mp4"), + URL(string: "https://user:synthetic@video.example/video")! + ] { + await assertFailure(.noLink, items: [extensionItem([ + NSItemProvider(object: invalid as NSURL), + textProvider(url.absoluteString) + ])]) + } + } + + func testLegacyURLStringProviderPreservesTitleAndLinkFallback() async throws { + let provider = NSItemProvider(item: "Watch this video: \(url.absoluteString)" as NSString, typeIdentifier: UTType.url.identifier) + let result = try await SharedInput.load([extensionItem([provider])]) + XCTAssertEqual(result, url) + } + + func testMultipleLinksInsideOneURLDataProviderAreRejected() async throws { + await assertFailure(.multipleLinks, items: [extensionItem([ + dataProvider(Data("\(url.absoluteString) https://other.example/video".utf8), type: .url) + ])]) + } + + func testUIKitURLPropertyListRepresentationLoadsWithTitleOnlyCompanion() async throws { + let data = try propertyList([url.absoluteString, "", [:]] as [Any]) + let result = try await SharedInput.load([extensionItem([ + dataProvider(data, type: .url), textProvider("A video worth watching") + ])]) + XCTAssertEqual(result, url) + } + + func testUIKitURLPropertyListRejectsNonmatchingShapes() async throws { + let shapes: [Any] = [ + url.absoluteString, + ["URL": url.absoluteString], + [url.absoluteString], + [url.absoluteString, ""], + [url.absoluteString, "", [:], "extra"] as [Any], + [Data(url.absoluteString.utf8), "", [:]] as [Any], + [url.absoluteString, 0, [:]] as [Any], + [url.absoluteString, "", []] as [Any], + [url.absoluteString, "https://base.example/", [:]] as [Any], + [url.absoluteString, "", ["unexpected": "value"]] as [Any] + ] + for (index, shape) in shapes.enumerated() { + await assertFailure(.noLink, items: [extensionItem([ + dataProvider(try propertyList(shape), type: .url), + textProvider(url.absoluteString) + ])], context: "plist shape \(index)") + } + } + + func testUIKitURLPropertyListRejectsFilesAndCredentials() async throws { + for value in ["file:///tmp/video.mp4", "https://user:synthetic@video.example/video"] { + await assertFailure(.noLink, items: [extensionItem([ + dataProvider(try propertyList([value, "", [:]] as [Any]), type: .url), + textProvider(url.absoluteString) + ])]) + } + } + + func testUIKitURLPropertyListPreservesExactOneLinkRequirement() async throws { + let another = "https://other.example/video" + let combined = try propertyList(["\(url.absoluteString) \(another)", "", [:]] as [Any]) + await assertFailure(.multipleLinks, items: [extensionItem([dataProvider(combined, type: .url)])]) + await assertFailure(.multipleLinks, items: [extensionItem([ + dataProvider(try propertyList([url.absoluteString, "", [:]] as [Any]), type: .url), + dataProvider(try propertyList([another, "", [:]] as [Any]), type: .url) + ])]) + } + + func testUIKitURLPropertyListIsNotDecodedForPlainTextProviders() async throws { + await assertFailure(.noLink, items: [extensionItem([ + dataProvider(try propertyList([url.absoluteString, "", [:]] as [Any]), type: .plainText) + ])]) + } + + func testArbitraryKeyedURLArchivesAreNotDecoded() async throws { + for value in [url as NSURL, url.absoluteString as NSString, Data(url.absoluteString.utf8) as NSData] as [Any] { + let archive = try NSKeyedArchiver.archivedData(withRootObject: value, requiringSecureCoding: true) + await assertFailure(.noLink, items: [extensionItem([ + dataProvider(archive, type: .url), textProvider(url.absoluteString) + ])]) + } + } + + func testTruncatedUIKitURLPropertyListIsRejected() async throws { + let data = try propertyList([url.absoluteString, "", [:]] as [Any]) + await assertFailure(.noLink, items: [extensionItem([ + dataProvider(data.dropLast(5), type: .url), textProvider(url.absoluteString) + ])]) + } + + func testExplicitURLTakesPrecedenceOverLinksInCompanionText() async throws { + let result = try await SharedInput.load([extensionItem([ + dataProvider(Data(url.absoluteString.utf8), type: .url), + textProvider("Visit https://other.example/channel for more") + ])]) + XCTAssertEqual(result, url) + } + + func testMultipleDistinctURLDataProvidersAreRejected() async throws { + await assertFailure(.multipleLinks, items: [extensionItem([ + dataProvider(Data(url.absoluteString.utf8), type: .url), + dataProvider(Data("https://video.example/another".utf8), type: .url) + ])]) + } + + func testRepeatedObjectAndDataURLRepresentationsAreOneLink() async throws { + let result = try await SharedInput.load([extensionItem([ + NSItemProvider(item: url as NSURL, typeIdentifier: UTType.url.identifier), + dataProvider(Data(url.absoluteString.utf8), type: .url) + ])]) + XCTAssertEqual(result, url) + } + + func testInvalidURLDataDoesNotFallBackToCompanionText() async throws { + for data in [Data([0xff, 0xfe, 0x80]), Data("not a URL".utf8), Data("file:///tmp/video.mp4".utf8)] { + await assertFailure(.noLink, items: [extensionItem([ + dataProvider(data, type: .url), + textProvider(url.absoluteString) + ])]) + } + } + + func testPlainTextDataFallbackExtractsLinkAndIgnoresTitleOnlyProvider() async throws { + let result = try await SharedInput.load([extensionItem([ + textProvider("A video worth watching"), + dataProvider(Data("Watch this: \(url.absoluteString)".utf8), type: .plainText) + ])]) + XCTAssertEqual(result, url) + } + + func testAttributedTextFallbackAndEmptyInput() async throws { + let item = NSExtensionItem() + item.attributedContentText = NSAttributedString(string: "Watch this: \(url.absoluteString)") + let result = try await SharedInput.load([item]) + XCTAssertEqual(result, url) + await assertFailure(.noLink, items: []) + } + + private func assertFailure(_ expected: SharedVideo.Error, items: [Any], context: String = "attachment", file: StaticString = #filePath, line: UInt = #line) async { + do { + _ = try await SharedInput.load(items) + XCTFail("Expected \(context) loading to fail", file: file, line: line) + } catch let error as SharedVideo.Error { + switch (expected, error) { + case (.noLink, .noLink), (.multipleLinks, .multipleLinks): break + default: XCTFail("Unexpected error: \(error)", file: file, line: line) + } + } catch { + XCTFail("Unexpected provider error: \(error)", file: file, line: line) + } + } + + private func extensionItem(_ providers: [NSItemProvider]) -> NSExtensionItem { + let item = NSExtensionItem() + item.attachments = providers + return item + } + + private func textProvider(_ text: String) -> NSItemProvider { + NSItemProvider(item: text as NSString, typeIdentifier: UTType.plainText.identifier) + } + + private func dataProvider(_ data: Data, type: UTType) -> NSItemProvider { + let provider = NSItemProvider() + provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) { completion in + completion(data, nil) + return nil + } + return provider + } + + private func propertyList(_ value: Any) throws -> Data { + try PropertyListSerialization.data(fromPropertyList: value, format: .binary, options: 0) + } +} diff --git a/ios/Tests/QueueCubeShareCoreTests/SharedVideoTests.swift b/ios/Tests/QueueCubeShareCoreTests/SharedVideoTests.swift new file mode 100644 index 0000000..3342622 --- /dev/null +++ b/ios/Tests/QueueCubeShareCoreTests/SharedVideoTests.swift @@ -0,0 +1,48 @@ +import Foundation +import XCTest +@testable import QueueCubeShareCore + +final class SharedVideoTests: XCTestCase { + func testAcceptsHTTPAndHTTPSURLsWithoutChangingTheirContents() throws { + for value in ["https://video.example/watch?v=one&t=12", "http://video.example/a%20b"] { + let url = try XCTUnwrap(URL(string: value)) + XCTAssertEqual(try SharedVideo.validate(url), url) + } + } + + func testExtractsLinkFromSharedTitleAndText() throws { + let url = try SharedVideo.extract(from: "A video to watch: https://video.example/watch?v=one\nSent from my phone") + XCTAssertEqual(url.absoluteString, "https://video.example/watch?v=one") + } + + func testRepeatedURLInTextAndAttachmentsIsOneLink() throws { + let url = try XCTUnwrap(URL(string: "https://video.example/watch?v=one")) + XCTAssertEqual(try SharedVideo.extract(from: "\(url)\nWatch it: \(url)"), url) + XCTAssertEqual(try SharedVideo.single([url, url]), url) + } + + func testRejectsMultipleDistinctLinks() throws { + XCTAssertThrowsError(try SharedVideo.extract(from: "https://video.example/one https://video.example/two")) { + guard case SharedVideo.Error.multipleLinks = $0 else { return XCTFail("Unexpected error: \($0)") } + } + XCTAssertThrowsError(try SharedVideo.single([ + XCTUnwrap(URL(string: "https://video.example/one")), + XCTUnwrap(URL(string: "https://video.example/two")) + ])) + } + + func testRejectsFilesOtherSchemesCredentialsAndMissingHosts() throws { + for value in [ + "file:///tmp/video.mp4", "ftp://video.example/video.mp4", "javascript:alert(1)", + "https://user:password@video.example/video", "https://user@video.example/video", + "https:///video", "/relative/video" + ] { + let url = try XCTUnwrap(URL(string: value)) + XCTAssertThrowsError(try SharedVideo.validate(url), "Accepted \(value)") { + guard case SharedVideo.Error.noLink = $0 else { return XCTFail("Unexpected error: \($0)") } + } + } + XCTAssertThrowsError(try SharedVideo.extract(from: "This title contains no video link")) + XCTAssertThrowsError(try SharedVideo.single([])) + } +}