Fix iOS ClearKey live-list DASH proxy immediate stop (0.5.2).
Preserve BaseURL/Location, warm up MPD, clear sinf for VLC, and retry early stops so custom live DRM plays instead of Gestopt.
This commit is contained in:
parent
fac38d905e
commit
d1b627752c
10 changed files with 558 additions and 64 deletions
18
apps/ios/ExportOptions.plist
Normal file
18
apps/ios/ExportOptions.plist
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>app-store</string>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>X25NYX3K49</string>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
<key>uploadSymbols</key>
|
||||||
|
<true/>
|
||||||
|
<key>destination</key>
|
||||||
|
<string>export</string>
|
||||||
|
<key>manageAppVersionAndBuildNumber</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -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.1+)
|
## Live TV / Events / ClearKey (0.5.2+)
|
||||||
|
|
||||||
- `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` voor live-lijsten én events.
|
||||||
- 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:** 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.
|
- **ClearKey/DASH:** lokale in-app proxy (`ClearKeyDashProxy`) haalt MPD/segmenten op (dynamic live: BaseURL/Location/query/Range), decrypt CENC/CBCS (`sinf`→`free`), 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.
|
||||||
|
|
|
||||||
|
|
@ -337,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 = 13;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -352,7 +352,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.1;
|
MARKETING_VERSION = 0.5.2;
|
||||||
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)";
|
||||||
|
|
@ -370,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 = 13;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -385,7 +385,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.1;
|
MARKETING_VERSION = 0.5.2;
|
||||||
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)";
|
||||||
|
|
|
||||||
|
|
@ -196,17 +196,11 @@ enum CencMp4Decryptor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace encv/enca with original format (same 4 bytes → no size rewrite).
|
// Replace encv/enca with original format (same 4 bytes → no size rewrite).
|
||||||
|
// Mark sinf as 'free' zodat VLC geen DRM-sample-entry meer ziet (anders: meteen Gestopt).
|
||||||
if let originalFormat {
|
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) {
|
for stsd in findBoxes(data, type: "stsd", recursive: true) {
|
||||||
guard stsd.start >= trak.start, stsd.payloadEnd <= trak.payloadEnd else { continue }
|
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)
|
var o = stsd.payloadStart + 8 // FullBox (4) + entry_count (4)
|
||||||
// FullBox (4) + entry_count (4)
|
|
||||||
o = stsd.payloadStart + 8
|
|
||||||
while o + 8 <= stsd.payloadEnd {
|
while o + 8 <= stsd.payloadEnd {
|
||||||
guard let entry = readBox(data, at: o, limit: stsd.payloadEnd) else { break }
|
guard let entry = readBox(data, at: o, limit: stsd.payloadEnd) else { break }
|
||||||
if entry.type == "encv" || entry.type == "enca" {
|
if entry.type == "encv" || entry.type == "enca" {
|
||||||
|
|
@ -216,6 +210,12 @@ enum CencMp4Decryptor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in
|
||||||
|
if box.type == "sinf" {
|
||||||
|
writeFourCC(&data, box.start + 4, "free")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if let crypto {
|
if let crypto {
|
||||||
if let trackId {
|
if let trackId {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
private let proxyLog = Logger(subsystem: "nl.vonas.mediacluster.ios", category: "ClearKeyProxy")
|
||||||
|
|
||||||
/// Lokale HTTP-proxy: haalt DASH MPD/segmenten op, strip DRM-metadata, decrypt CENC met ClearKey.
|
/// 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.
|
/// VLC speelt daarna plain DASH vanaf 127.0.0.1.
|
||||||
|
|
@ -13,12 +16,21 @@ final class ClearKeyDashProxy {
|
||||||
private var keyMap: ClearKeyKeyMap?
|
private var keyMap: ClearKeyKeyMap?
|
||||||
private var cryptoState = CencMp4Decryptor.SessionState()
|
private var cryptoState = CencMp4Decryptor.SessionState()
|
||||||
private var generation = 0
|
private var generation = 0
|
||||||
|
private let urlSession: URLSession = {
|
||||||
|
let cfg = URLSessionConfiguration.ephemeral
|
||||||
|
cfg.timeoutIntervalForRequest = 25
|
||||||
|
cfg.timeoutIntervalForResource = 60
|
||||||
|
cfg.httpMaximumConnectionsPerHost = 8
|
||||||
|
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
return URLSession(configuration: cfg)
|
||||||
|
}()
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
var isRunning: Bool { listener != nil && port > 0 }
|
var isRunning: Bool { listener != nil && port > 0 }
|
||||||
|
|
||||||
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug.
|
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug.
|
||||||
|
/// Warmt de MPD-fetch op zodat VLC niet meteen een lege/fout-response krijgt.
|
||||||
func start(manifestURL: URL, keys: [ClearKeyEntry]) throws -> URL {
|
func start(manifestURL: URL, keys: [ClearKeyEntry]) throws -> URL {
|
||||||
let map = ClearKeyKeyMap(entries: keys)
|
let map = ClearKeyKeyMap(entries: keys)
|
||||||
guard !map.isEmpty else {
|
guard !map.isEmpty else {
|
||||||
|
|
@ -29,6 +41,7 @@ final class ClearKeyDashProxy {
|
||||||
keyMap = map
|
keyMap = map
|
||||||
cryptoState = CencMp4Decryptor.SessionState()
|
cryptoState = CencMp4Decryptor.SessionState()
|
||||||
generation += 1
|
generation += 1
|
||||||
|
let gen = generation
|
||||||
|
|
||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
params.allowLocalEndpointReuse = true
|
params.allowLocalEndpointReuse = true
|
||||||
|
|
@ -45,8 +58,10 @@ final class ClearKeyDashProxy {
|
||||||
if let p = listener.port?.rawValue {
|
if let p = listener.port?.rawValue {
|
||||||
self.port = p
|
self.port = p
|
||||||
}
|
}
|
||||||
|
proxyLog.info("listener ready on port \(self.port, privacy: .public)")
|
||||||
ready.signal()
|
ready.signal()
|
||||||
case .failed(let err):
|
case .failed(let err):
|
||||||
|
proxyLog.error("listener failed: \(String(describing: err), privacy: .public)")
|
||||||
readyError = err
|
readyError = err
|
||||||
ready.signal()
|
ready.signal()
|
||||||
default:
|
default:
|
||||||
|
|
@ -63,16 +78,38 @@ final class ClearKeyDashProxy {
|
||||||
if let readyError { throw readyError }
|
if let readyError { throw readyError }
|
||||||
guard port > 0 else { throw ProxyError.bindFailed }
|
guard port > 0 else { throw ProxyError.bindFailed }
|
||||||
|
|
||||||
|
// Warm-up: MPD ophalen + herschrijven vóór VLC start (live dynamic MPD's falen anders meteen).
|
||||||
|
let warm = DispatchSemaphore(value: 0)
|
||||||
|
var warmError: Error?
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let (body, _) = try await self.payload(for: "/manifest.mpd")
|
||||||
|
guard !body.isEmpty, gen == self.generation else {
|
||||||
|
throw ProxyError.emptyManifest
|
||||||
|
}
|
||||||
|
let preview = String(data: body.prefix(200), encoding: .utf8) ?? ""
|
||||||
|
proxyLog.info("warmup MPD ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)")
|
||||||
|
} catch {
|
||||||
|
warmError = error
|
||||||
|
proxyLog.error("warmup failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
warm.signal()
|
||||||
|
}
|
||||||
|
_ = warm.wait(timeout: .now() + 20)
|
||||||
|
if let warmError { throw warmError }
|
||||||
|
|
||||||
return URL(string: "http://127.0.0.1:\(port)/manifest.mpd")!
|
return URL(string: "http://127.0.0.1:\(port)/manifest.mpd")!
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
|
generation += 1
|
||||||
listener?.cancel()
|
listener?.cancel()
|
||||||
listener = nil
|
listener = nil
|
||||||
port = 0
|
port = 0
|
||||||
remoteManifest = nil
|
remoteManifest = nil
|
||||||
keyMap = nil
|
keyMap = nil
|
||||||
cryptoState = CencMp4Decryptor.SessionState()
|
cryptoState = CencMp4Decryptor.SessionState()
|
||||||
|
proxyLog.info("proxy stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - HTTP
|
// MARK: - HTTP
|
||||||
|
|
@ -124,22 +161,66 @@ final class ClearKeyDashProxy {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let method = String(parts[0])
|
let method = String(parts[0])
|
||||||
let path = String(parts[1])
|
let pathAndQuery = String(parts[1])
|
||||||
guard method == "GET" || method == "HEAD" else {
|
guard method == "GET" || method == "HEAD" else {
|
||||||
respond(connection, status: 405, contentType: "text/plain", body: Data("method".utf8))
|
respond(connection, status: 405, contentType: "text/plain", body: Data("method".utf8))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let rangeHeader = Self.parseHeader(header, name: "Range")
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let (body, contentType) = try await self.payload(for: path)
|
let (body, contentType) = try await self.payload(for: pathAndQuery)
|
||||||
if method == "HEAD" {
|
if let rangeHeader, let byteRange = Self.parseByteRange(rangeHeader, total: body.count) {
|
||||||
self.respond(connection, status: 200, contentType: contentType, body: Data(), headOnly: true, contentLength: body.count)
|
let slice = body.subdata(in: byteRange.start..<(byteRange.end + 1))
|
||||||
|
if method == "HEAD" {
|
||||||
|
self.respond(
|
||||||
|
connection,
|
||||||
|
status: 206,
|
||||||
|
contentType: contentType,
|
||||||
|
body: Data(),
|
||||||
|
headOnly: true,
|
||||||
|
contentLength: slice.count,
|
||||||
|
extraHeaders: [
|
||||||
|
"Accept-Ranges: bytes",
|
||||||
|
"Content-Range: bytes \(byteRange.start)-\(byteRange.end)/\(body.count)",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
self.respond(
|
||||||
|
connection,
|
||||||
|
status: 206,
|
||||||
|
contentType: contentType,
|
||||||
|
body: slice,
|
||||||
|
extraHeaders: [
|
||||||
|
"Accept-Ranges: bytes",
|
||||||
|
"Content-Range: bytes \(byteRange.start)-\(byteRange.end)/\(body.count)",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if method == "HEAD" {
|
||||||
|
self.respond(
|
||||||
|
connection,
|
||||||
|
status: 200,
|
||||||
|
contentType: contentType,
|
||||||
|
body: Data(),
|
||||||
|
headOnly: true,
|
||||||
|
contentLength: body.count,
|
||||||
|
extraHeaders: ["Accept-Ranges: bytes"]
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
self.respond(connection, status: 200, contentType: contentType, body: body)
|
self.respond(
|
||||||
|
connection,
|
||||||
|
status: 200,
|
||||||
|
contentType: contentType,
|
||||||
|
body: body,
|
||||||
|
extraHeaders: ["Accept-Ranges: bytes"]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
let msg = error.localizedDescription
|
let msg = error.localizedDescription
|
||||||
|
proxyLog.error("serve \(pathAndQuery, privacy: .public) → \(msg, privacy: .public)")
|
||||||
self.respond(connection, status: 502, contentType: "text/plain; charset=utf-8", body: Data(msg.utf8))
|
self.respond(connection, status: 502, contentType: "text/plain; charset=utf-8", body: Data(msg.utf8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -149,17 +230,36 @@ final class ClearKeyDashProxy {
|
||||||
guard let keyMap, let remoteManifest else {
|
guard let keyMap, let remoteManifest else {
|
||||||
throw ProxyError.notStarted
|
throw ProxyError.notStarted
|
||||||
}
|
}
|
||||||
let pathOnly = pathAndQuery.split(separator: "?", maxSplits: 1).first.map(String.init) ?? pathAndQuery
|
let split = pathAndQuery.split(separator: "?", maxSplits: 1).map(String.init)
|
||||||
|
let pathOnly = split.first ?? pathAndQuery
|
||||||
|
let query = split.count > 1 ? split[1] : nil
|
||||||
|
|
||||||
if pathOnly == "/" || pathOnly == "/manifest.mpd" {
|
if pathOnly == "/" || pathOnly == "/manifest.mpd" {
|
||||||
let raw = try await fetch(remoteManifest)
|
let raw = try await fetch(remoteManifest)
|
||||||
let rewritten = rewriteMpd(String(data: raw, encoding: .utf8) ?? "", mpdURL: remoteManifest)
|
guard let xml = String(data: raw, encoding: .utf8), !xml.isEmpty else {
|
||||||
|
// Sommige CDN's sturen UTF-16 BOM; probeer lossy.
|
||||||
|
let lossy = String(decoding: raw, as: UTF8.self)
|
||||||
|
guard !lossy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
throw ProxyError.emptyManifest
|
||||||
|
}
|
||||||
|
let rewritten = rewriteMpd(lossy, mpdURL: remoteManifest)
|
||||||
|
return (Data(rewritten.utf8), "application/dash+xml")
|
||||||
|
}
|
||||||
|
let rewritten = rewriteMpd(xml, mpdURL: remoteManifest)
|
||||||
|
if !rewritten.localizedCaseInsensitiveContains("<MPD") {
|
||||||
|
throw ProxyError.emptyManifest
|
||||||
|
}
|
||||||
return (Data(rewritten.utf8), "application/dash+xml")
|
return (Data(rewritten.utf8), "application/dash+xml")
|
||||||
}
|
}
|
||||||
|
|
||||||
if pathOnly.hasPrefix("/a/") {
|
if pathOnly.hasPrefix("/a/") {
|
||||||
let b64 = String(pathOnly.dropFirst(3))
|
let b64 = String(pathOnly.dropFirst(3))
|
||||||
guard let url = decodeURL(b64) else { throw ProxyError.badPath }
|
guard var url = decodeURL(b64) else { throw ProxyError.badPath }
|
||||||
|
if let query, url.query == nil {
|
||||||
|
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
comps?.percentEncodedQuery = query
|
||||||
|
if let u = comps?.url { url = u }
|
||||||
|
}
|
||||||
return try await fetchAndDecrypt(url, keys: keyMap)
|
return try await fetchAndDecrypt(url, keys: keyMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,9 +267,12 @@ final class ClearKeyDashProxy {
|
||||||
let rest = String(pathOnly.dropFirst(3))
|
let rest = String(pathOnly.dropFirst(3))
|
||||||
guard let slash = rest.firstIndex(of: "/") else { throw ProxyError.badPath }
|
guard let slash = rest.firstIndex(of: "/") else { throw ProxyError.badPath }
|
||||||
let b64 = String(rest[..<slash])
|
let b64 = String(rest[..<slash])
|
||||||
let rel = String(rest[rest.index(after: slash)...])
|
var rel = String(rest[rest.index(after: slash)...])
|
||||||
|
if let query {
|
||||||
|
rel += (rel.contains("?") ? "&" : "?") + query
|
||||||
|
}
|
||||||
guard let base = decodeURL(b64) else { throw ProxyError.badPath }
|
guard let base = decodeURL(b64) else { throw ProxyError.badPath }
|
||||||
let joined = join(base: base, relative: rel.removingPercentEncoding ?? rel)
|
let joined = join(base: asDirectory(base), relative: rel.removingPercentEncoding ?? rel)
|
||||||
return try await fetchAndDecrypt(joined, keys: keyMap)
|
return try await fetchAndDecrypt(joined, keys: keyMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,14 +281,17 @@ final class ClearKeyDashProxy {
|
||||||
|
|
||||||
private func fetchAndDecrypt(_ url: URL, keys: ClearKeyKeyMap) async throws -> (Data, String) {
|
private func fetchAndDecrypt(_ url: URL, keys: ClearKeyKeyMap) async throws -> (Data, String) {
|
||||||
var data = try await fetch(url)
|
var data = try await fetch(url)
|
||||||
|
let lower = url.absoluteString.lowercased()
|
||||||
|
let isManifest = lower.contains(".mpd")
|
||||||
|
if isManifest, let xml = String(data: data, encoding: .utf8) ?? Optional(String(decoding: data, as: UTF8.self)) {
|
||||||
|
let rewritten = rewriteMpd(xml, mpdURL: url)
|
||||||
|
return (Data(rewritten.utf8), "application/dash+xml")
|
||||||
|
}
|
||||||
queue.sync {
|
queue.sync {
|
||||||
_ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoState)
|
_ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoState)
|
||||||
}
|
}
|
||||||
let ct: String
|
let ct: String
|
||||||
let lower = url.absoluteString.lowercased()
|
if lower.contains(".m4s") || lower.contains(".mp4") || lower.contains("init") || lower.contains(".cmfv") || lower.contains(".cmfa") {
|
||||||
if lower.contains(".mpd") {
|
|
||||||
ct = "application/dash+xml"
|
|
||||||
} else if lower.contains(".m4s") || lower.contains(".mp4") || lower.contains("init") {
|
|
||||||
ct = "video/mp4"
|
ct = "video/mp4"
|
||||||
} else {
|
} else {
|
||||||
ct = "application/octet-stream"
|
ct = "application/octet-stream"
|
||||||
|
|
@ -195,9 +301,11 @@ final class ClearKeyDashProxy {
|
||||||
|
|
||||||
private func fetch(_ url: URL) async throws -> Data {
|
private func fetch(_ url: URL) async throws -> Data {
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.timeoutInterval = 20
|
req.timeoutInterval = 25
|
||||||
req.setValue("VMC-iOS-ClearKey/0.5.1", forHTTPHeaderField: "User-Agent")
|
req.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
let (data, resp) = try await URLSession.shared.data(for: req)
|
req.setValue("VMC-iOS-ClearKey/0.5.2", forHTTPHeaderField: "User-Agent")
|
||||||
|
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
|
||||||
|
let (data, resp) = try await urlSession.data(for: req)
|
||||||
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||||
throw ProxyError.upstream(http.statusCode)
|
throw ProxyError.upstream(http.statusCode)
|
||||||
}
|
}
|
||||||
|
|
@ -218,17 +326,32 @@ final class ClearKeyDashProxy {
|
||||||
options: .regularExpression
|
options: .regularExpression
|
||||||
)
|
)
|
||||||
|
|
||||||
let originDir = mpdURL.deletingLastPathComponent()
|
// Live dynamic: Location mag niet naar remote DRM-MPD wijzen (VLC omzeilt proxy).
|
||||||
let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(originDir))/"
|
|
||||||
|
|
||||||
// Herschrijf bestaande BaseURL-tekst naar proxy.
|
|
||||||
s = s.replacingOccurrences(
|
s = s.replacingOccurrences(
|
||||||
of: #"<BaseURL>([\s\S]*?)</BaseURL>"#,
|
of: #"<Location\b[^>]*>[\s\S]*?</Location>"#,
|
||||||
with: "<BaseURL>\(proxyBase)</BaseURL>",
|
with: "<Location>http://127.0.0.1:\(port)/manifest.mpd</Location>",
|
||||||
options: .regularExpression
|
options: .regularExpression
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Herschrijf elke BaseURL naar de juiste resolved CDN-base via /p/<b64>/.
|
||||||
|
if let baseRegex = try? NSRegularExpression(pattern: #"<BaseURL>([\s\S]*?)</BaseURL>"#, options: []) {
|
||||||
|
let ns = s as NSString
|
||||||
|
let matches = baseRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
|
||||||
|
for match in matches.reversed() {
|
||||||
|
guard match.numberOfRanges >= 2 else { continue }
|
||||||
|
let content = ns.substring(with: match.range(at: 1))
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if content.contains("127.0.0.1:\(port)") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let resolved = resolveBaseURL(content, mpdURL: mpdURL)
|
||||||
|
let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(asDirectory(resolved)))/"
|
||||||
|
s = (s as NSString).replacingCharacters(in: match.range, with: "<BaseURL>\(proxyBase)</BaseURL>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !s.localizedCaseInsensitiveContains("<BaseURL") {
|
if !s.localizedCaseInsensitiveContains("<BaseURL") {
|
||||||
|
let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(asDirectory(mpdURL.deletingLastPathComponent())))/"
|
||||||
if let regex = try? NSRegularExpression(pattern: #"<MPD\b[^>]*>"#, options: []) {
|
if let regex = try? NSRegularExpression(pattern: #"<MPD\b[^>]*>"#, options: []) {
|
||||||
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
||||||
if let match = regex.firstMatch(in: s, options: [], range: range),
|
if let match = regex.firstMatch(in: s, options: [], range: range),
|
||||||
|
|
@ -244,7 +367,6 @@ final class ClearKeyDashProxy {
|
||||||
let matches = urlRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
|
let matches = urlRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
|
||||||
for match in matches.reversed() {
|
for match in matches.reversed() {
|
||||||
let abs = ns.substring(with: match.range)
|
let abs = ns.substring(with: match.range)
|
||||||
// Laat lokale proxy-URL's met rust.
|
|
||||||
if abs.contains("127.0.0.1:\(port)") { continue }
|
if abs.contains("127.0.0.1:\(port)") { continue }
|
||||||
let replacement = "http://127.0.0.1:\(port)/a/\(encodeURLString(abs))"
|
let replacement = "http://127.0.0.1:\(port)/a/\(encodeURLString(abs))"
|
||||||
s = (s as NSString).replacingCharacters(in: match.range, with: replacement)
|
s = (s as NSString).replacingCharacters(in: match.range, with: replacement)
|
||||||
|
|
@ -254,6 +376,36 @@ final class ClearKeyDashProxy {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve BaseURL tekst t.o.v. MPD-URL; altijd als directory-URL.
|
||||||
|
private func resolveBaseURL(_ raw: String, mpdURL: URL) -> URL {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
return asDirectory(mpdURL.deletingLastPathComponent())
|
||||||
|
}
|
||||||
|
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://"),
|
||||||
|
let abs = URL(string: trimmed) {
|
||||||
|
return asDirectory(abs)
|
||||||
|
}
|
||||||
|
if let joined = URL(string: trimmed, relativeTo: mpdURL)?.absoluteURL {
|
||||||
|
return asDirectory(joined)
|
||||||
|
}
|
||||||
|
return asDirectory(mpdURL.deletingLastPathComponent())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func asDirectory(_ url: URL) -> URL {
|
||||||
|
var s = url.absoluteString
|
||||||
|
if s.isEmpty { return url }
|
||||||
|
if !s.hasSuffix("/") {
|
||||||
|
// Als laatste path-component op een bestand lijkt (.mpd/.mp4), strip; anders slash toevoegen.
|
||||||
|
let last = url.lastPathComponent.lowercased()
|
||||||
|
if last.contains("."), !last.hasSuffix("/") {
|
||||||
|
return asDirectory(url.deletingLastPathComponent())
|
||||||
|
}
|
||||||
|
s += "/"
|
||||||
|
}
|
||||||
|
return URL(string: s) ?? url
|
||||||
|
}
|
||||||
|
|
||||||
private func join(base: URL, relative: String) -> URL {
|
private func join(base: URL, relative: String) -> URL {
|
||||||
if relative.hasPrefix("http://") || relative.hasPrefix("https://") {
|
if relative.hasPrefix("http://") || relative.hasPrefix("https://") {
|
||||||
return URL(string: relative) ?? base
|
return URL(string: relative) ?? base
|
||||||
|
|
@ -284,17 +436,47 @@ final class ClearKeyDashProxy {
|
||||||
return URL(string: s)
|
return URL(string: s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func parseHeader(_ header: String, name: String) -> String? {
|
||||||
|
let lines = header.split(separator: "\r\n")
|
||||||
|
let prefix = name.lowercased() + ":"
|
||||||
|
for line in lines.dropFirst() {
|
||||||
|
let l = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if l.lowercased().hasPrefix(prefix) {
|
||||||
|
return String(l.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseByteRange(_ value: String, total: Int) -> (start: Int, end: Int)? {
|
||||||
|
// bytes=START-END | bytes=START-
|
||||||
|
guard value.lowercased().hasPrefix("bytes=") else { return nil }
|
||||||
|
let spec = value.dropFirst(6)
|
||||||
|
let parts = spec.split(separator: "-", maxSplits: 1).map(String.init)
|
||||||
|
guard let start = Int(parts.first ?? "") else { return nil }
|
||||||
|
let end: Int
|
||||||
|
if parts.count > 1, let e = Int(parts[1]) {
|
||||||
|
end = min(e, total - 1)
|
||||||
|
} else {
|
||||||
|
end = total - 1
|
||||||
|
}
|
||||||
|
guard total > 0, start >= 0, start <= end, end < total else { return nil }
|
||||||
|
return (start, end)
|
||||||
|
}
|
||||||
|
|
||||||
private func respond(
|
private func respond(
|
||||||
_ connection: NWConnection,
|
_ connection: NWConnection,
|
||||||
status: Int,
|
status: Int,
|
||||||
contentType: String,
|
contentType: String,
|
||||||
body: Data,
|
body: Data,
|
||||||
headOnly: Bool = false,
|
headOnly: Bool = false,
|
||||||
contentLength: Int? = nil
|
contentLength: Int? = nil,
|
||||||
|
extraHeaders: [String] = []
|
||||||
) {
|
) {
|
||||||
let reason: String
|
let reason: String
|
||||||
switch status {
|
switch status {
|
||||||
case 200: reason = "OK"
|
case 200: reason = "OK"
|
||||||
|
case 206: reason = "Partial Content"
|
||||||
case 400: reason = "Bad Request"
|
case 400: reason = "Bad Request"
|
||||||
case 405: reason = "Method Not Allowed"
|
case 405: reason = "Method Not Allowed"
|
||||||
case 502: reason = "Bad Gateway"
|
case 502: reason = "Bad Gateway"
|
||||||
|
|
@ -307,6 +489,9 @@ final class ClearKeyDashProxy {
|
||||||
header += "Connection: close\r\n"
|
header += "Connection: close\r\n"
|
||||||
header += "Access-Control-Allow-Origin: *\r\n"
|
header += "Access-Control-Allow-Origin: *\r\n"
|
||||||
header += "Cache-Control: no-store\r\n"
|
header += "Cache-Control: no-store\r\n"
|
||||||
|
for h in extraHeaders {
|
||||||
|
header += h + "\r\n"
|
||||||
|
}
|
||||||
header += "\r\n"
|
header += "\r\n"
|
||||||
var payload = Data(header.utf8)
|
var payload = Data(header.utf8)
|
||||||
if !headOnly { payload.append(body) }
|
if !headOnly { payload.append(body) }
|
||||||
|
|
@ -320,6 +505,7 @@ final class ClearKeyDashProxy {
|
||||||
case bindFailed
|
case bindFailed
|
||||||
case notStarted
|
case notStarted
|
||||||
case badPath
|
case badPath
|
||||||
|
case emptyManifest
|
||||||
case upstream(Int)
|
case upstream(Int)
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
|
|
@ -328,6 +514,7 @@ final class ClearKeyDashProxy {
|
||||||
case .bindFailed: return "Kon lokale ClearKey-proxy niet starten"
|
case .bindFailed: return "Kon lokale ClearKey-proxy niet starten"
|
||||||
case .notStarted: return "ClearKey-proxy is niet actief"
|
case .notStarted: return "ClearKey-proxy is niet actief"
|
||||||
case .badPath: return "Ongeldig proxy-pad"
|
case .badPath: return "Ongeldig proxy-pad"
|
||||||
|
case .emptyManifest: return "Leeg of ongeldig DASH-manifest ontvangen"
|
||||||
case .upstream(let code): return "Upstream HTTP \(code)"
|
case .upstream(let code): return "Upstream HTTP \(code)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -393,14 +393,14 @@ struct PlayerScreen: View {
|
||||||
format: request.format,
|
format: request.format,
|
||||||
clearKeys: keys
|
clearKeys: keys
|
||||||
)
|
)
|
||||||
// ClearKey/DASH heeft meer opstarttijd (proxy + init/media segments).
|
// ClearKey/DASH: proxy-warmup + init/media + eventuele retry.
|
||||||
let waitNs: UInt64 = request.needsClearKey ? 8_000_000_000 : 2_500_000_000
|
let waitNs: UInt64 = request.needsClearKey ? 10_000_000_000 : 2_500_000_000
|
||||||
try? await Task.sleep(nanoseconds: waitNs)
|
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: 4_000_000_000)
|
try? await Task.sleep(nanoseconds: 6_000_000_000)
|
||||||
if player.hasError || !player.isPlaying {
|
if player.hasError || !player.isPlaying {
|
||||||
error = player.errorMessage
|
error = player.errorMessage
|
||||||
?? "Kon deze ClearKey-stream niet afspelen. Controleer netwerk of probeer opnieuw."
|
?? "Kon deze ClearKey-livezender niet starten. Het DASH-manifest of de sleutels zijn mogelijk ongeldig."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
private var lengthMs: Int32 = 0
|
private var lengthMs: Int32 = 0
|
||||||
private var tick: AnyCancellable?
|
private var tick: AnyCancellable?
|
||||||
private var clearKeyProxyActive = false
|
private var clearKeyProxyActive = false
|
||||||
|
private var livePlayURL: URL?
|
||||||
|
private var livePlayFormat: String?
|
||||||
|
private var liveStartedAt: Date?
|
||||||
|
private var liveRetryCount = 0
|
||||||
|
private var everPlayed = false
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
|
|
@ -78,6 +83,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
hasError = false
|
hasError = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
stateText = "Live laden…"
|
stateText = "Live laden…"
|
||||||
|
liveRetryCount = 0
|
||||||
|
everPlayed = false
|
||||||
|
liveStartedAt = Date()
|
||||||
|
|
||||||
var playURL = url
|
var playURL = url
|
||||||
var playFormat = format
|
var playFormat = format
|
||||||
|
|
@ -86,40 +94,75 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
if !clearKeys.isEmpty {
|
if !clearKeys.isEmpty {
|
||||||
do {
|
do {
|
||||||
// MobileVLCKit kan CENC/ClearKey niet zelf; lokale proxy decrypt segmenten.
|
// MobileVLCKit kan CENC/ClearKey niet zelf; lokale proxy decrypt segmenten.
|
||||||
|
// start() wacht tot listener + MPD-warmup klaar zijn.
|
||||||
playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
|
playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
|
||||||
playFormat = "dash"
|
playFormat = "dash"
|
||||||
clearKeyProxyActive = true
|
clearKeyProxyActive = true
|
||||||
stateText = "ClearKey laden…"
|
stateText = "ClearKey laden…"
|
||||||
} catch {
|
} catch {
|
||||||
hasError = true
|
hasError = true
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = dutchClearKeyError(error)
|
||||||
stateText = "Fout"
|
stateText = "Fout"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let media = VLCMedia(url: playURL)
|
livePlayURL = playURL
|
||||||
media.delegate = self
|
livePlayFormat = playFormat
|
||||||
Self.applyLiveOptions(to: media, url: playURL, format: playFormat)
|
startLiveMedia(url: playURL, format: playFormat)
|
||||||
mediaPlayer.media = media
|
|
||||||
media.parse()
|
|
||||||
mediaPlayer.play()
|
|
||||||
applySubtitleStyle()
|
|
||||||
if let fallback, clearKeys.isEmpty {
|
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 }
|
||||||
let fb = VLCMedia(url: fallback)
|
self.startLiveMedia(url: fallback, format: nil)
|
||||||
fb.delegate = self
|
|
||||||
Self.applyLiveOptions(to: fb, url: fallback, format: nil)
|
|
||||||
self.mediaPlayer.media = fb
|
|
||||||
fb.parse()
|
|
||||||
self.mediaPlayer.play()
|
|
||||||
self.applySubtitleStyle()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
startTicker()
|
startTicker()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startLiveMedia(url: URL, format: String?) {
|
||||||
|
let media = VLCMedia(url: url)
|
||||||
|
media.delegate = self
|
||||||
|
Self.applyLiveOptions(to: media, url: url, format: format)
|
||||||
|
mediaPlayer.media = media
|
||||||
|
// Geen parse() op live/DASH — blokkeert/stopt soms meteen op dynamic MPD.
|
||||||
|
mediaPlayer.play()
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dutchClearKeyError(_ error: Error) -> String {
|
||||||
|
let msg = error.localizedDescription
|
||||||
|
if msg.localizedCaseInsensitiveContains("upstream") {
|
||||||
|
return "Stream-server gaf een fout (\(msg)). Probeer opnieuw."
|
||||||
|
}
|
||||||
|
if msg.localizedCaseInsensitiveContains("leeg") || msg.localizedCaseInsensitiveContains("ongeldig") {
|
||||||
|
return "DASH-manifest kon niet worden geladen. Controleer de zender-URL."
|
||||||
|
}
|
||||||
|
if msg.localizedCaseInsensitiveContains("proxy") || msg.localizedCaseInsensitiveContains("bind") {
|
||||||
|
return "Lokale ClearKey-proxy startte niet. Herstart de app en probeer opnieuw."
|
||||||
|
}
|
||||||
|
if msg.localizedCaseInsensitiveContains("sleutel") {
|
||||||
|
return "Geen geldige ClearKey-sleutels voor deze zender."
|
||||||
|
}
|
||||||
|
return "ClearKey-stream mislukt: \(msg)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Na vroege Gestopt/error: één keer opnieuw afspelen (proxy blijft actief).
|
||||||
|
private func retryLiveIfNeeded() {
|
||||||
|
guard clearKeyProxyActive, !everPlayed, liveRetryCount < 2 else { return }
|
||||||
|
guard let url = livePlayURL else { return }
|
||||||
|
let elapsed = Date().timeIntervalSince(liveStartedAt ?? Date())
|
||||||
|
guard elapsed < 12 else { return }
|
||||||
|
liveRetryCount += 1
|
||||||
|
stateText = "Opnieuw verbinden…"
|
||||||
|
hasError = false
|
||||||
|
errorMessage = nil
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
|
||||||
|
guard let self, self.clearKeyProxyActive else { return }
|
||||||
|
self.startLiveMedia(url: url, format: self.livePlayFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func applyLiveOptions(
|
private static func applyLiveOptions(
|
||||||
to media: VLCMedia,
|
to media: VLCMedia,
|
||||||
url: URL,
|
url: URL,
|
||||||
|
|
@ -406,6 +449,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
tick = nil
|
tick = nil
|
||||||
mediaPlayer.stop()
|
mediaPlayer.stop()
|
||||||
mediaPlayer.media = nil
|
mediaPlayer.media = nil
|
||||||
|
livePlayURL = nil
|
||||||
|
livePlayFormat = nil
|
||||||
|
liveStartedAt = nil
|
||||||
|
liveRetryCount = 0
|
||||||
|
everPlayed = false
|
||||||
if clearKeyProxyActive {
|
if clearKeyProxyActive {
|
||||||
ClearKeyDashProxy.shared.stop()
|
ClearKeyDashProxy.shared.stop()
|
||||||
clearKeyProxyActive = false
|
clearKeyProxyActive = false
|
||||||
|
|
@ -422,15 +470,31 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
case .playing:
|
case .playing:
|
||||||
self.stateText = "Afspelen"
|
self.stateText = "Afspelen"
|
||||||
self.hasError = false
|
self.hasError = false
|
||||||
|
self.errorMessage = nil
|
||||||
|
self.everPlayed = true
|
||||||
self.refreshTracks()
|
self.refreshTracks()
|
||||||
self.applySubtitleStyle()
|
self.applySubtitleStyle()
|
||||||
case .paused: self.stateText = "Pauze"
|
case .paused: self.stateText = "Pauze"
|
||||||
case .stopped: self.stateText = "Gestopt"
|
case .stopped:
|
||||||
|
// Vroege "Gestopt" bij ClearKey is vaak tijdelijk (init/segment race) — niet meteen opgeven.
|
||||||
|
if self.clearKeyProxyActive && !self.everPlayed {
|
||||||
|
self.stateText = "Verbinden…"
|
||||||
|
self.retryLiveIfNeeded()
|
||||||
|
} else {
|
||||||
|
self.stateText = "Gestopt"
|
||||||
|
}
|
||||||
case .ended: self.stateText = "Einde"
|
case .ended: self.stateText = "Einde"
|
||||||
case .error:
|
case .error:
|
||||||
self.hasError = true
|
if self.clearKeyProxyActive && !self.everPlayed && self.liveRetryCount < 2 {
|
||||||
self.errorMessage = "Kon dit bestand niet afspelen"
|
self.stateText = "Opnieuw…"
|
||||||
self.stateText = "Fout"
|
self.retryLiveIfNeeded()
|
||||||
|
} else {
|
||||||
|
self.hasError = true
|
||||||
|
self.errorMessage = self.clearKeyProxyActive
|
||||||
|
? "Kon deze ClearKey-livezender niet afspelen. Controleer netwerk of probeer opnieuw."
|
||||||
|
: "Kon dit bestand niet afspelen"
|
||||||
|
self.stateText = "Fout"
|
||||||
|
}
|
||||||
case .esAdded:
|
case .esAdded:
|
||||||
self.refreshTracks()
|
self.refreshTracks()
|
||||||
@unknown default: break
|
@unknown default: break
|
||||||
|
|
|
||||||
|
|
@ -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.1")
|
LabeledContent("Versie", value: "0.5.2")
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
apps/ios/scripts/VMC-upload-TestFlight.command
Normal file
16
apps/ios/scripts/VMC-upload-TestFlight.command
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/bin/bash
|
||||||
|
exec > /tmp/vmc-tf-upload.log 2>&1
|
||||||
|
echo "TF_UPLOAD_START $(date)"
|
||||||
|
security list-keychains -s "$HOME/Library/Keychains/login.keychain-db" /Library/Keychains/System.keychain || true
|
||||||
|
security show-keychain-info "$HOME/Library/Keychains/login.keychain-db" 2>&1 || true
|
||||||
|
# Allow codesign non-interactively in this GUI login session (worked for prior export)
|
||||||
|
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "" "$HOME/Library/Keychains/login.keychain-db" 2>&1 || true
|
||||||
|
export KEY_ID=C4LTZP6328
|
||||||
|
export ISSUER_ID=ab4414a1-90fc-4fbf-b051-59d95c48097e
|
||||||
|
export AUTH_KEY_PATH="$HOME/AuthKey.p8"
|
||||||
|
bash "$HOME/upload-testflight.sh" --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" --auth-key "$AUTH_KEY_PATH"
|
||||||
|
EC=$?
|
||||||
|
echo "TF_UPLOAD_EXIT:$EC"
|
||||||
|
date > /tmp/vmc-tf-upload.done
|
||||||
|
echo "$EC" > /tmp/vmc-tf-upload.ec
|
||||||
|
exit "$EC"
|
||||||
209
apps/ios/scripts/upload-testflight.sh
Normal file
209
apps/ios/scripts/upload-testflight.sh
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# TestFlight upload via App Store Connect API key (headless, no GUI).
|
||||||
|
# Expects: ~/AuthKey.p8 (or AUTH_KEY_PATH)
|
||||||
|
# Requires: KEY_ID, ISSUER_ID (env or --key-id / --issuer-id)
|
||||||
|
# Optional: KEYCHAIN_PASS to unlock login keychain
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IOS_DIR="${IOS_DIR:-$HOME/Code/mediacluster/apps/ios}"
|
||||||
|
AUTH_KEY_PATH="${AUTH_KEY_PATH:-$HOME/AuthKey.p8}"
|
||||||
|
EXPORT_OPTIONS="${EXPORT_OPTIONS:-$IOS_DIR/ExportOptions.plist}"
|
||||||
|
ARCHIVE_PATH="${ARCHIVE_PATH:-$IOS_DIR/build/VMC.xcarchive}"
|
||||||
|
EXPORT_PATH="${EXPORT_PATH:-$IOS_DIR/build/export}"
|
||||||
|
SCHEME="${SCHEME:-VMC}"
|
||||||
|
CONFIGURATION="${CONFIGURATION:-Release}"
|
||||||
|
TEAM_ID="${TEAM_ID:-X25NYX3K49}"
|
||||||
|
BUNDLE_ID="${BUNDLE_ID:-nl.vonas.mediacluster.ios}"
|
||||||
|
SKIP_ARCHIVE=0
|
||||||
|
SKIP_EXPORT=0
|
||||||
|
IPA_PATH=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: upload-testflight.sh [options]
|
||||||
|
|
||||||
|
--key-id ID App Store Connect API Key ID (or env KEY_ID)
|
||||||
|
--issuer-id UUID App Store Connect Issuer ID (or env ISSUER_ID)
|
||||||
|
--auth-key PATH Path to .p8 (default: ~/AuthKey.p8)
|
||||||
|
--ipa PATH Skip archive/export; upload existing IPA
|
||||||
|
--skip-archive Reuse existing xcarchive
|
||||||
|
--skip-export Reuse existing IPA in export path
|
||||||
|
-h, --help Show help
|
||||||
|
|
||||||
|
Env:
|
||||||
|
KEY_ID, ISSUER_ID, AUTH_KEY_PATH, KEYCHAIN_PASS, IOS_DIR
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--key-id) KEY_ID="${2:-}"; shift 2 ;;
|
||||||
|
--issuer-id) ISSUER_ID="${2:-}"; shift 2 ;;
|
||||||
|
--auth-key) AUTH_KEY_PATH="${2:-}"; shift 2 ;;
|
||||||
|
--ipa) IPA_PATH="${2:-}"; SKIP_ARCHIVE=1; SKIP_EXPORT=1; shift 2 ;;
|
||||||
|
--skip-archive) SKIP_ARCHIVE=1; shift ;;
|
||||||
|
--skip-export) SKIP_EXPORT=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
KEY_ID="${KEY_ID:-}"
|
||||||
|
ISSUER_ID="${ISSUER_ID:-}"
|
||||||
|
|
||||||
|
# Derive Key ID from AuthKey_XXXXX.p8 filename if missing
|
||||||
|
if [[ -z "$KEY_ID" && -f "$AUTH_KEY_PATH" ]]; then
|
||||||
|
base="$(basename "$AUTH_KEY_PATH")"
|
||||||
|
if [[ "$base" =~ ^AuthKey_([A-Z0-9]+)\.p8$ ]]; then
|
||||||
|
KEY_ID="${BASH_REMATCH[1]}"
|
||||||
|
echo "KEY_ID derived from filename: $KEY_ID"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Also accept ~/AuthKey_XXXXX.p8 if ~/AuthKey.p8 missing
|
||||||
|
if [[ ! -f "$AUTH_KEY_PATH" ]]; then
|
||||||
|
for cand in "$HOME"/AuthKey_*.p8; do
|
||||||
|
if [[ -f "$cand" ]]; then
|
||||||
|
AUTH_KEY_PATH="$cand"
|
||||||
|
base="$(basename "$cand")"
|
||||||
|
if [[ -z "$KEY_ID" && "$base" =~ ^AuthKey_([A-Z0-9]+)\.p8$ ]]; then
|
||||||
|
KEY_ID="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
echo "Using API key file: $AUTH_KEY_PATH"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[[ -n "$KEY_ID" ]] || die "KEY_ID ontbreekt (env KEY_ID of --key-id, of AuthKey_XXXXX.p8)"
|
||||||
|
[[ -n "$ISSUER_ID" ]] || die "ISSUER_ID ontbreekt (env ISSUER_ID of --issuer-id)"
|
||||||
|
[[ -f "$AUTH_KEY_PATH" ]] || die "API key niet gevonden: $AUTH_KEY_PATH (scp AuthKey*.p8 naar ~ op de Mac)"
|
||||||
|
|
||||||
|
# Private keys for altool/transporter must live in a predictable API Keys folder
|
||||||
|
API_KEYS_DIR="$HOME/.appstoreconnect/private_keys"
|
||||||
|
mkdir -p "$API_KEYS_DIR"
|
||||||
|
API_KEY_DEST="$API_KEYS_DIR/AuthKey_${KEY_ID}.p8"
|
||||||
|
if [[ "$(cd "$(dirname "$AUTH_KEY_PATH")" && pwd)/$(basename "$AUTH_KEY_PATH")" != "$API_KEY_DEST" ]]; then
|
||||||
|
cp -f "$AUTH_KEY_PATH" "$API_KEY_DEST"
|
||||||
|
chmod 600 "$API_KEY_DEST"
|
||||||
|
fi
|
||||||
|
# Also keep canonical ~/AuthKey.p8 symlink/copy for convenience
|
||||||
|
if [[ ! -f "$HOME/AuthKey.p8" ]]; then
|
||||||
|
cp -f "$AUTH_KEY_PATH" "$HOME/AuthKey.p8"
|
||||||
|
chmod 600 "$HOME/AuthKey.p8"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== VMC TestFlight upload ==="
|
||||||
|
echo "KEY_ID=$KEY_ID"
|
||||||
|
echo "ISSUER_ID=$ISSUER_ID"
|
||||||
|
echo "AUTH_KEY=$AUTH_KEY_PATH"
|
||||||
|
echo "BUNDLE_ID=$BUNDLE_ID"
|
||||||
|
echo "TEAM_ID=$TEAM_ID"
|
||||||
|
|
||||||
|
# Unlock keychain if password provided (headless codesign)
|
||||||
|
if [[ -n "${KEYCHAIN_PASS:-}" ]]; then
|
||||||
|
echo "Unlocking login keychain..."
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PASS" "$HOME/Library/Keychains/login.keychain-db" || \
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PASS" "$HOME/Library/Keychains/login.keychain" || true
|
||||||
|
security set-keychain-settings -t 3600 -u "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
security list-keychains -s "$HOME/Library/Keychains/login.keychain-db" /Library/Keychains/System.keychain || true
|
||||||
|
|
||||||
|
cd "$IOS_DIR"
|
||||||
|
mkdir -p build "$EXPORT_PATH" store
|
||||||
|
|
||||||
|
# Ensure ExportOptions.plist for App Store
|
||||||
|
if [[ ! -f "$EXPORT_OPTIONS" ]]; then
|
||||||
|
cat > "$EXPORT_OPTIONS" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>app-store</string>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>${TEAM_ID}</string>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
<key>uploadSymbols</key>
|
||||||
|
<true/>
|
||||||
|
<key>destination</key>
|
||||||
|
<string>export</string>
|
||||||
|
<key>manageAppVersionAndBuildNumber</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
echo "Created $EXPORT_OPTIONS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_ARCHIVE" -eq 0 ]]; then
|
||||||
|
echo "=== Archive (Release / generic iOS) ==="
|
||||||
|
rm -rf "$ARCHIVE_PATH"
|
||||||
|
xcodebuild \
|
||||||
|
-project VMC.xcodeproj \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration "$CONFIGURATION" \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-archivePath "$ARCHIVE_PATH" \
|
||||||
|
-allowProvisioningUpdates \
|
||||||
|
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||||
|
CODE_SIGN_STYLE=Automatic \
|
||||||
|
archive
|
||||||
|
echo "ARCHIVE_OK: $ARCHIVE_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_EXPORT" -eq 0 ]]; then
|
||||||
|
[[ -d "$ARCHIVE_PATH" ]] || die "Archive ontbreekt: $ARCHIVE_PATH"
|
||||||
|
echo "=== Export IPA ==="
|
||||||
|
rm -rf "$EXPORT_PATH"
|
||||||
|
mkdir -p "$EXPORT_PATH"
|
||||||
|
xcodebuild -exportArchive \
|
||||||
|
-archivePath "$ARCHIVE_PATH" \
|
||||||
|
-exportPath "$EXPORT_PATH" \
|
||||||
|
-exportOptionsPlist "$EXPORT_OPTIONS" \
|
||||||
|
-allowProvisioningUpdates
|
||||||
|
IPA_PATH="$(find "$EXPORT_PATH" -name '*.ipa' -type f | head -1)"
|
||||||
|
[[ -n "$IPA_PATH" && -f "$IPA_PATH" ]] || die "Geen IPA na export in $EXPORT_PATH"
|
||||||
|
VER="$(/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:CFBundleShortVersionString' "$ARCHIVE_PATH/Info.plist" 2>/dev/null || echo '0.5.0')"
|
||||||
|
BUILD="$(/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:CFBundleVersion' "$ARCHIVE_PATH/Info.plist" 2>/dev/null || echo 'unknown')"
|
||||||
|
cp -f "$IPA_PATH" "$IOS_DIR/store/VMC-${VER}-${BUILD}.ipa"
|
||||||
|
echo "IPA_OK: $IPA_PATH (copied to store/VMC-${VER}-${BUILD}.ipa)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${IPA_PATH:-}" ]]; then
|
||||||
|
IPA_PATH="$(find "$EXPORT_PATH" -name '*.ipa' -type f 2>/dev/null | head -1 || true)"
|
||||||
|
fi
|
||||||
|
[[ -n "${IPA_PATH:-}" && -f "$IPA_PATH" ]] || die "Geen IPA om te uploaden"
|
||||||
|
|
||||||
|
echo "=== Upload via altool (API key) ==="
|
||||||
|
# Prefer modern notary/upload path; altool still works for ASC API key upload
|
||||||
|
set +e
|
||||||
|
xcrun altool --upload-app \
|
||||||
|
--type ios \
|
||||||
|
--file "$IPA_PATH" \
|
||||||
|
--apiKey "$KEY_ID" \
|
||||||
|
--apiIssuer "$ISSUER_ID" \
|
||||||
|
2>&1 | tee "$IOS_DIR/build/testflight-upload.log"
|
||||||
|
UPLOAD_EC=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ "$UPLOAD_EC" -ne 0 ]]; then
|
||||||
|
echo "altool failed (exit $UPLOAD_EC); trying iTMSTransporter..."
|
||||||
|
TRANSPORTER="/Applications/Xcode.app/Contents/SharedFrameworks/ContentDeliveryServices.framework/Versions/A/itms/bin/iTMSTransporter"
|
||||||
|
if [[ -x "$TRANSPORTER" ]]; then
|
||||||
|
"$TRANSPORTER" -m upload \
|
||||||
|
-assetFile "$IPA_PATH" \
|
||||||
|
-apiKey "$KEY_ID" \
|
||||||
|
-apiIssuer "$ISSUER_ID" \
|
||||||
|
-v informational 2>&1 | tee -a "$IOS_DIR/build/testflight-upload.log"
|
||||||
|
else
|
||||||
|
die "Upload mislukt (altool exit $UPLOAD_EC) en iTMSTransporter niet gevonden"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "UPLOAD_OK $(date)"
|
||||||
|
echo "IPA=$IPA_PATH"
|
||||||
|
echo "Log: $IOS_DIR/build/testflight-upload.log"
|
||||||
Loading…
Reference in a new issue