Add native SwiftUI player and macOS CI
iOS build and tests / simulator (push) Has been cancelled

This commit is contained in:
2026-09-10 09:11:55 +03:00
parent 6307070194
commit 6174be5610
20 changed files with 1729 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.png binary
*.jpg binary
+66
View File
@@ -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
View File
@@ -0,0 +1,11 @@
.DS_Store
DerivedData/
build/
*.xcuserstate
xcuserdata/
*.xcresult/
__pycache__/
*.pyc
*.p12
*.mobileprovision
*.ipa
+329
View File
@@ -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>
+136
View File
@@ -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)
}
}
+166
View File
@@ -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)
}
}
+39
View File
@@ -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>
+41
View File
@@ -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)).")
}
}
+116
View File
@@ -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
}
+15
View File
@@ -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) }
}
}
}
+163
View File
@@ -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
}
}
+67
View File
@@ -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"])
}
}
+22
View File
@@ -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
}
+57
View File
@@ -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
View File
@@ -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).
+55
View File
@@ -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`.
+108
View File
@@ -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.")
+15
View File
@@ -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.")
+34
View File
@@ -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.")