Enable iOS ClearKey DASH playback via local CENC decrypt proxy.
MobileVLCKit cannot decrypt ClearKey; the app now decrypts segments in-process and feeds plain DASH to VLC, removing the coming-soon DRM dead-end (0.5.1/13).
This commit is contained in:
parent
3e2549c33a
commit
fac38d905e
9 changed files with 1221 additions and 45 deletions
|
|
@ -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.
|
`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 `:`).
|
- `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`.
|
- Play-response parseert `format`, `drm` (ClearKey kids/keys) en `watchSessionId`.
|
||||||
- Heartbeat: `POST /api/v1/client/watching/heartbeat` ~elke 25s bij custom live + events.
|
- 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.
|
- Events-tab verschijnt als `/me` → `events.enabled` true is.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@
|
||||||
A6FDE760B2484854BAE25395 /* ProfileAvatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */; };
|
A6FDE760B2484854BAE25395 /* ProfileAvatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */; };
|
||||||
424E70D5A34B48EFA6EAE5E7 /* VLCPlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED163B837C404A1183E20226 /* VLCPlayerController.swift */; };
|
424E70D5A34B48EFA6EAE5E7 /* VLCPlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED163B837C404A1183E20226 /* VLCPlayerController.swift */; };
|
||||||
15ABF9F7AD2B4D04B118C331 /* PlayerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.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 */; };
|
F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522137D80C53480E95FF600F /* RootView.swift */; };
|
||||||
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; };
|
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; };
|
||||||
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.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 = "<group>"; };
|
8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAvatars.swift; sourceTree = "<group>"; };
|
||||||
ED163B837C404A1183E20226 /* VLCPlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerController.swift; sourceTree = "<group>"; };
|
ED163B837C404A1183E20226 /* VLCPlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerController.swift; sourceTree = "<group>"; };
|
||||||
2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerScreen.swift; sourceTree = "<group>"; };
|
2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerScreen.swift; sourceTree = "<group>"; };
|
||||||
|
B11C1EAF0000000000000001 /* ClearKeyMaterial.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearKeyMaterial.swift; sourceTree = "<group>"; };
|
||||||
|
B11C1EAF0000000000000002 /* AesCtr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AesCtr.swift; sourceTree = "<group>"; };
|
||||||
|
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CencMp4Decryptor.swift; sourceTree = "<group>"; };
|
||||||
|
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearKeyDashProxy.swift; sourceTree = "<group>"; };
|
||||||
522137D80C53480E95FF600F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
522137D80C53480E95FF600F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||||
BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = "<group>"; };
|
BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = "<group>"; };
|
||||||
314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
|
314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -151,6 +159,10 @@
|
||||||
children = (
|
children = (
|
||||||
ED163B837C404A1183E20226 /* VLCPlayerController.swift */,
|
ED163B837C404A1183E20226 /* VLCPlayerController.swift */,
|
||||||
2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */,
|
2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */,
|
||||||
|
B11C1EAF0000000000000001 /* ClearKeyMaterial.swift */,
|
||||||
|
B11C1EAF0000000000000002 /* AesCtr.swift */,
|
||||||
|
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */,
|
||||||
|
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */,
|
||||||
);
|
);
|
||||||
path = Playback;
|
path = Playback;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -258,6 +270,10 @@
|
||||||
A6FDE760B2484854BAE25395 /* ProfileAvatars.swift in Sources */,
|
A6FDE760B2484854BAE25395 /* ProfileAvatars.swift in Sources */,
|
||||||
424E70D5A34B48EFA6EAE5E7 /* VLCPlayerController.swift in Sources */,
|
424E70D5A34B48EFA6EAE5E7 /* VLCPlayerController.swift in Sources */,
|
||||||
15ABF9F7AD2B4D04B118C331 /* PlayerScreen.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 */,
|
F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */,
|
||||||
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */,
|
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */,
|
||||||
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */,
|
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */,
|
||||||
|
|
@ -321,7 +337,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 12;
|
CURRENT_PROJECT_VERSION = 13;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -336,7 +352,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.0;
|
MARKETING_VERSION = 0.5.1;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
@ -354,7 +370,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 12;
|
CURRENT_PROJECT_VERSION = 13;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -369,7 +385,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.0;
|
MARKETING_VERSION = 0.5.1;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
|
||||||
120
apps/ios/VMC/Playback/AesCtr.swift
Normal file
120
apps/ios/VMC/Playback/AesCtr.swift
Normal file
|
|
@ -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..<copyLen, with: iv.prefix(copyLen))
|
||||||
|
|
||||||
|
var cryptor: CCCryptorRef?
|
||||||
|
let status = counter.withUnsafeBytes { ivBytes in
|
||||||
|
key.withUnsafeBytes { keyBytes in
|
||||||
|
CCCryptorCreateWithMode(
|
||||||
|
CCOperation(kCCEncrypt),
|
||||||
|
CCMode(kCCModeCTR),
|
||||||
|
CCAlgorithm(kCCAlgorithmAES),
|
||||||
|
CCPadding(ccNoPadding),
|
||||||
|
ivBytes.baseAddress,
|
||||||
|
keyBytes.baseAddress,
|
||||||
|
key.count,
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CCModeOptions(kCCModeOptionCTR_BE),
|
||||||
|
&cryptor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard status == kCCSuccess, let cryptor else { return nil }
|
||||||
|
defer { CCCryptorRelease(cryptor) }
|
||||||
|
|
||||||
|
var out = Data(count: data.count)
|
||||||
|
var moved = 0
|
||||||
|
let cryptStatus = out.withUnsafeMutableBytes { outBytes in
|
||||||
|
data.withUnsafeBytes { inBytes in
|
||||||
|
CCCryptorUpdate(
|
||||||
|
cryptor,
|
||||||
|
inBytes.baseAddress,
|
||||||
|
data.count,
|
||||||
|
outBytes.baseAddress,
|
||||||
|
data.count,
|
||||||
|
&moved
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard cryptStatus == kCCSuccess, moved == data.count else { return nil }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CBCS pattern: decrypt `crypt` 16-byte blocks, skip `skip` blocks; leftover <16 clear.
|
||||||
|
static func cryptCbcs(
|
||||||
|
key: Data,
|
||||||
|
iv: Data,
|
||||||
|
data: Data,
|
||||||
|
cryptByteBlock: Int,
|
||||||
|
skipByteBlock: Int
|
||||||
|
) -> 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..<ivCopy, with: iv.prefix(ivCopy))
|
||||||
|
|
||||||
|
while offset < result.count {
|
||||||
|
let remaining = result.count - offset
|
||||||
|
if remaining < cryptLen { break }
|
||||||
|
let slice = result.subdata(in: offset..<(offset + cryptLen))
|
||||||
|
guard let plain = aesCbcDecryptNoPad(key: key, iv: blockIv, data: slice) else { return nil }
|
||||||
|
result.replaceSubrange(offset..<(offset + cryptLen), with: plain)
|
||||||
|
// Next IV = last ciphertext block (CBC chain within crypt run).
|
||||||
|
blockIv = slice.suffix(16)
|
||||||
|
offset += cryptLen
|
||||||
|
let skip = min(skipLen, result.count - offset)
|
||||||
|
offset += skip
|
||||||
|
// Pattern: IV resets per subsample in caller; within subsample after skip, continue with updated IV.
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func aesCbcDecryptNoPad(key: Data, iv: Data, data: Data) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
635
apps/ios/VMC/Playback/CencMp4Decryptor.swift
Normal file
635
apps/ios/VMC/Playback/CencMp4Decryptor.swift
Normal file
|
|
@ -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<String> = [
|
||||||
|
"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..<sampleCount {
|
||||||
|
if flags & 0x000100 != 0 { // sample-duration
|
||||||
|
o += 4
|
||||||
|
}
|
||||||
|
var size = Int(defaultSampleSize)
|
||||||
|
if flags & 0x000200 != 0 {
|
||||||
|
size = Int(readU32(data, o)); o += 4
|
||||||
|
}
|
||||||
|
sampleSizes.append(size)
|
||||||
|
if flags & 0x000400 != 0 { o += 4 } // sample-flags
|
||||||
|
if flags & 0x000800 != 0 {
|
||||||
|
o += version == 0 ? 4 : 4 // sample-composition-time-offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dataOffsetPresent {
|
||||||
|
// data_offset is relative to moof start when base-data-offset-present is NOT set;
|
||||||
|
// when base-data-offset is set, absolute = base + dataOffset.
|
||||||
|
// Common CMAF: no base-data-offset, data_offset relative to moof.
|
||||||
|
let tfhd = findBoxesInRange(data, type: "tfhd", from: traf.payloadStart, to: traf.payloadEnd).first
|
||||||
|
let tfhdFlags: UInt32 = {
|
||||||
|
guard let tfhd else { return 0 }
|
||||||
|
return readU32(data, tfhd.payloadStart) & 0x00FF_FFFF
|
||||||
|
}()
|
||||||
|
if tfhdFlags & 0x000001 != 0 {
|
||||||
|
mdatCursor = Int(baseDataOffset + Int64(dataOffset))
|
||||||
|
} else {
|
||||||
|
mdatCursor = moof.start + Int(dataOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = version
|
||||||
|
}
|
||||||
|
|
||||||
|
// senc
|
||||||
|
let crypto = state.byTrackId[trackId] ?? state.fallback
|
||||||
|
if let crypto {
|
||||||
|
perSampleIvSize = crypto.perSampleIvSize
|
||||||
|
}
|
||||||
|
for senc in findBoxesInRange(data, type: "senc", from: traf.payloadStart, to: traf.payloadEnd) {
|
||||||
|
sencSamples = parseSenc(data, box: senc, perSampleIvSize: perSampleIvSize, constantIv: crypto?.constantIv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saiz/saio fallback when no senc
|
||||||
|
if sencSamples.isEmpty {
|
||||||
|
sencSamples = parseSaizSaio(
|
||||||
|
data,
|
||||||
|
traf: traf,
|
||||||
|
moof: moof,
|
||||||
|
perSampleIvSize: perSampleIvSize,
|
||||||
|
constantIv: crypto?.constantIv,
|
||||||
|
sampleCount: sampleSizes.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !sampleSizes.isEmpty, sencSamples.count == sampleSizes.count || sencSamples.isEmpty == false else {
|
||||||
|
// Advance cursor even if we cannot decrypt.
|
||||||
|
for sz in sampleSizes { mdatCursor += sz }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let key: Data? = {
|
||||||
|
if let crypto, let k = keys.key(forKid: crypto.kid) { return k }
|
||||||
|
return keys.primaryKey
|
||||||
|
}()
|
||||||
|
guard let key else {
|
||||||
|
for sz in sampleSizes { mdatCursor += sz }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let scheme = (crypto?.scheme ?? "cenc").lowercased()
|
||||||
|
let cryptBlocks = crypto?.cryptByteBlock ?? 0
|
||||||
|
let skipBlocks = crypto?.skipByteBlock ?? 0
|
||||||
|
|
||||||
|
for i in 0..<sampleSizes.count {
|
||||||
|
let size = sampleSizes[i]
|
||||||
|
guard size > 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..<sampleCount {
|
||||||
|
var iv = Data(count: 16)
|
||||||
|
if perSampleIvSize > 0 {
|
||||||
|
let n = min(perSampleIvSize, 16)
|
||||||
|
guard o + n <= box.payloadEnd else { break }
|
||||||
|
iv.replaceSubrange(0..<n, with: data.subdata(in: o..<(o + n)))
|
||||||
|
o += n
|
||||||
|
} else if let constantIv {
|
||||||
|
let n = min(constantIv.count, 16)
|
||||||
|
iv.replaceSubrange(0..<n, with: constantIv.prefix(n))
|
||||||
|
}
|
||||||
|
var subs: [(clear: Int, protected: Int)]?
|
||||||
|
if useSubsamples {
|
||||||
|
guard o + 2 <= box.payloadEnd else { break }
|
||||||
|
let count = Int(readU16(data, o)); o += 2
|
||||||
|
var list: [(clear: Int, protected: Int)] = []
|
||||||
|
for _ in 0..<count {
|
||||||
|
guard o + 6 <= box.payloadEnd else { break }
|
||||||
|
let clear = Int(readU16(data, o)); o += 2
|
||||||
|
let protected = Int(readU32(data, o)); o += 4
|
||||||
|
list.append((clear, protected))
|
||||||
|
}
|
||||||
|
subs = list
|
||||||
|
}
|
||||||
|
out.append(SencSample(iv: iv, subsamples: subs))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseSaizSaio(
|
||||||
|
_ data: Data,
|
||||||
|
traf: Box,
|
||||||
|
moof: Box,
|
||||||
|
perSampleIvSize: Int,
|
||||||
|
constantIv: Data?,
|
||||||
|
sampleCount: Int
|
||||||
|
) -> [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..<count {
|
||||||
|
sizes.append(Int(data[o])); o += 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sizes = Array(repeating: defaultSize, count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
let saioVersionFlags = readU32(data, saio.payloadStart)
|
||||||
|
let saioVersion = Int(saioVersionFlags >> 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..<n {
|
||||||
|
let entrySize = sizes[i]
|
||||||
|
guard auxPos + entrySize <= data.count else { break }
|
||||||
|
var p = auxPos
|
||||||
|
var iv = Data(count: 16)
|
||||||
|
if perSampleIvSize > 0 {
|
||||||
|
let ivn = min(perSampleIvSize, 16)
|
||||||
|
iv.replaceSubrange(0..<ivn, with: data.subdata(in: p..<(p + ivn)))
|
||||||
|
p += ivn
|
||||||
|
} else if let constantIv {
|
||||||
|
let ivn = min(constantIv.count, 16)
|
||||||
|
iv.replaceSubrange(0..<ivn, with: constantIv.prefix(ivn))
|
||||||
|
}
|
||||||
|
var subs: [(clear: Int, protected: Int)]?
|
||||||
|
if entrySize > perSampleIvSize {
|
||||||
|
let sc = Int(readU16(data, p)); p += 2
|
||||||
|
var list: [(clear: Int, protected: Int)] = []
|
||||||
|
for _ in 0..<sc {
|
||||||
|
let clear = Int(readU16(data, p)); p += 2
|
||||||
|
let protected = Int(readU32(data, p)); p += 4
|
||||||
|
list.append((clear, protected))
|
||||||
|
}
|
||||||
|
subs = list
|
||||||
|
}
|
||||||
|
out.append(SencSample(iv: iv, subsamples: subs))
|
||||||
|
auxPos += entrySize
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decryptSample(
|
||||||
|
_ sample: Data,
|
||||||
|
key: Data,
|
||||||
|
iv: Data,
|
||||||
|
subsamples: [(clear: Int, protected: Int)]?,
|
||||||
|
scheme: String,
|
||||||
|
cryptByteBlock: Int,
|
||||||
|
skipByteBlock: Int
|
||||||
|
) -> 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..<end)
|
||||||
|
guard let dec = AesCtr.cryptCbcs(
|
||||||
|
key: key,
|
||||||
|
iv: iv,
|
||||||
|
data: slice,
|
||||||
|
cryptByteBlock: cryptByteBlock,
|
||||||
|
skipByteBlock: skipByteBlock
|
||||||
|
) else { return nil }
|
||||||
|
out.replaceSubrange(cursor..<end, with: dec)
|
||||||
|
cursor = end
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return AesCtr.cryptCbcs(
|
||||||
|
key: key,
|
||||||
|
iv: iv,
|
||||||
|
data: sample,
|
||||||
|
cryptByteBlock: cryptByteBlock,
|
||||||
|
skipByteBlock: skipByteBlock
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func protectedRanges(
|
||||||
|
sampleCount: Int,
|
||||||
|
subsamples: [(clear: Int, protected: Int)]?
|
||||||
|
) -> [Range<Int>]? {
|
||||||
|
guard let subsamples else {
|
||||||
|
return [0..<sampleCount]
|
||||||
|
}
|
||||||
|
var ranges: [Range<Int>] = []
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
335
apps/ios/VMC/Playback/ClearKeyDashProxy.swift
Normal file
335
apps/ios/VMC/Playback/ClearKeyDashProxy.swift
Normal file
|
|
@ -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..<range.lowerBound)
|
||||||
|
self.serve(connection: connection, headerData: headerData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isComplete {
|
||||||
|
connection.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if buf.count > 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[..<slash])
|
||||||
|
let rel = String(rest[rest.index(after: slash)...])
|
||||||
|
guard let base = decodeURL(b64) else { throw ProxyError.badPath }
|
||||||
|
let joined = join(base: base, relative: rel.removingPercentEncoding ?? rel)
|
||||||
|
return try await fetchAndDecrypt(joined, keys: keyMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ProxyError.badPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAndDecrypt(_ url: URL, keys: ClearKeyKeyMap) async throws -> (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: #"<ContentProtection\b[^>]*/>"#,
|
||||||
|
with: "",
|
||||||
|
options: .regularExpression
|
||||||
|
)
|
||||||
|
s = s.replacingOccurrences(
|
||||||
|
of: #"<ContentProtection\b[^>]*>[\s\S]*?</ContentProtection>"#,
|
||||||
|
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: #"<BaseURL>([\s\S]*?)</BaseURL>"#,
|
||||||
|
with: "<BaseURL>\(proxyBase)</BaseURL>",
|
||||||
|
options: .regularExpression
|
||||||
|
)
|
||||||
|
|
||||||
|
if !s.localizedCaseInsensitiveContains("<BaseURL") {
|
||||||
|
if let regex = try? NSRegularExpression(pattern: #"<MPD\b[^>]*>"#, options: []) {
|
||||||
|
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
||||||
|
if let match = regex.firstMatch(in: s, options: [], range: range),
|
||||||
|
let r = Range(match.range, in: s) {
|
||||||
|
s.insert(contentsOf: "\n <BaseURL>\(proxyBase)</BaseURL>", at: r.upperBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute http(s) URL's in attributen → /a/<b64>
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
apps/ios/VMC/Playback/ClearKeyMaterial.swift
Normal file
70
apps/ios/VMC/Playback/ClearKeyMaterial.swift
Normal file
|
|
@ -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..<j], radix: 16) else { return nil }
|
||||||
|
out.append(b)
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Microsoft GUID / sommige ClearKey packs: DWORD/WORD little-endian in eerste 8 bytes.
|
||||||
|
static func swapUuidEndianness(_ b: Data) -> 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],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -386,19 +386,21 @@ struct PlayerScreen: View {
|
||||||
loading = true
|
loading = true
|
||||||
error = nil
|
error = nil
|
||||||
if request.isLive {
|
if request.isLive {
|
||||||
|
let keys = request.needsClearKey ? (request.drm?.keys ?? []) : []
|
||||||
player.playLive(
|
player.playLive(
|
||||||
url: request.url,
|
url: request.url,
|
||||||
fallback: request.fallbackUrl,
|
fallback: request.fallbackUrl,
|
||||||
format: request.format,
|
format: request.format,
|
||||||
clearKeys: request.drm?.keys ?? []
|
clearKeys: keys
|
||||||
)
|
)
|
||||||
// Geef VLC tijd; bij ClearKey + blijvende fout → duidelijke NL-melding.
|
// ClearKey/DASH heeft meer opstarttijd (proxy + init/media segments).
|
||||||
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
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) {
|
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 {
|
if player.hasError || !player.isPlaying {
|
||||||
error = "Deze stream vereist DRM-ondersteuning die binnenkort komt"
|
error = player.errorMessage
|
||||||
player.stop()
|
?? "Kon deze ClearKey-stream niet afspelen. Controleer netwerk of probeer opnieuw."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
let mediaPlayer = VLCMediaPlayer()
|
let mediaPlayer = VLCMediaPlayer()
|
||||||
private var lengthMs: Int32 = 0
|
private var lengthMs: Int32 = 0
|
||||||
private var tick: AnyCancellable?
|
private var tick: AnyCancellable?
|
||||||
|
private var clearKeyProxyActive = false
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
|
|
@ -77,20 +78,39 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
hasError = false
|
hasError = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
stateText = "Live laden…"
|
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
|
media.delegate = self
|
||||||
Self.applyLiveOptions(to: media, url: url, format: format, clearKeys: clearKeys)
|
Self.applyLiveOptions(to: media, url: playURL, format: playFormat)
|
||||||
mediaPlayer.media = media
|
mediaPlayer.media = media
|
||||||
media.parse()
|
media.parse()
|
||||||
mediaPlayer.play()
|
mediaPlayer.play()
|
||||||
applySubtitleStyle()
|
applySubtitleStyle()
|
||||||
if let fallback {
|
if let fallback, clearKeys.isEmpty {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
|
||||||
guard let self, !self.isPlaying || self.hasError else { return }
|
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)
|
let fb = VLCMedia(url: fallback)
|
||||||
fb.delegate = self
|
fb.delegate = self
|
||||||
Self.applyLiveOptions(to: fb, url: fallback, format: nil, clearKeys: [])
|
Self.applyLiveOptions(to: fb, url: fallback, format: nil)
|
||||||
self.mediaPlayer.media = fb
|
self.mediaPlayer.media = fb
|
||||||
fb.parse()
|
fb.parse()
|
||||||
self.mediaPlayer.play()
|
self.mediaPlayer.play()
|
||||||
|
|
@ -100,13 +120,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
startTicker()
|
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(
|
private static func applyLiveOptions(
|
||||||
to media: VLCMedia,
|
to media: VLCMedia,
|
||||||
url: URL,
|
url: URL,
|
||||||
format: String?,
|
format: String?
|
||||||
clearKeys: [ClearKeyEntry]
|
|
||||||
) {
|
) {
|
||||||
media.addOption(":network-caching=1200")
|
media.addOption(":network-caching=1200")
|
||||||
media.addOption(":http-reconnect")
|
media.addOption(":http-reconnect")
|
||||||
|
|
@ -125,29 +142,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
} else if isHls {
|
} else if isHls {
|
||||||
media.addOption(":demux=hls")
|
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() {
|
func togglePlay() {
|
||||||
|
|
@ -412,6 +406,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
tick = nil
|
tick = nil
|
||||||
mediaPlayer.stop()
|
mediaPlayer.stop()
|
||||||
mediaPlayer.media = nil
|
mediaPlayer.media = nil
|
||||||
|
if clearKeyProxyActive {
|
||||||
|
ClearKeyDashProxy.shared.stop()
|
||||||
|
clearKeyProxyActive = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ struct SettingsScreen: View {
|
||||||
|
|
||||||
Section("Info") {
|
Section("Info") {
|
||||||
LabeledContent("App", value: "VMC iOS")
|
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("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
|
||||||
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
|
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue