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
59 changes: 59 additions & 0 deletions macos/Modore/Sources/Modore/Models/IncidentAssessment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ struct IncidentAssessment: Equatable {
enum Kind: Equatable {
case noResult
case securityDanger
case sustainedCPU
case storageCritical
case collectionIncomplete
case browserAutomation
Expand All @@ -16,6 +17,7 @@ struct IncidentAssessment: Equatable {
switch self {
case .noResult: return "no_result"
case .securityDanger: return "security_danger"
case .sustainedCPU: return "sustained_cpu"
case .storageCritical: return "storage_critical"
case .collectionIncomplete: return "collection_incomplete"
case .browserAutomation: return "browser_automation"
Expand Down Expand Up @@ -62,6 +64,31 @@ struct IncidentAssessment: Equatable {
)
}

// Trust and resource health are separate judgments. A known runtime can
// be perfectly legitimate and still be stuck consuming a full core.
// The old ladder only considered the row's security risk, so a trusted
// Node binary running inside Taxi.app was collected at 102.7% but buried
// below storage/browser findings. Correlate the observation with the
// point-in-time process row to recover the owning app and executable.
if let cpu = sustainedCPU(in: content) {
let window = cpu.row.windowSeconds > 0 ? "\(cpu.row.windowSeconds)초 관찰에서 " : "관찰 구간에 "
let path = cpu.path.isEmpty ? "" : " 실행 경로: \(cpu.path)"
return IncidentAssessment(
kind: .sustainedCPU,
title: "\(cpu.displayName)가 CPU 한 코어 이상을 계속 사용합니다",
detail: String(
format: "%@%@ 프로세스가 평균 %.1f%%를 사용했습니다.%@",
window,
cpu.row.name,
cpu.row.cpuPercent,
path
),
impact: "발열, 배터리 소모와 다른 앱의 응답 지연이 계속될 수 있습니다.",
value: String(format: "%.1f%%", cpu.row.cpuPercent),
symbol: "gauge.with.needle.fill"
)
}

if let storage = content.storage, storage.risk == "danger" {
return IncidentAssessment(
kind: .storageCritical,
Expand Down Expand Up @@ -154,4 +181,36 @@ struct IncidentAssessment: Equatable {
symbol: "checkmark.circle"
)
}

private struct SustainedCPU {
let row: BackgroundCpuRow
let displayName: String
let path: String
}

private static func sustainedCPU(in content: ScanContent) -> SustainedCPU? {
content.backgroundCpuRows
.filter { $0.cpuPercent >= 90 }
.compactMap { row -> SustainedCPU? in
let process = content.cpuRows.first { $0.pid == row.pid }
let path = process?.path ?? ""
guard row.name.caseInsensitiveCompare("Modore") != .orderedSame,
!path.localizedCaseInsensitiveContains("/Modore.app/Contents/") else {
return nil
}
let appName = appBundleName(in: path)
let responsible = row.responsibleName.trimmingCharacters(in: .whitespacesAndNewlines)
let displayName = appName
?? (responsible.isEmpty ? row.name : responsible)
return SustainedCPU(row: row, displayName: displayName, path: path)
}
.max { $0.row.cpuPercent < $1.row.cpuPercent }
}

private static func appBundleName(in path: String) -> String? {
guard let component = path.split(separator: "/").last(where: {
$0.lowercased().hasSuffix(".app")
}) else { return nil }
return String(component.dropLast(4))
}
}
6 changes: 6 additions & 0 deletions macos/Modore/Sources/Modore/Views/StorageOverviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ private struct StatusRecoverySection: View {
private var actionSymbol: String {
switch assessment.kind {
case .securityDanger, .securityAttention: return "lock.shield"
case .sustainedCPU: return "gauge.with.needle"
case .storageCritical: return "internaldrive"
case .collectionIncomplete, .noResult: return "arrow.clockwise"
case .browserAutomation, .runtimeAttention: return "hammer"
Expand All @@ -599,6 +600,7 @@ private struct StatusRecoverySection: View {
private var actionTitle: String {
switch assessment.kind {
case .securityDanger, .securityAttention: return "증거를 먼저 확인하세요"
case .sustainedCPU: return "지속 점유 프로세스를 확인하세요"
case .storageCritical: return "승인 가능한 정리 후보를 검토하세요"
case .collectionIncomplete, .noResult: return "검사 범위를 다시 수집하세요"
case .browserAutomation: return "자동화 소유자와 격리 설정을 확인하세요"
Expand All @@ -618,6 +620,8 @@ private struct StatusRecoverySection: View {
return "세션 기록과 개발 필수 자산은 자동 정리 대상에서 제외합니다."
case .securityDanger, .securityAttention:
return "경로, 실행 맥락과 수집 범위를 확인한 뒤 별도 제거 여부를 판단합니다."
case .sustainedCPU:
return "자동 종료하지 않습니다. 프로세스와 실행 경로를 확인한 뒤 의도한 작업인지 판단하세요."
case .runtimeAttention:
return "Codex·Claude 세션 데이터는 보존하고 실행 중인 작업만 구분합니다."
case .storageDrop:
Expand All @@ -640,6 +644,7 @@ private struct StatusRecoverySection: View {
switch assessment.kind {
case .collectionIncomplete, .noResult: return "정밀 검사"
case .securityDanger, .securityAttention: return "보안 보기"
case .sustainedCPU: return "활동 보기"
case .storageCritical: return "확보 계획"
case .browserAutomation, .runtimeAttention: return "개발 보기"
case .storageDrop, .clear: return "기록 보기"
Expand All @@ -650,6 +655,7 @@ private struct StatusRecoverySection: View {
switch assessment.kind {
case .collectionIncomplete, .noResult: return runScan
case .securityDanger, .securityAttention: return onOpenSecurity
case .sustainedCPU: return onOpenActivity
case .storageCritical: return { onOpenStorage(.goal) }
case .browserAutomation, .runtimeAttention: return { onOpenStorage(.development) }
case .storageDrop, .clear: return onOpenActivity
Expand Down
58 changes: 58 additions & 0 deletions macos/Modore/Tests/ModoreTests/IncidentAssessmentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,64 @@ final class IncidentAssessmentTests: XCTestCase {
XCTAssertEqual(assessment.kind, .storageCritical)
}

func testSustainedCPUOutranksStorageAndNamesOwningApp() throws {
var value = root(collection: completeCollection())
var sections = try XCTUnwrap(value["sections"] as? [String: Any])
var storage = try XCTUnwrap(sections["storage"] as? [String: Any])
var volume = try XCTUnwrap(storage["volume"] as? [String: Any])
volume["risk"] = "danger"
storage["volume"] = volume
storage["browserAutomation"] = [
"verdict": "orphaned", "rootCount": 1,
"note": "소유 작업을 찾지 못했습니다.",
]
sections["storage"] = storage
sections["backgroundCpu"] = [[
"windowSeconds": 3, "name": "node", "pid_": 3559,
"cpuPercent": 102.7, "responsiblePid": 3559,
"responsibleName": "node", "selfResponsible": true,
"startedFromShell": false, "risk": "warning",
]]
sections["cpu"] = [[
"name": "node", "pid_": 3559, "cpu": 92.5, "memoryMB": 174,
"path": "/Users/test/Taxi.app/Contents/Resources/runtime/bin/node",
"risk": "safe", "note": "Node.js 런타임",
]]
value["sections"] = sections

let assessment = IncidentAssessment.make(
content: ScanContent(root: value), storageChange: nil
)

XCTAssertEqual(assessment.kind, .sustainedCPU)
XCTAssertTrue(assessment.title.contains("Taxi"))
XCTAssertTrue(assessment.detail.contains("102.7%"))
XCTAssertTrue(assessment.detail.contains("Taxi.app"))
}

func testModoreOwnScanCPUDoesNotBecomeIncident() {
var value = root(collection: completeCollection())
var sections = value["sections"] as! [String: Any]
sections["backgroundCpu"] = [[
"windowSeconds": 3, "name": "Modore", "pid_": 100,
"cpuPercent": 120.0, "responsiblePid": 100,
"responsibleName": "Modore", "selfResponsible": true,
"startedFromShell": false, "risk": "warning",
]]
sections["cpu"] = [[
"name": "Modore", "pid_": 100, "cpu": 99.0, "memoryMB": 150,
"path": "/Applications/Modore.app/Contents/MacOS/Modore",
"risk": "safe", "note": "Modore",
]]
value["sections"] = sections

let assessment = IncidentAssessment.make(
content: ScanContent(root: value), storageChange: nil
)

XCTAssertEqual(assessment.kind, .clear)
}

func testSecurityAttentionWhenWarningOnly() {
var value = root(collection: completeCollection())
value["findings"] = [
Expand Down