* commit '87239557059e0bf8618647d066fe733188f89984': ios: add share extension to queue and play video links
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.
|
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
|
## iOS TestFlight releases
|
||||||
|
|
||||||
Gitea Actions builds and uploads the iOS app when a version tag is pushed. The
|
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
|
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
|
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`
|
a signed IPA without uploading it, while `bundle exec fastlane ios beta`
|
||||||
performs the release.
|
performs the release.
|
||||||
|
|
||||||
|
|||||||
+23
-14
@@ -6,8 +6,10 @@ require "tempfile"
|
|||||||
default_platform(:ios)
|
default_platform(:ios)
|
||||||
|
|
||||||
BUNDLE_IDENTIFIER = "net.buzzert.QueueCube"
|
BUNDLE_IDENTIFIER = "net.buzzert.QueueCube"
|
||||||
|
SHARE_BUNDLE_IDENTIFIER = "net.buzzert.QueueCube.Share"
|
||||||
DEVELOPMENT_TEAM = "DQQH5H6GBD"
|
DEVELOPMENT_TEAM = "DQQH5H6GBD"
|
||||||
PROVISIONING_PROFILE_NAME = "QueueCube AppStore CI"
|
PROVISIONING_PROFILE_NAME = "QueueCube AppStore CI"
|
||||||
|
SHARE_PROVISIONING_PROFILE_NAME = "QueueCube Share AppStore CI"
|
||||||
CI_KEYCHAIN_NAME = "queuecube_ci_keychain"
|
CI_KEYCHAIN_NAME = "queuecube_ci_keychain"
|
||||||
CI_KEYCHAIN_PASSWORD = "queuecube-ci-keychain-password"
|
CI_KEYCHAIN_PASSWORD = "queuecube-ci-keychain-password"
|
||||||
CI_KEYCHAIN_DB_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}-db")
|
CI_KEYCHAIN_DB_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}-db")
|
||||||
@@ -108,6 +110,8 @@ def upload_export_options
|
|||||||
<dict>
|
<dict>
|
||||||
<key>#{BUNDLE_IDENTIFIER}</key>
|
<key>#{BUNDLE_IDENTIFIER}</key>
|
||||||
<string>#{PROVISIONING_PROFILE_NAME}</string>
|
<string>#{PROVISIONING_PROFILE_NAME}</string>
|
||||||
|
<key>#{SHARE_BUNDLE_IDENTIFIER}</key>
|
||||||
|
<string>#{SHARE_PROVISIONING_PROFILE_NAME}</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>manageAppVersionAndBuildNumber</key>
|
<key>manageAppVersionAndBuildNumber</key>
|
||||||
<false/>
|
<false/>
|
||||||
@@ -156,18 +160,23 @@ platform :ios do
|
|||||||
end
|
end
|
||||||
|
|
||||||
private_lane :sync_signing do |options|
|
private_lane :sync_signing do |options|
|
||||||
match(
|
{
|
||||||
type: "appstore",
|
BUNDLE_IDENTIFIER => PROVISIONING_PROFILE_NAME,
|
||||||
readonly: options.fetch(:readonly),
|
SHARE_BUNDLE_IDENTIFIER => SHARE_PROVISIONING_PROFILE_NAME
|
||||||
app_identifier: BUNDLE_IDENTIFIER,
|
}.each do |identifier, profile_name|
|
||||||
team_id: DEVELOPMENT_TEAM,
|
match(
|
||||||
profile_name: PROVISIONING_PROFILE_NAME,
|
type: "appstore",
|
||||||
git_url: ENV.fetch("MATCH_GIT_URL"),
|
readonly: options.fetch(:readonly),
|
||||||
git_branch: ENV.fetch("MATCH_GIT_BRANCH", "master"),
|
app_identifier: identifier,
|
||||||
git_full_name: "QueueCube Release Bot",
|
team_id: DEVELOPMENT_TEAM,
|
||||||
git_user_email: "james.magahern@me.com",
|
profile_name: profile_name,
|
||||||
api_key: options.fetch(:api_key)
|
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
|
end
|
||||||
|
|
||||||
desc "Create or update match signing assets"
|
desc "Create or update match signing assets"
|
||||||
@@ -200,12 +209,12 @@ platform :ios do
|
|||||||
"clean",
|
"clean",
|
||||||
"archive",
|
"archive",
|
||||||
"DEVELOPMENT_TEAM=#{DEVELOPMENT_TEAM}",
|
"DEVELOPMENT_TEAM=#{DEVELOPMENT_TEAM}",
|
||||||
"PRODUCT_BUNDLE_IDENTIFIER=#{BUNDLE_IDENTIFIER}",
|
|
||||||
"MARKETING_VERSION=#{version}",
|
"MARKETING_VERSION=#{version}",
|
||||||
"CURRENT_PROJECT_VERSION=#{build}",
|
"CURRENT_PROJECT_VERSION=#{build}",
|
||||||
"CODE_SIGN_STYLE=Manual",
|
"CODE_SIGN_STYLE=Manual",
|
||||||
"CODE_SIGN_IDENTITY=Apple Distribution",
|
"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
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **Settings.swift**: Manages multiple server configurations stored in UserDefaults with validation through `SettingsViewModel` that tests connectivity on URL changes.
|
||||||
|
|
||||||
### Data Flow
|
### Data Flow
|
||||||
@@ -64,7 +67,8 @@ The server API includes these endpoints:
|
|||||||
- `GET /playlist` - Current playlist items
|
- `GET /playlist` - Current playlist items
|
||||||
- `GET /favorites` - User favorites
|
- `GET /favorites` - User favorites
|
||||||
- `POST /play`, `/pause`, `/skip`, `/previous` - Playback controls
|
- `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
|
- `DELETE /playlist/{index}` - Remove playlist item
|
||||||
- `POST /volume` - Set volume level
|
- `POST /volume` - Set volume level
|
||||||
- `WS /events` - WebSocket for real-time updates
|
- `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
|
- **PlaylistView**: Scrollable list of queued media with reorder/delete actions, includes embedded NowPlayingView
|
||||||
- **NowPlayingView**: Playback controls and current track display
|
- **NowPlayingView**: Playback controls and current track display
|
||||||
- **AddMediaBarView**: Input field for adding new media URLs to playlist
|
- **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;
|
objectVersion = 77;
|
||||||
objects = {
|
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 */
|
/* Begin PBXFileReference section */
|
||||||
CD4E9B972D7691C20066FC17 /* QueueCube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QueueCube.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -19,6 +64,14 @@
|
|||||||
);
|
);
|
||||||
target = CD4E9B962D7691C20066FC17 /* QueueCube */;
|
target = CD4E9B962D7691C20066FC17 /* QueueCube */;
|
||||||
};
|
};
|
||||||
|
CD2026102000000000000006 /* Exceptions for "QueueCubeShare" folder in "QueueCubeShare" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Entitlements.plist,
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = CD2026102000000000000004 /* QueueCubeShare */;
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -30,6 +83,14 @@
|
|||||||
path = QueueCube;
|
path = QueueCube;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
CD2026102000000000000007 /* QueueCubeShare */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
CD2026102000000000000006 /* Exceptions for "QueueCubeShare" folder in "QueueCubeShare" target */,
|
||||||
|
);
|
||||||
|
path = QueueCubeShare;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -40,6 +101,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
CD2026102000000000000008 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -47,6 +115,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
CD4E9B992D7691C20066FC17 /* QueueCube */,
|
CD4E9B992D7691C20066FC17 /* QueueCube */,
|
||||||
|
CD2026102000000000000007 /* QueueCubeShare */,
|
||||||
|
CD2026102000000000000009 /* Shared Extension Sources */,
|
||||||
CD4E9B982D7691C20066FC17 /* Products */,
|
CD4E9B982D7691C20066FC17 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -55,10 +125,27 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
CD4E9B972D7691C20066FC17 /* QueueCube.app */,
|
CD4E9B972D7691C20066FC17 /* QueueCube.app */,
|
||||||
|
CD2026102000000000000002 /* QueueCubeShare.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -69,10 +156,12 @@
|
|||||||
CD4E9B932D7691C20066FC17 /* Sources */,
|
CD4E9B932D7691C20066FC17 /* Sources */,
|
||||||
CD4E9B942D7691C20066FC17 /* Frameworks */,
|
CD4E9B942D7691C20066FC17 /* Frameworks */,
|
||||||
CD4E9B952D7691C20066FC17 /* Resources */,
|
CD4E9B952D7691C20066FC17 /* Resources */,
|
||||||
|
CD2026102000000000000005 /* Embed App Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
CD202610200000000000000A /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
CD4E9B992D7691C20066FC17 /* QueueCube */,
|
CD4E9B992D7691C20066FC17 /* QueueCube */,
|
||||||
@@ -84,6 +173,28 @@
|
|||||||
productReference = CD4E9B972D7691C20066FC17 /* QueueCube.app */;
|
productReference = CD4E9B972D7691C20066FC17 /* QueueCube.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -94,8 +205,17 @@
|
|||||||
LastSwiftUpdateCheck = 1700;
|
LastSwiftUpdateCheck = 1700;
|
||||||
LastUpgradeCheck = 1700;
|
LastUpgradeCheck = 1700;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
|
CD2026102000000000000004 = {
|
||||||
|
CreatedOnToolsVersion = 26.5;
|
||||||
|
SystemCapabilities = {
|
||||||
|
com.apple.ApplicationGroups.iOS = {enabled = 1; };
|
||||||
|
};
|
||||||
|
};
|
||||||
CD4E9B962D7691C20066FC17 = {
|
CD4E9B962D7691C20066FC17 = {
|
||||||
CreatedOnToolsVersion = 17.0;
|
CreatedOnToolsVersion = 17.0;
|
||||||
|
SystemCapabilities = {
|
||||||
|
com.apple.ApplicationGroups.iOS = {enabled = 1; };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -114,6 +234,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
CD4E9B962D7691C20066FC17 /* QueueCube */,
|
CD4E9B962D7691C20066FC17 /* QueueCube */,
|
||||||
|
CD2026102000000000000004 /* QueueCubeShare */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -126,6 +247,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
CD202610200000000000000D /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -136,8 +264,31 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
CD202610200000000000000A /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = CD2026102000000000000004 /* QueueCubeShare */;
|
||||||
|
targetProxy = CD2026102000000000000003 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
CD4E9BA02D7691C40066FC17 /* Debug */ = {
|
CD4E9BA02D7691C40066FC17 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -286,6 +437,7 @@
|
|||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.2;
|
MARKETING_VERSION = 1.5.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "$(QUEUECUBE_APP_PROFILE)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = YES;
|
SUPPORTS_MACCATALYST = YES;
|
||||||
@@ -322,6 +474,7 @@
|
|||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.2;
|
MARKETING_VERSION = 1.5.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "$(QUEUECUBE_APP_PROFILE)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = YES;
|
SUPPORTS_MACCATALYST = YES;
|
||||||
@@ -332,6 +485,76 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -353,6 +576,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
CD202610200000000000000B /* Build configuration list for PBXNativeTarget "QueueCubeShare" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
CD202610200000000000000E /* Debug */,
|
||||||
|
CD202610200000000000000F /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = CD4E9B8F2D7691C20066FC17 /* Project object */;
|
rootObject = CD4E9B8F2D7691C20066FC17 /* Project object */;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict/>
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.net.buzzert.QueueCube</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -70,11 +70,13 @@ struct NowPlayingInfo: Codable
|
|||||||
actor API
|
actor API
|
||||||
{
|
{
|
||||||
let baseURL: URL
|
let baseURL: URL
|
||||||
|
private let requestTimeout: TimeInterval
|
||||||
|
|
||||||
private var pingTask: Task<(), any Swift.Error>? = nil
|
private var pingTask: Task<(), any Swift.Error>? = nil
|
||||||
|
|
||||||
init(baseURL: URL) {
|
init(baseURL: URL, requestTimeout: TimeInterval = 60) {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
|
self.requestTimeout = requestTimeout
|
||||||
}
|
}
|
||||||
|
|
||||||
public func fetchNowPlayingInfo() async throws -> NowPlayingInfo {
|
public func fetchNowPlayingInfo() async throws -> NowPlayingInfo {
|
||||||
@@ -260,7 +262,7 @@ actor API
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func request() -> RequestBuilder {
|
private func request() -> RequestBuilder {
|
||||||
RequestBuilder(url: baseURL)
|
RequestBuilder(url: baseURL, timeout: requestTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Types
|
// 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 Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
struct Server: Identifiable, Codable, Equatable
|
struct Server: Identifiable, Codable, Equatable
|
||||||
{
|
{
|
||||||
@@ -28,10 +29,22 @@ struct Server: Identifiable, Codable, Equatable
|
|||||||
init?(serviceName: String?, host: String, port: UInt16) {
|
init?(serviceName: String?, host: String, port: UInt16) {
|
||||||
self.serviceName = serviceName
|
self.serviceName = serviceName
|
||||||
|
|
||||||
// Assumes this is the local service discovery path, which is http
|
// Network.framework can append an interface to IPv4 and IPv6 addresses.
|
||||||
// Bounjour gives us the interface sometimes, which we can handle, but need to percent encode.
|
// URL hosts only support that scope on IPv6 literals.
|
||||||
let host = host.replacingOccurrences(of: "%", with: "%25")
|
var host = host
|
||||||
guard let url = URL(string: "http://\(host):\(port)/api") else {
|
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
|
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
|
import Foundation
|
||||||
|
|
||||||
struct Settings
|
struct Settings: Codable
|
||||||
{
|
{
|
||||||
var selectedServer: Server?
|
var selectedServer: Server?
|
||||||
|
|
||||||
@@ -27,22 +27,44 @@ struct Settings
|
|||||||
|
|
||||||
var isConfigured: Bool { !configuredServers.isEmpty }
|
var isConfigured: Bool { !configuredServers.isEmpty }
|
||||||
|
|
||||||
static func fromDefaults() -> Settings {
|
static func fromDefaults(defaults: UserDefaults = .standard,
|
||||||
let defaults = UserDefaults.standard
|
sharedDefaults: UserDefaults? = UserDefaults(suiteName: appGroupIdentifier)) -> Settings {
|
||||||
return Settings(
|
let settings = Settings(
|
||||||
selectedServer: defaults[SelectedServerKey.self],
|
selectedServer: defaults[SelectedServerKey.self],
|
||||||
configuredServers: defaults[ConfiguredServersKey.self]
|
configuredServers: defaults[ConfiguredServersKey.self]
|
||||||
)
|
)
|
||||||
|
settings.publishForSharing(defaults: sharedDefaults)
|
||||||
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
func save() {
|
func save(defaults: UserDefaults = .standard,
|
||||||
let defaults = UserDefaults.standard
|
sharedDefaults: UserDefaults? = UserDefaults(suiteName: Self.appGroupIdentifier)) {
|
||||||
defaults[ConfiguredServersKey.self] = configuredServers
|
defaults[ConfiguredServersKey.self] = configuredServers
|
||||||
defaults[SelectedServerKey.self] = selectedServer
|
defaults[SelectedServerKey.self] = selectedServer
|
||||||
|
|
||||||
|
publishForSharing(defaults: sharedDefaults)
|
||||||
postSettingsChanged()
|
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() {
|
func postSettingsChanged() {
|
||||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
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 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
|
extension Color
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
// Created by James Magahern on 6/10/25.
|
// Created by James Magahern on 6/10/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Network
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AddServerView: View
|
struct AddServerView: View
|
||||||
@@ -60,14 +59,18 @@ struct AddServerView: View
|
|||||||
|
|
||||||
// Discovered
|
// Discovered
|
||||||
Section(.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 {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
Text(.findingServers)
|
Text(.findingServers)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
List(model.discoveredServers) { (server: DiscoveredEndpoint) in
|
List(model.discovery.servers) { (server: DiscoveredEndpoint) in
|
||||||
Button {
|
Button {
|
||||||
resolveEndpoint(server)
|
resolveEndpoint(server)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -90,23 +93,30 @@ struct AddServerView: View
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task {
|
.task {
|
||||||
model.startDiscovery()
|
model.discovery.start()
|
||||||
}
|
}
|
||||||
|
.onDisappear { model.discovery.stop() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveEndpoint(_ endpoint: DiscoveredEndpoint) {
|
private func resolveEndpoint(_ endpoint: DiscoveredEndpoint) {
|
||||||
Task {
|
Task {
|
||||||
model.resolvingServers.insert(endpoint)
|
model.resolvingServers.insert(endpoint)
|
||||||
|
model.resolutionError = nil
|
||||||
let server = try await endpoint.resolve()
|
defer { model.resolvingServers.remove(endpoint) }
|
||||||
onAddServer(server)
|
do {
|
||||||
|
let server = try await endpoint.resolve()
|
||||||
model.resolvingServers.remove(endpoint)
|
onAddServer(server)
|
||||||
|
} catch is CancellationError {
|
||||||
|
// The view or its caller no longer needs this result.
|
||||||
|
} catch {
|
||||||
|
model.resolutionError = error.localizedDescription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Types
|
// MARK: - Types
|
||||||
|
|
||||||
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
class ViewModel
|
class ViewModel
|
||||||
{
|
{
|
||||||
@@ -114,10 +124,9 @@ struct AddServerView: View
|
|||||||
var validationURL: String = ""
|
var validationURL: String = ""
|
||||||
var validationState: ValidationState = .empty
|
var validationState: ValidationState = .empty
|
||||||
|
|
||||||
var discoveredServers: [DiscoveredEndpoint] = []
|
let discovery = ServerDiscovery()
|
||||||
var resolvingServers = Set<DiscoveredEndpoint>()
|
var resolvingServers = Set<DiscoveredEndpoint>()
|
||||||
|
var resolutionError: String?
|
||||||
private let browser = NWBrowser(for: .bonjour(type: "_queuecube._tcp.", domain: "local."), using: .tcp)
|
|
||||||
|
|
||||||
private var validationTimer: Timer? = nil
|
private var validationTimer: Timer? = nil
|
||||||
|
|
||||||
@@ -125,21 +134,6 @@ struct AddServerView: View
|
|||||||
observeForValidation()
|
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() {
|
private func observeForValidation() {
|
||||||
withObservationTracking {
|
withObservationTracking {
|
||||||
_ = serverURL
|
_ = serverURL
|
||||||
@@ -156,7 +150,9 @@ struct AddServerView: View
|
|||||||
self.validationURL = self.serverURL
|
self.validationURL = self.serverURL
|
||||||
self.validationTimer?.invalidate()
|
self.validationTimer?.invalidate()
|
||||||
self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
|
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