From 87239557059e0bf8618647d066fe733188f89984 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Tue, 20 Oct 2026 22:08:12 -0700 Subject: [PATCH] ios: add share extension to queue and play video links Embed an iOS and Catalyst share extension using existing discovery and player APIs. Share saved players through an App Group snapshot, preserve the queue, and verify advancing playback before reporting success. Cover URL provider transport, settings migration, discovery addresses, duplicate handling, cancellation, and partial enqueue/play outcomes with focused tests. Document authentication and signing limits. --- README.md | 9 +- fastlane/Fastfile | 37 +- ios/CLAUDE.md | 14 +- ios/Package.swift | 22 ++ ios/QueueCube.xcodeproj/project.pbxproj | 232 +++++++++++ ios/QueueCube/App/Entitlements.plist | 7 +- ios/QueueCube/Backend/API.swift | 6 +- ios/QueueCube/Backend/RequestBuilder.swift | 114 ++++++ ios/QueueCube/Backend/Server.swift | 21 +- ios/QueueCube/Backend/ServerDiscovery.swift | 168 ++++++++ ios/QueueCube/Backend/Settings.swift | 34 +- ios/QueueCube/Backend/SharePlayback.swift | 104 +++++ ios/QueueCube/Backend/SharedInput.swift | 60 +++ ios/QueueCube/Backend/SharedVideo.swift | 38 ++ ios/QueueCube/Backend/Utilities.swift | 109 ------ .../Views/Settings View/AddServerView.swift | 126 ++---- ios/QueueCubeShare/Entitlements.plist | 10 + ios/QueueCubeShare/Info.plist | 38 ++ ios/QueueCubeShare/ShareViewController.swift | 229 +++++++++++ ios/SHARE_EXTENSION.md | 102 +++++ .../RequestBuilderTests.swift | 113 ++++++ .../QueueCubeShareCoreTests/ServerTests.swift | 47 +++ .../SettingsTests.swift | 79 ++++ .../SharePlaybackTests.swift | 369 ++++++++++++++++++ .../SharedInputTests.swift | 209 ++++++++++ .../SharedVideoTests.swift | 48 +++ 26 files changed, 2104 insertions(+), 241 deletions(-) create mode 100644 ios/Package.swift create mode 100644 ios/QueueCube/Backend/RequestBuilder.swift create mode 100644 ios/QueueCube/Backend/ServerDiscovery.swift create mode 100644 ios/QueueCube/Backend/SharePlayback.swift create mode 100644 ios/QueueCube/Backend/SharedInput.swift create mode 100644 ios/QueueCube/Backend/SharedVideo.swift create mode 100644 ios/QueueCubeShare/Entitlements.plist create mode 100644 ios/QueueCubeShare/Info.plist create mode 100644 ios/QueueCubeShare/ShareViewController.swift create mode 100644 ios/SHARE_EXTENSION.md create mode 100644 ios/Tests/QueueCubeShareCoreTests/RequestBuilderTests.swift create mode 100644 ios/Tests/QueueCubeShareCoreTests/ServerTests.swift create mode 100644 ios/Tests/QueueCubeShareCoreTests/SettingsTests.swift create mode 100644 ios/Tests/QueueCubeShareCoreTests/SharePlaybackTests.swift create mode 100644 ios/Tests/QueueCubeShareCoreTests/SharedInputTests.swift create mode 100644 ios/Tests/QueueCubeShareCoreTests/SharedVideoTests.swift 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([])) + } +}