ios: add share extension to queue and play video links
Embed an iOS and Catalyst share extension using existing discovery and player APIs. Share saved players through an App Group snapshot, preserve the queue, and verify advancing playback before reporting success. Cover URL provider transport, settings migration, discovery addresses, duplicate handling, cancellation, and partial enqueue/play outcomes with focused tests. Document authentication and signing limits.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+23
-14
@@ -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
|
||||
<dict>
|
||||
<key>#{BUNDLE_IDENTIFIER}</key>
|
||||
<string>#{PROVISIONING_PROFILE_NAME}</string>
|
||||
<key>#{SHARE_BUNDLE_IDENTIFIER}</key>
|
||||
<string>#{SHARE_PROVISIONING_PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
@@ -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
|
||||
|
||||
|
||||
+12
-2
@@ -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
|
||||
- **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.
|
||||
|
||||
@@ -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"])
|
||||
]
|
||||
)
|
||||
@@ -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 = "<group>"; };
|
||||
CD2026102000000000000101 /* RequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestBuilder.swift; sourceTree = "<group>"; };
|
||||
CD2026102000000000000102 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = "<group>"; };
|
||||
CD2026102000000000000103 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
|
||||
CD2026102000000000000104 /* ServerDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; };
|
||||
CD2026102000000000000105 /* SharedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedVideo.swift; sourceTree = "<group>"; };
|
||||
CD2026102000000000000106 /* SharePlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePlayback.swift; sourceTree = "<group>"; };
|
||||
CD2026102000000000000107 /* SharedInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedInput.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
CD2026102000000000000007 /* QueueCubeShare */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
CD2026102000000000000006 /* Exceptions for "QueueCubeShare" folder in "QueueCubeShare" target */,
|
||||
);
|
||||
path = QueueCubeShare;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
@@ -55,10 +125,27 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CD4E9B972D7691C20066FC17 /* QueueCube.app */,
|
||||
CD2026102000000000000002 /* QueueCubeShare.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.net.buzzert.QueueCube</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<T: Decodable>() 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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[..<scope])) != nil {
|
||||
host = String(host[..<scope])
|
||||
}
|
||||
if host.contains(":") {
|
||||
if !host.hasPrefix("[") { host = "[\(host)]" }
|
||||
}
|
||||
var components = URLComponents()
|
||||
components.scheme = "http"
|
||||
components.host = host
|
||||
components.port = Int(port)
|
||||
components.path = "/api"
|
||||
guard let url = components.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Shared Bonjour discovery for the app and share extension.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ServerDiscovery
|
||||
{
|
||||
private(set) var servers: [DiscoveredEndpoint] = []
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
private var browser: NWBrowser?
|
||||
|
||||
func start() {
|
||||
guard browser == nil else { return }
|
||||
errorMessage = nil
|
||||
let browser = NWBrowser(for: .bonjour(type: "_queuecube._tcp.", domain: "local."), using: .tcp)
|
||||
self.browser = browser
|
||||
|
||||
browser.browseResultsChangedHandler = { [weak self, weak browser] results, _ in
|
||||
Task { @MainActor in
|
||||
guard let self, let browser, self.browser === browser else { return }
|
||||
self.servers = results.map(DiscoveredEndpoint.init(result:)).sorted {
|
||||
$0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
||||
browser.stateUpdateHandler = { [weak self, weak browser] state in
|
||||
Task { @MainActor in
|
||||
guard let self, let browser, self.browser === browser else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
self.errorMessage = nil
|
||||
case .failed(let error), .waiting(let error):
|
||||
self.errorMessage = "Unable to discover players: \(error.localizedDescription)"
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
browser.start(queue: .main)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
servers = []
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveredEndpoint: Identifiable, Hashable
|
||||
{
|
||||
let endpoint: NWEndpoint
|
||||
let serviceName: String
|
||||
|
||||
var displayName: String { serviceName.queueCubeServiceName }
|
||||
var id: NWEndpoint { endpoint }
|
||||
|
||||
init(result: NWBrowser.Result) {
|
||||
endpoint = result.endpoint
|
||||
if case .service(name: let name, type: _, domain: _, interface: _) = result.endpoint {
|
||||
serviceName = name
|
||||
} else {
|
||||
serviceName = "(Unknown)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resolve() async throws -> 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<Server, any Swift.Error>?
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
|
||||
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<Server, any Swift.Error>) {
|
||||
guard let continuation else { return }
|
||||
self.continuation = nil
|
||||
timeoutTask?.cancel()
|
||||
timeoutTask = nil
|
||||
connection.stateUpdateHandler = nil
|
||||
connection.cancel()
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Int>
|
||||
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..<attempts {
|
||||
try Task.checkCancellation()
|
||||
guard ContinuousClock.now < deadline else { throw VerificationError.itemMissing }
|
||||
let candidates = try await api.fetchPlaylist().filter {
|
||||
!previousIDs.contains($0.id) && $0.filename == url.absoluteString
|
||||
}
|
||||
// Concurrent identical shares cannot be safely disambiguated.
|
||||
guard candidates.count <= 1 else { throw VerificationError.ambiguousItem }
|
||||
if let item = candidates.first {
|
||||
addedID = item.id
|
||||
queued = true
|
||||
break
|
||||
}
|
||||
if attempt + 1 < attempts { try await Task.sleep(for: pollInterval) }
|
||||
}
|
||||
guard let addedID else { throw VerificationError.itemMissing }
|
||||
|
||||
// Use the latest index, not the queue length from before append.
|
||||
let playlist = try await api.fetchPlaylist()
|
||||
let finalCandidates = playlist.filter {
|
||||
!previousIDs.contains($0.id) && $0.filename == url.absoluteString
|
||||
}
|
||||
guard finalCandidates.count == 1, finalCandidates.first?.id == addedID else {
|
||||
throw VerificationError.ambiguousItem
|
||||
}
|
||||
guard let index = playlist.firstIndex(where: { $0.id == addedID }) else {
|
||||
throw VerificationError.itemMissing
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard ContinuousClock.now < deadline else { throw VerificationError.playbackFailed }
|
||||
try await api.skip(index)
|
||||
try Task.checkCancellation()
|
||||
try await api.play()
|
||||
|
||||
var previousPosition: Double?
|
||||
for attempt in 0..<attempts {
|
||||
try Task.checkCancellation()
|
||||
guard ContinuousClock.now < deadline else { throw VerificationError.playbackFailed }
|
||||
let info = try await api.fetchNowPlayingInfo()
|
||||
if info.playingItem?.id == addedID, info.playingItem?.playbackError != nil {
|
||||
throw VerificationError.playbackFailed
|
||||
}
|
||||
// A current, unpaused item can still be loading. Require time
|
||||
// progression for this ID before claiming playback started.
|
||||
if info.playingItem?.id == addedID, !info.isPaused,
|
||||
let position = info.timePosition, position.isFinite, position >= 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<T: Decodable>() 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
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import Network
|
||||
import SwiftUI
|
||||
|
||||
struct AddServerView: View
|
||||
@@ -60,14 +59,18 @@ struct AddServerView: View
|
||||
|
||||
// Discovered
|
||||
Section(.discovered) {
|
||||
if model.discoveredServers.isEmpty {
|
||||
if let message = model.resolutionError ?? model.discovery.errorMessage {
|
||||
Text(message)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if model.discovery.servers.isEmpty {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text(.findingServers)
|
||||
}
|
||||
} else {
|
||||
List(model.discoveredServers) { (server: DiscoveredEndpoint) in
|
||||
List(model.discovery.servers) { (server: DiscoveredEndpoint) in
|
||||
Button {
|
||||
resolveEndpoint(server)
|
||||
} label: {
|
||||
@@ -90,23 +93,30 @@ struct AddServerView: View
|
||||
}
|
||||
|
||||
.task {
|
||||
model.startDiscovery()
|
||||
model.discovery.start()
|
||||
}
|
||||
.onDisappear { model.discovery.stop() }
|
||||
}
|
||||
|
||||
private func resolveEndpoint(_ endpoint: DiscoveredEndpoint) {
|
||||
Task {
|
||||
model.resolvingServers.insert(endpoint)
|
||||
|
||||
let server = try await endpoint.resolve()
|
||||
onAddServer(server)
|
||||
|
||||
model.resolvingServers.remove(endpoint)
|
||||
model.resolutionError = nil
|
||||
defer { model.resolvingServers.remove(endpoint) }
|
||||
do {
|
||||
let server = try await endpoint.resolve()
|
||||
onAddServer(server)
|
||||
} catch is CancellationError {
|
||||
// The view or its caller no longer needs this result.
|
||||
} catch {
|
||||
model.resolutionError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
class ViewModel
|
||||
{
|
||||
@@ -114,10 +124,9 @@ struct AddServerView: View
|
||||
var validationURL: String = ""
|
||||
var validationState: ValidationState = .empty
|
||||
|
||||
var discoveredServers: [DiscoveredEndpoint] = []
|
||||
let discovery = ServerDiscovery()
|
||||
var resolvingServers = Set<DiscoveredEndpoint>()
|
||||
|
||||
private let browser = NWBrowser(for: .bonjour(type: "_queuecube._tcp.", domain: "local."), using: .tcp)
|
||||
var resolutionError: String?
|
||||
|
||||
private var validationTimer: Timer? = nil
|
||||
|
||||
@@ -125,21 +134,6 @@ struct AddServerView: View
|
||||
observeForValidation()
|
||||
}
|
||||
|
||||
public func startDiscovery() {
|
||||
browser.browseResultsChangedHandler = { [weak self] results, changes in
|
||||
guard let self else { return }
|
||||
self.discoveredServers = results.map { DiscoveredEndpoint(result: $0) }
|
||||
}
|
||||
|
||||
browser.stateUpdateHandler = { state in
|
||||
if case .failed(let error) = state {
|
||||
print("Discovery error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
browser.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
|
||||
private func observeForValidation() {
|
||||
withObservationTracking {
|
||||
_ = serverURL
|
||||
@@ -156,7 +150,9 @@ struct AddServerView: View
|
||||
self.validationURL = self.serverURL
|
||||
self.validationTimer?.invalidate()
|
||||
self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
|
||||
self?.validateSettings()
|
||||
Task { @MainActor [weak self] in
|
||||
self?.validateSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,77 +200,3 @@ struct AddServerView: View
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveredEndpoint: Identifiable, Hashable
|
||||
{
|
||||
let endpoint: NWEndpoint
|
||||
let serviceName: String
|
||||
|
||||
var displayName: String {
|
||||
serviceName.queueCubeServiceName
|
||||
}
|
||||
|
||||
var id: String { serviceName }
|
||||
|
||||
init(result: NWBrowser.Result) {
|
||||
self.endpoint = result.endpoint
|
||||
|
||||
switch result.endpoint {
|
||||
case .service(name: let name, type: _, domain: _, interface: _):
|
||||
self.serviceName = name
|
||||
default:
|
||||
self.serviceName = "(Unknown)"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func resolve() async throws -> Server {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .preparing: break
|
||||
case .ready:
|
||||
// xxx: is this really the right way to do this? Maybe we should not try to turn this into a URL.
|
||||
if case .hostPort(host: let host, port: let port) = connection.currentPath?.remoteEndpoint {
|
||||
let address = switch host {
|
||||
case .name(let string, _): string
|
||||
case .ipv4(let iPv4Address): iPv4Address.debugDescription
|
||||
case .ipv6(let iPv6Address): iPv6Address.debugDescription
|
||||
default: "unknown"
|
||||
}
|
||||
|
||||
if let server = Server(serviceName: serviceName, host: address, port: port.rawValue) {
|
||||
continuation.resume(returning: server)
|
||||
} else {
|
||||
continuation.resume(throwing: Self.Error.urlError)
|
||||
}
|
||||
} else {
|
||||
continuation.resume(throwing: Self.Error.endpointIncorrect)
|
||||
}
|
||||
|
||||
connection.cancel()
|
||||
case .cancelled:
|
||||
// expected
|
||||
break
|
||||
case .failed(let error):
|
||||
continuation.resume(throwing: error)
|
||||
connection.cancel()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Error: Swift.Error
|
||||
{
|
||||
case cancelledConnection
|
||||
case endpointIncorrect
|
||||
case urlError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.net.buzzert.QueueCube</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_queuecube._tcp.</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>QueueCube discovers nearby players so you can choose where to play this video.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsText</key>
|
||||
<true/>
|
||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,229 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
final class ShareViewController: UIViewController {
|
||||
private let model = ShareViewModel()
|
||||
private var inputTask: Task<Void, Never>?
|
||||
private var playbackTask: Task<Void, Never>?
|
||||
|
||||
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<Void, Never>?
|
||||
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<NowPlayingInfo, TestError>]
|
||||
private let addError: TestError?
|
||||
private let playError: TestError?
|
||||
private let cancellation: Cancellation?
|
||||
private(set) var calls = Calls()
|
||||
|
||||
init(playlists: [Result<[MediaItem], TestError>], nowPlaying: [Result<NowPlayingInfo, TestError>], 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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([]))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user