diff --git a/apps/ios/README.md b/apps/ios/README.md index 64663f4..88792aa 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -42,10 +42,10 @@ open VMCTV.xcodeproj `Vendor/MobileVLCKit.xcframework` en `Vendor/TVVLCKit.xcframework` staan niet in git (te groot). Altijd op de Mac via de scripts installeren. -## Live TV / Events / ClearKey (0.5.x) +## Live TV / Events / ClearKey (0.5.1+) - `streamId` is een **String** (`"123"` of `"custom:uuid"`); path-segmenten worden URL-geëncodeerd (inclusief `:`). - Play-response parseert `format`, `drm` (ClearKey kids/keys) en `watchSessionId`. - Heartbeat: `POST /api/v1/client/watching/heartbeat` ~elke 25s bij custom live + events. -- **ClearKey/DASH:** best-effort via VLC media options (`:demux=dash`, `:cenc-key=…`). MobileVLCKit heeft geen officiële ClearKey-API zoals ExoPlayer; Xtream HLS/TS speelt normaal. Als decryptie faalt toont de app: *“Deze stream vereist DRM-ondersteuning die binnenkort komt”*. +- **ClearKey/DASH:** lokale in-app proxy (`ClearKeyDashProxy`) haalt MPD/segmenten op, decrypt CENC/CBCS met de API-keys (zelfde hex kid/key + GUID-endianness als Android), en speelt plain DASH via MobileVLCKit. Xtream HLS/TS speelt direct zonder proxy. - Events-tab verschijnt als `/me` → `events.enabled` true is. diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index 42ed35d..2a316e5 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -16,6 +16,10 @@ A6FDE760B2484854BAE25395 /* ProfileAvatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */; }; 424E70D5A34B48EFA6EAE5E7 /* VLCPlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED163B837C404A1183E20226 /* VLCPlayerController.swift */; }; 15ABF9F7AD2B4D04B118C331 /* PlayerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */; }; + A11C1EAF0000000000000001 /* ClearKeyMaterial.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000001 /* ClearKeyMaterial.swift */; }; + A11C1EAF0000000000000002 /* AesCtr.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000002 /* AesCtr.swift */; }; + A11C1EAF0000000000000003 /* CencMp4Decryptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */; }; + A11C1EAF0000000000000004 /* ClearKeyDashProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */; }; F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522137D80C53480E95FF600F /* RootView.swift */; }; D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; }; 1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.swift */; }; @@ -47,6 +51,10 @@ 8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAvatars.swift; sourceTree = ""; }; ED163B837C404A1183E20226 /* VLCPlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerController.swift; sourceTree = ""; }; 2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerScreen.swift; sourceTree = ""; }; + B11C1EAF0000000000000001 /* ClearKeyMaterial.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearKeyMaterial.swift; sourceTree = ""; }; + B11C1EAF0000000000000002 /* AesCtr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AesCtr.swift; sourceTree = ""; }; + B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CencMp4Decryptor.swift; sourceTree = ""; }; + B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearKeyDashProxy.swift; sourceTree = ""; }; 522137D80C53480E95FF600F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = ""; }; 314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; @@ -151,6 +159,10 @@ children = ( ED163B837C404A1183E20226 /* VLCPlayerController.swift */, 2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */, + B11C1EAF0000000000000001 /* ClearKeyMaterial.swift */, + B11C1EAF0000000000000002 /* AesCtr.swift */, + B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */, + B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */, ); path = Playback; sourceTree = ""; @@ -258,6 +270,10 @@ A6FDE760B2484854BAE25395 /* ProfileAvatars.swift in Sources */, 424E70D5A34B48EFA6EAE5E7 /* VLCPlayerController.swift in Sources */, 15ABF9F7AD2B4D04B118C331 /* PlayerScreen.swift in Sources */, + A11C1EAF0000000000000001 /* ClearKeyMaterial.swift in Sources */, + A11C1EAF0000000000000002 /* AesCtr.swift in Sources */, + A11C1EAF0000000000000003 /* CencMp4Decryptor.swift in Sources */, + A11C1EAF0000000000000004 /* ClearKeyDashProxy.swift in Sources */, F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */, D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */, 1EF110B9F82E440F999ECE37 /* Components.swift in Sources */, @@ -321,7 +337,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -336,7 +352,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.0; + MARKETING_VERSION = 0.5.1; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -354,7 +370,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -369,7 +385,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.0; + MARKETING_VERSION = 0.5.1; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apps/ios/VMC/Playback/AesCtr.swift b/apps/ios/VMC/Playback/AesCtr.swift new file mode 100644 index 0000000..f0cc2b1 --- /dev/null +++ b/apps/ios/VMC/Playback/AesCtr.swift @@ -0,0 +1,120 @@ +import CommonCrypto +import Foundation + +enum AesCtr { + /// AES-128-CTR (big-endian counter), zoals CENC `cenc` scheme. + static func crypt(key: Data, iv: Data, data: Data) -> Data? { + guard key.count == 16, data.count > 0 else { + return data.isEmpty ? data : nil + } + var counter = Data(count: 16) + let copyLen = min(iv.count, 16) + counter.replaceSubrange(0.. Data? { + guard key.count == 16 else { return nil } + if cryptByteBlock <= 0 { + return data + } + let cryptLen = 16 * cryptByteBlock + let skipLen = 16 * max(skipByteBlock, 0) + var result = data + var offset = 0 + var blockIv = Data(count: 16) + let ivCopy = min(iv.count, 16) + blockIv.replaceSubrange(0.. Data? { + guard data.count % 16 == 0, data.count > 0 else { return data.isEmpty ? data : nil } + var out = Data(count: data.count) + var moved = 0 + var ivMut = iv + if ivMut.count < 16 { ivMut.append(Data(count: 16 - ivMut.count)) } + let status = out.withUnsafeMutableBytes { outBytes in + data.withUnsafeBytes { inBytes in + key.withUnsafeBytes { keyBytes in + ivMut.withUnsafeMutableBytes { ivBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(0), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + inBytes.baseAddress, + data.count, + outBytes.baseAddress, + data.count, + &moved + ) + } + } + } + } + guard status == kCCSuccess, moved == data.count else { return nil } + return out + } +} diff --git a/apps/ios/VMC/Playback/CencMp4Decryptor.swift b/apps/ios/VMC/Playback/CencMp4Decryptor.swift new file mode 100644 index 0000000..106087f --- /dev/null +++ b/apps/ios/VMC/Playback/CencMp4Decryptor.swift @@ -0,0 +1,635 @@ +import Foundation + +/// In-place CENC/CBCS decryptie van gefragmenteerde MP4 (init + media segments). +enum CencMp4Decryptor { + struct TrackCrypto { + var kid: Data + var perSampleIvSize: Int + var constantIv: Data? + var scheme: String // cenc | cbcs | cbc1 | cens + var cryptByteBlock: Int + var skipByteBlock: Int + } + + struct SessionState { + var byTrackId: [UInt32: TrackCrypto] = [:] + var fallback: TrackCrypto? + } + + @discardableResult + static func process(data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) -> Bool { + guard !data.isEmpty, !keys.isEmpty else { return false } + var touched = false + if containsBox(data, type: "moov") { + harvestAndClearInit(&data, keys: keys, state: &state) + touched = true + } + if containsBox(data, type: "moof") { + decryptFragments(&data, keys: keys, state: state) + touched = true + } + return touched + } + + // MARK: - Box helpers + + private struct Box { + let type: String + let start: Int + let headerSize: Int + let size: Int + var payloadStart: Int { start + headerSize } + var payloadEnd: Int { start + size } + var payloadLength: Int { size - headerSize } + } + + private static func containsBox(_ data: Data, type: String) -> Bool { + findBoxes(data, type: type, recursive: true).isEmpty == false + } + + private static func findBoxes(_ data: Data, type: String, recursive: Bool) -> [Box] { + var out: [Box] = [] + walk(data, from: 0, to: data.count, recursive: recursive) { box in + if box.type == type { out.append(box) } + return true + } + return out + } + + private static func walk( + _ data: Data, + from: Int, + to: Int, + recursive: Bool, + visit: (Box) -> Bool + ) { + var offset = from + let containers: Set = [ + "moov", "trak", "mdia", "minf", "stbl", "moof", "traf", + "mvex", "edts", "sinf", "schi", "dinf", "udta", "meta", + ] + while offset + 8 <= to { + guard let box = readBox(data, at: offset, limit: to) else { break } + let cont = visit(box) + if !cont { return } + if recursive, containers.contains(box.type), box.payloadLength > 0 { + walk(data, from: box.payloadStart, to: box.payloadEnd, recursive: true, visit: visit) + } + offset = box.payloadEnd + if box.size <= 0 { break } + } + } + + private static func readBox(_ data: Data, at offset: Int, limit: Int) -> Box? { + guard offset + 8 <= limit else { return nil } + var size = Int(readU32(data, offset)) + let type = readFourCC(data, offset + 4) + var header = 8 + if size == 1 { + guard offset + 16 <= limit else { return nil } + let large = readU64(data, offset + 8) + size = Int(large) + header = 16 + } else if size == 0 { + size = limit - offset + } + guard size >= header, offset + size <= limit else { return nil } + return Box(type: type, start: offset, headerSize: header, size: size) + } + + private static func readU16(_ data: Data, _ o: Int) -> UInt16 { + UInt16(data[o]) << 8 | UInt16(data[o + 1]) + } + + private static func readU32(_ data: Data, _ o: Int) -> UInt32 { + UInt32(data[o]) << 24 | UInt32(data[o + 1]) << 16 | UInt32(data[o + 2]) << 8 | UInt32(data[o + 3]) + } + + private static func readU64(_ data: Data, _ o: Int) -> UInt64 { + (UInt64(readU32(data, o)) << 32) | UInt64(readU32(data, o + 4)) + } + + private static func readFourCC(_ data: Data, _ o: Int) -> String { + String(bytes: data[o..<(o + 4)], encoding: .ascii) ?? "????" + } + + private static func writeFourCC(_ data: inout Data, _ o: Int, _ s: String) { + let chars = Array(s.utf8) + guard chars.count == 4, o + 4 <= data.count else { return } + for i in 0..<4 { data[o + i] = chars[i] } + } + + // MARK: - Init segment + + private static func harvestAndClearInit(_ data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) { + // Track id from tkhd, crypto from sinf/tenc/frma/schm inside same trak. + for trak in findBoxes(data, type: "trak", recursive: true) { + var trackId: UInt32? + walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in + if box.type == "tkhd", box.payloadLength >= 8 { + let version = Int(data[box.payloadStart]) + let idOff = box.payloadStart + (version == 1 ? 20 : 12) + if idOff + 4 <= box.payloadEnd { + trackId = readU32(data, idOff) + } + } + return true + } + var crypto: TrackCrypto? + var originalFormat: String? + walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in + if box.type == "frma", box.payloadLength >= 4 { + originalFormat = readFourCC(data, box.payloadStart) + } + if box.type == "schm", box.payloadLength >= 8 { + // version/flags (4) + scheme_type (4) + let scheme = readFourCC(data, box.payloadStart + 4) + if crypto == nil { + crypto = TrackCrypto( + kid: Data(count: 16), + perSampleIvSize: 8, + constantIv: nil, + scheme: scheme, + cryptByteBlock: 0, + skipByteBlock: 0 + ) + } else { + crypto?.scheme = scheme + } + } + if box.type == "tenc", box.payloadLength >= 20 { + let version = Int(data[box.payloadStart]) + let base = box.payloadStart + 4 // na version+flags + var o = base + o += 1 // reserved + var cryptBlocks = 0 + var skipBlocks = 0 + if version >= 1 { + let pattern = data[o] + cryptBlocks = Int((pattern >> 4) & 0x0F) + skipBlocks = Int(pattern & 0x0F) + } + o += 1 // reserved (v0) of pattern (v1) + let isProtectedIndex = o + let isProtected = Int(data[o]); o += 1 + let ivSize = Int(data[o]); o += 1 + let kid = data.subdata(in: o..<(o + 16)); o += 16 + _ = isProtected + var constantIv: Data? + if ivSize == 0, o < box.payloadEnd { + let constSize = Int(data[o]); o += 1 + if constSize > 0, o + constSize <= box.payloadEnd { + constantIv = data.subdata(in: o..<(o + constSize)) + } + } + crypto = TrackCrypto( + kid: kid, + perSampleIvSize: ivSize, + constantIv: constantIv, + scheme: crypto?.scheme ?? "cenc", + cryptByteBlock: cryptBlocks, + skipByteBlock: skipBlocks + ) + data[isProtectedIndex] = 0 + } + return true + } + + // Replace encv/enca with original format (same 4 bytes → no size rewrite). + if let originalFormat { + walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in + // Sample entries live in stsd; type is at box.type for encv/enca boxes themselves. + return true + } + // Find encv/enca boxes by scanning stsd children manually. + for stsd in findBoxes(data, type: "stsd", recursive: true) { + guard stsd.start >= trak.start, stsd.payloadEnd <= trak.payloadEnd else { continue } + var o = stsd.payloadStart + 4 // version/flags + entry_count follows version/flags (4) then count (4) + // FullBox (4) + entry_count (4) + o = stsd.payloadStart + 8 + while o + 8 <= stsd.payloadEnd { + guard let entry = readBox(data, at: o, limit: stsd.payloadEnd) else { break } + if entry.type == "encv" || entry.type == "enca" { + writeFourCC(&data, entry.start + 4, originalFormat) + } + o = entry.payloadEnd + } + } + } + + if let crypto { + if let trackId { + state.byTrackId[trackId] = crypto + } + state.fallback = crypto + _ = keys.key(forKid: crypto.kid) + } + } + } + + // MARK: - Media fragments + + private static func decryptFragments(_ data: inout Data, keys: ClearKeyKeyMap, state: SessionState) { + // Pair each moof with the following mdat. + var offset = 0 + while offset + 8 <= data.count { + guard let box = readBox(data, at: offset, limit: data.count) else { break } + if box.type == "moof" { + let moof = box + var mdat: Box? + var scan = moof.payloadEnd + while scan + 8 <= data.count { + guard let next = readBox(data, at: scan, limit: data.count) else { break } + if next.type == "mdat" { + mdat = next + break + } + if next.type == "moof" { break } + scan = next.payloadEnd + } + if let mdat { + decryptMoofMdat(moof: moof, mdat: mdat, data: &data, keys: keys, state: state) + } + } + offset = box.payloadEnd + } + } + + private static func decryptMoofMdat( + moof: Box, + mdat: Box, + data: inout Data, + keys: ClearKeyKeyMap, + state: SessionState + ) { + let trafs = findBoxesInRange(data, type: "traf", from: moof.payloadStart, to: moof.payloadEnd) + var mdatCursor = mdat.payloadStart + + for traf in trafs { + var trackId: UInt32 = 0 + var defaultSampleSize: UInt32 = 0 + var baseDataOffset: Int64 = Int64(mdat.payloadStart) + var dataOffsetPresent = false + var sampleSizes: [Int] = [] + var sencSamples: [SencSample] = [] + var perSampleIvSize = state.fallback?.perSampleIvSize ?? 8 + + // tfhd + for tfhd in findBoxesInRange(data, type: "tfhd", from: traf.payloadStart, to: traf.payloadEnd) { + let versionFlags = readU32(data, tfhd.payloadStart) + let flags = versionFlags & 0x00FF_FFFF + var o = tfhd.payloadStart + 4 + trackId = readU32(data, o); o += 4 + if let crypto = state.byTrackId[trackId] { + perSampleIvSize = crypto.perSampleIvSize + } + if flags & 0x000001 != 0 { // base-data-offset + baseDataOffset = Int64(readU64(data, o)); o += 8 + dataOffsetPresent = true + } + if flags & 0x000002 != 0 { o += 4 } // sample-description-index + if flags & 0x000008 != 0 { + defaultSampleSize = readU32(data, o); o += 4 + } + _ = versionFlags + } + + // trun + for trun in findBoxesInRange(data, type: "trun", from: traf.payloadStart, to: traf.payloadEnd) { + let versionFlags = readU32(data, trun.payloadStart) + let version = Int(versionFlags >> 24) + let flags = versionFlags & 0x00FF_FFFF + var o = trun.payloadStart + 4 + let sampleCount = Int(readU32(data, o)); o += 4 + var dataOffset: Int32 = 0 + if flags & 0x000001 != 0 { + dataOffset = Int32(bitPattern: readU32(data, o)); o += 4 + dataOffsetPresent = true + } + if flags & 0x000004 != 0 { o += 4 } // first-sample-flags + sampleSizes.removeAll(keepingCapacity: true) + for _ in 0.. 0, mdatCursor + size <= data.count else { break } + let sampleRange = mdatCursor..<(mdatCursor + size) + var sample = data.subdata(in: sampleRange) + let senc = i < sencSamples.count ? sencSamples[i] : nil + if let senc { + if let decrypted = decryptSample( + sample, + key: key, + iv: senc.iv, + subsamples: senc.subsamples, + scheme: scheme, + cryptByteBlock: cryptBlocks, + skipByteBlock: skipBlocks + ) { + sample = decrypted + data.replaceSubrange(sampleRange, with: sample) + } + } + mdatCursor += size + } + } + } + + private struct SencSample { + var iv: Data + var subsamples: [(clear: Int, protected: Int)]? + } + + private static func parseSenc( + _ data: Data, + box: Box, + perSampleIvSize: Int, + constantIv: Data? + ) -> [SencSample] { + let versionFlags = readU32(data, box.payloadStart) + let flags = versionFlags & 0x00FF_FFFF + var o = box.payloadStart + 4 + let sampleCount = Int(readU32(data, o)); o += 4 + var out: [SencSample] = [] + out.reserveCapacity(sampleCount) + let useSubsamples = (flags & 0x2) != 0 + for _ in 0.. 0 { + let n = min(perSampleIvSize, 16) + guard o + n <= box.payloadEnd else { break } + iv.replaceSubrange(0.. [SencSample] { + let saizBoxes = findBoxesInRange(data, type: "saiz", from: traf.payloadStart, to: traf.payloadEnd) + let saioBoxes = findBoxesInRange(data, type: "saio", from: traf.payloadStart, to: traf.payloadEnd) + guard let saiz = saizBoxes.first, let saio = saioBoxes.first else { return [] } + + let saizFlags = readU32(data, saiz.payloadStart) & 0x00FF_FFFF + var o = saiz.payloadStart + 4 + if saizFlags & 0x1 != 0 { o += 8 } // aux_info_type + parameter + let defaultSize = Int(data[o]); o += 1 + let count = Int(readU32(data, o)); o += 4 + var sizes: [Int] = [] + if defaultSize == 0 { + for _ in 0..> 24) + let saioFlags = saioVersionFlags & 0x00FF_FFFF + var so = saio.payloadStart + 4 + if saioFlags & 0x1 != 0 { so += 8 } + let entryCount = Int(readU32(data, so)); so += 4 + guard entryCount >= 1 else { return [] } + let auxOffset: Int + if saioVersion == 0 { + auxOffset = Int(readU32(data, so)) + } else { + auxOffset = Int(readU64(data, so)) + } + // Offsets are usually relative to moof start (ISO) or absolute file offset. + var auxPos = auxOffset + if auxPos < moof.start || auxPos > data.count { + auxPos = moof.start + auxOffset + } + guard auxPos >= 0, auxPos < data.count else { return [] } + + var out: [SencSample] = [] + let n = min(sizes.count, sampleCount > 0 ? sampleCount : sizes.count) + for i in 0.. 0 { + let ivn = min(perSampleIvSize, 16) + iv.replaceSubrange(0.. perSampleIvSize { + let sc = Int(readU16(data, p)); p += 2 + var list: [(clear: Int, protected: Int)] = [] + for _ in 0.. Data? { + if scheme == "cbcs" || scheme == "cbc1" { + return decryptCbcsSample( + sample, + key: key, + iv: iv, + subsamples: subsamples, + cryptByteBlock: max(cryptByteBlock, 1), + skipByteBlock: skipByteBlock + ) + } + // cenc / cens: AES-CTR. Concatenate protected ranges → one CTR stream → merge. + guard let ranges = protectedRanges(sampleCount: sample.count, subsamples: subsamples), !ranges.isEmpty else { + return AesCtr.crypt(key: key, iv: iv, data: sample) + } + var encrypted = Data() + for r in ranges { + encrypted.append(sample.subdata(in: r)) + } + guard let plain = AesCtr.crypt(key: key, iv: iv, data: encrypted) else { return nil } + var out = sample + var read = 0 + for r in ranges { + let len = r.count + out.replaceSubrange(r, with: plain.subdata(in: read..<(read + len))) + read += len + } + return out + } + + private static func decryptCbcsSample( + _ sample: Data, + key: Data, + iv: Data, + subsamples: [(clear: Int, protected: Int)]?, + cryptByteBlock: Int, + skipByteBlock: Int + ) -> Data? { + var out = sample + if let subsamples { + var cursor = 0 + for sub in subsamples { + cursor += sub.clear + let end = cursor + sub.protected + guard end <= out.count else { break } + let slice = out.subdata(in: cursor.. [Range]? { + guard let subsamples else { + return [0..] = [] + var cursor = 0 + for sub in subsamples { + cursor += sub.clear + if sub.protected > 0 { + ranges.append(cursor..<(cursor + sub.protected)) + } + cursor += sub.protected + } + return ranges + } + + private static func findBoxesInRange(_ data: Data, type: String, from: Int, to: Int) -> [Box] { + var out: [Box] = [] + walk(data, from: from, to: to, recursive: true) { box in + if box.type == type { out.append(box) } + return true + } + return out + } +} diff --git a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift new file mode 100644 index 0000000..0d00b17 --- /dev/null +++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift @@ -0,0 +1,335 @@ +import Foundation +import Network + +/// Lokale HTTP-proxy: haalt DASH MPD/segmenten op, strip DRM-metadata, decrypt CENC met ClearKey. +/// VLC speelt daarna plain DASH vanaf 127.0.0.1. +final class ClearKeyDashProxy { + static let shared = ClearKeyDashProxy() + + private let queue = DispatchQueue(label: "nl.vonas.vmc.clearkey-proxy") + private var listener: NWListener? + private var port: UInt16 = 0 + private var remoteManifest: URL? + private var keyMap: ClearKeyKeyMap? + private var cryptoState = CencMp4Decryptor.SessionState() + private var generation = 0 + + private init() {} + + var isRunning: Bool { listener != nil && port > 0 } + + /// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug. + func start(manifestURL: URL, keys: [ClearKeyEntry]) throws -> URL { + let map = ClearKeyKeyMap(entries: keys) + guard !map.isEmpty else { + throw ProxyError.noKeys + } + stop() + remoteManifest = manifestURL + keyMap = map + cryptoState = CencMp4Decryptor.SessionState() + generation += 1 + + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + let listener = try NWListener(using: params, on: .any) + self.listener = listener + + let ready = DispatchSemaphore(value: 0) + var readyError: Error? + + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + if let p = listener.port?.rawValue { + self.port = p + } + ready.signal() + case .failed(let err): + readyError = err + ready.signal() + default: + break + } + } + + listener.newConnectionHandler = { [weak self] conn in + self?.handle(connection: conn) + } + + listener.start(queue: queue) + _ = ready.wait(timeout: .now() + 5) + if let readyError { throw readyError } + guard port > 0 else { throw ProxyError.bindFailed } + + return URL(string: "http://127.0.0.1:\(port)/manifest.mpd")! + } + + func stop() { + listener?.cancel() + listener = nil + port = 0 + remoteManifest = nil + keyMap = nil + cryptoState = CencMp4Decryptor.SessionState() + } + + // MARK: - HTTP + + private func handle(connection: NWConnection) { + connection.start(queue: queue) + receiveRequest(on: connection, buffer: Data()) + } + + private func receiveRequest(on connection: NWConnection, buffer: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, isComplete, error in + guard let self else { + connection.cancel() + return + } + if error != nil { + connection.cancel() + return + } + var buf = buffer + if let data { buf.append(data) } + if let range = buf.range(of: Data("\r\n\r\n".utf8)) { + let headerData = buf.subdata(in: 0.. 256 * 1024 { + connection.cancel() + return + } + self.receiveRequest(on: connection, buffer: buf) + } + } + + private func serve(connection: NWConnection, headerData: Data) { + guard let header = String(data: headerData, encoding: .utf8), + let firstLine = header.split(separator: "\r\n", omittingEmptySubsequences: false).first + else { + respond(connection, status: 400, contentType: "text/plain", body: Data("bad request".utf8)) + return + } + let parts = firstLine.split(separator: " ") + guard parts.count >= 2 else { + respond(connection, status: 400, contentType: "text/plain", body: Data("bad request".utf8)) + return + } + let method = String(parts[0]) + let path = String(parts[1]) + guard method == "GET" || method == "HEAD" else { + respond(connection, status: 405, contentType: "text/plain", body: Data("method".utf8)) + return + } + + Task { + do { + let (body, contentType) = try await self.payload(for: path) + if method == "HEAD" { + self.respond(connection, status: 200, contentType: contentType, body: Data(), headOnly: true, contentLength: body.count) + } else { + self.respond(connection, status: 200, contentType: contentType, body: body) + } + } catch { + let msg = error.localizedDescription + self.respond(connection, status: 502, contentType: "text/plain; charset=utf-8", body: Data(msg.utf8)) + } + } + } + + private func payload(for pathAndQuery: String) async throws -> (Data, String) { + guard let keyMap, let remoteManifest else { + throw ProxyError.notStarted + } + let pathOnly = pathAndQuery.split(separator: "?", maxSplits: 1).first.map(String.init) ?? pathAndQuery + + if pathOnly == "/" || pathOnly == "/manifest.mpd" { + let raw = try await fetch(remoteManifest) + let rewritten = rewriteMpd(String(data: raw, encoding: .utf8) ?? "", mpdURL: remoteManifest) + return (Data(rewritten.utf8), "application/dash+xml") + } + + if pathOnly.hasPrefix("/a/") { + let b64 = String(pathOnly.dropFirst(3)) + guard let url = decodeURL(b64) else { throw ProxyError.badPath } + return try await fetchAndDecrypt(url, keys: keyMap) + } + + if pathOnly.hasPrefix("/p/") { + let rest = String(pathOnly.dropFirst(3)) + guard let slash = rest.firstIndex(of: "/") else { throw ProxyError.badPath } + let b64 = String(rest[.. (Data, String) { + var data = try await fetch(url) + queue.sync { + _ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoState) + } + let ct: String + let lower = url.absoluteString.lowercased() + if lower.contains(".mpd") { + ct = "application/dash+xml" + } else if lower.contains(".m4s") || lower.contains(".mp4") || lower.contains("init") { + ct = "video/mp4" + } else { + ct = "application/octet-stream" + } + return (data, ct) + } + + private func fetch(_ url: URL) async throws -> Data { + var req = URLRequest(url: url) + req.timeoutInterval = 20 + req.setValue("VMC-iOS-ClearKey/0.5.1", forHTTPHeaderField: "User-Agent") + let (data, resp) = try await URLSession.shared.data(for: req) + if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + throw ProxyError.upstream(http.statusCode) + } + return data + } + + private func rewriteMpd(_ xml: String, mpdURL: URL) -> String { + var s = xml + // Strip ContentProtection (ClearKey/Widevine/PlayReady/mp4protection). + s = s.replacingOccurrences( + of: #"]*/>"#, + with: "", + options: .regularExpression + ) + s = s.replacingOccurrences( + of: #"]*>[\s\S]*?"#, + with: "", + options: .regularExpression + ) + + let originDir = mpdURL.deletingLastPathComponent() + let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(originDir))/" + + // Herschrijf bestaande BaseURL-tekst naar proxy. + s = s.replacingOccurrences( + of: #"([\s\S]*?)"#, + with: "\(proxyBase)", + options: .regularExpression + ) + + if !s.localizedCaseInsensitiveContains("]*>"#, options: []) { + let range = NSRange(s.startIndex..\(proxyBase)", at: r.upperBound) + } + } + } + + // Absolute http(s) URL's in attributen → /a/ + if let urlRegex = try? NSRegularExpression(pattern: #"https?://[^\s"'<>]+"#, options: []) { + let ns = s as NSString + let matches = urlRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length)) + for match in matches.reversed() { + let abs = ns.substring(with: match.range) + // Laat lokale proxy-URL's met rust. + if abs.contains("127.0.0.1:\(port)") { continue } + let replacement = "http://127.0.0.1:\(port)/a/\(encodeURLString(abs))" + s = (s as NSString).replacingCharacters(in: match.range, with: replacement) + } + } + + return s + } + + private func join(base: URL, relative: String) -> URL { + if relative.hasPrefix("http://") || relative.hasPrefix("https://") { + return URL(string: relative) ?? base + } + return URL(string: relative, relativeTo: base)?.absoluteURL ?? base.appendingPathComponent(relative) + } + + private func encodeURL(_ url: URL) -> String { + encodeURLString(url.absoluteString) + } + + private func encodeURLString(_ s: String) -> String { + Data(s.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private func decodeURL(_ b64url: String) -> URL? { + var b64 = b64url + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let pad = (4 - b64.count % 4) % 4 + if pad > 0 { b64 += String(repeating: "=", count: pad) } + guard let data = Data(base64Encoded: b64), + let s = String(data: data, encoding: .utf8) + else { return nil } + return URL(string: s) + } + + private func respond( + _ connection: NWConnection, + status: Int, + contentType: String, + body: Data, + headOnly: Bool = false, + contentLength: Int? = nil + ) { + let reason: String + switch status { + case 200: reason = "OK" + case 400: reason = "Bad Request" + case 405: reason = "Method Not Allowed" + case 502: reason = "Bad Gateway" + default: reason = "Error" + } + let len = contentLength ?? body.count + var header = "HTTP/1.1 \(status) \(reason)\r\n" + header += "Content-Type: \(contentType)\r\n" + header += "Content-Length: \(len)\r\n" + header += "Connection: close\r\n" + header += "Access-Control-Allow-Origin: *\r\n" + header += "Cache-Control: no-store\r\n" + header += "\r\n" + var payload = Data(header.utf8) + if !headOnly { payload.append(body) } + connection.send(content: payload, completion: .contentProcessed { _ in + connection.cancel() + }) + } + + enum ProxyError: LocalizedError { + case noKeys + case bindFailed + case notStarted + case badPath + case upstream(Int) + + var errorDescription: String? { + switch self { + case .noKeys: return "Geen geldige ClearKey-sleutels" + case .bindFailed: return "Kon lokale ClearKey-proxy niet starten" + case .notStarted: return "ClearKey-proxy is niet actief" + case .badPath: return "Ongeldig proxy-pad" + case .upstream(let code): return "Upstream HTTP \(code)" + } + } + } +} diff --git a/apps/ios/VMC/Playback/ClearKeyMaterial.swift b/apps/ios/VMC/Playback/ClearKeyMaterial.swift new file mode 100644 index 0000000..642fecc --- /dev/null +++ b/apps/ios/VMC/Playback/ClearKeyMaterial.swift @@ -0,0 +1,70 @@ +import Foundation + +/// ClearKey kids/keys zoals Android `buildClearKeyLicenseJson` (hex 32 + GUID-endianness). +struct ClearKeyKeyMap { + /// kid-hex (lowercase, 32 chars) → key bytes (16) + private let keysByKid: [String: Data] + + init(entries: [ClearKeyEntry]) { + var map: [String: Data] = [:] + for entry in entries { + guard let kid = Self.hex16(entry.kid), let key = Self.hex16(entry.key) else { continue } + let kidHex = kid.map { String(format: "%02x", $0) }.joined() + map[kidHex] = key + let swapped = Self.swapUuidEndianness(kid) + let swappedHex = swapped.map { String(format: "%02x", $0) }.joined() + if swappedHex != kidHex { + map[swappedHex] = key + } + } + keysByKid = map + } + + var isEmpty: Bool { keysByKid.isEmpty } + + /// Eerste key (streams hebben meestal één kid/key-paar). + var primaryKey: Data? { keysByKid.values.first } + + func key(forKid kid: Data) -> Data? { + guard kid.count == 16 else { return primaryKey } + let hex = kid.map { String(format: "%02x", $0) }.joined() + if let k = keysByKid[hex] { return k } + let swapped = Self.swapUuidEndianness(kid) + let swappedHex = swapped.map { String(format: "%02x", $0) }.joined() + return keysByKid[swappedHex] ?? primaryKey + } + + static func sanitizeHex(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "0x", with: "", options: .caseInsensitive) + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: ":", with: "") + .replacingOccurrences(of: " ", with: "") + .lowercased() + } + + static func hex16(_ raw: String) -> Data? { + let clean = sanitizeHex(raw) + guard clean.count == 32, clean.allSatisfy({ $0.isHexDigit }) else { return nil } + var out = Data(capacity: 16) + var i = clean.startIndex + while i < clean.endIndex { + let j = clean.index(i, offsetBy: 2) + guard let b = UInt8(clean[i.. Data { + guard b.count == 16 else { return b } + return Data([ + b[3], b[2], b[1], b[0], + b[5], b[4], + b[7], b[6], + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15], + ]) + } +} diff --git a/apps/ios/VMC/Playback/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift index d97b8fc..076ec30 100644 --- a/apps/ios/VMC/Playback/PlayerScreen.swift +++ b/apps/ios/VMC/Playback/PlayerScreen.swift @@ -386,19 +386,21 @@ struct PlayerScreen: View { loading = true error = nil if request.isLive { + let keys = request.needsClearKey ? (request.drm?.keys ?? []) : [] player.playLive( url: request.url, fallback: request.fallbackUrl, format: request.format, - clearKeys: request.drm?.keys ?? [] + clearKeys: keys ) - // Geef VLC tijd; bij ClearKey + blijvende fout → duidelijke NL-melding. - try? await Task.sleep(nanoseconds: 2_500_000_000) + // ClearKey/DASH heeft meer opstarttijd (proxy + init/media segments). + let waitNs: UInt64 = request.needsClearKey ? 8_000_000_000 : 2_500_000_000 + try? await Task.sleep(nanoseconds: waitNs) if request.needsClearKey && (player.hasError || !player.isPlaying) { - try? await Task.sleep(nanoseconds: 3_500_000_000) + try? await Task.sleep(nanoseconds: 4_000_000_000) if player.hasError || !player.isPlaying { - error = "Deze stream vereist DRM-ondersteuning die binnenkort komt" - player.stop() + error = player.errorMessage + ?? "Kon deze ClearKey-stream niet afspelen. Controleer netwerk of probeer opnieuw." } } } else { diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift index 68b4563..4e212eb 100644 --- a/apps/ios/VMC/Playback/VLCPlayerController.swift +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -19,6 +19,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg let mediaPlayer = VLCMediaPlayer() private var lengthMs: Int32 = 0 private var tick: AnyCancellable? + private var clearKeyProxyActive = false override init() { super.init() @@ -77,20 +78,39 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg hasError = false errorMessage = nil stateText = "Live laden…" - let media = VLCMedia(url: url) + + var playURL = url + var playFormat = format + clearKeyProxyActive = false + + if !clearKeys.isEmpty { + do { + // MobileVLCKit kan CENC/ClearKey niet zelf; lokale proxy decrypt segmenten. + playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys) + playFormat = "dash" + clearKeyProxyActive = true + stateText = "ClearKey laden…" + } catch { + hasError = true + errorMessage = error.localizedDescription + stateText = "Fout" + return + } + } + + let media = VLCMedia(url: playURL) media.delegate = self - Self.applyLiveOptions(to: media, url: url, format: format, clearKeys: clearKeys) + Self.applyLiveOptions(to: media, url: playURL, format: playFormat) mediaPlayer.media = media media.parse() mediaPlayer.play() applySubtitleStyle() - if let fallback { + if let fallback, clearKeys.isEmpty { DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in guard let self, !self.isPlaying || self.hasError else { return } - // Geen ClearKey op fallback tenzij zelfde DRM nodig is — probeer plain HLS/TS. let fb = VLCMedia(url: fallback) fb.delegate = self - Self.applyLiveOptions(to: fb, url: fallback, format: nil, clearKeys: []) + Self.applyLiveOptions(to: fb, url: fallback, format: nil) self.mediaPlayer.media = fb fb.parse() self.mediaPlayer.play() @@ -100,13 +120,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg startTicker() } - /// Best-effort DASH + ClearKey via libvlc media options. - /// MobileVLCKit heeft geen officiële ClearKey-API; we zetten gangbare demux/cenc-opties. private static func applyLiveOptions( to media: VLCMedia, url: URL, - format: String?, - clearKeys: [ClearKeyEntry] + format: String? ) { media.addOption(":network-caching=1200") media.addOption(":http-reconnect") @@ -125,29 +142,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } else if isHls { media.addOption(":demux=hls") } - - guard !clearKeys.isEmpty else { return } - // Probeer meerdere option-namen die in libvlc/cenc-builds voorkomen. - for entry in clearKeys { - let kid = sanitizeHex(entry.kid) - let key = sanitizeHex(entry.key) - guard kid.count == 32, key.count == 32 else { continue } - media.addOption(":cenc-key=\(kid):\(key)") - media.addOption(":encryption-key=\(key)") - // Sommige builds: kid zonder scheiding + key als hex. - media.addOption(":demuxdump-key=\(key)") - } - // Extra hint voor ISO-BMFF CENC in DASH. - media.addOption(":no-spu") - } - - private static func sanitizeHex(_ raw: String) -> String { - raw.trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "0x", with: "", options: .caseInsensitive) - .replacingOccurrences(of: "-", with: "") - .replacingOccurrences(of: ":", with: "") - .replacingOccurrences(of: " ", with: "") - .lowercased() } func togglePlay() { @@ -412,6 +406,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg tick = nil mediaPlayer.stop() mediaPlayer.media = nil + if clearKeyProxyActive { + ClearKeyDashProxy.shared.stop() + clearKeyProxyActive = false + } } func mediaPlayerStateChanged(_ aNotification: Notification) { diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index ca7c69a..2c6edf7 100644 --- a/apps/ios/VMC/UI/SettingsScreen.swift +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -96,7 +96,7 @@ struct SettingsScreen: View { Section("Info") { LabeledContent("App", value: "VMC iOS") - LabeledContent("Versie", value: "0.5.0") + LabeledContent("Versie", value: "0.5.1") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit") LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit") }