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
4 changes: 4 additions & 0 deletions macos/Modore/Sources/Modore/Models/ScanModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,15 @@ struct DevtoolUpdateRow: Identifiable {
let name: String
let current: String
let latest: String
/// `brew pin`ned: an update exists but the owner deliberately held this
/// package back, so listing it without saying so reads as a missed update.
let pinned: Bool

init?(json: [String: Any]) {
name = JsonRead.string(json, "name")
current = JsonRead.string(json, "current")
latest = JsonRead.string(json, "latest")
pinned = JsonRead.bool(json, "pinned") ?? false
guard !name.isEmpty, !current.isEmpty, !latest.isEmpty else { return nil }
}
}
Expand Down
13 changes: 10 additions & 3 deletions macos/Modore/Sources/Modore/Models/StorageModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,20 @@ struct StorageItem: Identifiable {
risk = json["risk"] as? String ?? "unknown"
kind = json["kind"] as? String ?? "unknown"
label = json["label"] as? String ?? kind
// A non-finite size propagates: it poisons every sum it enters, makes
// the goal slider's range comparison false (`1 <= NaN`), and trips
// ClosedRange's precondition -- a full-screen crash traceable to one
// field. `Double("1e999")` and a bare 1e999 in JSON both produce one,
// so treat it as unmeasured rather than trusting the producer.
let rawSize: Double
if let number = json["sizeGB"] as? NSNumber {
sizeGB = number.doubleValue
rawSize = number.doubleValue
} else if let string = json["sizeGB"] as? String {
sizeGB = Double(string) ?? 0
rawSize = Double(string) ?? 0
} else {
sizeGB = 0
rawSize = 0
}
sizeGB = rawSize.isFinite ? rawSize : 0
path = json["path"] as? String ?? ""
action = json["action"] as? String ?? "확인 필요"
note = json["note"] as? String ?? ""
Expand Down
11 changes: 9 additions & 2 deletions macos/Modore/Sources/Modore/Services/LoginItemService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,15 @@ enum LoginItemService {
}

extension ScanModel {
// `isBusy` as well as the per-action flag: the Security page stays
// interactive during a scan, and confirming a removal kicks off its own
// rescan. Without this a right-click removal mid-scan started a second
// ScanPipeline writing the same scan_result.json/report files as the
// first, with two finishRun()s racing over whichever mix survived --
// and the second run isn't held in `scanTask`, so 검사 취소 could not
// stop it. prepareCleanup already guards this way.
func previewLoginItemRemoval(_ name: String) {
guard loginItemActionInFlight == nil else { return }
guard !isBusy, loginItemActionInFlight == nil else { return }
loginItemActionInFlight = name
errorMessage = nil
let root = projectRoot
Expand All @@ -145,7 +152,7 @@ extension ScanModel {
}

func confirmLoginItemRemoval() {
guard let pending = pendingLoginItemRemoval, loginItemActionInFlight == nil else { return }
guard !isBusy, let pending = pendingLoginItemRemoval, loginItemActionInFlight == nil else { return }
pendingLoginItemRemoval = nil
loginItemActionInFlight = pending.name
let root = projectRoot
Expand Down
20 changes: 15 additions & 5 deletions macos/Modore/Sources/Modore/Services/MothballService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,19 @@ enum MothballService {
.sorted { $0.repo.sizeBytes > $1.repo.sizeBytes }
}

static func scanCandidates(lineagePaths: [ScreeLineagePath]) async -> [ArchiveCandidate] {
/// `scanReport`, not `scan`: the latter drops the inspection failures,
/// and MothballCore's own API comment warns why that matters -- a repo
/// found but not inspectable (corrupt .git, permission denied, git
/// timeout) would otherwise be indistinguishable from no repo at all, and
/// the page would state "nothing worth archiving" when the truth is that
/// it could not look.
static func scanCandidates(
lineagePaths: [ScreeLineagePath]
) async -> (candidates: [ArchiveCandidate], failureCount: Int) {
let roots = candidateRoots(from: lineagePaths)
guard !roots.isEmpty else { return [] }
let repos = await RepoScanner().scan(roots: roots)
return rankCandidates(repos: repos)
guard !roots.isEmpty else { return ([], 0) }
let report = await RepoScanner().scanReport(roots: roots)
return (rankCandidates(repos: report.repos), report.failures.count)
}
}

Expand All @@ -63,7 +71,9 @@ extension ScanModel {
let paths = report.lineagePaths
Task {
defer { archiveLoading = false }
archiveCandidates = await MothballService.scanCandidates(lineagePaths: paths)
let outcome = await MothballService.scanCandidates(lineagePaths: paths)
archiveCandidates = outcome.candidates
archiveInspectionFailures = outcome.failureCount
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ enum ObservationService {

extension ScanModel {
func observeNow(windowSeconds: Int) {
guard !observationInFlight else { return }
// Symmetric with isBusy including observationInFlight: neither side
// may run underneath the other, or each measures the other's work.
guard !isBusy, !observationInFlight else { return }
observationInFlight = true
observationErrorMessage = nil
let root = projectRoot
Expand All @@ -136,6 +138,10 @@ extension ScanModel {
case .ready(let result):
observationResult = result
case .failure(let message):
// Drop the previous run's rows: keeping them left the header
// reporting "N초 관찰됨" from an older window while the body
// showed this run's failure.
observationResult = nil
observationErrorMessage = message
}
}
Expand Down
7 changes: 7 additions & 0 deletions macos/Modore/Sources/Modore/Services/ScanModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ final class ScanModel: ObservableObject {
@Published var screeError: String?
@Published var screePreserveInFlightSource: String?
@Published var archiveCandidates: [ArchiveCandidate]?
@Published var archiveInspectionFailures = 0
@Published var archiveLoading = false
@Published var pendingLoginItemRemoval: PendingLoginItemRemoval?
@Published var loginItemActionInFlight: String?
Expand Down Expand Up @@ -67,6 +68,12 @@ final class ScanModel: ObservableObject {
|| cleanupInFlight
|| browserAutomationStopInFlight
|| storageWatchInFlight
// An observation measures what this Mac is doing on its own. A
// scan or cleanup started underneath it lands in its own results
// -- the scanner's du/lsof become the top "real CPU use" rows and
// VirusTotal lookups appear as new connections -- so the window
// would report the app's own work as the finding.
|| observationInFlight
|| resultLoading
}
var logText: String { logStore.text }
Expand Down
11 changes: 9 additions & 2 deletions macos/Modore/Sources/Modore/Views/ActivityView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ private struct StorageWatchActivitySection: View {
private struct ContinuousObservationSection: View {
@EnvironmentObject private var model: ScanModel
@State private var windowSeconds = 60
/// The window the in-flight run was actually started with, so the
/// progress caption cannot drift from it.
@State private var runningWindowSeconds = 60

var body: some View {
Section {
Expand All @@ -91,19 +94,23 @@ private struct ContinuousObservationSection: View {
}
.labelsHidden()
.frame(maxWidth: 140)
// Left enabled mid-run, the picker rewrote the caption below
// to a window the run in progress is not actually using.
.disabled(model.observationInFlight)
Spacer()
Button(model.observationInFlight ? "관찰 중…" : "지금 관찰하기") {
runningWindowSeconds = windowSeconds
model.observeNow(windowSeconds: windowSeconds)
}
.buttonStyle(.bordered)
.disabled(model.observationInFlight)
.disabled(model.isBusy || model.observationInFlight)
}

if model.observationInFlight {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("\(windowSeconds)초 동안 CPU와 네트워크를 관찰하는 중입니다…")
Text("\(runningWindowSeconds)초 동안 CPU와 네트워크를 관찰하는 중입니다…")
.font(.callout)
.foregroundStyle(.secondary)
}
Expand Down
15 changes: 13 additions & 2 deletions macos/Modore/Sources/Modore/Views/MothballView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ struct MothballPage: View {
}

if let candidates = model.archiveCandidates {
MothballCandidateSection(candidates: candidates)
MothballCandidateSection(
candidates: candidates,
inspectionFailures: model.archiveInspectionFailures
)
}
}
.macSettingsFormStyle()
Expand All @@ -65,11 +68,19 @@ struct MothballPage: View {

private struct MothballCandidateSection: View {
let candidates: [ArchiveCandidate]
let inspectionFailures: Int

var body: some View {
Section {
if candidates.isEmpty {
Text("보관할 만한 저장소가 없습니다.")
// "None found" and "could not look" are different answers.
Text(inspectionFailures > 0
? "저장소 \(inspectionFailures)개를 검사하지 못해 보관 후보를 판단할 수 없습니다."
: "보관할 만한 저장소가 없습니다.")
.foregroundStyle(.secondary)
} else if inspectionFailures > 0 {
Text("저장소 \(inspectionFailures)개는 검사하지 못했습니다. 아래 목록은 확인된 것만입니다.")
.font(.callout)
.foregroundStyle(.secondary)
}
ForEach(candidates) { candidate in
Expand Down
6 changes: 4 additions & 2 deletions macos/Modore/Sources/Modore/Views/SecurityView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,11 @@ struct SecurityPage: View {
DisclosureGroup(isExpanded: $showsDevtoolUpdates) {
ForEach(model.devtoolUpdateRows) { row in
SecurityDetailRow(
symbol: "shippingbox",
symbol: row.pinned ? "pin" : "shippingbox",
title: row.name,
detail: "\(row.current) → \(row.latest)"
detail: row.pinned
? "\(row.current) → \(row.latest) · 고정해 둔 패키지입니다"
: "\(row.current) → \(row.latest)"
)
}
} label: {
Expand Down
16 changes: 14 additions & 2 deletions macos/Modore/Sources/Modore/Views/SpaceGoalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,30 @@ import SwiftUI
/// regardless of the scan's original ordering -- not an exact/optimal subset
/// sum, just a simple, explainable greedy-largest-first rule.
enum SpaceGoalSelection {
/// Sizes arrive rounded to a tenth of a GB, and a tenth is not exact in
/// binary: four items truly summing to 3.0 add up to 2.9999999999999996,
/// so a bare `>=` walked past the exact-match set and appended one more
/// item than the goal needed, then reported the result as short of it.
private static let goalTolerance = 0.000_001

static func select(from candidates: [StorageItem], targetGB: Double) -> [StorageItem] {
guard targetGB > 0 else { return [] }
let eligible = candidates
.filter(\.canCleanup)
.sorted { lhs, rhs in
if lhs.sizeGB != rhs.sizeGB { return lhs.sizeGB > rhs.sizeGB }
return lhs.label < rhs.label
if lhs.label != rhs.label { return lhs.label < rhs.label }
// Same size and same label still has to resolve to one fixed
// order, or the "same set regardless of scan order" promise
// above is only true until two rows collide -- which they do:
// label falls back to `kind`, so two same-size rows of one
// kind tie. Paths are unique per row.
return lhs.path < rhs.path
}
var selected: [StorageItem] = []
var total = 0.0
for item in eligible {
if total >= targetGB { break }
if total >= targetGB - goalTolerance { break }
selected.append(item)
total += item.sizeGB
}
Expand Down
21 changes: 21 additions & 0 deletions macos/Modore/Tests/ModoreTests/DevtoolUpdateRowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,25 @@ final class DevtoolUpdateRowTests: XCTestCase {
XCTAssertNil(DevtoolUpdateRow(json: ["name": "foo", "current": "1.0"]))
XCTAssertNil(DevtoolUpdateRow(json: [:]))
}

// brew appends " [pinned at X]" for a pinned formula or cask. The row is
// kept (dropping it made the collector's count disagree with the list)
// and flagged, since "an update exists" reads differently for a package
// the owner deliberately held back.
func testDecodesAPinnedPackage() throws {
let row = try XCTUnwrap(DevtoolUpdateRow(json: [
"name": "node", "current": "18.0.0", "latest": "20.0.0", "pinned": true,
]))

XCTAssertTrue(row.pinned)
XCTAssertEqual(row.latest, "20.0.0")
}

func testDefaultsToNotPinnedWhenTheFieldIsAbsent() throws {
let row = try XCTUnwrap(DevtoolUpdateRow(json: [
"name": "ada-url", "current": "3.4.4", "latest": "4.0.0",
]))

XCTAssertFalse(row.pinned)
}
}
69 changes: 67 additions & 2 deletions macos/Modore/Tests/ModoreTests/SpaceGoalSelectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ final class SpaceGoalSelectionTests: XCTestCase {
label: String,
sizeGB: Double,
cleanupID: String = "npm_cache",
measureStatus: String = "ok"
measureStatus: String = "ok",
path: String? = nil
) -> StorageItem {
StorageItem(json: [
"risk": "warning",
"kind": "cache",
"label": label,
"sizeGB": sizeGB,
"path": "/tmp/\(label)",
"path": path ?? "/tmp/\(label)",
"action": "정리",
"note": "",
"measureStatus": measureStatus,
Expand Down Expand Up @@ -101,4 +102,68 @@ final class SpaceGoalSelectionTests: XCTestCase {
XCTAssertEqual(SpaceGoalSelection.select(from: items, targetGB: 0).count, 0)
XCTAssertEqual(SpaceGoalSelection.select(from: items, targetGB: -1).count, 0)
}

// The doc comment promises the same candidate set yields the same
// selection regardless of scan order. Size and label alone do not
// guarantee that: `label` falls back to `kind`, so two same-size rows of
// one kind tie completely and the result then depended on emit order.
func testIdenticalSizeAndLabelStillResolveToOneFixedOrder() {
let first = item(label: "cache", sizeGB: 0.5, path: "/tmp/a")
let second = item(label: "cache", sizeGB: 0.5, path: "/tmp/b")

let forward = SpaceGoalSelection.select(from: [first, second], targetGB: 1)
let reversed = SpaceGoalSelection.select(from: [second, first], targetGB: 1)

XCTAssertEqual(forward.map(\.path), reversed.map(\.path))
XCTAssertEqual(forward.map(\.path), ["/tmp/a", "/tmp/b"])
}

func testSelectionOrderIsIndependentOfInputOrderAtTheGoalBoundary() {
// Only one of the two tied 0.5GB rows is needed to cross the goal, so
// which one gets picked is exactly where input order used to leak.
let big = item(label: "big", sizeGB: 2.5)
let tiedA = item(label: "cache", sizeGB: 0.5, path: "/tmp/a")
let tiedB = item(label: "cache", sizeGB: 0.5, path: "/tmp/b")

let forward = SpaceGoalSelection.select(from: [big, tiedA, tiedB], targetGB: 3)
let reversed = SpaceGoalSelection.select(from: [tiedB, tiedA, big], targetGB: 3)

XCTAssertEqual(forward.count, 2)
XCTAssertEqual(forward.map(\.path), reversed.map(\.path))
}

// Sizes arrive rounded to a tenth, and a tenth is not exact in binary.
// 2.4 + 0.3 + 0.3 is exactly 3.0 in decimal but accumulates to
// 2.9999999999999996 in Double, so a bare `>=` walked past the set that
// actually meets the goal and appended a fourth item -- then reported the
// result as short of the goal it had in fact reached.
func testExactlyMetGoalDoesNotPickUpAnExtraItemFromFloatError() {
let items = [
item(label: "a", sizeGB: 2.4),
item(label: "b", sizeGB: 0.3),
item(label: "c", sizeGB: 0.3),
item(label: "d", sizeGB: 0.2),
]

let selected = SpaceGoalSelection.select(from: items, targetGB: 3)

XCTAssertEqual(selected.map(\.label), ["a", "b", "c"])
}

// A non-finite size poisons every sum it enters and makes the goal
// slider's range precondition trap; it is treated as unmeasured instead.
func testNonFiniteSizeIsTreatedAsUnmeasured() {
let poisoned = StorageItem(json: [
"risk": "warning",
"kind": "cache",
"label": "poisoned",
"sizeGB": "1e999",
"path": "/tmp/poisoned",
"measureStatus": "ok",
"cleanupId": "npm_cache",
])!

XCTAssertEqual(poisoned.sizeGB, 0)
XCTAssertTrue(poisoned.sizeGB.isFinite)
}
}
Loading