Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions App/iOSDeveloperToolkit/Model/LiveLogsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ final class LogSession: Identifiable {
var findings: [LiveLogFinding] = []
var investigationReference = ""
var hasUnsavedData = true
/// The .logarchive or .trace a collected source keeps, once it exists.
var artifactURL: URL?
fileprivate var task: Task<Void, Never>?
private var pending: [LogLine] = []
private var flushScheduled = false
Expand Down Expand Up @@ -105,6 +107,8 @@ final class LogSession: Identifiable {
final class LiveLogsModel {
var sessions: [LogSession] = []
var selectedSessionID: UUID?
/// The time window for OSLog archives and DVT recordings, in seconds.
var collectionSeconds = 60

var selectedSession: LogSession? {
sessions.first { $0.id == selectedSessionID } ?? sessions.last
Expand All @@ -123,15 +127,18 @@ final class LiveLogsModel {
selectedSessionID = session.id
let runner = app.runner
let started = Date()
let seconds = collectionSeconds
session.task = Task { [weak session] in
do {
let stream = try await LiveLogSource.open(kind, target: target, runner: runner)
let artifact = kind.artifactExtension.map { capture.spoolURL.deletingPathExtension().appendingPathExtension($0) }
let stream = try await LiveLogSource.open(kind, target: target, runner: runner, windowSeconds: seconds, artifact: artifact)
session?.state = .running
for try await chunk in stream {
try await capture.append(chunk)
session?.receive(chunk)
}
session?.stop(reason: "the device ended the stream")
if let artifact, FileManager.default.fileExists(atPath: artifact.path) { session?.artifactURL = artifact }
session?.stop(reason: kind.isCollected ? "collection finished" : "the device ended the stream")
app.record(title: kind.title, workspace: .liveLogs, target: target, transport: kind.serviceDescription, argv: [], started: started, finished: Date(), outcome: .succeeded, error: nil, outputPaths: [capture.spoolURL.path])
} catch is CancellationError {
app.record(title: kind.title, workspace: .liveLogs, target: target, transport: kind.serviceDescription, argv: [], started: started, finished: Date(), outcome: .cancelled, error: nil, outputPaths: [capture.spoolURL.path])
Expand Down
2 changes: 2 additions & 0 deletions App/iOSDeveloperToolkit/Views/DataViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ struct EvidenceView: View {
Toggle("Network packet capture (PCAP)", isOn: $evidence.options.includePacketCapture)
Toggle("Screenshot", isOn: $evidence.options.includeScreenshot)
Toggle("Copy crash reports", isOn: $evidence.options.includeCrashReports)
Toggle("OSLog archive: the device's saved logs from the last hour", isOn: $evidence.options.includeOSLogArchive)
Toggle("DVT logging through Instruments (\(evidence.options.dvtSeconds) s; needs Developer Mode and the developer image)", isOn: $evidence.options.includeDVTLogging)
Text("Logs, packet captures, screenshots, and crash reports can contain private information.").font(.caption).foregroundStyle(.secondary)
ReadinessStatusView(requirements: evidence.options.requirements, device: device, subject: "this collection")
}
Expand Down
72 changes: 54 additions & 18 deletions App/iOSDeveloperToolkit/Views/LogViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,51 @@ struct LiveLogsView: View {
TargetHeader(allowedKinds: [.physical, .simulator])
if let device = model.selectedDevice {
let kinds = LogStreamKind.available(for: device.kind)
HStack(spacing: 10) {
ForEach(kinds) { kind in
Button {
model.logs.start(kind, target: device.target, app: model)
} label: {
Label("Start \(kind.title)", systemImage: "play.fill")
}
.help(kind.summary)
.accessibilityIdentifier("start-\(kind.rawValue)")
let live = kinds.filter { !$0.isCollected }
let collected = kinds.filter(\.isCollected)
if kinds.isEmpty {
Text("Live logs are not available for the demo device.").foregroundStyle(.secondary)
}
if !live.isEmpty {
HStack(spacing: 10) {
Text("Stream").font(.callout.weight(.semibold)).frame(width: 64, alignment: .leading)
ForEach(live) { kind in startButton(kind, device: device, label: "Start \(kind.title)", symbol: "play.fill") }
Spacer()
}
if kinds.isEmpty {
Text("Live logs are not available for the demo device.").foregroundStyle(.secondary)
}
if !collected.isEmpty {
@Bindable var logs = model.logs
HStack(spacing: 10) {
Text("Collect").font(.callout.weight(.semibold)).frame(width: 64, alignment: .leading)
Picker("Window", selection: $logs.collectionSeconds) {
ForEach(CollectedLogs.windows, id: \.self) { seconds in
Text(seconds < 60 ? "\(seconds) s" : "\(seconds / 60) min").tag(seconds)
}
}
.fixedSize()
.help("OSLog Archive collects this much saved history; DVT Logging records for this long.")
.accessibilityIdentifier("collection-window")
ForEach(collected) { kind in
startButton(kind, device: device, label: kind == .osLogArchive ? "Collect OSLog Archive" : "Record DVT Logging", symbol: kind == .osLogArchive ? "tray.and.arrow.down" : "record.circle")
}
Spacer()
}
Spacer()
}
if !kinds.isEmpty {
Text(kinds.map(\.summary).joined(separator: " "))
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
DisclosureGroup("About these sources") {
VStack(alignment: .leading, spacing: 4) {
ForEach(kinds) { kind in
Text("**\(kind.title):** \(kind.summary)").fixedSize(horizontal: false, vertical: true)
}
}
.padding(.top, 4)
}
.font(.callout)
.foregroundStyle(.secondary)
}
}
if model.logs.sessions.isEmpty {
ContentUnavailableView("No Log Streams", systemImage: "text.alignleft", description: Text("Start a stream above. Every byte is saved to a private spool on this Mac, even while the view is paused or filtered."))
ContentUnavailableView("No Logs Yet", systemImage: "text.alignleft", description: Text("Start a stream or a collection above. Every byte is saved to a private spool on this Mac, even while the view is paused or filtered."))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Picker("Stream", selection: Binding(get: { model.logs.selectedSession?.id }, set: { model.logs.selectedSessionID = $0 })) {
Expand Down Expand Up @@ -65,6 +86,16 @@ struct LiveLogsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(20)
}

private func startButton(_ kind: LogStreamKind, device: Device, label: String, symbol: String) -> some View {
Button {
model.logs.start(kind, target: device.target, app: model)
} label: {
Label(label, systemImage: symbol)
}
.help(kind.summary)
.accessibilityIdentifier("start-\(kind.rawValue)")
}
}

/// The working view for one log stream.
Expand Down Expand Up @@ -131,7 +162,7 @@ struct LogSessionView: View {
.accessibilityLabel("Log lines")
.overlay {
if lines.isEmpty {
Text(session.state == .starting ? "Connecting…" : (session.filter.isEmpty ? "Waiting for log messages…" : "No lines match the filter."))
Text(session.state == .starting ? "Connecting…" : (session.filter.isEmpty ? (session.kind.isCollected ? "Collecting… the lines appear when it finishes." : "Waiting for log messages…") : "No lines match the filter."))
.foregroundStyle(.secondary)
}
}
Expand Down Expand Up @@ -169,6 +200,11 @@ struct LogSessionView: View {
Button("Show Spool in Finder") { FilePanels.reveal(session.capture.spoolURL) }
}
.fixedSize()
if let artifact = session.artifactURL {
Button(session.kind == .dvt ? "Open in Instruments" : "Open in Console") { NSWorkspace.shared.open(artifact) }
.help(artifact.path)
Button("Show in Finder") { FilePanels.reveal(artifact) }
}
}
}
.sheet(isPresented: $isMarkingFinding) {
Expand Down
13 changes: 7 additions & 6 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Swift device discovery (usbmuxd + CoreDevice + simctl), so nothing is lost.
| 10 | Location Lab (coordinate, nudge, saved places, offline map, map-link parsing, route generator, GPX inspection/replay, evidence log, clear) | `pmd3 developer dvt simulate-location` | Physical: CoreDevice `simulate location coordinate/route/clear`; Simulator: `simctl location`; GPX replay driven by the app; offline MapKit-free world map | devicectl, simctl | No | ✅ simulators · 🟡 physical |
| 11 | Live Logs — Unified | `pmd3 syslog live --format json` (os_trace_relay) | Native `com.apple.os_trace_relay` client; Simulator: `simctl spawn log stream --style ndjson` | Lockdown service / simctl | No | ✅ simulators · 🟡 physical |
| 12 | Live Logs — Classic syslog | `pmd3 syslog live-old` | Native `com.apple.syslog_relay` client | Lockdown service | No | 🟡 |
| 13 | Live Logs — DVT OSLog | `pmd3 developer dvt oslog` | 🔁 Covered by #11 (os_trace_relay needs no DDI); DVT/DTX is not an Apple-public interface | — | No | 🔁 🟡 |
| 13 | Live Logs — DVT OSLog | `pmd3 developer dvt oslog` | 🔁 Live Logs › **DVT Logging**: a timed Instruments Logging recording (`xctrace record`, then `xctrace export` of the os-log table), shown line by line and kept as a `.trace`; also an Evidence Capture option. Live streaming as in 0.3.x would need a DTX client over Xcode's private tunnel. The live Unified stream (#11) needs no developer image. Live Logs › **OSLog Archive** adds the device's saved history (`log collect`). | Xcode (xctrace); macOS `log` | No | 🔁 ✅ simulator · 🟡 physical |
| 14 | Live log spool, pause, filter (literal/regex/case), findings, review, raw/filtered save, evidence bundle, metadata sidecar | `live_logs.py` | Ported (`LogCapture`, `FindingsStore`, `InvestigationReport`) | Foundation | No | ✅ |
| 15 | Command Center — 49 `pmd3` presets + Advanced Mode + risk classes + typed confirmation | `command_catalog.py`, `action_safety.py` | 🔁 Guided **Actions** catalog backed by native services / devicectl / simctl / xctrace, same risk classes and device-bound `RUN XXXXXX` / `IRREVERSIBLE XXXXXX` phrases; Advanced Mode for `devicectl` with safety classification | Yes | No | 🔁 ✅ simulators · 🟡 physical |
| 16 | Guided Command Drift | `pmd3 <route> --help` probes | 🔁 **Toolchain Check**: verifies every devicectl/simctl/xctrace route the app uses is present in the installed Xcode | Yes | No | 🔁 ✅ |
Expand Down Expand Up @@ -297,8 +297,9 @@ that changes the device was run: no mounting, location, installation, or backup.
macOS is 14 (was 13).
- Guided actions replace the 49 raw `pymobiledevice3` presets; Advanced Mode runs `devicectl`
instead of arbitrary `pymobiledevice3` subcommands.
- DVT telemetry streams are replaced by Instruments recordings (`xctrace`); the DVT OSLog stream by
the Unified Logging stream, which needs no developer image.
- DVT telemetry streams are replaced by Instruments recordings (`xctrace`). The DVT OSLog stream is
replaced by DVT Logging, a timed Instruments Logging recording shown line by line; the live
Unified Logging stream needs no developer image, and the OSLog Archive adds the saved history.
- Features that need a developer tunnel (iOS 17+) now require Xcode, which owns the tunnel.

## 7. Migration log
Expand Down Expand Up @@ -407,7 +408,7 @@ and 0.3.x's shortcuts for the tenth and later pages and focus (⌘0, ⇧⌘E/M/S
| dvt-list (`developer dvt ls`) | — | — | §6.1: DTX over RemoteXPC on iOS 17+ |
| crash-list, crash-pull | Actions (native AFC) | = | `BackupAndAFCTests` |
| syslog | Live Logs · Classic syslog | = | `ServiceTests` |
| oslog (DVT) | Live Logs · Unified (os_trace_relay, no DDI) | 🔁 | `ServiceTests`, real-simulator test |
| oslog (DVT) | Live Logs · DVT Logging (Instruments Logging recording, exported) and Unified (os_trace_relay, no DDI); OSLog Archive (`log collect`) | 🔁 | `CollectedLogTests`, real-simulator test (DVT: 9,608 lines in 3 s) |
| pcap | Action `packet-capture`, Evidence stream | = | `nativeActionsRunAgainstTheCapturedTarget` |
| btlogger (`--format pcapng`) | Action `bluetooth-capture` (native, `.pklg`) | 🔁 (**G4 resolved**; PacketLogger format instead of pcapng) | `bluetoothRecordsBecomeAPacketLoggerFile`, `nativeActionsRunAgainstTheCapturedTarget` |
| dvt-device, dvt-proclist, dvt-applist | device details / processes / apps | 🔁 | — |
Expand Down Expand Up @@ -448,7 +449,7 @@ and 0.3.x's shortcuts for the tenth and later pages and focus (⌘0, ⇧⌘E/M/S

| Item | Swift | Class |
|---|---|---|
| `ios-developer-collect` (all options) | `idt collect` — `--include-oslog` renamed `--include-unified-logs`; the old name is still accepted (hidden from help) | = (**G7 resolved**) |
| `ios-developer-collect` (all options) | `idt collect` — `--include-oslog` (0.3.x DVT OSLog) is accepted and selects `--include-dvt-logs`; `--include-oslog-archive` adds `log collect` | = (**G7 resolved**) |
| `ios-ipa-inspect`, `ios-local-ddi` | `idt inspect-ipa`, `idt ddi` | = / 🔁 |
| Evidence snapshots (17) | lockdown, images, diagnostics ×4, apps, provisioning, crashes, AFC root, CoreDevice details; processes and configuration profiles (native over USB since G1/G2); cryptex list and DVT ×3 excluded (RemoteXPC/DTX) | = (**G1**, **G2** resolved) |
| `tests/` behaviours | ported to Swift tests (see §5.1); packaging/runtime tests replaced by `scripts/build-release.sh` checks | = |
Expand All @@ -463,7 +464,7 @@ and 0.3.x's shortcuts for the tenth and later pages and focus (⌘0, ⇧⌘E/M/S
| G4 | Bluetooth HCI capture (`com.apple.bluetooth.BTPacketLogger`) to `.pklg` | P1 | ✅ resolved — action “Bluetooth capture” |
| G5 | Guided reconnect | P2 | ✅ resolved — Device › Reconnect a Device…, also on the Connection diagnostics and No-device cards |
| G6 | Import 0.3.x workspace profiles; profile fields for the developer-image mechanism and selected action | P2 | ✅ resolved — tested with a profile written by 0.3.4's own exporter |
| G7 | `idt collect --include-oslog` accepted as an alias | P2 | ✅ resolved — hidden alias of `--include-unified-logs` |
| G7 | `idt collect --include-oslog` accepted as an alias | P2 | ✅ resolved — hidden alias of `--include-dvt-logs` (DVT logging through Instruments) |
| G8 | Instruments readiness row (replaces the DVT row) | P2 | ✅ resolved — Readiness row “Instruments (xctrace)” from `xctrace list devices` (available / offline / not listed); the Instruments recording action waits for it |
| G9 | Add current coordinate as a route waypoint | P3 | ✅ resolved — Location Lab › Route › Add Current Coordinate |
| G10 | Copy the findings register | P3 | ✅ resolved — Live Logs › Findings › Copy Register (Markdown, same as the evidence bundle's report) |
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Instruments. It needs no Python, no Homebrew packages, and no administrator righ
| **Devices** | Automatic discovery of USB and Wi-Fi–synced devices (event-driven, no polling), Xcode-paired network devices, and simulators — shown in separate *Physical Devices* and *Simulators* sections. |
| **Plain-language device details** | Name, model, hardware identifier, UDID, iOS version and build, architecture, connection, trust, Developer Mode, and developer-service status, each with an explanation. Identifying values stay hidden until you choose to show them. |
| **Readiness Check** | A read-only check of every prerequisite (Xcode, the macOS device service, connection, trust, Developer Mode, Xcode's device service, developer services, Instruments, lock state, logging and backup services, Safari Web Inspector) with a next step for anything not ready. |
| **Live Logs** | Unified Logging and classic syslog from physical devices, and the simulator's unified log. Every byte is spooled and hashed; the view can be paused and filtered (literal or regex) without affecting capture. Mark findings, then export the raw capture, filtered lines, or an evidence bundle. |
| **Live Logs** | Unified Logging and classic syslog streamed from physical devices, and the simulator's unified log; plus two collected sources: an **OSLog archive** (the device's saved log history for a time window, kept as a `.logarchive` for Console) and **DVT logging** (os_log recorded through Instruments, kept as a `.trace`). Every byte is spooled and hashed; the view can be paused and filtered (literal or regex) without affecting capture. Mark findings, then export the raw capture, filtered lines, or an evidence bundle. |
| **Location Lab** | Set a coordinate (offline world map, map-link parsing, nudges, saved places), move along a route at constant speed, or replay a GPX track. Always clearable; every change is logged. |
| **Apps** | Search and sort installed apps (with sizes over USB), launch, and remove with confirmation. |
| **Install App** | Inspect an `.ipa` on the Mac first — contents, provisioning profile, and code signature verified with Security.framework — then install it on a device, or install an `.app` on a simulator. |
Expand Down Expand Up @@ -253,7 +253,7 @@ idt ddi unmount --udid <UDID> --confirm "RUN ABC123"
idt toolchain # check the installed Xcode
```

`--include-oslog`, the 0.3.x name of `--include-unified-logs`, is still accepted.
`--include-oslog-archive` adds the device's saved Unified Log history for the last hour (`log collect`); `--include-dvt-logs` records os_log through Instruments (DVT) for the stream duration. `--include-oslog`, the 0.3.x flag for the DVT OSLog stream, is still accepted and selects DVT logging.

`idt collect` exits with `0` when complete, `2` when finished with coverage gaps, and `1` when the
device could not be identified.
Expand Down
2 changes: 2 additions & 0 deletions Sources/ToolkitCore/AppleTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public enum AppleTool: String, CaseIterable, Sendable {
case ditto
case rvictl
case swVers = "sw_vers"
case log

public var candidates: [URL] {
switch self {
Expand All @@ -24,6 +25,7 @@ public enum AppleTool: String, CaseIterable, Sendable {
case .ditto: return [URL(fileURLWithPath: "/usr/bin/ditto")]
case .rvictl: return [URL(fileURLWithPath: "/Library/Apple/usr/bin/rvictl"), URL(fileURLWithPath: "/usr/bin/rvictl")]
case .swVers: return [URL(fileURLWithPath: "/usr/bin/sw_vers")]
case .log: return [URL(fileURLWithPath: "/usr/bin/log")]
}
}

Expand Down
Loading
Loading