diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..46526f6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.png binary +*.jpg binary diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..00b313d --- /dev/null +++ b/.github/workflows/ios.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7be199 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +DerivedData/ +build/ +*.xcuserstate +xcuserdata/ +*.xcresult/ +__pycache__/ +*.pyc +*.p12 +*.mobileprovision +*.ipa diff --git a/MusicBridge.xcodeproj/project.pbxproj b/MusicBridge.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e5af05a --- /dev/null +++ b/MusicBridge.xcodeproj/project.pbxproj @@ -0,0 +1,329 @@ +// !$*UTF8*$! +{ + "archiveVersion" = 1; + "classes" = { + }; + "objectVersion" = 56; + "objects" = { + "065A9D559DC80AAF876DA757" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "BridgeClient.swift"; + "sourceTree" = ""; + }; + "82F1C33CE4F8B8F641FF0461" = { + "isa" = "PBXBuildFile"; + "fileRef" = "065A9D559DC80AAF876DA757"; + }; + "971C0AE92F167DE7AB145086" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "ContentView.swift"; + "sourceTree" = ""; + }; + "16EA630DE1FE9F0A16580C9D" = { + "isa" = "PBXBuildFile"; + "fileRef" = "971C0AE92F167DE7AB145086"; + }; + "76FB6BFFC478599567424755" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "KeychainStore.swift"; + "sourceTree" = ""; + }; + "DA06B3B12E52FB69A6A34887" = { + "isa" = "PBXBuildFile"; + "fileRef" = "76FB6BFFC478599567424755"; + }; + "124CF3F5AC075D1D6042DF64" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "Models.swift"; + "sourceTree" = ""; + }; + "DEDCCD95A140162B6D654871" = { + "isa" = "PBXBuildFile"; + "fileRef" = "124CF3F5AC075D1D6042DF64"; + }; + "ADF0CD56882201BD3AC1581B" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "MusicBridgeApp.swift"; + "sourceTree" = ""; + }; + "48E2FDCA11A6694953C86E6D" = { + "isa" = "PBXBuildFile"; + "fileRef" = "ADF0CD56882201BD3AC1581B"; + }; + "9C432A0DA0BEC173839EBF86" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "PlayerStore.swift"; + "sourceTree" = ""; + }; + "47693934E8A974162CF08A38" = { + "isa" = "PBXBuildFile"; + "fileRef" = "9C432A0DA0BEC173839EBF86"; + }; + "026F39792BBA0E5032536CED" = { + "isa" = "PBXGroup"; + "path" = "MusicBridge"; + "sourceTree" = ""; + "children" = ("065A9D559DC80AAF876DA757", "971C0AE92F167DE7AB145086", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86",); + }; + "ACD0393DCDDDF044F2966340" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "ProtocolTests.swift"; + "sourceTree" = ""; + }; + "17E248E58CB43C680B48DD51" = { + "isa" = "PBXBuildFile"; + "fileRef" = "ACD0393DCDDDF044F2966340"; + }; + "E1AD768E105BF42350625991" = { + "isa" = "PBXGroup"; + "path" = "MusicBridgeTests"; + "sourceTree" = ""; + "children" = ("ACD0393DCDDDF044F2966340", "F16D05EC6B29248D2C61ADB1",); + }; + "F16D05EC6B29248D2C61ADB1" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "text.json"; + "path" = "state-v1.json"; + "sourceTree" = ""; + }; + "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" = ""; + "children" = ("7C35A0276A762371ABAE5596", "5E4BE508157A7B2CB7B4C2F2",); + }; + "0D6E4079E36703EBD37C0072" = { + "isa" = "PBXGroup"; + "sourceTree" = ""; + "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"; +} diff --git a/MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme b/MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme new file mode 100644 index 0000000..13f58dc --- /dev/null +++ b/MusicBridge.xcodeproj/xcshareddata/xcschemes/MusicBridge.xcscheme @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/MusicBridge/BridgeClient.swift b/MusicBridge/BridgeClient.swift new file mode 100644 index 0000000..4fae344 --- /dev/null +++ b/MusicBridge/BridgeClient.swift @@ -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) + } +} diff --git a/MusicBridge/ContentView.swift b/MusicBridge/ContentView.swift new file mode 100644 index 0000000..aec9b6e --- /dev/null +++ b/MusicBridge/ContentView.swift @@ -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) + } +} diff --git a/MusicBridge/Info.plist b/MusicBridge/Info.plist new file mode 100644 index 0000000..4ef1dee --- /dev/null +++ b/MusicBridge/Info.plist @@ -0,0 +1,39 @@ + + + + + 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 + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + NSLocalNetworkUsageDescription + MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением. + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/MusicBridge/KeychainStore.swift b/MusicBridge/KeychainStore.swift new file mode 100644 index 0000000..7bb0d7e --- /dev/null +++ b/MusicBridge/KeychainStore.swift @@ -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)).") + } +} diff --git a/MusicBridge/Models.swift b/MusicBridge/Models.swift new file mode 100644 index 0000000..635c02c --- /dev/null +++ b/MusicBridge/Models.swift @@ -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 +} diff --git a/MusicBridge/MusicBridgeApp.swift b/MusicBridge/MusicBridgeApp.swift new file mode 100644 index 0000000..b25ce18 --- /dev/null +++ b/MusicBridge/MusicBridgeApp.swift @@ -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) } + } + } +} diff --git a/MusicBridge/PlayerStore.swift b/MusicBridge/PlayerStore.swift new file mode 100644 index 0000000..a84c8b0 --- /dev/null +++ b/MusicBridge/PlayerStore.swift @@ -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? + private var artworkTask: Task? + 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 + } +} diff --git a/MusicBridgeTests/ProtocolTests.swift b/MusicBridgeTests/ProtocolTests.swift new file mode 100644 index 0000000..72eccf0 --- /dev/null +++ b/MusicBridgeTests/ProtocolTests.swift @@ -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"]) + } +} diff --git a/MusicBridgeTests/state-v1.json b/MusicBridgeTests/state-v1.json new file mode 100644 index 0000000..0e67fee --- /dev/null +++ b/MusicBridgeTests/state-v1.json @@ -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 +} diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..5bfc563 --- /dev/null +++ b/PLAN.md @@ -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. diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..2a9f3c4 --- /dev/null +++ b/PROTOCOL.md @@ -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":""}`. +Отказ 403: `success:false`, `token:null`, `code` равен `invalid_code`, +`pairing_closed`, `device_limit` или `invalid_name`. +Если сохранить устройство не удалось: HTTP 503, `code:"storage_failed"`, +токен не выдаётся и устройство не получает доступа. + +Все следующие запросы, включая WebSocket handshake, требуют заголовка: + +```http +Authorization: Bearer +``` + +## Хранение и восстановление + +Файл `%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": "", + "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). diff --git a/README.md b/README.md index e69de29..56aa234 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/tools/generate_project.py b/tools/generate_project.py new file mode 100644 index 0000000..7104f4c --- /dev/null +++ b/tools/generate_project.py @@ -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="") + refs.append(ref) + builds.append(add("build/" + path.relative_to(ROOT).as_posix(), "PBXBuildFile", fileRef=ref)) + return add(folder, "PBXGroup", path=folder, sourceTree="", 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="") +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="", children=products) +main_group = add("main", "PBXGroup", sourceTree="", 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'' +app_ref = reference("MusicBridge", "MusicBridge.app") +test_ref = reference("MusicBridgeTests", "MusicBridgeTests.xctest") +(scheme_dir / "MusicBridge.xcscheme").write_bytes(f''' + + +{app_ref} + +{test_ref} +{app_ref} +{app_ref} + +'''.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.") diff --git a/tools/select_simulator.py b/tools/select_simulator.py new file mode 100644 index 0000000..f198cda --- /dev/null +++ b/tools/select_simulator.py @@ -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.") diff --git a/tools/validate_project.py b/tools/validate_project.py new file mode 100644 index 0000000..ddb3939 --- /dev/null +++ b/tools/validate_project.py @@ -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.")