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
36 changes: 36 additions & 0 deletions macos/Modore/Sources/Modore/Models/ObservationModels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

/// One process's actual CPU usage during the observation window, from
/// idle_cpu.sh's two-sample delta (not ps's lifetime-decayed average).
struct ObservedProcessRow: Identifiable {
let id = UUID()
let percent: Double
let pid: Int
let name: String
let ownerPid: Int
let ownerName: String
let startedFromShell: Bool

/// Same meaning as ScanModels' BackgroundCpuRow: work not attributable to
/// the process's own owner, so quitting the named app wouldn't stop it.
var isDetachedFromAnApp: Bool { startedFromShell && ownerPid != pid }
}

/// One connection or listening port that appeared during the observation
/// window and wasn't present at its start, from network_watch.sh.
struct ObservedConnectionRow: Identifiable {
let id = UUID()
let kind: String
let process: String
let pid: Int
let address: String

var isListening: Bool { kind == "listen" }
}

struct ObservationResult {
let windowSeconds: Int
let processRows: [ObservedProcessRow]
let newConnectionRows: [ObservedConnectionRow]
let networkUnavailable: Bool
}
143 changes: 143 additions & 0 deletions macos/Modore/Sources/Modore/Services/ObservationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import Foundation

/// Runs idle_cpu.sh and network_watch.sh concurrently over the same window,
/// on demand -- see the two scripts' own header comments for why this is a
/// bounded, user-triggered observation rather than a scheduled background
/// watch like storage_watch.sh. storage_watch.sh's launchd job is entirely
/// hardcoded to one script/label/plist (schedule.sh has no generic multi-job
/// concept), and CPU/network are fast, bursty signals where a fixed hourly
/// sample would almost always just catch them idle -- unlike free disk
/// space, which genuinely changes slowly enough for that cadence to make
/// sense. Windows' own scripts/monitor.ps1 is the cross-platform precedent
/// for this shape: synchronous, foreground, user-triggered, never scheduled.
enum ObservationOutcome {
case ready(ObservationResult)
case failure(String)
}

enum ObservationService {
/// Pure and independently testable: idle_cpu.sh's own TSV protocol,
/// `process\t{percent}\t{pid}\t{name}\t{ownerPid}\t{ownerName}\t{startedFromShell}`.
static func parseProcessRows(_ output: String) -> [ObservedProcessRow] {
output.split(separator: "\n").compactMap { line -> ObservedProcessRow? in
let fields = line.components(separatedBy: "\t")
guard fields.count == 7, fields[0] == "process",
let percent = Double(fields[1]), let pid = Int(fields[2]),
let ownerPid = Int(fields[4]) else { return nil }
return ObservedProcessRow(
percent: percent,
pid: pid,
name: fields[3],
ownerPid: ownerPid,
ownerName: fields[5],
startedFromShell: fields[6] == "true"
)
}
}

/// Pure and independently testable: network_watch.sh's own TSV protocol,
/// `established|listen\t{process}\t{pid}\t{address}`.
static func parseConnectionRows(_ output: String) -> [ObservedConnectionRow] {
output.split(separator: "\n").compactMap { line -> ObservedConnectionRow? in
let fields = line.components(separatedBy: "\t")
guard fields.count == 4, fields[0] == "established" || fields[0] == "listen",
let pid = Int(fields[2]) else { return nil }
return ObservedConnectionRow(kind: fields[0], process: fields[1], pid: pid, address: fields[3])
}
}

static func observe(projectRoot: URL, windowSeconds: Int) async -> ObservationOutcome {
guard let execution = await Task.detached(priority: .userInitiated, operation: {
RuntimeWorkspace.prepareExecution(projectRoot: projectRoot)
}).value else {
return .failure("서명된 실행 런타임을 확인하지 못해 실행하지 않았습니다.")
}
guard let cpuInvocation = execution.pinnedInvocation(relativePath: "scripts/idle_cpu.sh", name: "idle_cpu"),
let networkInvocation = execution.pinnedInvocation(
relativePath: "scripts/network_watch.sh",
name: "network_watch"
) else {
return .failure("봉인한 관찰 스크립트를 확인하지 못해 실행하지 않았습니다.")
}

let timeout = TimeInterval(windowSeconds + 20)
async let cpuOutcome = run(
argument: cpuInvocation.argument,
files: cpuInvocation.files,
arguments: ["--window", String(windowSeconds)],
execution: execution,
timeout: timeout
)
async let networkOutcome = run(
argument: networkInvocation.argument,
files: networkInvocation.files,
arguments: ["--window", String(windowSeconds)],
execution: execution,
timeout: timeout
)
let (cpuResult, networkResult) = await (cpuOutcome, networkOutcome)

guard case .success(let cpuOutput) = cpuResult else {
if case .failure(let message) = cpuResult { return .failure(message) }
return .failure("CPU 관찰을 실행하지 못했습니다.")
}
guard case .success(let networkOutput) = networkResult else {
if case .failure(let message) = networkResult { return .failure(message) }
return .failure("네트워크 관찰을 실행하지 못했습니다.")
}

let networkValues = StorageWatchService.protocolValues(networkOutput)
return .ready(ObservationResult(
windowSeconds: windowSeconds,
processRows: parseProcessRows(cpuOutput),
newConnectionRows: parseConnectionRows(networkOutput),
networkUnavailable: networkValues["error"] != nil
))
}

private enum RawOutcome {
case success(String)
case failure(String)
}

private static func run(
argument: String,
files: [String: Data],
arguments: [String],
execution: RuntimeExecutionContext,
timeout: TimeInterval
) async -> RawOutcome {
let result = await LocalProcessRunner.capture(
executable: "/bin/bash",
arguments: [argument] + arguments,
currentDirectory: execution.runtimeRoot,
expectedCurrentDirectoryIdentity: execution.runtimeRootIdentity,
expectedSignedBundleURL: execution.signedBundleURL,
pinnedFiles: files,
environment: [:],
timeout: timeout
)
guard result.status == 0, result.endState == .exited else {
return .failure("관찰 스크립트 실행이 실패했습니다 (status \(result.status)).")
}
return .success(result.output)
}
}

