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([]))
+ }
+}