diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1cab55d..7bfcea4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,2 +1,4 @@ +{"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-evt","title":"Enable WebView inspection and playback diagnostics","description":"Add development-only WKWebView inspection and token-safe playback diagnostics so Dreamio can debug hosted Stremio media failures without changing app navigation, login, or playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T02:30:26Z","created_by":"dirtydishes","updated_at":"2026-05-25T02:34:55Z","started_at":"2026-05-25T02:30:32Z","closed_at":"2026-05-25T02:34:55Z","close_reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-a5b","title":"Track HTML diff rendering tooling as dev dependency","description":"Move the HTML diff rendering package into devDependencies and ignore installed Node modules so the repo tracks reproducible tooling without vendoring dependencies.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:12:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:12:44Z","started_at":"2026-05-25T01:12:14Z","closed_at":"2026-05-25T01:12:44Z","close_reason":"Moved @pierre/diffs to devDependencies and ignored node_modules.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 6d0376a..eac3597 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -4,6 +4,7 @@ import WebKit final class DreamioWebViewController: UIViewController { private enum Constants { static let stremioWebURL = URL(string: "https://web.stremio.com/")! + static let diagnosticsMessageHandler = "dreamioDiagnostics" } private lazy var webView: WKWebView = { @@ -12,6 +13,13 @@ final class DreamioWebViewController: UIViewController { configuration.allowsInlineMediaPlayback = true configuration.mediaTypesRequiringUserActionForPlayback = [] configuration.preferences.javaScriptCanOpenWindowsAutomatically = true +#if DEBUG + configuration.userContentController.add( + WeakScriptMessageHandler(delegate: self), + name: Constants.diagnosticsMessageHandler + ) + configuration.userContentController.addUserScript(Self.playbackDiagnosticsScript) +#endif let webView = WKWebView(frame: .zero, configuration: configuration) webView.translatesAutoresizingMaskIntoConstraints = false @@ -20,6 +28,11 @@ final class DreamioWebViewController: UIViewController { webView.navigationDelegate = self webView.uiDelegate = self webView.scrollView.contentInsetAdjustmentBehavior = .never +#if DEBUG + if #available(iOS 16.4, *) { + webView.isInspectable = true + } +#endif return webView }() @@ -32,6 +45,96 @@ final class DreamioWebViewController: UIViewController { private var progressObservation: NSKeyValueObservation? +#if DEBUG + private static let playbackDiagnosticsScript = WKUserScript( + source: """ + (() => { + if (window.__dreamioPlaybackDiagnosticsInstalled) { + return; + } + window.__dreamioPlaybackDiagnosticsInstalled = true; + + const post = (type, payload = {}) => { + try { + window.webkit.messageHandlers.dreamioDiagnostics.postMessage({ + type, + payload, + href: window.location.href + }); + } catch (_) {} + }; + + const describeValue = (value) => { + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack + }; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch (_) { + return String(value); + } + }; + + ["error", "warn"].forEach((level) => { + const original = console[level]; + console[level] = (...args) => { + post(`console.${level}`, { args: args.map(describeValue) }); + original.apply(console, args); + }; + }); + + window.addEventListener("unhandledrejection", (event) => { + post("unhandledrejection", { + reason: describeValue(event.reason) + }); + }); + + const videoState = (video) => ({ + currentSrc: video.currentSrc || video.src || "", + networkState: video.networkState, + readyState: video.readyState, + errorCode: video.error ? video.error.code : null, + errorMessage: video.error ? video.error.message : null + }); + + const attachVideoDiagnostics = (video) => { + if (!video || video.__dreamioDiagnosticsAttached) { + return; + } + video.__dreamioDiagnosticsAttached = true; + video.addEventListener("error", () => { + post("video.error", videoState(video)); + }, true); + }; + + document.querySelectorAll("video").forEach(attachVideoDiagnostics); + + new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node instanceof HTMLVideoElement) { + attachVideoDiagnostics(node); + } + if (node.querySelectorAll) { + node.querySelectorAll("video").forEach(attachVideoDiagnostics); + } + }); + }); + }).observe(document.documentElement, { childList: true, subtree: true }); + })(); + """, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) +#endif + override func viewDidLoad() { super.viewDidLoad() @@ -77,6 +180,70 @@ final class DreamioWebViewController: UIViewController { }) present(alert, animated: true) } + +#if DEBUG + private func logDiagnostic(type: String, payload: Any, pageURL: String?) { + let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown" + let redactedPayload = redactDiagnosticValue(payload) + print("[DreamioDiagnostics] \(type) page=\(redactedPageURL) payload=\(redactedPayload)") + } + + private func redactDiagnosticValue(_ value: Any) -> Any { + switch value { + case let string as String: + return redactedURLString(string) + case let array as [Any]: + return array.map(redactDiagnosticValue) + case let dictionary as [String: Any]: + return dictionary.reduce(into: [String: Any]()) { result, entry in + result[entry.key] = redactDiagnosticValue(entry.value) + } + default: + return value + } + } + + private func redactedURLString(_ value: String) -> String { + guard var components = URLComponents(string: value), components.scheme != nil else { + return redactTokenLikeFragments(in: value) + } + + components.query = nil + components.fragment = nil + if let path = components.percentEncodedPath.nilIfEmpty { + components.percentEncodedPath = redactTokenLikePathSegments(in: path) + } + return redactTokenLikeFragments(in: components.string ?? value) + } + + private func redactTokenLikePathSegments(in path: String) -> String { + path + .split(separator: "/", omittingEmptySubsequences: false) + .map { segment -> String in + let text = String(segment) + if text.range(of: #"^[A-Za-z0-9_-]{24,}$"#, options: .regularExpression) != nil { + return "[redacted]" + } + return text + } + .joined(separator: "/") + } + + private func redactTokenLikeFragments(in value: String) -> String { + let patterns = [ + #"(?i)((?:token|access_token|auth|signature|sig|key|apikey|api_key|jwt|session|password)=)([^&\s]+)"#, + #"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+"# + ] + + return patterns.reduce(value) { redacted, pattern in + redacted.replacingOccurrences( + of: pattern, + with: "$1[redacted]", + options: .regularExpression + ) + } + } +#endif } extension DreamioWebViewController: WKNavigationDelegate { @@ -99,11 +266,31 @@ extension DreamioWebViewController: WKNavigationDelegate { decisionHandler(.allow) } + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { +#if DEBUG + if let response = navigationResponse.response as? HTTPURLResponse { + let url = response.url?.absoluteString ?? "unknown" + print("[DreamioNavigation] status=\(response.statusCode) url=\(redactedURLString(url))") + } +#endif + decisionHandler(.allow) + } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { +#if DEBUG + print("[DreamioNavigation] didFail url=\(redactedURLString(webView.url?.absoluteString ?? "unknown")) error=\(redactedURLString(error.localizedDescription))") +#endif showLoadFailure(error) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { +#if DEBUG + print("[DreamioNavigation] didFailProvisional url=\(redactedURLString(webView.url?.absoluteString ?? "unknown")) error=\(redactedURLString(error.localizedDescription))") +#endif showLoadFailure(error) } @@ -124,6 +311,44 @@ extension DreamioWebViewController: WKNavigationDelegate { } } +#if DEBUG +extension DreamioWebViewController: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.name == Constants.diagnosticsMessageHandler, + let body = message.body as? [String: Any], + let type = body["type"] as? String + else { + return + } + + logDiagnostic( + type: type, + payload: body["payload"] ?? [:], + pageURL: body["href"] as? String + ) + } +} + +private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { + weak var delegate: WKScriptMessageHandler? + + init(delegate: WKScriptMessageHandler) { + self.delegate = delegate + super.init() + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + delegate?.userContentController(userContentController, didReceive: message) + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} +#endif + extension DreamioWebViewController: WKUIDelegate { func webView( _ webView: WKWebView, diff --git a/docs/turns/2026-05-24-enable-web-inspector-playback-diagnostics.html b/docs/turns/2026-05-24-enable-web-inspector-playback-diagnostics.html new file mode 100644 index 0000000..116c281 --- /dev/null +++ b/docs/turns/2026-05-24-enable-web-inspector-playback-diagnostics.html @@ -0,0 +1,282 @@ + + + + + + Dreamio Web Inspector and Playback Diagnostics + + + + +
+
+
Repository implementation turn
+

Dreamio Web Inspector and Playback Diagnostics

+

Enabled development-only Safari inspection for Dreamio's WKWebView and added token-safe diagnostics for console warnings, promise rejections, video failures, navigation errors, and HTTP navigation statuses.

+
+ +
+

Summary

+

This pass improves observability without changing Dreamio's login, navigation, addon browsing, or playback behavior. Debug builds on iOS 16.4 and newer now opt the WebView into Safari Web Inspector, and page diagnostics flow back to Xcode logs with URL queries, fragments, bearer tokens, and long token-like path segments redacted.

+
+ +
+

Changes Made

+ +
+ +
+

Context

+

The current Debridio VOD failure appears to need browser-level evidence: stream URL shape, request headers, response metadata, MIME type, and JavaScript media errors. Before adding a native player path, Dreamio needs a reliable way to inspect hosted Stremio Web inside the app and collect media failure details from the page itself.

+
+ +
+

Important Implementation Details

+ +
+ +
+

Relevant Diff Snippets

+

The snippets below are rendered with @pierre/diffs from diffs.com-compatible components.

+
+
+ +
+ +
+

Expected Impact for End-Users

+

There should be no visible behavior change for ordinary app use. For development builds, Safari should now show an inspectable Dreamio or web.stremio.com target while the app is foregrounded, making the playback failure much easier to diagnose.

+
+ +
+

Validation

+ +
Manual real-device validation is still needed on kellcd: launch Dreamio, open Safari inspection, reproduce the Debridio VOD failure, and collect Console, Network, and media logs.
+
+ +
+

Issues, Limitations, and Mitigations

+ +
+ +
+

Follow-up Work

+ +
+
+ +