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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ All notable changes to DevScope are documented here. The format follows

### Changed

- Process inspectors show process CPU and memory; machine-wide GPU utilization
remains in the system activity dashboard.
- Favorites and watch matching use direct SHA-256 hex encoding and lexical
executable names, preserving existing saved keys without filesystem probes.
- Activity counts stay on one line when the process inventory exceeds 999 items.
- Access Settings show Checking during a scan, withholding stale requirement
guidance and diagnostics until the assessment finishes.

### Fixed

- Blocking process scans run on dispatch workers in live refresh, access checks,
and automation verification instead of occupying Swift cooperative workers.
- Oversized automation-command output triggers process-group termination while
both streams are drained, rather than waiting for the execution deadline.
- Process metrics reject negative and non-finite values and unsafe RSS conversion;
elapsed-time parsing rejects totals that exceed `Int64`.
- Release builds use SwiftPM's default build system without the deprecated
forced-native option on Swift 6.4.

### Documentation

- Recorded Rafal Sikora as copyright owner and RSI Tech as the public
maintainer, with `rsitech.ai` and `info@rsitech.ai` as the project routes.
- Updated release evidence to distinguish the installed Developer ID identity
Expand Down
8 changes: 6 additions & 2 deletions Sources/DevScope/App/AutomationComposition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ enum DevScopeComposition {
fileSystem: fileSystem,
currentUID: currentUID,
processSnapshot: {
try processScanner.snapshot(includeCurrentDirectories: false)
try await BlockingSystemWork.run {
try processScanner.snapshot(includeCurrentDirectories: false)
}
}
),
legacy: LegacyLoginItemAutomationExecutor(
Expand Down Expand Up @@ -132,7 +134,9 @@ enum DevScopeComposition {
await inventoryService.refreshAfterCurrent()
},
refreshProcesses: {
try processScanner.snapshot(includeCurrentDirectories: false)
try await BlockingSystemWork.run {
try processScanner.snapshot(includeCurrentDirectories: false)
}
},
backupDirectory: recoveryRoot,
currentUID: currentUID
Expand Down
17 changes: 17 additions & 0 deletions Sources/DevScope/Infrastructure/BlockingSystemWork.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

/// Runs synchronous system I/O without occupying Swift's cooperative executor.
enum BlockingSystemWork {
static func run<Value: Sendable>(
_ operation: @escaping @Sendable () throws -> Value
) async throws -> Value {
try Task.checkCancellation()
let value = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .utility).async {
continuation.resume(with: Result { try operation() })
}
}
try Task.checkCancellation()
return value
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ private final class AutomationChildProcessController: @unchecked Sendable {
guard escalationTask == nil else { return }
terminate(group, signal: SIGTERM)
escalationTask = Task.detached {
try? await Task.sleep(for: .seconds(2))
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled else { return }
_ = Darwin.kill(-group, SIGKILL)
}
Expand Down Expand Up @@ -111,15 +111,17 @@ actor SystemAutomationCommandRunner: AutomationCommandRunning {
try await Self.performBlocking {
try Self.drain(
spawned.standardOutput,
maximumCapturedBytes: self.maximumCapturedBytes
maximumCapturedBytes: self.maximumCapturedBytes,
onLimitExceeded: { controller.cancel() }
)
}
}
let errorTask = Task.detached {
try await Self.performBlocking {
try Self.drain(
spawned.standardError,
maximumCapturedBytes: self.maximumCapturedBytes
maximumCapturedBytes: self.maximumCapturedBytes,
onLimitExceeded: { controller.cancel() }
)
}
}
Expand Down Expand Up @@ -333,7 +335,8 @@ actor SystemAutomationCommandRunner: AutomationCommandRunning {

nonisolated private static func drain(
_ handle: FileHandle,
maximumCapturedBytes: Int
maximumCapturedBytes: Int,
onLimitExceeded: @Sendable () -> Void
) throws -> Data {
defer { try? handle.close() }
var captured = Data()
Expand All @@ -343,7 +346,10 @@ actor SystemAutomationCommandRunner: AutomationCommandRunning {
if available > 0 {
captured.append(chunk.prefix(available))
}
if chunk.count > available { exceededLimit = true }
if chunk.count > available, !exceededLimit {
exceededLimit = true
onLimitExceeded()
}
}
if exceededLimit { throw SystemAutomationCommandError.outputLimitExceeded }
return captured
Expand Down
4 changes: 2 additions & 2 deletions Sources/DevScope/Stores/ProcessStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,11 @@ final class ProcessStore: ObservableObject {
if includeCurrentDirectories {
await snapshotWorker.invalidateWorkspaceFacts()
}
let scanResult = try await Task.detached(priority: .userInitiated) {
let scanResult = try await BlockingSystemWork.run {
let processes = try scanner.snapshot(includeCurrentDirectories: includeCurrentDirectories)
let gpuMetric = includeGPUMetric ? try? gpuMetricProvider.snapshot() : nil
return (processes: processes, gpuMetric: gpuMetric)
}.value
}
if includeGPUMetric {
cachedGPUMetric = scanResult.gpuMetric
lastGPUMetricRefresh = Date()
Expand Down
4 changes: 3 additions & 1 deletion Sources/DevScope/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1390,7 +1390,9 @@ private struct RuntimeRailItem: View {
.font(.caption2.monospacedDigit().weight(.semibold))
.foregroundStyle(isSelected ? .primary : .tertiary)
.contentTransition(.numericText())
.frame(width: 22, alignment: .trailing)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.frame(minWidth: 22, alignment: .trailing)
}
}
.padding(.horizontal, isCollapsed ? 5 : 7)
Expand Down
7 changes: 1 addition & 6 deletions Sources/DevScope/Views/ProcessDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ struct ProcessDetailView: View {
process.process.resourceUsage?.residentMemoryBytes))
DetailChip(label: "Mem Avg", value: ProcessMetricFormat.memory(averageMemory))
DetailChip(label: "Mem Peak", value: ProcessMetricFormat.memory(peakMemory))
DetailChip(label: "GPU", value: ProcessMetricFormat.gpu(latestGPU))
DetailChip(
label: "Running", value: process.process.resourceUsage?.elapsedTime ?? "-")
DetailChip(label: "Samples", value: "\(metricHistory.count)")
Expand Down Expand Up @@ -190,10 +189,6 @@ struct ProcessDetailView: View {
.map(String.init)
}

private var latestGPU: Double? {
metricHistory.last { $0.gpuPercent != nil }?.gpuPercent
}

private var averageCPU: Double? {
average(metricHistory.map(\.cpuPercent))
}
Expand Down Expand Up @@ -252,7 +247,7 @@ private struct EmptyProcessInspector: View {
VStack(alignment: .leading, spacing: 8) {
EmptyInspectorLine(
symbolName: "gauge.with.dots.needle.67percent", title: "Resources",
detail: "CPU, memory, GPU sample, peaks, averages, and sample count")
detail: "Process CPU and memory, peaks, averages, and sample count")
EmptyInspectorLine(
symbolName: "point.3.connected.trianglepath.dotted", title: "Tree",
detail: "Parent, children, and retained ended-process state")
Expand Down
17 changes: 1 addition & 16 deletions Sources/DevScope/Views/ProcessMetricHistoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@ struct ProcessMetricHistoryView: View {
HStack(spacing: 12) {
LegendSwatch(color: .cyan, label: "CPU")
LegendSwatch(color: .purple, label: "Memory")
if hasGPUSamples {
LegendSwatch(color: .orange, label: "GPU")
}
Spacer()
Text(summaryText)
.font(.caption.monospacedDigit())
Expand Down Expand Up @@ -52,11 +49,7 @@ struct ProcessMetricHistoryView: View {
}

return
"\(ProcessMetricFormat.cpu(latest.cpuPercent)) · \(ProcessMetricFormat.memory(latest.residentMemoryBytes)) · GPU \(ProcessMetricFormat.gpu(latest.gpuPercent))"
}

private var hasGPUSamples: Bool {
samples.contains { $0.gpuPercent != nil }
"\(ProcessMetricFormat.cpu(latest.cpuPercent)) · \(ProcessMetricFormat.memory(latest.residentMemoryBytes))"
}
}

Expand Down Expand Up @@ -88,14 +81,6 @@ private struct MetricChart: View {
style: StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round)
)

let gpuValues = samples.compactMap(\.gpuPercent)
if gpuValues.count == samples.count {
context.stroke(
path(for: gpuValues, maxValue: 100, in: rect),
with: .color(.orange),
style: StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round)
)
}
}
.background(
.quaternary.opacity(0.24), in: RoundedRectangle(cornerRadius: 10, style: .continuous)
Expand Down
42 changes: 28 additions & 14 deletions Sources/DevScope/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ struct SettingsView: View {
errorDescription: nil
)
@State private var isCheckingAccess = false
@State private var hasCheckedAccess = false

var body: some View {
TabView {
Expand Down Expand Up @@ -140,7 +141,7 @@ struct SettingsView: View {

Divider()

ForEach(accessAssessment.requirements, id: \.kind) { requirement in
ForEach(isAccessAssessmentPending ? [] : accessAssessment.requirements, id: \.kind) { requirement in
AccessRequirementRow(requirement: requirement) { action in
performAccessAction(action)
}
Expand Down Expand Up @@ -265,6 +266,7 @@ struct SettingsView: View {
}
.accessibilityLabel("Copy access diagnostics")
.accessibilityHint("Copies DevScope access and sandbox diagnostics to the clipboard.")
.disabled(isAccessAssessmentPending)
}
}

Expand Down Expand Up @@ -435,7 +437,11 @@ struct SettingsView: View {
}

private var accessSummary: ProcessAccessSummary {
ProcessAccessSummary(assessment: accessAssessment)
ProcessAccessSummary(assessment: accessAssessment, isChecking: isAccessAssessmentPending)
}

private var isAccessAssessmentPending: Bool {
!hasCheckedAccess || isCheckingAccess
}

private var neededAccessActions: [ProcessAccessAction] {
Expand All @@ -458,7 +464,8 @@ struct SettingsView: View {
}

private var shouldShowFullDiskAccessGuidance: Bool {
ProcessAccessStatus.isSandboxed || neededAccessActions.contains(.fullDiskAccess)
!isAccessAssessmentPending
&& (ProcessAccessStatus.isSandboxed || neededAccessActions.contains(.fullDiskAccess))
}

private func refreshAccessAssessment() {
Expand All @@ -469,22 +476,23 @@ struct SettingsView: View {
let isSandboxed = ProcessAccessStatus.isSandboxed
isCheckingAccess = true
Task { @MainActor in
accessAssessment = await Task.detached(priority: .userInitiated) {
do {
do {
accessAssessment = try await BlockingSystemWork.run {
let processes = try SystemProcessScanner().snapshot(includeCurrentDirectories: true)
return ProcessAccessAssessment.assess(
isSandboxed: isSandboxed,
processes: processes,
errorDescription: nil
)
} catch {
return ProcessAccessAssessment.assess(
isSandboxed: isSandboxed,
processes: nil,
errorDescription: error.localizedDescription
)
}
}.value
} catch {
accessAssessment = ProcessAccessAssessment.assess(
isSandboxed: isSandboxed,
processes: nil,
errorDescription: error.localizedDescription
)
}
hasCheckedAccess = true
isCheckingAccess = false
}
}
Expand Down Expand Up @@ -608,8 +616,14 @@ private struct ProcessAccessSummary {
let symbolName: String
let tint: Color

init(assessment: ProcessAccessAssessment) {
if assessment.hasBlockedRequirement {
init(assessment: ProcessAccessAssessment, isChecking: Bool) {
if isChecking {
title = "Checking process access"
badge = "Checking"
message = "Inspecting current process metadata and working-directory access."
symbolName = "arrow.clockwise"
tint = .secondary
} else if assessment.hasBlockedRequirement {
title = "Process access blocked"
badge = "Blocked"
message =
Expand Down
5 changes: 4 additions & 1 deletion Sources/DevScopeCore/DevProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ public struct DevProcess: Identifiable, Equatable, Hashable, Sendable {
}

public var executableName: String {
URL(fileURLWithPath: executable).lastPathComponent
// This is a lexical process fact. Constructing a file URL probes the file
// system (and resolves relative paths) on every row/filter evaluation.
executable.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init)
?? executable
}
}

Expand Down
5 changes: 3 additions & 2 deletions Sources/DevScopeCore/ProcessMetricHistoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public struct ProcessMetricHistoryStore: Sendable {

public mutating func record(
processes: [DevProcess],
gpuMetric: DevGPUMetric?,
gpuMetric _: DevGPUMetric?,
timestamp: Date
) {
let activeProcessIDs = Set(processes.map(\.pid))
Expand All @@ -40,7 +40,8 @@ public struct ProcessMetricHistoryStore: Sendable {
timestamp: timestamp,
cpuPercent: usage.cpuPercent,
residentMemoryBytes: usage.residentMemoryBytes,
gpuPercent: gpuMetric?.utilizationPercent
// IORegistry reports machine utilization, not usage by this process.
gpuPercent: nil
)
)
let overflow = (entries[process.pid]?.samples.count ?? 0) - limit
Expand Down
22 changes: 17 additions & 5 deletions Sources/DevScopeCore/ProcessPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -376,13 +376,17 @@ public enum ProcessPresentation {
}

public static func identityKeys(for item: ClassifiedDevProcess) -> Set<String> {
let primary = identityKey(for: item)
guard let directory = item.process.currentDirectory, !directory.isEmpty else {
return [primary]
}
let fallback = hashedIdentityKey(
executableName: item.process.executableName,
currentDirectory: "",
command: item.process.command
)

return [identityKey(for: item), fallback]
return [primary, fallback]
}

private static func hashedIdentityKey(
Expand All @@ -398,9 +402,14 @@ public enum ProcessPresentation {

private static func hashedIdentityKey(source: String) -> String {
let digest = SHA256.hash(data: Data(source.utf8))
.map { String(format: "%02x", $0) }
.joined()
return "v2:\(digest)"
let alphabet = Array("0123456789abcdef".utf8)
var encoded = Array("v2:".utf8)
encoded.reserveCapacity(67)
for byte in digest {
encoded.append(alphabet[Int(byte >> 4)])
encoded.append(alphabet[Int(byte & 0x0f)])
}
return String(decoding: encoded, as: UTF8.self)
}

public static func isSaved(_ item: ClassifiedDevProcess, in keys: Set<String>) -> Bool {
Expand Down Expand Up @@ -469,7 +478,10 @@ public enum ProcessPresentation {
guard let hours, hours >= 0, hours < 24 else {
return -1
}
return days * 86_400 + hours * 3_600 + minutes * 60 + seconds
let daySeconds = days * 86_400
let timeSeconds = hours * 3_600 + minutes * 60 + seconds
guard daySeconds <= Int64.max - timeSeconds else { return -1 }
return daySeconds + timeSeconds
}

public static func redactedCommand(_ command: String) -> String {
Expand Down
Loading
Loading