2 Commits
Author SHA1 Message Date
soconnor b7faf0ac70 Harden release packaging 2026-08-14 14:25:50 -04:00
soconnor 991867fef0 Improve process table columns and memory display 2026-08-14 14:15:49 -04:00
4 changed files with 329 additions and 103 deletions
+1
View File
@@ -9,6 +9,7 @@ Validated with automated tests, debug and release builds, packaged-app code-sign
- Live process list with CPU, memory, disk I/O, and network usage
- Cached native application icons throughout process groups, app history, startup apps, users, services, properties, and dependency views
- Search and sortable process columns, including live Disk and Network rates
- Drag-resizable, persisted Processes columns with memory hover details showing both resident bytes and physical-memory percentage
- Column sorting automatically flattens process categories for a true global order; the header menu can restore Group by type
- Expandable application groups plus an advanced Details table with persisted optional columns for threads, open handles, architecture, priority/nice, parent PID, CPU time, and other diagnostics
- Collapsible Windows-style Apps, Background processes, and macOS processes categories
+272 -60
View File
@@ -15,6 +15,40 @@ private enum ResourceDisplayMode: String, CaseIterable {
case percentages = "Percentages"
}
private let processNumericTextInset: CGFloat = 7
private enum ProcessColumn: String, CaseIterable, Codable {
case name, pid, status, cpu, memory, disk, network
var defaultWidth: CGFloat {
switch self {
case .name: 300
case .pid: 70
case .status: 80
case .cpu: 86
case .memory: 110
case .disk, .network: 88
}
}
var minimumWidth: CGFloat {
switch self {
case .name: 160
case .pid: 54
case .status: 68
case .cpu: 64
case .memory: 86
case .disk, .network: 76
}
}
var maximumWidth: CGFloat { self == .name ? 720 : 260 }
static var defaultWidths: [ProcessColumn: CGFloat] {
Dictionary(uniqueKeysWithValues: allCases.map { ($0, $0.defaultWidth) })
}
}
private enum ProcessCategory: String, CaseIterable, Identifiable {
case apps = "Apps"
case background = "Background processes"
@@ -35,6 +69,10 @@ struct ProcessesView: View {
@AppStorage("processSortAscending") private var ascending = false
@AppStorage("processGroupByType") private var groupByType = true
@AppStorage("processResourceDisplayMode") private var resourceDisplayMode: ResourceDisplayMode = .values
@AppStorage("processColumnWidths") private var columnWidthsStorage = ""
@State private var columnWidths = ProcessColumn.defaultWidths
@State private var groupsFrozenForResize: [ProcessGroup]?
@State private var isResizingColumn = false
@State private var terminationRequest: TerminationRequest?
@State private var groupTerminationRequest: ProcessGroup?
@State private var inspectedProcess: ProcessRecord?
@@ -65,6 +103,8 @@ struct ProcessesView: View {
}
}
private var displayedGroups: [ProcessGroup] { groupsFrozenForResize ?? grouped }
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Processes", subtitle: "\(monitor.processes.count) running processes") {
@@ -74,17 +114,31 @@ struct ProcessesView: View {
}
}
ResourceSummaryBar(snapshot: monitor.snapshot)
ScrollView(.horizontal) {
VStack(spacing: 0) {
ProcessTableHeader(
sort: $sort,
ascending: $ascending,
groupByType: $groupByType,
resourceDisplayMode: $resourceDisplayMode
resourceDisplayMode: $resourceDisplayMode,
columnWidths: $columnWidths,
onResizeStarted: {
isResizingColumn = true
if groupsFrozenForResize == nil { groupsFrozenForResize = grouped }
},
onResizeEnded: {
persistColumnWidths()
isResizingColumn = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
if !isResizingColumn { groupsFrozenForResize = nil }
}
}
)
ScrollView {
LazyVStack(spacing: 0) {
if groupByType {
ForEach(ProcessCategory.allCases) { category in
let categoryGroups = grouped.filter { $0.category == category }
let categoryGroups = displayedGroups.filter { $0.category == category }
if !categoryGroups.isEmpty {
ProcessCategoryHeader(
category: category,
@@ -106,13 +160,16 @@ struct ProcessesView: View {
}
}
} else {
ForEach(grouped) { group in
ForEach(displayedGroups) { group in
processGroup(group)
}
}
}
.padding(.leading, 10)
}
}
.frame(width: processTableWidth, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
HStack {
UpdateStatus()
@@ -162,6 +219,29 @@ struct ProcessesView: View {
.sheet(item: $inspectedProcess) { process in
ProcessPropertiesView(process: process)
}
.onAppear(perform: restoreColumnWidths)
}
private var processTableWidth: CGFloat {
ProcessColumn.allCases.reduce(0) { $0 + (columnWidths[$1] ?? $1.defaultWidth) }
+ CGFloat(ProcessColumn.allCases.count - 1) * 12
+ 32
}
private func restoreColumnWidths() {
guard let data = columnWidthsStorage.data(using: .utf8),
let stored = try? JSONDecoder().decode([String: Double].self, from: data) else { return }
for column in ProcessColumn.allCases {
guard let value = stored[column.rawValue] else { continue }
columnWidths[column] = min(column.maximumWidth, max(column.minimumWidth, CGFloat(value)))
}
}
private func persistColumnWidths() {
let stored = Dictionary(uniqueKeysWithValues: columnWidths.map { ($0.key.rawValue, Double($0.value)) })
guard let data = try? JSONEncoder().encode(stored),
let value = String(data: data, encoding: .utf8) else { return }
columnWidthsStorage = value
}
private func expansionBinding(for group: ProcessGroup) -> Binding<Bool> {
@@ -178,27 +258,16 @@ struct ProcessesView: View {
private func processGroup(_ group: ProcessGroup) -> some View {
if group.processes.count > 1 {
VStack(spacing: 0) {
HStack(spacing: 0) {
Button {
let binding = expansionBinding(for: group)
binding.wrappedValue.toggle()
} label: {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.rotationEffect(.degrees(expansionBinding(for: group).wrappedValue ? 90 : 0))
.frame(width: 16, height: 48)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)")
.accessibilityLabel(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)")
ProcessGroupRow(
group: group,
selected: selectedGroupID == group.id,
displayMode: resourceDisplayMode,
leadingPadding: 0
columnWidths: columnWidths,
isExpanded: expansionBinding(for: group),
onToggleExpansion: {
let binding = expansionBinding(for: group)
binding.wrappedValue.toggle()
}
)
.contentShape(Rectangle())
.onTapGesture {
@@ -213,7 +282,6 @@ struct ProcessesView: View {
onShowProperties: { inspectedProcess = $0 }
)
}
}
if expansionBinding(for: group).wrappedValue {
ForEach(group.processes.sorted { $0.pid < $1.pid }) { process in
@@ -227,7 +295,13 @@ struct ProcessesView: View {
}
private func processRow(_ process: ProcessRecord, isChild: Bool) -> some View {
ProcessRow(process: process, selected: selectedPID == process.pid, isChild: isChild, displayMode: resourceDisplayMode)
ProcessRow(
process: process,
selected: selectedPID == process.pid,
isChild: isChild,
displayMode: resourceDisplayMode,
columnWidths: columnWidths
)
.contentShape(Rectangle())
.onTapGesture {
selectedPID = process.pid
@@ -362,16 +436,24 @@ private struct ProcessTableHeader: View {
@Binding var ascending: Bool
@Binding var groupByType: Bool
@Binding var resourceDisplayMode: ResourceDisplayMode
@Binding var columnWidths: [ProcessColumn: CGFloat]
let onResizeStarted: () -> Void
let onResizeEnded: () -> Void
var body: some View {
HStack(spacing: 12) {
header("Name", field: .name).frame(maxWidth: .infinity, alignment: .leading)
header("PID", field: .pid).frame(width: 70, alignment: .trailing)
Text("Status").frame(width: 80, alignment: .leading)
header("CPU", field: .cpu).frame(width: 86, alignment: .trailing)
header("Memory", field: .memory).frame(width: 110, alignment: .trailing)
header("Disk", field: .disk).frame(width: 88, alignment: .trailing)
header("Network", field: .network).frame(width: 88, alignment: .trailing)
resizable(.name, alignment: .leading) { header("Name", field: .name, column: .name) }
resizable(.pid, alignment: .leading) { header("PID", field: .pid, column: .pid) }
resizable(.status, alignment: .leading) {
Text("Status")
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, processNumericTextInset)
}
resizable(.cpu, alignment: .leading) { header("CPU", field: .cpu, column: .cpu) }
resizable(.memory, alignment: .leading) { header("Memory", field: .memory, column: .memory) }
resizable(.disk, alignment: .leading) { header("Disk", field: .disk, column: .disk) }
resizable(.network, alignment: .leading) { header("Network", field: .network, column: .network) }
}
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
@@ -394,17 +476,100 @@ private struct ProcessTableHeader: View {
}
}
private func header(_ title: String, field: ProcessSort) -> some View {
Button {
private func resizable<Content: View>(
_ column: ProcessColumn,
alignment: Alignment,
@ViewBuilder content: @escaping () -> Content
) -> some View {
ResizableProcessHeaderCell(
width: Binding(
get: { columnWidths[column] ?? column.defaultWidth },
set: { columnWidths[column] = $0 }
),
minimumWidth: column.minimumWidth,
maximumWidth: column.maximumWidth,
alignment: alignment,
onResizeStarted: onResizeStarted,
onResizeEnded: onResizeEnded,
content: content
)
}
private func header(
_ title: String,
field: ProcessSort,
column: ProcessColumn
) -> some View {
HStack(spacing: 0) {
Text(title).lineLimit(1)
Spacer(minLength: 4)
if sort == field {
Color.clear.frame(width: 6, height: 0)
Image(systemName: ascending ? "chevron.up" : "chevron.down")
.frame(width: 10)
}
Color.clear.frame(width: processNumericTextInset, height: 0)
}
.frame(width: columnWidths[column] ?? column.defaultWidth)
.contentShape(Rectangle())
.onTapGesture {
groupByType = false
if sort == field { ascending.toggle() } else { sort = field; ascending = false }
} label: {
HStack(spacing: 3) {
Text(title)
if sort == field { Image(systemName: ascending ? "chevron.up" : "chevron.down") }
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(title)
.accessibilityValue(sort == field ? (ascending ? "Sorted ascending" : "Sorted descending") : "Not sorted")
}
}
private struct ResizableProcessHeaderCell<Content: View>: View {
@Binding var width: CGFloat
let minimumWidth: CGFloat
let maximumWidth: CGFloat
let alignment: Alignment
let onResizeStarted: () -> Void
let onResizeEnded: () -> Void
@ViewBuilder let content: () -> Content
@State private var dragStartWidth: CGFloat?
@State private var proposedWidth: CGFloat?
@State private var isHandleHovered = false
var body: some View {
content()
.frame(width: width, alignment: alignment)
.clipped()
.overlay(alignment: .trailing) {
ZStack {
Rectangle()
.fill(isHandleHovered || dragStartWidth != nil ? Color.accentColor : Color.secondary.opacity(0.25))
.frame(width: isHandleHovered || dragStartWidth != nil ? 2 : 1, height: 22)
}
.frame(width: 10, height: 34)
.offset(x: (proposedWidth ?? width) - width + 5)
.contentShape(Rectangle())
.onHover { hovering in
isHandleHovered = hovering
(hovering ? NSCursor.resizeLeftRight : NSCursor.arrow).set()
}
.highPriorityGesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if dragStartWidth == nil {
dragStartWidth = width
onResizeStarted()
}
let proposed = (dragStartWidth ?? width) + value.translation.width
proposedWidth = min(maximumWidth, max(minimumWidth, proposed))
}
.onEnded { _ in
if let proposedWidth { width = proposedWidth }
proposedWidth = nil
dragStartWidth = nil
onResizeEnded()
}
)
.help("Drag to resize column")
}
.buttonStyle(.plain)
}
}
@@ -414,6 +579,7 @@ private struct ProcessRow: View {
let selected: Bool
let isChild: Bool
let displayMode: ResourceDisplayMode
let columnWidths: [ProcessColumn: CGFloat]
private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() }
@@ -424,25 +590,33 @@ private struct ProcessRow: View {
Image(systemName: "arrow.turn.down.right")
.font(.caption)
.foregroundStyle(.tertiary)
.frame(width: 12)
.frame(width: 16)
} else {
Color.clear.frame(width: 16)
}
ProcessIcon(name: process.displayName, path: process.executablePath)
VStack(alignment: .leading, spacing: 2) {
Text(process.displayName).lineLimit(1)
Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Text(process.displayName).lineLimit(1).truncationMode(.tail)
Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
Text("\(process.pid)").monospacedDigit().frame(width: 70, alignment: .trailing)
Text(process.state.hasPrefix("R") ? "Running" : "").frame(width: 80, alignment: .leading)
}
.frame(width: width(.name), alignment: .leading)
.clipped()
Text("\(process.pid)").monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
.padding(.trailing, processNumericTextInset)
.frame(width: width(.pid), alignment: .trailing).clipped()
Text(process.state.hasPrefix("R") ? "Running" : "").lineLimit(1).truncationMode(.tail)
.frame(width: width(.status), alignment: .leading).clipped()
HeatCell(text: Formatters.percent(process.cpu), intensity: process.cpu / 100)
.frame(width: 86, alignment: .trailing)
.frame(width: width(.cpu), alignment: .trailing)
HeatCell(text: memoryText, intensity: process.memoryPercent / 30)
.frame(width: 110, alignment: .trailing)
.frame(width: width(.memory), alignment: .trailing)
.help(memoryHelp)
HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000)
.frame(width: 88, alignment: .trailing)
.frame(width: width(.disk), alignment: .trailing)
HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000)
.frame(width: 88, alignment: .trailing)
.frame(width: width(.network), alignment: .trailing)
}
.font(.callout)
.padding(.horizontal, 16)
@@ -455,6 +629,14 @@ private struct ProcessRow: View {
displayMode == .percentages ? Formatters.percent(process.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))
}
private var memoryHelp: String {
"Resident memory: \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))\(Formatters.percent(process.memoryPercent)) of physical memory"
}
private func width(_ column: ProcessColumn) -> CGFloat {
columnWidths[column] ?? column.defaultWidth
}
private var diskText: String {
displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal)
}
@@ -474,35 +656,53 @@ private struct ProcessGroupRow: View {
let group: ProcessGroup
let selected: Bool
let displayMode: ResourceDisplayMode
let leadingPadding: CGFloat
let columnWidths: [ProcessColumn: CGFloat]
@Binding var isExpanded: Bool
let onToggleExpansion: () -> Void
private var activity: ProcessActivityRate { group.activity(using: monitor.processActivity) }
var body: some View {
HStack(spacing: 12) {
HStack(spacing: 10) {
Button(action: onToggleExpansion) {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 16, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)")
.accessibilityLabel(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)")
ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath)
VStack(alignment: .leading, spacing: 2) {
Text("\(group.name) (\(group.processes.count))").lineLimit(1)
Text("Application group").font(.caption).foregroundStyle(.secondary)
}
Text("\(group.name) (\(group.processes.count))").lineLimit(1).truncationMode(.tail)
Text("Application group").font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
Text("").frame(width: 70, alignment: .trailing).foregroundStyle(.secondary)
}
.frame(width: width(.name), alignment: .leading)
.clipped()
Text("").lineLimit(1)
.padding(.trailing, processNumericTextInset)
.frame(width: width(.pid), alignment: .trailing).foregroundStyle(.secondary).clipped()
Text(group.processes.contains(where: { $0.state.hasPrefix("R") }) ? "Running" : "")
.frame(width: 80, alignment: .leading)
.lineLimit(1).truncationMode(.tail)
.frame(width: width(.status), alignment: .leading).clipped()
HeatCell(text: Formatters.percent(group.cpu), intensity: group.cpu / 100)
.frame(width: 86, alignment: .trailing)
.frame(width: width(.cpu), alignment: .trailing)
HeatCell(text: memoryText, intensity: group.memoryPercent / 30)
.frame(width: 110, alignment: .trailing)
.frame(width: width(.memory), alignment: .trailing)
.help(memoryHelp)
HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000)
.frame(width: 88, alignment: .trailing)
.frame(width: width(.disk), alignment: .trailing)
HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000)
.frame(width: 88, alignment: .trailing)
.frame(width: width(.network), alignment: .trailing)
}
.font(.callout.weight(.medium))
.padding(.leading, leadingPadding)
.padding(.trailing, 16)
.padding(.horizontal, 16)
.frame(height: 48)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
@@ -515,6 +715,14 @@ private struct ProcessGroupRow: View {
displayMode == .percentages ? Formatters.percent(group.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(group.residentBytes))
}
private var memoryHelp: String {
"Resident memory: \(Formatters.bytes.string(fromByteCount: Int64(group.residentBytes)))\(Formatters.percent(group.memoryPercent)) of physical memory"
}
private func width(_ column: ProcessColumn) -> CGFloat {
columnWidths[column] ?? column.defaultWidth
}
private var diskText: String {
displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal)
}
@@ -610,9 +818,13 @@ private struct HeatCell: View {
var body: some View {
Text(text)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.72)
.truncationMode(.tail)
.padding(.vertical, 9)
.padding(.horizontal, 7)
.padding(.horizontal, processNumericTextInset)
.frame(maxWidth: .infinity, alignment: .trailing)
.clipped()
.background(Color.accentColor.opacity(0.05 + min(0.32, max(0, intensity) * 0.32)))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
+13 -2
View File
@@ -3,8 +3,12 @@ set -euo pipefail
PROJECT_DIR="${0:A:h:h}"
APP_DIR="$PROJECT_DIR/dist/Task Manager.app"
CONTENTS_DIR="$APP_DIR/Contents"
ICON_SOURCE="$PROJECT_DIR/mactaskmanager.icon"
STAGING_ROOT="$(mktemp -d /tmp/mactaskmanager-build.XXXXXX)"
STAGED_APP="$STAGING_ROOT/Task Manager.app"
CONTENTS_DIR="$STAGED_APP/Contents"
trap '/bin/rm -rf -- "$STAGING_ROOT"' EXIT
cd "$PROJECT_DIR"
swift build -c release
@@ -23,5 +27,12 @@ if [[ -d "$ICON_SOURCE" ]]; then
--output-partial-info-plist "$CONTENTS_DIR/Resources/IconInfo.plist" >/dev/null
fi
codesign --force --deep --sign - "$APP_DIR" >/dev/null
# Finder and cloud-sync metadata can make an otherwise valid app bundle
# unsignable when rebuilding in place.
xattr -cr "$STAGED_APP"
codesign --force --deep --sign - "$STAGED_APP" >/dev/null
codesign --verify --deep --strict "$STAGED_APP"
mkdir -p "$PROJECT_DIR/dist"
ditto --norsrc "$STAGED_APP" "$APP_DIR"
print "Built $APP_DIR"
+3 -1
View File
@@ -15,7 +15,9 @@ if [[ ! -d "$APP_PATH" ]]; then
"$PROJECT_DIR/scripts/build-app.sh"
fi
ditto "$APP_PATH" "$STAGING_DIR/Task Manager.app"
ditto --norsrc "$APP_PATH" "$STAGING_DIR/Task Manager.app"
xattr -cr "$STAGING_DIR/Task Manager.app"
codesign --verify --deep --strict "$STAGING_DIR/Task Manager.app"
ln -s /Applications "$STAGING_DIR/Applications"
hdiutil create \