From 3f5f958eeee1bd366e524b4bc8313dce913bf7b8 Mon Sep 17 00:00:00 2001 From: Gabriel Souza Date: Tue, 16 Jun 2026 22:44:30 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20un-redacted=20physical-device=20logs=20?= =?UTF-8?q?=E2=80=94=20wire=20IOSDeviceConsoleLogSource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Physical-device os_log showed every private argument as : the only wired source was the passive LoggingSupport relay, and logd redacts before it sends. The un-redacting source -- IOSDeviceConsoleLogSource -- has existed since #11 but was "retained but unwired" for a future opt-in and was never connected to a session. Wire it by swapping the physical-device primary per target: - empty target -> passive whole-device LoggingSupport stream (real levels, no launch) - a bundle id -> launch the app under `devicectl --console` with OS_ACTIVITY_DT_MODE=enable, mirroring that app's os_log un-redacted plus print()/stdout (the Xcode-console experience). One source per mode, so no duplicate lines. The app picker now passes the bundle id (the console launches by bundle id) rather than the display name. Tradeoff: selecting an app (re)launches it and waits for the device to be unlocked -- by design, the only way to tap un-redacted app output without a debugger or a logging profile. Validated on a connected iPhone 12 (iOS 26.5): the devicectl console launch yields 0 lines (vs. all-private passive), real content + the app's glyph/category Logger format the parser already handles. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Features/LogView/LogSessionView.swift | 13 ++++--------- Sources/Model/AppModel.swift | 19 +++++++++++++------ Sources/Model/LogSession.swift | 16 ++++++++-------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/Sources/Features/LogView/LogSessionView.swift b/Sources/Features/LogView/LogSessionView.swift index aa97385..d6dc7b9 100644 --- a/Sources/Features/LogView/LogSessionView.swift +++ b/Sources/Features/LogView/LogSessionView.swift @@ -414,15 +414,10 @@ private struct PackagePicker: View { // Dismiss the popover first, then apply the filter on the next runloop — // mutating ancestor state while the popover tears down can crash on macOS. show = false - // Android & simulator resolve the package/bundle id to PIDs. Physical iOS - // streams structured os_log and scopes by the app's *process name* (the - // display name), so pass that there. `nil` = "All processes". - let value: String - if let app { - value = session.device.platform == .iosDevice ? app.display : app.id - } else { - value = "" - } + // Every platform now scopes by the package / bundle id: Android & simulator + // resolve it to PIDs; physical iOS launches that bundle under devicectl's console + // for un-redacted os_log + print(). Empty = "All processes" (whole-device passive). + let value = app?.id ?? "" DispatchQueue.main.async { onSelect(value) } } } diff --git a/Sources/Model/AppModel.swift b/Sources/Model/AppModel.swift index 415fe7b..33a5db4 100644 --- a/Sources/Model/AppModel.swift +++ b/Sources/Model/AppModel.swift @@ -354,12 +354,19 @@ final class AppModel { case .android: return adb.map { AndroidLogSource(adbURL: $0, serial: device.id) } case .iosSimulator: return SimulatorLogSource(udid: device.id) case .iosDevice: - // Structured logs (level · subsystem · category) via Apple's private - // LoggingSupport engine (OSActivityStream) — the Xcode/Console-grade - // stream. `bundleID` here is the selected app's process/display name and - // narrows the whole-device stream to that app (empty = whole device). - // Falls back to idevicesyslog internally if the private API is unavailable. - return IOSDeviceOSLogSource(udid: device.id, processFilter: bundleID) + // Whole device → passive structured stream via Apple's LoggingSupport + // engine (real level · subsystem · category, no app launch); falls back to + // idevicesyslog internally if the private API is unavailable. Its catch: + // private os_log args arrive as (the relay redacts them). + // + // A targeted app → launch it under devicectl's console with + // OS_ACTIVITY_DT_MODE=enable, which mirrors that app's os_log **un-redacted** + // plus print()/stdout — the Xcode-console experience, scoped to the app. + // `bundleID` is the app's bundle id (launching is by bundle id, so it + // re-launches the app each time capture starts, by design). + return bundleID.isEmpty + ? IOSDeviceOSLogSource(udid: device.id, processFilter: nil) + : IOSDeviceConsoleLogSource(udid: device.id, bundleID: bundleID) } } // Simulators can additionally stream the targeted app's stdout (`print()`), diff --git a/Sources/Model/LogSession.swift b/Sources/Model/LogSession.swift index f7e3bba..31f2695 100644 --- a/Sources/Model/LogSession.swift +++ b/Sources/Model/LogSession.swift @@ -387,9 +387,9 @@ final class LogSession: WorkspaceTab { $0.pids = package.isEmpty ? nil : [] $0.processNameQuery = "" case .iosDevice: - // The structured (LoggingSupport) source narrows to the targeted app's - // process itself, so no per-line LogFilter process query is needed; the - // label is the app's process/display name. + // The *source itself* is swapped per target (whole-device passive stream + // vs. devicectl-console launch of the bundle), so no per-line LogFilter + // process query is needed. The label is the app's bundle id. $0.processNameQuery = "" $0.pids = nil } @@ -399,11 +399,11 @@ final class LogSession: WorkspaceTab { restartPrimaryForTargetChange() } - /// Physical iOS re-scopes its *primary* structured source when the targeted app - /// changes (unlike Android/simulator, which keep one source and filter/launch-console - /// on top): an empty target streams the whole device, a name scopes to that app's - /// process. Stopping the current source lets the reconnect loop respawn with the new - /// scope; the source emits its own "▶︎ structured device logs (…)" marker. + /// Physical iOS swaps its *primary* source when the targeted app changes (unlike + /// Android/simulator, which keep one source and filter on top): an empty target → + /// whole-device passive LoggingSupport stream; a bundle id → a devicectl-console + /// launch of that app (un-redacted os_log + print()). Stopping the current source + /// lets the reconnect loop respawn the right one; each source emits its own marker. private func restartPrimaryForTargetChange() { guard isRunning, device.platform == .iosDevice else { return } swappingSource = true