extension ScanModel {
func observeNow(windowSeconds: Int) {
guard !observationInFlight else { return }
observationInFlight = true
observationErrorMessage = nil
let root = projectRoot
Task {
defer { observationInFlight = false }
switch await ObservationService.observe(projectRoot: root, windowSeconds: windowSeconds) {
case .ready(let result):
observationResult = result
case .failure(let message):
observationErrorMessage = message
}
}
}
}
3 changes: 3 additions & 0 deletions macos/Modore/Sources/Modore/Services/ScanModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ final class ScanModel: ObservableObject {
@Published var pendingLoginItemRemoval: PendingLoginItemRemoval?
@Published var loginItemActionInFlight: String?
@Published var archiveError: String?
@Published var observationResult: ObservationResult?
@Published var observationInFlight = false
@Published var observationErrorMessage: String?

let logStore = ScanLogStore()
let projectRoot: URL
Expand Down
109 changes: 109 additions & 0 deletions macos/Modore/Sources/Modore/Views/ActivityView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ struct ActivityPage: View {
var body: some View {
Form {
StorageWatchActivitySection()
ContinuousObservationSection()

if let latestEvent = model.storageWatchPathEvents.last {
StorageWatchPathEvidenceSection(event: latestEvent)
Expand Down Expand Up @@ -75,6 +76,114 @@ private struct StorageWatchActivitySection: View {
}
}

private struct ContinuousObservationSection: View {
@EnvironmentObject private var model: ScanModel
@State private var windowSeconds = 60

var body: some View {
Section {
HStack {
Picker("관찰 시간", selection: $windowSeconds) {
Text("30초").tag(30)
Text("1분").tag(60)
Text("2분").tag(120)
Text("5분").tag(300)
}
.labelsHidden()
.frame(maxWidth: 140)
Spacer()
Button(model.observationInFlight ? "관찰 중…" : "지금 관찰하기") {
model.observeNow(windowSeconds: windowSeconds)
}
.buttonStyle(.bordered)
.disabled(model.observationInFlight)
}

if model.observationInFlight {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("\(windowSeconds)초 동안 CPU와 네트워크를 관찰하는 중입니다…")
.font(.callout)
.foregroundStyle(.secondary)
}
} else if let message = model.observationErrorMessage {
Text(message)
.font(.callout)
.foregroundStyle(.secondary)
} else if let result = model.observationResult {
ObservationResultRows(result: result)
} else {
Text("기본 검사는 순간 스냅샷만 봅니다. 관찰을 시작하면 지정한 시간 동안 실제 CPU 사용과 새로 나타난 네트워크 연결만 골라 보여줍니다.")
.font(.callout)
.foregroundStyle(.secondary)
}
} header: {
NativeSectionHeader(
title: "CPU·네트워크 관찰",
subtitle: "지정한 시간 동안 두 시점을 비교해 실제 점유와 새 연결만 보고합니다. 예약 실행이 아니라 누를 때만 동작합니다.",
value: model.observationResult.map { "\($0.windowSeconds)초 관찰됨" } ?? "미실행"
)
}
}
}

private struct ObservationResultRows: View {
let result: ObservationResult

var body: some View {
if result.processRows.isEmpty {
Text("관찰 구간 동안 뚜렷한 CPU 사용이 없었습니다.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
ForEach(result.processRows) { row in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(row.name)
.font(.body.weight(.medium))
Text(row.isDetachedFromAnApp ? "\(row.ownerName)에서 시작된 셸 작업" : row.ownerName)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(String(format: "%.1f%%", row.percent))
.font(.callout.weight(.medium))
.monospacedDigit()
}
}
}

if result.networkUnavailable {
Text("lsof를 사용할 수 없어 네트워크는 관찰하지 못했습니다.")
.font(.callout)
.foregroundStyle(.secondary)
} else if result.newConnectionRows.isEmpty {
Text("관찰 구간 동안 새로 나타난 연결이나 포트가 없었습니다.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
ForEach(result.newConnectionRows) { row in
HStack {
Image(systemName: row.isListening ? "antenna.radiowaves.left.and.right" : "network")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(row.process)
.font(.body.weight(.medium))
Text(row.isListening ? "새 수신 포트" : "새 연결")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(row.address)
.font(.callout.monospaced())
.foregroundStyle(.secondary)
}
}
}
}
}

private struct StorageWatchPathEvidenceSection: View {
let event: StorageWatchPathEvent

Expand Down
85 changes: 85 additions & 0 deletions macos/Modore/Tests/ModoreTests/ObservationServiceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import XCTest
@testable import Modore

final class ObservationServiceTests: XCTestCase {
// MARK: parseProcessRows

func testParsesProcessRowsFromIdleCpuProtocol() {
let output = """
version\t1
operation\tidle-cpu
windowSeconds\t60
minPercent\t1
process\t42.5\t501\tnode\t500\tzsh\ttrue
process\t10.0\t900\tElectron\t900\tElectron\tfalse
observed\t2
reported\t2
"""

let rows = ObservationService.parseProcessRows(output)

XCTAssertEqual(rows.map(\.name), ["node", "Electron"])
XCTAssertEqual(rows[0].percent, 42.5, accuracy: 0.001)
XCTAssertEqual(rows[0].pid, 501)
XCTAssertEqual(rows[0].ownerPid, 500)
XCTAssertEqual(rows[0].ownerName, "zsh")
XCTAssertTrue(rows[0].startedFromShell)
// A process that owns itself, not started from a shell.
XCTAssertFalse(rows[1].startedFromShell)
XCTAssertFalse(rows[1].isDetachedFromAnApp)
}

// Same reasoning as BackgroundCpuRow: work not attributable to the
// application it appears to belong to, so quitting that app wouldn't
// stop it.
func testFlagsWorkDetachedFromAnyOwningApp() {
let output = "process\t99.0\t501\tnode\t500\tzsh\ttrue"

let rows = ObservationService.parseProcessRows(output)

XCTAssertEqual(rows.count, 1)
XCTAssertTrue(rows[0].isDetachedFromAnApp)
}

func testIgnoresNonProcessLinesAndMalformedRows() {
let output = """
version\t1
windowSeconds\t60
process\tnot-a-number\t501\tnode\t500\tzsh\ttrue
process\t10.0\t501\tnode\t500
observed\t0
"""

XCTAssertTrue(ObservationService.parseProcessRows(output).isEmpty)
}

// MARK: parseConnectionRows

func testParsesNewConnectionAndListenRows() {
let output = """
version\t1
operation\tnetwork-watch
windowSeconds\t60
established\tCodex\t2000\t2.2.2.2:8080
listen\tnewsvc\t3000\t*:9999
newEstablished\t1
newListen\t1
"""

let rows = ObservationService.parseConnectionRows(output)

XCTAssertEqual(rows.count, 2)
XCTAssertEqual(rows[0].kind, "established")
XCTAssertEqual(rows[0].process, "Codex")
XCTAssertEqual(rows[0].pid, 2000)
XCTAssertEqual(rows[0].address, "2.2.2.2:8080")
XCTAssertFalse(rows[0].isListening)
XCTAssertTrue(rows[1].isListening)
}

func testIgnoresMetadataLinesWhenParsingConnections() {
let output = "version\t1\nwindowSeconds\t60\nnewEstablished\t0\nnewListen\t0"

XCTAssertTrue(ObservationService.parseConnectionRows(output).isEmpty)
}
}
Loading