This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
@@ -0,0 +1,66 @@
|
||||
name: iOS build and tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ios-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
simulator:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Inspect toolchain and project
|
||||
run: |
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
python3 tools/validate_project.py
|
||||
xcodebuild -list -project MusicBridge.xcodeproj
|
||||
- name: Choose available iPhone simulator
|
||||
run: |
|
||||
xcrun simctl list devices available --json > /tmp/musicbridge-simulators.json
|
||||
python3 tools/select_simulator.py /tmp/musicbridge-simulators.json >> "$GITHUB_ENV"
|
||||
- name: Build and run XCTest
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p build
|
||||
xcodebuild test \
|
||||
-project MusicBridge.xcodeproj \
|
||||
-scheme MusicBridge \
|
||||
-configuration Debug \
|
||||
-destination "platform=iOS Simulator,id=$SIMULATOR_ID" \
|
||||
-destination-timeout 120 \
|
||||
-derivedDataPath DerivedData \
|
||||
-resultBundlePath build/MusicBridge.xcresult \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
| tee build/xcodebuild.log
|
||||
- name: Save diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-test-results
|
||||
path: |
|
||||
build/xcodebuild.log
|
||||
build/MusicBridge.xcresult
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
- name: Archive simulator application
|
||||
run: ditto -c -k --sequesterRsrc --keepParent DerivedData/Build/Products/Debug-iphonesimulator/MusicBridge.app build/MusicBridge-simulator.zip
|
||||
- name: Save simulator application
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MusicBridge-simulator
|
||||
path: build/MusicBridge-simulator.zip
|
||||
retention-days: 7
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
.DS_Store
|
||||
DerivedData/
|
||||
build/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
*.xcresult/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.p12
|
||||
*.mobileprovision
|
||||
*.ipa
|
||||
@@ -0,0 +1,329 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
"archiveVersion" = 1;
|
||||
"classes" = {
|
||||
};
|
||||
"objectVersion" = 56;
|
||||
"objects" = {
|
||||
"065A9D559DC80AAF876DA757" = {
|
||||
"isa" = "PBXFileReference";
|
||||
"lastKnownFileType" = "sourcecode.swift";
|
||||
"path" = "BridgeClient.swift";
|
||||
"sourceTree" = "<group>";
|
||||
};
|
||||
"82F1C33CE4F8B8F641FF0461" = {
|
||||
"isa" = "PBXBuildFile";
|
||||
"fileRef" = "065A9D559DC80AAF876DA757";
|
||||
};
|
||||
"971C0AE92F167DE7AB145086" = {
|
||||
"isa" = "PBXFileReference";
|
||||
"lastKnownFileType" = "sourcecode.swift";
|
||||
"path" = "ContentView.swift";
|
||||
"sourceTree" = "<group>";
|
||||
};
|
||||
"16EA630DE1FE9F0A16580C9D" = {
|
||||
"isa" = "PBXBuildFile";
|
||||
"fileRef" = "971C0AE92F167DE7AB145086";
|
||||
};
|
||||
"76FB6BFFC478599567424755" = {
|
||||
"isa" = "PBXFileReference";
|
||||
"lastKnownFileType" = "sourcecode.swift";
|
||||
"path" = "KeychainStore.swift";
|
||||
"sourceTree" = "<group>";
|
||||
};
|
||||
"DA06B3B12E52FB69A6A34887" = {
|
||||
"isa" = "PBXBuildFile";
|
||||
"fileRef" = "76FB6BFFC478599567424755";
|
||||
};
|
||||
"124CF3F5AC075D1D6042DF64" = {
|
||||
"isa" = "PBXFileReference";
|
||||
"lastKnownFileType" = "sourcecode.swift";
|
||||
"path" = "Models.swift";
|
||||
"sourceTree" = "<group>";
|
||||
};
|
||||
"DEDCCD95A140162B6D654871" = {
|
||||
"isa" = "PBXBuildFile";
|
||||
"fileRef" = "124CF3F5AC075D1D6042DF64";
|
||||
};
|
||||
"ADF0CD56882201BD3AC1581B" = {
|
||||
"isa" = "PBXFileReference";
|
||||
"lastKnownFileType" = "sourcecode.swift";
|
||||
"path" = "MusicBridgeApp.swift";
|
||||
"sourceTree" = "<group>";
|
||||
};
|
||||
"48E2FDCA11A6694953C86E6D" = {
|
||||
"isa" = "PBXBuildFile";
|
||||
"fileRef" = "ADF0CD56882201BD3AC1581B";
|
||||
};
|
||||
"9C432A0DA0BEC173839EBF86" = {
|
||||
"isa" = "PBXFileReference";
|
||||
"lastKnownFileType" = "sourcecode.swift";
|
||||
"path" = "PlayerStore.swift";
|
||||
"sourceTree" = "<group>";
|
||||
};
|
||||
"47693934E8A974162CF08A38" = {
|
||||
"isa" = "PBXBuildFile";
|
||||
"fileRef" = "9C432A0DA0BEC173839EBF86";
|
||||
};
|
||||
"026F39792BBA0E5032536CED" = {
|
||||
"isa" = "PBXGroup";
|
||||
"path" = "MusicBridge";
|
||||
"sourceTree" = "<group>";
|
||||
"children" = ("065A9D559DC80AAF876DA757", "971C0AE92F167DE7AB145086", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86",);
|
||||
};
|
||||
"ACD0393DCDDDF044F2966340" = {
|
||||
"isa" = "PBXFileReference";
|
||||
"lastKnownFileType" = "sourcecode.swift";
|
||||
"path" = "ProtocolTests.swift";
|
||||
"sourceTree" = "<group>";
|
||||
};
|
||||
"17E248E58CB43C680B48DD51" = {
|
||||
"isa" = "PBXBuildFile";
|
||||
"fileRef" = "ACD0393DCDDDF044F2966340";
|
||||
};
|
||||
"E1AD768E105BF42350625991" = {
|
||||
"isa" = "PBXGroup";
|
||||
"path" = "MusicBridgeTests";
|
||||
"sourceTree" = "<group>";
|
||||
"children" = ("ACD0393DCDDDF044F2966340", "F16D05EC6B29248D2C61ADB1",);
|
||||
};
|
||||
"F16D05EC6B29248D2C61ADB1" = {
|
||||
"isa" = "PBXFileReference";
|
||||
"lastKnownFileType" = "text.json";
|
||||
"path" = "state-v1.json";
|
||||
"sourceTree" = "<group>";
|
||||
};
|
||||
"F8A2DE539EBA355A3807D729" = {
|
||||
"isa" = "PBXBuildFile";
|
||||
"fileRef" = "F16D05EC6B29248D2C61ADB1";
|
||||
};
|
||||
"7C35A0276A762371ABAE5596" = {
|
||||
"isa" = "PBXFileReference";
|
||||
"explicitFileType" = "wrapper.application";
|
||||
"includeInIndex" = 0;
|
||||
"path" = "MusicBridge.app";
|
||||
"sourceTree" = "BUILT_PRODUCTS_DIR";
|
||||
};
|
||||
"72B74C8A2143F64FD53D71E5" = {
|
||||
"isa" = "PBXSourcesBuildPhase";
|
||||
"buildActionMask" = 2147483647;
|
||||
"files" = ("82F1C33CE4F8B8F641FF0461", "16EA630DE1FE9F0A16580C9D", "DA06B3B12E52FB69A6A34887", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38",);
|
||||
"runOnlyForDeploymentPostprocessing" = 0;
|
||||
};
|
||||
"734C4650C7A5604092763C32" = {
|
||||
"isa" = "PBXFrameworksBuildPhase";
|
||||
"buildActionMask" = 2147483647;
|
||||
"files" = ();
|
||||
"runOnlyForDeploymentPostprocessing" = 0;
|
||||
};
|
||||
"69816CB820E8173702C54C37" = {
|
||||
"isa" = "PBXResourcesBuildPhase";
|
||||
"buildActionMask" = 2147483647;
|
||||
"files" = ();
|
||||
"runOnlyForDeploymentPostprocessing" = 0;
|
||||
};
|
||||
"E3322BA79DBD9A84650B2CAE" = {
|
||||
"isa" = "XCBuildConfiguration";
|
||||
"name" = "Debug";
|
||||
"buildSettings" = {
|
||||
"PRODUCT_NAME" = "$(TARGET_NAME)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge";
|
||||
"SWIFT_VERSION" = "5.0";
|
||||
"IPHONEOS_DEPLOYMENT_TARGET" = "17.0";
|
||||
"SDKROOT" = "iphoneos";
|
||||
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
|
||||
"TARGETED_DEVICE_FAMILY" = "1";
|
||||
"CODE_SIGN_STYLE" = "Automatic";
|
||||
"GENERATE_INFOPLIST_FILE" = "NO";
|
||||
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
|
||||
"CURRENT_PROJECT_VERSION" = "1";
|
||||
"MARKETING_VERSION" = "0.1.0";
|
||||
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
|
||||
"ENABLE_TESTABILITY" = "YES";
|
||||
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
|
||||
};
|
||||
};
|
||||
"154C0FDFB29F6EEF743EF418" = {
|
||||
"isa" = "XCBuildConfiguration";
|
||||
"name" = "Release";
|
||||
"buildSettings" = {
|
||||
"PRODUCT_NAME" = "$(TARGET_NAME)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge";
|
||||
"SWIFT_VERSION" = "5.0";
|
||||
"IPHONEOS_DEPLOYMENT_TARGET" = "17.0";
|
||||
"SDKROOT" = "iphoneos";
|
||||
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
|
||||
"TARGETED_DEVICE_FAMILY" = "1";
|
||||
"CODE_SIGN_STYLE" = "Automatic";
|
||||
"GENERATE_INFOPLIST_FILE" = "NO";
|
||||
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
|
||||
"CURRENT_PROJECT_VERSION" = "1";
|
||||
"MARKETING_VERSION" = "0.1.0";
|
||||
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
|
||||
};
|
||||
};
|
||||
"C6336ED5908EABEBF60AE675" = {
|
||||
"isa" = "XCConfigurationList";
|
||||
"buildConfigurations" = ("E3322BA79DBD9A84650B2CAE", "154C0FDFB29F6EEF743EF418",);
|
||||
"defaultConfigurationIsVisible" = 0;
|
||||
"defaultConfigurationName" = "Release";
|
||||
};
|
||||
"F380385F14BF54F0FA81EEAE" = {
|
||||
"isa" = "PBXNativeTarget";
|
||||
"name" = "MusicBridge";
|
||||
"productName" = "MusicBridge";
|
||||
"productReference" = "7C35A0276A762371ABAE5596";
|
||||
"productType" = "com.apple.product-type.application";
|
||||
"buildConfigurationList" = "C6336ED5908EABEBF60AE675";
|
||||
"buildPhases" = ("72B74C8A2143F64FD53D71E5", "734C4650C7A5604092763C32", "69816CB820E8173702C54C37",);
|
||||
"buildRules" = ();
|
||||
"dependencies" = ();
|
||||
};
|
||||
"5E4BE508157A7B2CB7B4C2F2" = {
|
||||
"isa" = "PBXFileReference";
|
||||
"explicitFileType" = "wrapper.cfbundle";
|
||||
"includeInIndex" = 0;
|
||||
"path" = "MusicBridgeTests.xctest";
|
||||
"sourceTree" = "BUILT_PRODUCTS_DIR";
|
||||
};
|
||||
"AB453A3F0E1490D48374A69A" = {
|
||||
"isa" = "PBXSourcesBuildPhase";
|
||||
"buildActionMask" = 2147483647;
|
||||
"files" = ("17E248E58CB43C680B48DD51",);
|
||||
"runOnlyForDeploymentPostprocessing" = 0;
|
||||
};
|
||||
"9AA2A4C344B5C474AF5D336C" = {
|
||||
"isa" = "PBXFrameworksBuildPhase";
|
||||
"buildActionMask" = 2147483647;
|
||||
"files" = ();
|
||||
"runOnlyForDeploymentPostprocessing" = 0;
|
||||
};
|
||||
"5D92C2C610AA78B83DDF2133" = {
|
||||
"isa" = "PBXResourcesBuildPhase";
|
||||
"buildActionMask" = 2147483647;
|
||||
"files" = ("F8A2DE539EBA355A3807D729",);
|
||||
"runOnlyForDeploymentPostprocessing" = 0;
|
||||
};
|
||||
"DAB01D73CBBCF1FEDE84E2D6" = {
|
||||
"isa" = "PBXContainerItemProxy";
|
||||
"containerPortal" = "244210E48437B6556980A702";
|
||||
"proxyType" = 1;
|
||||
"remoteGlobalIDString" = "F380385F14BF54F0FA81EEAE";
|
||||
"remoteInfo" = "MusicBridge";
|
||||
};
|
||||
"F9A94CEF8CF7D85CE881E703" = {
|
||||
"isa" = "PBXTargetDependency";
|
||||
"target" = "F380385F14BF54F0FA81EEAE";
|
||||
"targetProxy" = "DAB01D73CBBCF1FEDE84E2D6";
|
||||
};
|
||||
"9CBAABBB5F735F2FB57FD4C4" = {
|
||||
"isa" = "XCBuildConfiguration";
|
||||
"name" = "Debug";
|
||||
"buildSettings" = {
|
||||
"PRODUCT_NAME" = "$(TARGET_NAME)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.tests";
|
||||
"SWIFT_VERSION" = "5.0";
|
||||
"IPHONEOS_DEPLOYMENT_TARGET" = "17.0";
|
||||
"SDKROOT" = "iphoneos";
|
||||
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
|
||||
"TARGETED_DEVICE_FAMILY" = "1";
|
||||
"CODE_SIGN_STYLE" = "Automatic";
|
||||
"GENERATE_INFOPLIST_FILE" = "YES";
|
||||
"TEST_HOST" = "$(BUILT_PRODUCTS_DIR)/MusicBridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MusicBridge";
|
||||
"BUNDLE_LOADER" = "$(TEST_HOST)";
|
||||
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
|
||||
"ENABLE_TESTABILITY" = "YES";
|
||||
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
|
||||
};
|
||||
};
|
||||
"725E9F925C55AFF7714F5047" = {
|
||||
"isa" = "XCBuildConfiguration";
|
||||
"name" = "Release";
|
||||
"buildSettings" = {
|
||||
"PRODUCT_NAME" = "$(TARGET_NAME)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER" = "ru.yukinoki.musicbridge.tests";
|
||||
"SWIFT_VERSION" = "5.0";
|
||||
"IPHONEOS_DEPLOYMENT_TARGET" = "17.0";
|
||||
"SDKROOT" = "iphoneos";
|
||||
"SUPPORTED_PLATFORMS" = "iphoneos iphonesimulator";
|
||||
"TARGETED_DEVICE_FAMILY" = "1";
|
||||
"CODE_SIGN_STYLE" = "Automatic";
|
||||
"GENERATE_INFOPLIST_FILE" = "YES";
|
||||
"TEST_HOST" = "$(BUILT_PRODUCTS_DIR)/MusicBridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MusicBridge";
|
||||
"BUNDLE_LOADER" = "$(TEST_HOST)";
|
||||
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
|
||||
};
|
||||
};
|
||||
"E7B318B477BE140FC789D2AF" = {
|
||||
"isa" = "XCConfigurationList";
|
||||
"buildConfigurations" = ("9CBAABBB5F735F2FB57FD4C4", "725E9F925C55AFF7714F5047",);
|
||||
"defaultConfigurationIsVisible" = 0;
|
||||
"defaultConfigurationName" = "Release";
|
||||
};
|
||||
"C2A8D97FC5416EA6103FBF9A" = {
|
||||
"isa" = "PBXNativeTarget";
|
||||
"name" = "MusicBridgeTests";
|
||||
"productName" = "MusicBridgeTests";
|
||||
"productReference" = "5E4BE508157A7B2CB7B4C2F2";
|
||||
"productType" = "com.apple.product-type.bundle.unit-test";
|
||||
"buildConfigurationList" = "E7B318B477BE140FC789D2AF";
|
||||
"buildPhases" = ("AB453A3F0E1490D48374A69A", "9AA2A4C344B5C474AF5D336C", "5D92C2C610AA78B83DDF2133",);
|
||||
"buildRules" = ();
|
||||
"dependencies" = ("F9A94CEF8CF7D85CE881E703",);
|
||||
};
|
||||
"0A3E27B8CA818264D75C8D81" = {
|
||||
"isa" = "PBXGroup";
|
||||
"name" = "Products";
|
||||
"sourceTree" = "<group>";
|
||||
"children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2",);
|
||||
};
|
||||
"0D6E4079E36703EBD37C0072" = {
|
||||
"isa" = "PBXGroup";
|
||||
"sourceTree" = "<group>";
|
||||
"children" = ("026F39792BBA0E5032536CED", "E1AD768E105BF42350625991", "0A3E27B8CA818264D75C8D81",);
|
||||
};
|
||||
"73EBF6644258C120893E685D" = {
|
||||
"isa" = "XCBuildConfiguration";
|
||||
"name" = "Debug";
|
||||
"buildSettings" = {
|
||||
"CLANG_ENABLE_MODULES" = "YES";
|
||||
"CLANG_ENABLE_OBJC_ARC" = "YES";
|
||||
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
|
||||
"ENABLE_TESTABILITY" = "YES";
|
||||
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
|
||||
};
|
||||
};
|
||||
"0B0C687DF8B6B8DC790B519C" = {
|
||||
"isa" = "XCBuildConfiguration";
|
||||
"name" = "Release";
|
||||
"buildSettings" = {
|
||||
"CLANG_ENABLE_MODULES" = "YES";
|
||||
"CLANG_ENABLE_OBJC_ARC" = "YES";
|
||||
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
|
||||
};
|
||||
};
|
||||
"76F8DEC3DFE0BDB465198890" = {
|
||||
"isa" = "XCConfigurationList";
|
||||
"buildConfigurations" = ("73EBF6644258C120893E685D", "0B0C687DF8B6B8DC790B519C",);
|
||||
"defaultConfigurationIsVisible" = 0;
|
||||
"defaultConfigurationName" = "Release";
|
||||
};
|
||||
"244210E48437B6556980A702" = {
|
||||
"isa" = "PBXProject";
|
||||
"attributes" = {
|
||||
"LastUpgradeCheck" = "1600";
|
||||
"BuildIndependentTargetsInParallel" = "YES";
|
||||
};
|
||||
"buildConfigurationList" = "76F8DEC3DFE0BDB465198890";
|
||||
"compatibilityVersion" = "Xcode 14.0";
|
||||
"developmentRegion" = "ru";
|
||||
"knownRegions" = ("ru", "en", "Base",);
|
||||
"mainGroup" = "0D6E4079E36703EBD37C0072";
|
||||
"productRefGroup" = "0A3E27B8CA818264D75C8D81";
|
||||
"projectDirPath" = "";
|
||||
"projectRoot" = "";
|
||||
"targets" = ("F380385F14BF54F0FA81EEAE", "C2A8D97FC5416EA6103FBF9A",);
|
||||
};
|
||||
};
|
||||
"rootObject" = "244210E48437B6556980A702";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion="1600" version="1.3">
|
||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"><BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F380385F14BF54F0FA81EEAE" BuildableName="MusicBridge.app" BlueprintName="MusicBridge" ReferencedContainer="container:MusicBridge.xcodeproj"/></BuildActionEntry>
|
||||
</BuildActionEntries></BuildAction>
|
||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"><Testables><TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="C2A8D97FC5416EA6103FBF9A" BuildableName="MusicBridgeTests.xctest" BlueprintName="MusicBridgeTests" ReferencedContainer="container:MusicBridge.xcodeproj"/></TestableReference></Testables></TestAction>
|
||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES"><BuildableProductRunnable runnableDebuggingMode="0"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F380385F14BF54F0FA81EEAE" BuildableName="MusicBridge.app" BlueprintName="MusicBridge" ReferencedContainer="container:MusicBridge.xcodeproj"/></BuildableProductRunnable></LaunchAction>
|
||||
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES"><BuildableProductRunnable runnableDebuggingMode="0"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F380385F14BF54F0FA81EEAE" BuildableName="MusicBridge.app" BlueprintName="MusicBridge" ReferencedContainer="container:MusicBridge.xcodeproj"/></BuildableProductRunnable></ProfileAction>
|
||||
<AnalyzeAction buildConfiguration="Debug"/><ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import CryptoKit
|
||||
|
||||
// One session per pinned PC. Never forward credentials to a redirected endpoint.
|
||||
final class PinnedDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
|
||||
let connection: Connection
|
||||
init(_ connection: Connection) { self.connection = connection }
|
||||
|
||||
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
challenge.protectionSpace.host == connection.endpoint.host,
|
||||
challenge.protectionSpace.port == (connection.endpoint.port ?? 443),
|
||||
let trust = challenge.protectionSpace.serverTrust,
|
||||
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], let leaf = chain.first else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil); return
|
||||
}
|
||||
let digest = SHA256.hash(data: SecCertificateCopyData(leaf) as Data)
|
||||
.map { String(format: "%02X", $0) }.joined()
|
||||
guard digest == connection.fingerprint else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil); return
|
||||
}
|
||||
// Trust only this exact out-of-band certificate, while checking its validity dates.
|
||||
SecTrustSetAnchorCertificates(trust, [leaf] as CFArray)
|
||||
SecTrustSetAnchorCertificatesOnly(trust, true)
|
||||
SecTrustSetPolicies(trust, SecPolicyCreateBasicX509())
|
||||
guard SecTrustEvaluateWithError(trust, nil) else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil); return
|
||||
}
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
final class BridgeClient {
|
||||
let connection: Connection
|
||||
private let session: URLSession
|
||||
private var socket: URLSessionWebSocketTask?
|
||||
|
||||
init(_ connection: Connection) {
|
||||
self.connection = connection
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 10
|
||||
config.timeoutIntervalForResource = 15
|
||||
config.urlCache = nil
|
||||
config.httpCookieStorage = nil
|
||||
session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil)
|
||||
}
|
||||
|
||||
func close() { socket?.cancel(with: .goingAway, reason: nil); session.invalidateAndCancel() }
|
||||
|
||||
private func request(_ path: String, body: Data? = nil, authenticated: Bool = true) -> URLRequest {
|
||||
var request = URLRequest(url: connection.endpoint.appendingPathComponent(path))
|
||||
if authenticated, let token = connection.token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||
if let body {
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
private func data(_ request: URLRequest, limit: Int = 256 * 1024) async throws -> Data {
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let response = response as? HTTPURLResponse else { throw BridgeError.message("Нет ответа от ПК.") }
|
||||
if response.statusCode == 401 { throw BridgeError.unauthorized }
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
throw BridgeError.message("ПК отклонил запрос (HTTP \(response.statusCode)). Проверьте код и состояние агента.")
|
||||
}
|
||||
var result = Data()
|
||||
for try await byte in bytes {
|
||||
guard result.count < limit else { throw BridgeError.message("Ответ ПК слишком большой.") }
|
||||
result.append(byte)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func pair(code: String) async throws -> String {
|
||||
let body = try JSONSerialization.data(withJSONObject: ["code": code, "deviceName": "MusicBridge iPhone"])
|
||||
let response = try JSONDecoder().decode(PairResponse.self, from: await data(request("v1/pair", body: body, authenticated: false)))
|
||||
guard response.success, let token = response.token, Connection.isFingerprint(token) else {
|
||||
throw BridgeError.message("Сопряжение отклонено: \(response.code)")
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func state() async throws -> MediaState {
|
||||
try Self.validate(JSONDecoder().decode(MediaState.self, from: await data(request("v1/state"))))
|
||||
}
|
||||
|
||||
static func validate(_ state: MediaState) throws -> MediaState {
|
||||
guard state.protocolVersion == 1 else { throw BridgeError.message("Версия протокола ПК не поддерживается.") }
|
||||
return state
|
||||
}
|
||||
|
||||
func command(_ command: Command) async throws {
|
||||
let response = try JSONDecoder().decode(CommandResponse.self, from: await data(request("v1/command", body: JSONEncoder().encode(command))))
|
||||
guard response.success else { throw BridgeError.message(response.message) }
|
||||
}
|
||||
|
||||
func artwork(_ id: String) async throws -> Data {
|
||||
guard Connection.isFingerprint(id) else { throw BridgeError.message("Неверный ID обложки.") }
|
||||
return try await data(request("v1/artwork/\(id)"), limit: 2 * 1024 * 1024)
|
||||
}
|
||||
|
||||
func openEvents() {
|
||||
var req = request("v1/events")
|
||||
var url = URLComponents(url: req.url!, resolvingAgainstBaseURL: false)!
|
||||
url.scheme = "wss"
|
||||
req.url = url.url!
|
||||
let socket = session.webSocketTask(with: req)
|
||||
socket.maximumMessageSize = 256 * 1024
|
||||
self.socket = socket
|
||||
socket.resume()
|
||||
}
|
||||
|
||||
func nextState() async throws -> MediaState {
|
||||
guard let socket else { throw BridgeError.message("Соединение закрыто.") }
|
||||
let message = try await socket.receive()
|
||||
let payload: Data
|
||||
switch message {
|
||||
case .data(let data): payload = data
|
||||
case .string(let string): payload = Data(string.utf8)
|
||||
@unknown default: throw BridgeError.message("Неизвестный формат сообщения.")
|
||||
}
|
||||
let event = try JSONDecoder().decode(StateEvent.self, from: payload)
|
||||
guard event.type == "state" else { throw BridgeError.message("Неизвестное событие.") }
|
||||
return try Self.validate(event.state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@ObservedObject var store: PlayerStore
|
||||
@State private var showForget = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Label(store.status, systemImage: store.connected ? "wifi" : "wifi.slash")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
if store.connection == nil {
|
||||
PairingView(store: store)
|
||||
} else {
|
||||
PlayerView(store: store)
|
||||
Text(store.connection?.endpoint.host ?? "")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if !store.connected {
|
||||
Button("Подключиться снова") { store.resume() }
|
||||
}
|
||||
Button("Забыть компьютер", role: .destructive) { showForget = true }
|
||||
.font(.footnote)
|
||||
}
|
||||
if let error = store.error {
|
||||
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled)
|
||||
}
|
||||
}.padding(24)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("MusicBridge")
|
||||
.confirmationDialog("Забыть этот компьютер?", isPresented: $showForget, titleVisibility: .visible) {
|
||||
Button("Забыть", role: .destructive) { store.forget() }
|
||||
} message: {
|
||||
Text("Токен будет удалён с iPhone. Чтобы отозвать доступ на ПК, удалите устройство в окне MusicBridge.")
|
||||
}
|
||||
}.tint(.indigo)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PairingView: View {
|
||||
@ObservedObject var store: PlayerStore
|
||||
@State private var endpoint = "https://"
|
||||
@State private var fingerprint = ""
|
||||
@State private var code = ""
|
||||
@State private var invitation = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Image(systemName: "desktopcomputer.and.arrow.down")
|
||||
.font(.system(size: 48)).foregroundStyle(.indigo)
|
||||
.frame(maxWidth: .infinity).accessibilityHidden(true)
|
||||
Text("Музыка на ПК. Управление здесь.").font(.title2.bold())
|
||||
Text("Откройте сопряжение в MusicBridge на Windows. Подключите iPhone к той же домашней сети.")
|
||||
.foregroundStyle(.secondary)
|
||||
Group {
|
||||
TextField("Адрес ПК: https://192.168.1.10:8765", text: $endpoint).keyboardType(.URL)
|
||||
TextField("SHA-256 сертификата с экрана ПК", text: $fingerprint)
|
||||
TextField("Восьмизначный код", text: $code).keyboardType(.numberPad)
|
||||
}.textFieldStyle(.roundedBorder).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
Text("Сверьте весь отпечаток с экраном ПК перед подключением.").font(.caption).foregroundStyle(.secondary)
|
||||
Button {
|
||||
Task { await store.pair(endpoint: endpoint.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
fingerprint: fingerprint.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
code: code.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
||||
} label: {
|
||||
HStack {
|
||||
if store.pairing { ProgressView().tint(.white) }
|
||||
Text(store.pairing ? "Сопряжение…" : "Подключить компьютер")
|
||||
}.frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing)
|
||||
DisclosureGroup("Импорт строки из QR") {
|
||||
VStack(spacing: 12) {
|
||||
TextField("musicbridge://pair?data=…", text: $invitation, axis: .vertical)
|
||||
.lineLimit(3...5).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
Button("Заполнить из приглашения") {
|
||||
do {
|
||||
let parsed = try Invitation.parse(invitation.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
endpoint = parsed.endpoint
|
||||
fingerprint = parsed.certificateSha256
|
||||
code = parsed.code
|
||||
store.error = nil
|
||||
} catch { store.error = error.localizedDescription }
|
||||
}
|
||||
}.padding(.top, 12)
|
||||
}.disabled(store.pairing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlayerView: View {
|
||||
@ObservedObject var store: PlayerStore
|
||||
@State private var seekValue = 0.0
|
||||
@State private var seeking = false
|
||||
@State private var volumeValue = 0.0
|
||||
@State private var changingVolume = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Group {
|
||||
if let artwork = store.artwork {
|
||||
Image(uiImage: artwork).resizable().scaledToFit()
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 24).fill(.indigo.opacity(0.12))
|
||||
.overlay { Image(systemName: "music.note").font(.system(size: 72)).foregroundStyle(.indigo) }
|
||||
}
|
||||
}.frame(height: 270).clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
.accessibilityLabel("Обложка трека")
|
||||
if let state = store.state {
|
||||
Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК")
|
||||
.font(.title2.bold()).multilineTextAlignment(.center)
|
||||
Text(state.artist).foregroundStyle(.secondary)
|
||||
TimelineView(.periodic(from: .now, by: 0.25)) { timeline in
|
||||
let position = state.position(elapsed: timeline.date.timeIntervalSince(store.receivedAt))
|
||||
VStack {
|
||||
Slider(value: Binding(get: { seeking ? seekValue : position }, set: { seekValue = $0 }),
|
||||
in: state.minSeekSeconds...max(state.minSeekSeconds + 0.001, state.maxSeekSeconds)) { editing in
|
||||
if editing { seekValue = position }
|
||||
seeking = editing
|
||||
if !editing { Task { await store.send("seek", value: seekValue) } }
|
||||
}.disabled(!state.capabilities.seek || state.maxSeekSeconds <= state.minSeekSeconds || store.busy)
|
||||
.accessibilityLabel("Позиция трека")
|
||||
HStack {
|
||||
Text(time((seeking ? seekValue : position) - state.startSeconds))
|
||||
Spacer()
|
||||
Text(time(state.endSeconds - state.startSeconds))
|
||||
}.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 36) {
|
||||
control("previous", icon: "backward.end.fill", label: "Предыдущий трек", enabled: state.capabilities.previous)
|
||||
let playing = state.playbackStatus == "Playing"
|
||||
let command = state.capabilities.toggle ? "toggle" : (playing ? "pause" : "play")
|
||||
control(command, icon: playing ? "pause.circle.fill" : "play.circle.fill", label: playing ? "Пауза" : "Воспроизвести",
|
||||
enabled: state.capabilities.toggle || (playing ? state.capabilities.pause : state.capabilities.play), size: 64)
|
||||
control("next", icon: "forward.end.fill", label: "Следующий трек", enabled: state.capabilities.next)
|
||||
}
|
||||
HStack {
|
||||
control("toggleMute", icon: state.muted == true ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||
label: state.muted == true ? "Включить звук ПК" : "Выключить звук ПК", enabled: state.muted != nil, size: 22)
|
||||
Slider(value: Binding(get: { changingVolume ? volumeValue : (state.volume ?? 0) }, set: { volumeValue = $0 }), in: 0...1) { editing in
|
||||
if editing { volumeValue = state.volume ?? 0 }
|
||||
changingVolume = editing
|
||||
if !editing { Task { await store.send("volume", value: volumeValue) } }
|
||||
}.disabled(state.volume == nil || store.busy).accessibilityLabel("Системная громкость ПК")
|
||||
}
|
||||
ForEach([state.mediaError, state.volumeError, state.artworkError].compactMap { $0 }, id: \.self) {
|
||||
Text($0).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Ожидаем состояние компьютера").foregroundStyle(.secondary)
|
||||
}
|
||||
}.disabled(!store.connected)
|
||||
}
|
||||
|
||||
private func control(_ type: String, icon: String, label: String, enabled: Bool, size: CGFloat = 28) -> some View {
|
||||
Button { Task { await store.send(type) } } label: {
|
||||
Image(systemName: icon).font(.system(size: size)).frame(minWidth: 44, minHeight: 44)
|
||||
}.disabled(!enabled || store.busy).accessibilityLabel(label)
|
||||
}
|
||||
|
||||
private func time(_ seconds: Double) -> String {
|
||||
let value = Int(min(max(0, seconds), 359999))
|
||||
return String(format: "%d:%02d", value / 60, value % 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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>CFBundleDevelopmentRegion</key>
|
||||
<string>ru</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>MusicBridge</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainStore {
|
||||
private static var query: [String: Any] {
|
||||
[kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "MusicBridge.connection.v1", kSecAttrAccount as String: "primaryPC"]
|
||||
}
|
||||
|
||||
static func load() throws -> Connection? {
|
||||
var q = query
|
||||
q[kSecReturnData as String] = true
|
||||
q[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(q as CFDictionary, &item)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = item as? Data else { throw failure(status) }
|
||||
let saved = try JSONDecoder().decode(Connection.self, from: data)
|
||||
guard let token = saved.token, Connection.isFingerprint(token) else { throw BridgeError.message("Повреждена запись сопряжения.") }
|
||||
return try Connection(endpoint: saved.endpoint.absoluteString, fingerprint: saved.fingerprint, token: token)
|
||||
}
|
||||
|
||||
static func save(_ connection: Connection) throws {
|
||||
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly]
|
||||
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
|
||||
}
|
||||
guard status == errSecSuccess else { throw failure(status) }
|
||||
}
|
||||
|
||||
static func delete() throws {
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else { throw failure(status) }
|
||||
}
|
||||
|
||||
private static func failure(_ status: OSStatus) -> BridgeError {
|
||||
.message("Не удалось прочитать или сохранить сопряжение в Keychain (\(status)).")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
|
||||
enum BridgeError: LocalizedError {
|
||||
case message(String)
|
||||
case unauthorized
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .message(let text): return text
|
||||
case .unauthorized: return "Доступ отозван. Выполните сопряжение заново."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Connection: Codable {
|
||||
let endpoint: URL
|
||||
let fingerprint: String
|
||||
var token: String?
|
||||
|
||||
init(endpoint: String, fingerprint: String, token: String? = nil) throws {
|
||||
guard let url = URL(string: endpoint), let parts = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
parts.scheme == "https", parts.user == nil, parts.password == nil,
|
||||
parts.query == nil, parts.fragment == nil,
|
||||
parts.path.isEmpty || parts.path == "/",
|
||||
let host = parts.host, Self.isPrivateIPv4(host),
|
||||
parts.port == nil || (1...65535).contains(parts.port!) else {
|
||||
throw BridgeError.message("Укажите HTTPS-адрес ПК в домашней сети, например https://192.168.1.10:8765.")
|
||||
}
|
||||
guard Self.isFingerprint(fingerprint) else {
|
||||
throw BridgeError.message("Отпечаток сертификата должен содержать 64 шестнадцатеричных символа.")
|
||||
}
|
||||
self.endpoint = url
|
||||
self.fingerprint = fingerprint.uppercased()
|
||||
self.token = token
|
||||
}
|
||||
|
||||
static func isFingerprint(_ value: String) -> Bool {
|
||||
value.utf8.count == 64 && value.utf8.allSatisfy { (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }
|
||||
}
|
||||
|
||||
static func isPrivateIPv4(_ host: String) -> Bool {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 4 else { return false }
|
||||
let bytes = parts.compactMap { part -> UInt8? in
|
||||
guard !part.isEmpty, part.utf8.allSatisfy({ (48...57).contains($0) }),
|
||||
part.count == 1 || part.first != "0" else { return nil }
|
||||
return UInt8(part)
|
||||
}
|
||||
guard bytes.count == 4 else { return false }
|
||||
return bytes[0] == 10 || (bytes[0] == 172 && (16...31).contains(bytes[1])) || (bytes[0] == 192 && bytes[1] == 168)
|
||||
}
|
||||
}
|
||||
|
||||
struct Invitation: Decodable {
|
||||
let version: Int
|
||||
let endpoint: String
|
||||
let certificateSha256: String
|
||||
let code: String
|
||||
let expiresAt: String
|
||||
|
||||
static func parse(_ text: String, now: Date = Date()) throws -> Invitation {
|
||||
guard text.utf8.count <= 8192,
|
||||
let url = URLComponents(string: text), url.scheme == "musicbridge", url.host == "pair",
|
||||
url.user == nil, url.password == nil, url.port == nil, url.path.isEmpty, url.fragment == nil,
|
||||
let items = url.queryItems, items.count == 1, items[0].name == "data",
|
||||
let encoded = items[0].value else { throw BridgeError.message("Неверная строка сопряжения.") }
|
||||
var base64 = encoded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
||||
guard let data = Data(base64Encoded: base64) else { throw BridgeError.message("Повреждённые данные QR.") }
|
||||
let invitation = try JSONDecoder().decode(Invitation.self, from: data)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
var expiry = formatter.date(from: invitation.expiresAt)
|
||||
if expiry == nil {
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
expiry = formatter.date(from: invitation.expiresAt)
|
||||
}
|
||||
guard invitation.version == 1, validCode(invitation.code), let expiry, expiry > now else {
|
||||
throw BridgeError.message("Код истёк или версия QR не поддерживается. Откройте новый QR на ПК.")
|
||||
}
|
||||
_ = try Connection(endpoint: invitation.endpoint, fingerprint: invitation.certificateSha256)
|
||||
return invitation
|
||||
}
|
||||
|
||||
static func validCode(_ code: String) -> Bool {
|
||||
code.utf8.count == 8 && code.utf8.allSatisfy { (48...57).contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
struct Capabilities: Decodable {
|
||||
let play, pause, toggle, next, previous, seek: Bool
|
||||
}
|
||||
|
||||
struct MediaState: Decodable {
|
||||
let protocolVersion: Int
|
||||
let hasSession: Bool
|
||||
let title, artist, source, playbackStatus: String
|
||||
let positionSeconds, startSeconds, endSeconds, minSeekSeconds, maxSeekSeconds, playbackRate: Double
|
||||
let capabilities: Capabilities
|
||||
let volume: Double?
|
||||
let muted: Bool?
|
||||
let mediaError, volumeError, artworkId, artworkError: String?
|
||||
|
||||
func position(elapsed: TimeInterval) -> Double {
|
||||
let projected = positionSeconds + (playbackStatus == "Playing" ? max(0, elapsed) * playbackRate : 0)
|
||||
return min(max(startSeconds, projected), max(startSeconds, endSeconds))
|
||||
}
|
||||
}
|
||||
|
||||
struct StateEvent: Decodable { let type: String; let state: MediaState }
|
||||
struct PairResponse: Decodable { let success: Bool; let token: String?; let code: String }
|
||||
struct CommandResponse: Decodable { let success: Bool; let code, message: String }
|
||||
struct Command: Encodable {
|
||||
let type: String
|
||||
let value: Double?
|
||||
let id = UUID().uuidString
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MusicBridgeApp: App {
|
||||
@StateObject private var store = PlayerStore()
|
||||
@Environment(\.scenePhase) private var phase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView(store: store)
|
||||
.task { store.setActive(phase == .active) }
|
||||
.onChange(of: phase) { _, value in store.setActive(value == .active) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class PlayerStore: ObservableObject {
|
||||
@Published var connection: Connection?
|
||||
@Published var state: MediaState?
|
||||
@Published var artwork: UIImage?
|
||||
@Published var status = "Подключите компьютер"
|
||||
@Published var error: String?
|
||||
@Published var connected = false
|
||||
@Published var busy = false
|
||||
@Published var pairing = false
|
||||
private(set) var receivedAt = Date()
|
||||
private var client: BridgeClient?
|
||||
private var runner: Task<Void, Never>?
|
||||
private var artworkTask: Task<Void, Never>?
|
||||
private var artworkID: String?
|
||||
private var generation = UUID()
|
||||
private var active = false
|
||||
|
||||
init() {
|
||||
do { connection = try KeychainStore.load() }
|
||||
catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
func pair(endpoint: String, fingerprint: String, code: String) async {
|
||||
guard !pairing else { return }
|
||||
pairing = true
|
||||
defer { pairing = false }
|
||||
error = nil
|
||||
do {
|
||||
guard Invitation.validCode(code) else { throw BridgeError.message("Нужен восьмизначный код с ПК.") }
|
||||
var saved = try Connection(endpoint: endpoint, fingerprint: fingerprint)
|
||||
let pairingClient = BridgeClient(saved)
|
||||
defer { pairingClient.close() }
|
||||
saved.token = try await pairingClient.pair(code: code)
|
||||
do { try KeychainStore.save(saved) }
|
||||
catch { throw BridgeError.message("ПК принял сопряжение, но Keychain не сохранил его. Удалите запись MusicBridge iPhone на ПК и повторите сопряжение.") }
|
||||
connection = saved
|
||||
resume()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
self.active = active
|
||||
if active { resume() } else { stop(); status = "Соединение приостановлено" }
|
||||
}
|
||||
|
||||
func resume() {
|
||||
stop()
|
||||
guard active, let connection else { return }
|
||||
let current = generation
|
||||
runner = Task { [weak self] in
|
||||
var delay: UInt64 = 1
|
||||
while !Task.isCancelled {
|
||||
guard let self, self.generation == current else { return }
|
||||
let client = BridgeClient(connection)
|
||||
self.client = client
|
||||
self.status = "Подключение к ПК…"
|
||||
do {
|
||||
let initial = try await client.state()
|
||||
try Task.checkCancellation()
|
||||
guard self.generation == current else { client.close(); return }
|
||||
self.accept(initial, client: client)
|
||||
client.openEvents()
|
||||
// Abort a silent/stalled stream even if the OS has not detected a lost Wi-Fi link.
|
||||
let watchdog = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
do { try await Task.sleep(nanoseconds: 5_000_000_000) } catch { return }
|
||||
guard let self, self.generation == current else { return }
|
||||
if Date().timeIntervalSince(self.receivedAt) > 15 { client.close(); return }
|
||||
}
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
while !Task.isCancelled {
|
||||
let snapshot = try await client.nextState()
|
||||
try Task.checkCancellation()
|
||||
guard self.generation == current else { return }
|
||||
self.accept(snapshot, client: client)
|
||||
delay = 1
|
||||
}
|
||||
} catch {
|
||||
client.close()
|
||||
guard !Task.isCancelled, self.generation == current else { return }
|
||||
self.connected = false
|
||||
self.state = nil
|
||||
self.artworkTask?.cancel()
|
||||
self.artwork = nil
|
||||
self.artworkID = nil
|
||||
if case BridgeError.unauthorized = error {
|
||||
self.status = "Нужно повторное сопряжение"
|
||||
self.error = error.localizedDescription
|
||||
return
|
||||
}
|
||||
self.status = "ПК недоступен. Повтор через \(delay) с"
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
do { try await Task.sleep(nanoseconds: delay * 1_000_000_000) } catch { return }
|
||||
delay = min(delay * 2, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func accept(_ snapshot: MediaState, client: BridgeClient) {
|
||||
state = snapshot
|
||||
receivedAt = Date()
|
||||
connected = true
|
||||
status = "Подключено"
|
||||
error = nil
|
||||
guard artworkID != snapshot.artworkId else { return }
|
||||
artworkID = snapshot.artworkId
|
||||
artworkTask?.cancel()
|
||||
artwork = nil
|
||||
guard let id = snapshot.artworkId else { return }
|
||||
let current = generation
|
||||
artworkTask = Task { [weak self] in
|
||||
guard let data = try? await client.artwork(id), !Task.isCancelled,
|
||||
let self, self.generation == current, self.artworkID == id else { return }
|
||||
self.artwork = UIImage(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ type: String, value: Double? = nil) async {
|
||||
guard connected, !busy, let client else { return }
|
||||
let current = generation
|
||||
busy = true
|
||||
defer { if generation == current { busy = false } }
|
||||
do {
|
||||
// Commands are never retried: their IDs are not idempotency keys.
|
||||
try await client.command(Command(type: type, value: value))
|
||||
let fresh = try await client.state()
|
||||
guard generation == current else { return }
|
||||
accept(fresh, client: client)
|
||||
} catch {
|
||||
guard generation == current else { return }
|
||||
self.error = error.localizedDescription
|
||||
if case BridgeError.unauthorized = error { stop(); status = "Нужно повторное сопряжение" }
|
||||
}
|
||||
}
|
||||
|
||||
func forget() {
|
||||
do { try KeychainStore.delete() }
|
||||
catch { self.error = error.localizedDescription; return }
|
||||
stop()
|
||||
connection = nil
|
||||
status = "Подключите компьютер"
|
||||
error = nil
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
generation = UUID()
|
||||
runner?.cancel()
|
||||
runner = nil
|
||||
artworkTask?.cancel()
|
||||
client?.close()
|
||||
client = nil
|
||||
connected = false
|
||||
busy = false
|
||||
state = nil
|
||||
artwork = nil
|
||||
artworkID = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import XCTest
|
||||
@testable import MusicBridge
|
||||
|
||||
final class ProtocolTests: XCTestCase {
|
||||
private let fingerprint = String(repeating: "A", count: 64)
|
||||
|
||||
func testPrivateAddresses() throws {
|
||||
for address in ["10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.1.10"] {
|
||||
XCTAssertTrue(Connection.isPrivateIPv4(address))
|
||||
}
|
||||
for address in ["127.0.0.1", "8.8.8.8", "172.32.0.1", "169.254.1.2", "192.168.1.999", "192.168.01.1", "localhost", "::1"] {
|
||||
XCTAssertFalse(Connection.isPrivateIPv4(address))
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsCredentialOrPathInjection() {
|
||||
for address in ["http://192.168.1.2", "https://user@192.168.1.2", "https://192.168.1.2/path",
|
||||
"https://192.168.1.2?token=secret", "https://192.168.1.2#fragment", "https://192.168.1.2:0"] {
|
||||
XCTAssertThrowsError(try Connection(endpoint: address, fingerprint: fingerprint))
|
||||
}
|
||||
}
|
||||
|
||||
func testFingerprintValidation() throws {
|
||||
XCTAssertThrowsError(try Connection(endpoint: "https://192.168.1.2", fingerprint: String(repeating: "Z", count: 64)))
|
||||
XCTAssertThrowsError(try Connection(endpoint: "https://192.168.1.2", fingerprint: "AA"))
|
||||
XCTAssertEqual(try Connection(endpoint: "https://192.168.1.2:8765", fingerprint: fingerprint.lowercased()).fingerprint, fingerprint)
|
||||
}
|
||||
|
||||
func testInvitationAndExpiry() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let payload: [String: Any] = ["version": 1, "endpoint": "https://192.168.1.10:8765",
|
||||
"certificateSha256": fingerprint, "code": "01234567",
|
||||
"expiresAt": ISO8601DateFormatter().string(from: now.addingTimeInterval(60))]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload).base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||
let text = "musicbridge://pair?data=\(data)"
|
||||
XCTAssertEqual(try Invitation.parse(text, now: now).code, "01234567")
|
||||
XCTAssertThrowsError(try Invitation.parse(text, now: now.addingTimeInterval(61)))
|
||||
XCTAssertThrowsError(try Invitation.parse(text + "&data=other", now: now))
|
||||
}
|
||||
|
||||
func testCodeRequiresEightASCIIDigits() {
|
||||
XCTAssertTrue(Invitation.validCode("01234567"))
|
||||
XCTAssertFalse(Invitation.validCode("1234567"))
|
||||
XCTAssertFalse(Invitation.validCode("12345678"))
|
||||
}
|
||||
|
||||
func testWindowsSnapshotAndTimeline() throws {
|
||||
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "state-v1", withExtension: "json"))
|
||||
let state = try JSONDecoder().decode(MediaState.self, from: Data(contentsOf: url))
|
||||
XCTAssertEqual(state.position(elapsed: 2), 44.5)
|
||||
XCTAssertEqual(state.position(elapsed: 9999), 180)
|
||||
XCTAssertTrue(state.capabilities.seek)
|
||||
XCTAssertEqual(state.volume, 0.5)
|
||||
XCTAssertNoThrow(try BridgeClient.validate(state))
|
||||
}
|
||||
|
||||
func testCommandsCarryUniqueIDsAndOptionalValue() throws {
|
||||
let command = Command(type: "seek", value: 90.5)
|
||||
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(command)) as? [String: Any])
|
||||
XCTAssertEqual(object["type"] as? String, "seek")
|
||||
XCTAssertEqual(object["value"] as? Double, 90.5)
|
||||
XCTAssertNotEqual(command.id, Command(type: "seek", value: 90.5).id)
|
||||
let toggle = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(Command(type: "toggle", value: nil))) as? [String: Any])
|
||||
XCTAssertNil(toggle["value"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"timestamp": "2026-09-09T00:00:00+00:00",
|
||||
"hasSession": true,
|
||||
"title": "Название",
|
||||
"artist": "Исполнитель",
|
||||
"source": "идентификатор приложения",
|
||||
"playbackStatus": "Playing",
|
||||
"positionSeconds": 42.5,
|
||||
"startSeconds": 0,
|
||||
"endSeconds": 180,
|
||||
"minSeekSeconds": 0,
|
||||
"maxSeekSeconds": 180,
|
||||
"playbackRate": 1,
|
||||
"capabilities": {"play": false, "pause": true, "toggle": true, "next": true, "previous": true, "seek": true},
|
||||
"volume": 0.5,
|
||||
"muted": false,
|
||||
"mediaError": null,
|
||||
"volumeError": null,
|
||||
"artworkId": null,
|
||||
"artworkError": null
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# MusicBridge iOS — план
|
||||
|
||||
Обновлено: 2026-09-10. Пользователь разрешил начать нативное iOS-приложение.
|
||||
Репозиторий: `C:/Users/areso/iOS MusicBridge`; origin — dev.yukinoki.ru/musicbridge/ios.
|
||||
Windows-агент — соседний проект `C:/Users/areso/MusicBridge/MusicBridge.Agent`.
|
||||
|
||||
## Правила проекта
|
||||
|
||||
- SwiftUI, без веб-клиента. Музыка играет на Windows.
|
||||
- Сверять контракт с Windows PROTOCOL.md; не менять серверный протокол молча.
|
||||
- Токены только в Keychain, проверка сертификата до сопряжения, запрет redirect.
|
||||
- Не повторять команды автоматически после потери ответа.
|
||||
- Не называть проверки Python сборкой Swift или запуском XCTest.
|
||||
- После этапа обновлять этот файл: реализация, проверки, ограничения, следующий шаг.
|
||||
|
||||
## Реализовано в исходниках, требует Xcode-проверки
|
||||
|
||||
- [x] Проект Xcode, shared scheme, iOS 17+, SwiftUI, без внешних зависимостей.
|
||||
- [x] Модели состояния и команд v1; валидация HTTPS IPv4 домашней сети и QR-строки.
|
||||
- [x] Ручное сопряжение, импорт приглашения, проверка SHA-256 DER-сертификата.
|
||||
- [x] Keychain: адрес, отпечаток и bearer-токен; удаление локального сопряжения.
|
||||
- [x] HTTPS-команды, обложка до 2 МиБ, поток WSS с ограничением размера сообщения.
|
||||
- [x] Экран трека, возможности кнопок, прогресс, перемотка, громкость, mute.
|
||||
- [x] Переподключение 1…16 секунд, watchdog потока, отключение в фоне, возврат.
|
||||
- [x] XCTest исходники: диапазоны адресов, отказ опасным URL, отпечатки,
|
||||
QR/срок/коды, JSON Windows, расчёт позиции, формат команд.
|
||||
- [x] GitHub Actions: сборка/тесты симулятора, логи, xcresult и артефакт приложения.
|
||||
|
||||
## Проверки и следующие шаги
|
||||
|
||||
- [ ] Первый успешный xcodebuild + XCTest на macOS. На текущем Windows Swift/Xcode нет.
|
||||
- [ ] Подключить выбранный пользователем GitHub remote и запустить Actions.
|
||||
- [ ] Интеграционные тесты TLS: верный/чужой/истёкший сертификат, redirect, WSS.
|
||||
- [ ] Проверить 401 и отзыв устройства, Keychain после переустановки/блокировки.
|
||||
- [ ] Настроить подпись и установку на физический iPhone.
|
||||
- [ ] Проверить LAN/плеер/команды/обложку с настоящим Windows-агентом.
|
||||
- [ ] Камера для QR, системные разрешения и обработка отказа.
|
||||
- [ ] Bonjour-поиск `_musicbridge._tcp`, список ПК и обновление адреса при смене IP.
|
||||
- [ ] Улучшить ошибки сопряжения по серверным кодам; повтор загрузки обложки после 404.
|
||||
- [ ] Иконка приложения, визуальная проверка разных размеров экрана и Dynamic Type.
|
||||
- [ ] Live Activities / Dynamic Island / App Intents — отдельный последующий этап.
|
||||
|
||||
Ограничения: хранится один ПК; при смене его IP пока нужно повторное сопряжение.
|
||||
Нет фонового воспроизведения, App Store/TestFlight и подписанной IPA.
|
||||
Потеря ответа на успешное сопряжение может оставить устройство на ПК: удалить его
|
||||
в окне агента перед повтором. Облачный runner не имеет доступа к домашней LAN.
|
||||
|
||||
## Карта
|
||||
|
||||
- `MusicBridge/Models.swift`: контракт и проверка входных данных.
|
||||
- `BridgeClient.swift`: сертификат, HTTPS, WSS, ограничения ответов.
|
||||
- `KeychainStore.swift`: хранение сопряжения.
|
||||
- `PlayerStore.swift`: состояние, команды, жизненный цикл и восстановление.
|
||||
- `ContentView.swift`: подключение и плеер.
|
||||
- `MusicBridgeTests/`: XCTest и пример Windows JSON.
|
||||
- `tools/`: генератор проекта, структурные проверки, выбор симулятора для CI.
|
||||
- `.github/workflows/ios.yml`: CI на macOS.
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
# MusicBridge protocol v1
|
||||
|
||||
Транспорт для будущего **нативного iPhone-клиента**. HTML, Safari/PWA и веб-клиента нет.
|
||||
|
||||
## Запуск
|
||||
|
||||
### Окно управления и трей
|
||||
|
||||
```powershell
|
||||
dotnet run -c Release -- --tray --lan
|
||||
```
|
||||
|
||||
`--tray` открывает Windows-окно и значок в области уведомлений. Крестик скрывает
|
||||
окно; двойной щелчок по значку снова его показывает. Для остановки выбрать
|
||||
«Завершить MusicBridge» в меню значка. При старте агент отсоединяется от консоли,
|
||||
не закрывая терминал пользователя. Без `--tray` сохраняется диагностическая консоль.
|
||||
|
||||
Окно показывает трек/обложку, позицию и системную громкость. Поддерживает
|
||||
play/pause, следующий/предыдущий трек, ползунки позиции/громкости, mute,
|
||||
новое сопряжение, QR и отзыв доступа выбранного устройства. Недоступные команды
|
||||
отключены. Без `--lan` агент остаётся доступен только с самого ПК.
|
||||
|
||||
Чтобы пользоваться уже собранной версией, можно запускать
|
||||
`bin\Release\net10.0-windows10.0.19041.0\MusicBridge.Agent.exe --tray --lan`.
|
||||
Пока это framework-dependent сборка: необходим .NET 10 Desktop Runtime и
|
||||
ASP.NET Core Runtime. Для установки без отдельных Runtime подготовлен
|
||||
`dist/MusicBridge-win-x64.zip`; см. [INSTALL.md](INSTALL.md).
|
||||
|
||||
Меню трея «Запускать вместе с Windows» включает/выключает автозапуск текущего
|
||||
пользователя. Сохраняются текущий путь, порт и режим сети. `--minimized` включает
|
||||
трей и скрывает главное окно при старте. Автозапуск по умолчанию выключен;
|
||||
лучше включать его после установки в постоянную папку.
|
||||
|
||||
Из каталога репозитория:
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
|
||||
По умолчанию агент принимает соединения только на `https://127.0.0.1:8765`.
|
||||
Для локальной сети:
|
||||
|
||||
```powershell
|
||||
dotnet run -- --lan
|
||||
```
|
||||
|
||||
Другой порт: `dotnet run -- --lan --port 8766`.
|
||||
Консоль показывает IPv4-адреса ПК. При нескольких адаптерах нужен адрес домашней сети.
|
||||
Автоматического изменения брандмауэра нет. Если Windows запросит доступ к сети,
|
||||
для домашнего использования разрешать только частную сеть. Порт на роутере не открывать.
|
||||
|
||||
## TLS и сопряжение
|
||||
|
||||
- Только HTTPS/WSS. HTTP не поддерживается.
|
||||
- Агент создаёт самоподписанный сертификат при первом запуске и показывает SHA-256
|
||||
отпечаток всего DER-сертификата: 64 шестнадцатеричных символа.
|
||||
- Будущий нативный клиент должен сверять этот отпечаток **до отправки кода**,
|
||||
затем проверять его на каждом соединении. Пока отпечаток сверяют по экрану ПК;
|
||||
в следующем этапе он войдёт в QR. Отключение проверки всех сертификатов недопустимо.
|
||||
- Сертификат и доверенные устройства сохраняются между запусками. Новый код
|
||||
после обычного перезапуска не нужен: клиент использует сохранённый токен и отпечаток.
|
||||
- Восьмизначный код действует 5 минут и принимается один раз. Десять неудачных
|
||||
попыток закрывают окно сопряжения. Клавиша `B` на ПК открывает новое окно.
|
||||
- Максимум 8 доверенных устройств. В консоли показан нумерованный список;
|
||||
клавиши `1`…`8` отзывают доступ у соответствующего устройства. Его открытый WSS
|
||||
отключается, новые запросы получают 401. Уже принятые команды не отменяются.
|
||||
- Если устройства уже сохранены, при запуске окно сопряжения закрыто.
|
||||
Для нового телефона нажать `B`. При пустом списке первое окно открывается автоматически.
|
||||
- Bearer-токен не передавать в URL, не записывать в логи. В будущем хранить в Keychain.
|
||||
|
||||
Сопряжение:
|
||||
|
||||
```http
|
||||
POST /v1/pair
|
||||
Content-Type: application/json
|
||||
|
||||
{"code":"<код с экрана ПК>","deviceName":"Мой iPhone"}
|
||||
```
|
||||
|
||||
`deviceName` необязателен (по умолчанию `iPhone`), до 64 символов, без управляющих
|
||||
символов и не только пробелы. Название служит подписью в локальном списке,
|
||||
а не доказательством личности телефона.
|
||||
|
||||
Ответ 200: `{"success":true,"token":"<64 hex characters>","code":"ok","deviceId":"<id>"}`.
|
||||
Отказ 403: `success:false`, `token:null`, `code` равен `invalid_code`,
|
||||
`pairing_closed`, `device_limit` или `invalid_name`.
|
||||
Если сохранить устройство не удалось: HTTP 503, `code:"storage_failed"`,
|
||||
токен не выдаётся и устройство не получает доступа.
|
||||
|
||||
Все следующие запросы, включая WebSocket handshake, требуют заголовка:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
## Хранение и восстановление
|
||||
|
||||
Файл `%LOCALAPPDATA%\MusicBridge\identity.dat` содержит сертификат с закрытым
|
||||
ключом, ID/имена/даты сопряжения устройств и SHA-256 хеши токенов. Весь файл
|
||||
защищён Windows DPAPI текущего пользователя. Сами bearer-токены не сохраняются
|
||||
на ПК. DPAPI не защищает от программ, уже работающих от имени этого пользователя.
|
||||
|
||||
Запись выполняется через защищённый временный файл и замену основного файла.
|
||||
Ошибка записи не изменяет активное доверие. `identity.lock` блокирует одновременный
|
||||
запуск двух агентов с одним хранилищем; после закрытия агента блокировка снимается.
|
||||
Наличие самого файла блокировки после выхода нормально.
|
||||
|
||||
Повреждённое, недоступное или неизвестное по версии хранилище не заменяется
|
||||
автоматически: агент завершится с ошибкой в консоли. Сертификат выдан на 5 лет;
|
||||
истёкший сертификат также не заменяется молча, чтобы не нарушить закреплённый отпечаток.
|
||||
Автоматическое обновление сертификата — будущая задача.
|
||||
|
||||
Для осознанного полного сброса: остановить агент, сохранить резервную копию и
|
||||
переименовать `identity.dat`, затем запустить агент и заново сопрячь телефоны
|
||||
с новым отпечатком. Обычный перезапуск **не отзывает** доступ.
|
||||
Не переносить этот файл на другой компьютер в расчёте на сохранение подключения:
|
||||
он привязан к защите Windows текущего пользователя.
|
||||
|
||||
## Состояние
|
||||
|
||||
Авторизованный `GET /v1/info` идентифицирует агента. Клавиша `I` открывает
|
||||
Windows-окно с QR; `B` создаёт новый код. При `--lan` агент объявляет сервис
|
||||
`_musicbridge._tcp.local.` по mDNS. Подробности и ограничения — в
|
||||
[CONNECTION-ROADMAP.md](CONNECTION-ROADMAP.md). iOS-клиент пока не реализован.
|
||||
|
||||
`GET /v1/state` возвращает последний снимок. События смены сессии, метаданных,
|
||||
воспроизведения и позиции Windows запускают обновление общего снимка для всех
|
||||
клиентов. События объединяются в течение 25 мс. Резервный таймер примерно раз
|
||||
в 500 мс поддерживает прогресс и чтение системной громкости.
|
||||
Метаданные и обложка кешируются: перечитываются по событию либо раз в 10 секунд
|
||||
на случай пропущенного уведомления. При смене сессии подписки переключаются.
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"timestamp": "2026-09-09T00:00:00+00:00",
|
||||
"hasSession": true,
|
||||
"title": "Название",
|
||||
"artist": "Исполнитель",
|
||||
"source": "идентификатор приложения",
|
||||
"playbackStatus": "Playing",
|
||||
"positionSeconds": 42.5,
|
||||
"startSeconds": 0,
|
||||
"endSeconds": 180,
|
||||
"minSeekSeconds": 0,
|
||||
"maxSeekSeconds": 180,
|
||||
"playbackRate": 1,
|
||||
"capabilities": {
|
||||
"play": false, "pause": true, "toggle": true,
|
||||
"next": true, "previous": true, "seek": true
|
||||
},
|
||||
"volume": 0.5,
|
||||
"muted": false,
|
||||
"mediaError": null,
|
||||
"volumeError": null,
|
||||
"artworkId": "<SHA-256 обложки или null>",
|
||||
"artworkError": null
|
||||
}
|
||||
```
|
||||
|
||||
Время — секунды от начала таймлайна плеера, включая дробную часть.
|
||||
Длительность — `endSeconds - startSeconds`, когда конец известен.
|
||||
`positionSeconds` уже учитывает время с последнего обновления Windows.
|
||||
Будущий клиент может плавно продолжать позицию между снимками при `Playing`,
|
||||
учитывая скорость; каждый следующий снимок корректирует локальный расчёт.
|
||||
|
||||
При отсутствии сессии `hasSession:false`, строки пустые, возможности выключены.
|
||||
Громкость доступна независимо от медиасессии; при ошибке аудиоустройства
|
||||
`volume` и `muted` равны `null`, а `volumeError` содержит причину.
|
||||
При ошибке чтения медиасессии заполняется `mediaError`.
|
||||
|
||||
## Обложка
|
||||
|
||||
`GET /v1/artwork/{artworkId}` требует того же Bearer-токена и возвращает бинарное
|
||||
изображение с Content-Type. В JSON и WebSocket передаётся только идентификатор,
|
||||
поэтому изображение не пересылается при каждом обновлении прогресса.
|
||||
|
||||
- `artworkId` — SHA-256 содержимого, 64 hex-символа; `null`, если обложки нет.
|
||||
- Клиент загружает обложку при изменении ID и может хранить её в своём кеше.
|
||||
- В агенте хранится только текущая обложка, не более 2 МиБ. Чтение ограничено
|
||||
двумя секундами; ошибка обложки не скрывает название и состояние трека.
|
||||
- Поддерживаются PNG, JPEG, GIF и WebP; тип определяется по сигнатуре. Сервер
|
||||
не декодирует изображение: клиенту нужно обрабатывать ошибки декодирования.
|
||||
- При смене трека, отсутствии или ошибке обложки предыдущая удаляется из кеша.
|
||||
Неизвестный/устаревший ID возвращает 404. При гонке со сменой трека клиент
|
||||
перечитывает состояние; при `artworkId:null` показывает заглушку.
|
||||
- `artworkError` содержит причину ошибки чтения; без ошибки — `null`.
|
||||
|
||||
## Команды
|
||||
|
||||
`POST /v1/command`, `Content-Type: application/json`:
|
||||
|
||||
```json
|
||||
{"id":"seek-1","type":"seek","value":90.5}
|
||||
```
|
||||
|
||||
| type | value | Действие |
|
||||
|---|---|---|
|
||||
| play / pause | отсутствует | Начать / приостановить воспроизведение |
|
||||
| toggle | отсутствует | Play/pause |
|
||||
| next / previous | отсутствует | Следующий / предыдущий трек |
|
||||
| seek | секунды ≥ 0 | Абсолютная позиция, ограниченная диапазоном плеера |
|
||||
| seekRelative | секунды от −3600 до 3600 | Сдвиг от текущей вычисленной позиции |
|
||||
| volume | число 0…1 | Абсолютная системная громкость |
|
||||
| volumeRelative | число −1…1 | Сдвиг громкости с ограничением 0…1 |
|
||||
| toggleMute | отсутствует | Переключить mute основного устройства вывода |
|
||||
|
||||
Изменение громкости сохраняет текущее состояние mute.
|
||||
Результат: `{"success":true,"code":"ok","message":"…","id":"seek-1"}`.
|
||||
У медиакоманд успех означает принятие команды плеером; фактическое состояние
|
||||
подтверждается последующим снимком. Отказ не должен оптимистично менять UI.
|
||||
|
||||
`id` необязателен, максимум 64 символа. Он связывает ответ с запросом,
|
||||
**не является ключом идемпотентности**. Не повторять автоматически next, toggle
|
||||
или относительные команды после потери ответа. Сначала перечитать состояние.
|
||||
|
||||
Ошибки команды: `invalid_command`, `no_session`, `unsupported`, `rejected`,
|
||||
`operation_failed`. Некорректная команда/JSON — HTTP 400, тело больше 4096 байт —
|
||||
413, отсутствие/неверный токен — 401. Отказ плеера возвращается HTTP 200 с
|
||||
`success:false`, чтобы отличать транспорт от выполнения команды.
|
||||
Общий лимит HTTP-запросов: 60 в секунду, сверх лимита — 429.
|
||||
|
||||
## WebSocket
|
||||
|
||||
`wss://<адрес>:<порт>/v1/events`, с тем же Authorization.
|
||||
Сразу после подключения, по событиям Windows и при резервном обновлении
|
||||
примерно каждые 500 мс сервер присылает текстовый JSON:
|
||||
|
||||
```json
|
||||
{"type":"state","state":{"protocolVersion":1,"…":"поля снимка выше"}}
|
||||
```
|
||||
|
||||
Поток только для состояния. Команды отправляются через HTTPS; каждый запрос
|
||||
имеет отдельный ответ. Передача клиентского payload в поток закрывает соединение.
|
||||
Поддерживается до 8 одновременных WebSocket. После потери соединения клиент
|
||||
переподключается с задержкой и получает полный снимок. Медленный клиент получает
|
||||
последнее состояние; промежуточные снимки не накапливаются в неограниченной очереди.
|
||||
Отправка снимка ограничена 5 секундами. Keepalive отправляется через 20 секунд,
|
||||
ожидание ответа ограничено 10 секундами; оборванные соединения освобождают слот.
|
||||
При ошибке чтения медиасессии сервер публикует пустое состояние с `mediaError`
|
||||
и продолжает опрос. После восстановления очередной снимок содержит свежие данные.
|
||||
На чтение передаётся отмена через 5 секунд.
|
||||
Запросы с Origin
|
||||
отклоняются: браузерные клиенты в проекте не поддерживаются.
|
||||
|
||||
## Проверка
|
||||
|
||||
```powershell
|
||||
dotnet build
|
||||
dotnet run --project tests/MusicBridge.Agent.Tests.csproj
|
||||
```
|
||||
|
||||
Проверки используют тестовый медиасервис и настоящий локальный HTTPS/WSS с
|
||||
проверкой отпечатка. Музыку и громкость пользователя они не меняют.
|
||||
|
||||
Дополнительно прочитать реальную медиасессию и обложку Windows:
|
||||
|
||||
```powershell
|
||||
dotnet run --project tests/MusicBridge.Agent.Tests.csproj -- --live-read
|
||||
```
|
||||
|
||||
Этот режим также не отправляет команд плееру. При отсутствии обложки он сообщает
|
||||
об этом, а проверки её содержимого пропускает.
|
||||
|
||||
Проверки постоянного доверия используют отдельный временный каталог и удаляют
|
||||
его после выполнения. Рабочее хранилище пользователя не затрагивается.
|
||||
Ошибки тестов выводятся как `TEST FAILED` с кодом завершения 1. Диагностический
|
||||
аргумент `--test-failure` проверяет этот путь контролируемым исключением.
|
||||
|
||||
Реализация транспорта опирается на документацию Microsoft:
|
||||
[Kestrel HTTPS](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints?view=aspnetcore-10.0)
|
||||
и [WebSocket](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/websockets?view=aspnetcore-10.0).
|
||||
Обновления таймлайна используют
|
||||
[событие Windows TimelinePropertiesChanged](https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssession.timelinepropertieschanged).
|
||||
Защита хранилища использует
|
||||
[Windows CryptProtectData](https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata).
|
||||
@@ -0,0 +1,55 @@
|
||||
# MusicBridge для iPhone
|
||||
|
||||
Нативный пульт на SwiftUI для MusicBridge.Agent на Windows. Звук играет на ПК.
|
||||
Минимальная версия iOS — 17. Зависимостей Swift Package/CocoaPods нет.
|
||||
|
||||
## Первый вариант
|
||||
|
||||
- Сопряжение по HTTPS-адресу домашнего ПК, SHA-256 сертификата и коду.
|
||||
- Импорт строки `musicbridge://pair?data=…` из QR агента; камера пока не реализована.
|
||||
- Проверка закреплённого DER-сертификата до отправки кода и при следующих соединениях.
|
||||
- Хранение адреса, отпечатка и токена в Keychain только на этом устройстве.
|
||||
- Название, исполнитель, обложка, позиция, play/pause, следующий/предыдущий трек,
|
||||
перемотка ползунком, системная громкость и mute.
|
||||
- WSS-состояние, переподключение с задержкой, остановка сети в фоне и восстановление
|
||||
при возврате. Команды автоматически не повторяются.
|
||||
|
||||
**Статус:** исходники первого варианта. Сборка Swift и запуск на устройстве ещё
|
||||
не подтверждены. Windows не содержит Xcode. Следующий обязательный шаг — первый
|
||||
прогон GitHub Actions или Xcode на Mac и исправление обнаруженных ошибок.
|
||||
|
||||
## Сборка на Mac
|
||||
|
||||
1. Открыть `MusicBridge.xcodeproj` в Xcode 16 или новее.
|
||||
2. Выбрать схему MusicBridge и симулятор iPhone. Run запускает приложение,
|
||||
Test выполняет `MusicBridgeTests`.
|
||||
3. Для физического iPhone выбрать свою Team в Signing & Capabilities. Сертификаты
|
||||
и provisioning profiles не хранить в Git. Bundle ID при необходимости сменить
|
||||
в `tools/generate_project.py`, затем повторно сгенерировать проект.
|
||||
4. На ПК запустить агент с `--tray --lan`, открыть сопряжение. В приложении
|
||||
ввести адрес, отпечаток и код с экрана ПК. Разрешить доступ к локальной сети.
|
||||
|
||||
Проект уже сгенерирован и включён в Git; Python для обычной сборки не требуется.
|
||||
После добавления Swift-файлов выполнить `python3 tools/generate_project.py`.
|
||||
Структурная проверка на Windows/macOS: `python tools/validate_project.py`.
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
`.github/workflows/ios.yml` собирает приложение и выполняет XCTest на `macos-15`
|
||||
при push в main, pull request или ручном запуске. Сохраняет лог, `.xcresult`
|
||||
и ZIP приложения **для симулятора**, который нельзя установить на физический iPhone.
|
||||
Apple ID и сертификаты для этой проверки не требуются. Первый облачный запуск пока
|
||||
не выполнен: нужен GitHub-репозиторий и авторизованный доступ к нему.
|
||||
|
||||
Основной origin: `ssh://git@dev.yukinoki.ru:2222/musicbridge/ios.git`.
|
||||
Для Actions можно добавить отдельный remote `github`, сохранив основной origin.
|
||||
Код автоматически не зеркалируется между серверами.
|
||||
|
||||
## Источники
|
||||
|
||||
- [Apple: проверка доверия сервера](https://developer.apple.com/documentation/Foundation/performing-manual-server-trust-authentication)
|
||||
- [Apple: доступ к локальной сети](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
|
||||
- [GitHub: macOS runners](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)
|
||||
- [GitHub: лимиты и оплата Actions](https://docs.github.com/en/billing/concepts/product-billing/github-actions)
|
||||
|
||||
План и ограничения — в `PLAN.md`. Копия протокола агента — в `PROTOCOL.md`.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Generate a dependency-free Xcode project. Run with Python 3 from any directory."""
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import plistlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
objects = {}
|
||||
|
||||
def uid(name):
|
||||
return hashlib.sha256(name.encode()).hexdigest()[:24].upper()
|
||||
|
||||
def add(key_name, isa, **fields):
|
||||
key = uid(key_name)
|
||||
objects[key] = dict(isa=isa, **fields)
|
||||
return key
|
||||
|
||||
def configurations(name, settings):
|
||||
configs = []
|
||||
for mode in ("Debug", "Release"):
|
||||
values = dict(settings)
|
||||
values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O")
|
||||
if mode == "Debug":
|
||||
values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG")
|
||||
configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values))
|
||||
return add(name + "configs", "XCConfigurationList", buildConfigurations=configs,
|
||||
defaultConfigurationIsVisible=0, defaultConfigurationName="Release")
|
||||
|
||||
def source_group(folder):
|
||||
refs, builds = [], []
|
||||
for path in sorted((ROOT / folder).glob("*.swift")):
|
||||
ref = add(path.relative_to(ROOT).as_posix(), "PBXFileReference", lastKnownFileType="sourcecode.swift", path=path.name, sourceTree="<group>")
|
||||
refs.append(ref)
|
||||
builds.append(add("build/" + path.relative_to(ROOT).as_posix(), "PBXBuildFile", fileRef=ref))
|
||||
return add(folder, "PBXGroup", path=folder, sourceTree="<group>", children=refs), builds
|
||||
|
||||
app_group, app_sources = source_group("MusicBridge")
|
||||
test_group, test_sources = source_group("MusicBridgeTests")
|
||||
fixture = add("fixture", "PBXFileReference", lastKnownFileType="text.json", path="state-v1.json", sourceTree="<group>")
|
||||
objects[test_group]["children"].append(fixture)
|
||||
fixture_build = add("fixture-build", "PBXBuildFile", fileRef=fixture)
|
||||
products = []
|
||||
targets = []
|
||||
for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True)]:
|
||||
product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.cfbundle" if is_test else "wrapper.application",
|
||||
includeInIndex=0, path=name + (".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR")
|
||||
products.append(product)
|
||||
phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0),
|
||||
add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], runOnlyForDeploymentPostprocessing=0),
|
||||
add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[fixture_build] if is_test else [], runOnlyForDeploymentPostprocessing=0)]
|
||||
settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".tests" if is_test else ""),
|
||||
SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="17.0", SDKROOT="iphoneos",
|
||||
SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic",
|
||||
GENERATE_INFOPLIST_FILE="YES" if is_test else "NO")
|
||||
deps = []
|
||||
if is_test:
|
||||
settings.update(TEST_HOST="$(BUILT_PRODUCTS_DIR)/MusicBridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MusicBridge", BUNDLE_LOADER="$(TEST_HOST)")
|
||||
proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge")
|
||||
deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)]
|
||||
else:
|
||||
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="1", MARKETING_VERSION="0.1.0")
|
||||
targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product,
|
||||
productType="com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application",
|
||||
buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps))
|
||||
|
||||
product_group = add("products", "PBXGroup", name="Products", sourceTree="<group>", children=products)
|
||||
main_group = add("main", "PBXGroup", sourceTree="<group>", children=[app_group, test_group, product_group])
|
||||
project = add("project", "PBXProject", attributes=dict(LastUpgradeCheck="1600", BuildIndependentTargetsInParallel="YES"),
|
||||
buildConfigurationList=configurations("project", dict(CLANG_ENABLE_MODULES="YES", CLANG_ENABLE_OBJC_ARC="YES")),
|
||||
compatibilityVersion="Xcode 14.0", developmentRegion="ru", knownRegions=["ru", "en", "Base"],
|
||||
mainGroup=main_group, productRefGroup=product_group, projectDirPath="", projectRoot="", targets=targets)
|
||||
|
||||
def encode(value, indent=0):
|
||||
if isinstance(value, dict):
|
||||
return "{\n" + "".join("\t" * (indent + 1) + json.dumps(k) + " = " + encode(v, indent + 1) + ";\n" for k, v in value.items()) + "\t" * indent + "}"
|
||||
if isinstance(value, list):
|
||||
return "(" + ", ".join(encode(v, indent) for v in value) + ("," if value else "") + ")"
|
||||
return json.dumps(value, ensure_ascii=True)
|
||||
|
||||
project_dir = ROOT / "MusicBridge.xcodeproj"
|
||||
project_dir.mkdir(exist_ok=True)
|
||||
(project_dir / "project.pbxproj").write_bytes(("// !$*UTF8*$!\n" + encode(dict(archiveVersion=1, classes={}, objectVersion=56, objects=objects, rootObject=project)) + "\n").encode("utf-8"))
|
||||
scheme_dir = project_dir / "xcshareddata" / "xcschemes"
|
||||
scheme_dir.mkdir(parents=True, exist_ok=True)
|
||||
def reference(target, product):
|
||||
return f'<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{uid(target + "target")}" BuildableName="{product}" BlueprintName="{target}" ReferencedContainer="container:MusicBridge.xcodeproj"/>'
|
||||
app_ref = reference("MusicBridge", "MusicBridge.app")
|
||||
test_ref = reference("MusicBridgeTests", "MusicBridgeTests.xctest")
|
||||
(scheme_dir / "MusicBridge.xcscheme").write_bytes(f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion="1600" version="1.3">
|
||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"><BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">{app_ref}</BuildActionEntry>
|
||||
</BuildActionEntries></BuildAction>
|
||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"><Testables><TestableReference skipped="NO">{test_ref}</TestableReference></Testables></TestAction>
|
||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES"><BuildableProductRunnable runnableDebuggingMode="0">{app_ref}</BuildableProductRunnable></LaunchAction>
|
||||
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES"><BuildableProductRunnable runnableDebuggingMode="0">{app_ref}</BuildableProductRunnable></ProfileAction>
|
||||
<AnalyzeAction buildConfiguration="Debug"/><ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
||||
</Scheme>'''.encode("utf-8"))
|
||||
|
||||
info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBridge", CFBundleExecutable="$(EXECUTABLE_NAME)",
|
||||
CFBundleIdentifier="$(PRODUCT_BUNDLE_IDENTIFIER)", CFBundleInfoDictionaryVersion="6.0", CFBundleName="$(PRODUCT_NAME)",
|
||||
CFBundlePackageType="APPL", CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)",
|
||||
LSRequiresIPhoneOS=True, UILaunchScreen={},
|
||||
UISupportedInterfaceOrientations=["UIInterfaceOrientationPortrait"],
|
||||
NSLocalNetworkUsageDescription="MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.",
|
||||
NSAppTransportSecurity=dict(NSAllowsLocalNetworking=True))
|
||||
(ROOT / "MusicBridge" / "Info.plist").write_bytes(plistlib.dumps(info, sort_keys=False))
|
||||
print("Generated Xcode project, shared scheme and Info.plist.")
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Select an available iPhone from simctl JSON; print a GitHub environment entry."""
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as stream:
|
||||
runtimes = json.load(stream)["devices"]
|
||||
for runtime in sorted(runtimes, reverse=True):
|
||||
if ".iOS-" not in runtime:
|
||||
continue
|
||||
for device in runtimes[runtime]:
|
||||
if device.get("isAvailable") and device.get("name", "").startswith("iPhone"):
|
||||
print("SIMULATOR_ID=" + str(uuid.UUID(device["udid"])))
|
||||
sys.exit(0)
|
||||
sys.exit("No available iPhone simulator in selected Xcode runtime.")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Cross-platform structural checks only; this does not compile or test Swift."""
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import plistlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
generated = [root / "MusicBridge.xcodeproj/project.pbxproj",
|
||||
root / "MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme",
|
||||
root / "MusicBridge/Info.plist"]
|
||||
before = [p.read_bytes() for p in generated]
|
||||
subprocess.run([sys.executable, str(root / "tools/generate_project.py")], check=True)
|
||||
assert before == [p.read_bytes() for p in generated], "Generated project is stale; review and commit regeneration."
|
||||
project = generated[0].read_text(encoding="utf-8")
|
||||
for folder in ("MusicBridge", "MusicBridgeTests"):
|
||||
for source in (root / folder).glob("*.swift"):
|
||||
key = hashlib.sha256(source.relative_to(root).as_posix().encode()).hexdigest()[:24].upper()
|
||||
assert key in project, f"Missing source: {source.name}"
|
||||
defined = set(re.findall(r'"([A-F0-9]{24})" = \{', project))
|
||||
referenced = set(re.findall(r'"([A-F0-9]{24})"', project))
|
||||
assert defined == referenced, "Project has dangling object references."
|
||||
info = plistlib.loads(generated[2].read_bytes())
|
||||
assert info["NSLocalNetworkUsageDescription"]
|
||||
assert info["NSAppTransportSecurity"] == {"NSAllowsLocalNetworking": True}
|
||||
ET.parse(generated[1])
|
||||
fixture = json.loads((root / "MusicBridgeTests/state-v1.json").read_text(encoding="utf-8"))
|
||||
assert fixture["protocolVersion"] == 1 and fixture["positionSeconds"] == 42.5
|
||||
assert set(fixture["capabilities"]) == {"play", "pause", "toggle", "next", "previous", "seek"}
|
||||
print("PASS: deterministic project, source membership, object references, plist, scheme XML, protocol fixture.")
|
||||
print("Swift compilation and XCTest must run on macOS/Xcode.")
|
||||
Reference in New Issue
Block a user