diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e550a95 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-test: + name: Build & Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # macos-15 ships Xcode 16 (Swift 6.1): exercises the older-SDK fallback + # compile path. macos-26 ships Xcode 26 (Swift 6.2): the real shipping + # toolchain, with the macOS 26 SDK and Liquid Glass. The app uses + # MeshGradient (macOS 15) and glassEffect (macOS 26), so it needs the + # macOS 15+ SDK to compile, macos-14 cannot build it. + os: [macos-15, macos-26] + steps: + - uses: actions/checkout@v4 + + - name: Cache SwiftPM + uses: actions/cache@v4 + with: + path: | + .build + ~/Library/Caches/org.swift.swiftpm + key: ${{ matrix.os }}-spm-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ matrix.os }}-spm- + + # Static-analysis gate: the Swift compiler (with StrictConcurrency enabled + # in Package.swift) type-checks and concurrency-checks all production code. + - name: Build + run: swift build -v + + - name: Test + run: swift test diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..e094785 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "16096b517bff461c6fe8f1407ae92f21b2980ceea27de1c7be2b00618e486ccd", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 810ca58..61f3c68 100644 --- a/Package.swift +++ b/Package.swift @@ -7,9 +7,15 @@ let package = Package( products: [ .executable(name: "MacDirStat", targets: ["MacDirStat"]) ], + dependencies: [ + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1") + ], targets: [ .executableTarget( name: "MacDirStat", + dependencies: [ + .product(name: "Sparkle", package: "Sparkle") + ], path: "Sources", swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") diff --git a/README.md b/README.md index f0c389c..3d9f513 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@

See where your disk space went.
A fast, beautiful macOS disk usage visualizer — built entirely in Swift.

+[![CI](https://github.com/Ti-03/MacDirStat/actions/workflows/ci.yml/badge.svg)](https://github.com/Ti-03/MacDirStat/actions/workflows/ci.yml) [![App Store](https://img.shields.io/badge/Mac_App_Store-Download-0D96F6?style=flat-square&logo=apple)](https://apps.apple.com/app/dirstat/id6766033292?mt=12) [![macOS](https://img.shields.io/badge/macOS-14%2B-black?style=flat-square&logo=apple)](https://www.apple.com/macos/) [![License](https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square)](./LICENSE) diff --git a/Sources/App/GlassCompat.swift b/Sources/App/GlassCompat.swift index c5104ec..a8aef08 100644 --- a/Sources/App/GlassCompat.swift +++ b/Sources/App/GlassCompat.swift @@ -3,77 +3,114 @@ import SwiftUI // Compatibility shims for macOS 26 Liquid Glass and macOS 14/15 APIs. // On older OS versions these fall back to NSVisualEffectView-based materials. +// `glassEffect` / `.glass` button styles only exist in the macOS 26 SDK (Xcode 26, +// Swift 6.2). `#available` is a *runtime* check, it does not stop the compiler from +// needing the symbol, so on an older SDK these references fail to compile. Gate them +// at *compile* time with `#if compiler(>=6.2)`; the runtime `#available` stays inside +// so an Xcode-26 build still back-deploys correctly to macOS 14/15. extension View { @ViewBuilder func glassCapsule() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(in: .capsule) } else { self.background(.ultraThinMaterial, in: Capsule()) } + #else + self.background(.ultraThinMaterial, in: Capsule()) + #endif } @ViewBuilder func glassCircle() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular, in: .circle) } else { self.background(.ultraThinMaterial, in: Circle()) } + #else + self.background(.ultraThinMaterial, in: Circle()) + #endif } @ViewBuilder func glassCard(cornerRadius: CGFloat) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius)) } else { self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) } + #else + self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + #endif } @ViewBuilder func glassInteractiveCard(cornerRadius: CGFloat) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: cornerRadius)) } else { self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) } + #else + self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + #endif } @ViewBuilder func glassTintedCard(tint: Color, cornerRadius: CGFloat) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular.tint(tint), in: .rect(cornerRadius: cornerRadius)) } else { self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) } + #else + self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + #endif } @ViewBuilder func glassTintedInteractiveCapsule(tint: Color) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular.tint(tint).interactive(), in: .capsule) } else { self.background(.ultraThinMaterial, in: Capsule()) } + #else + self.background(.ultraThinMaterial, in: Capsule()) + #endif } @ViewBuilder func glassButton() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.buttonStyle(.glass) } else { self.buttonStyle(.bordered) } + #else + self.buttonStyle(.bordered) + #endif } @ViewBuilder func glassProminentButton() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.buttonStyle(.glassProminent) } else { self.buttonStyle(.borderedProminent) } + #else + self.buttonStyle(.borderedProminent) + #endif } } diff --git a/Tests/TreemapLayoutTests.swift b/Tests/TreemapLayoutTests.swift index 872e749..51c77b2 100644 --- a/Tests/TreemapLayoutTests.swift +++ b/Tests/TreemapLayoutTests.swift @@ -4,6 +4,14 @@ import SwiftUI final class TreemapLayoutTests: XCTestCase { + override func setUp() { + super.setUp() + // ByteFormatter reads "useBinarySize" from UserDefaults. Pin it to the + // default (decimal / SI) so the formatter tests are deterministic + // regardless of any value the app left on this machine. + UserDefaults.standard.set(false, forKey: "useBinarySize") + } + func test_extension_color_map_returns_consistent_color() { let root = makeTree([("a.pdf", 100), ("b.pdf", 200), ("c.mp4", 300)]) let map = ExtensionColorMap(root: root) @@ -19,15 +27,15 @@ final class TreemapLayoutTests: XCTestCase { } func test_byte_formatter_kb() { - XCTAssertEqual(ByteFormatter.string(from: 1024), "1.0 KB") + XCTAssertEqual(ByteFormatter.string(from: 1_000), "1.0 KB") } func test_byte_formatter_mb() { - XCTAssertEqual(ByteFormatter.string(from: 1024 * 1024), "1.0 MB") + XCTAssertEqual(ByteFormatter.string(from: 1_000_000), "1.0 MB") } func test_byte_formatter_gb() { - XCTAssertEqual(ByteFormatter.string(from: 1024 * 1024 * 1024), "1.0 GB") + XCTAssertEqual(ByteFormatter.string(from: 1_000_000_000), "1.0 GB") } func test_byte_formatter_bytes() { @@ -47,66 +55,76 @@ final class TreemapLayoutTests: XCTestCase { return root } - func test_layout_single_item_fills_rect() { + // The layout is a radial sunburst: a node's children fill the angular range + // [-pi/2, 1.5*pi] (a full 2*pi circle), each child's arc proportional to its + // size. Cells sit in radial bands. These tests assert on angles/radii. + // Note: compute() needs min(width, height) > ~212 to produce any cells. + + func test_layout_single_item_spans_full_circle() { let root = makeTree([("a.pdf", 1000)]) let map = ExtensionColorMap(root: root) - let rect = CGRect(x: 0, y: 0, width: 200, height: 100) + let rect = CGRect(x: 0, y: 0, width: 400, height: 400) let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map) XCTAssertEqual(cells.count, 1) - XCTAssertTrue(cells[0].rect.intersects(rect)) + XCTAssertEqual(cells[0].endAngle - cells[0].startAngle, 2 * .pi, accuracy: 0.001) } - func test_layout_two_equal_items_fill_rect() { + func test_layout_two_equal_items_split_circle_evenly() { let root = makeTree([("a.pdf", 500), ("b.mp4", 500)]) let map = ExtensionColorMap(root: root) - let rect = CGRect(x: 0, y: 0, width: 200, height: 100) + let rect = CGRect(x: 0, y: 0, width: 400, height: 400) let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map) XCTAssertEqual(cells.count, 2) - let totalArea = cells.reduce(0.0) { $0 + $1.rect.width * $1.rect.height } - XCTAssertEqual(totalArea, rect.width * rect.height, accuracy: 10) + for cell in cells { + XCTAssertEqual(cell.endAngle - cell.startAngle, .pi, accuracy: 0.001) + } + let totalArc = cells.reduce(0.0) { $0 + ($1.endAngle - $1.startAngle) } + XCTAssertEqual(totalArc, 2 * .pi, accuracy: 0.001) } - func test_layout_cells_dont_overlap() { + func test_layout_sibling_arcs_dont_overlap() { let root = makeTree([("a.pdf", 300), ("b.mp4", 200), ("c.zip", 100), ("d.txt", 400)]) let map = ExtensionColorMap(root: root) - let rect = CGRect(x: 0, y: 0, width: 400, height: 300) + let rect = CGRect(x: 0, y: 0, width: 500, height: 500) let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map) - for i in 0..=6.2)` (Swift 6.2 ships +with the Xcode 26 / macOS 26 SDK). Older toolchains compile only the `NSVisualEffectView`-based +fallback; the runtime `#available` stays inside the new-SDK branch so an Xcode-26 build still +back-deploys correctly to macOS 14/15. After this fix the matrix goes green on all runners. + +**Lesson:** a green local build proves nothing about other SDKs. The OS matrix turned a latent +portability bug (that would have bitten anyone building the package without the macOS 26 SDK) into +a one-PR fix. + +### ...and then it caught a second one: `macos-14` can't build the app at all + +With the glass code guarded, `macos-15` went green but `macos-14` still failed, this time on +`ContentView.swift`: `cannot find 'MeshGradient' in scope`. `MeshGradient` is a **macOS 15** API, +so the macOS 14 SDK doesn't have it either (and, like `glassEffect`, it's behind a runtime +`if #available(macOS 15, *)` that can't help at compile time). The app genuinely uses macOS 15 and +macOS 26 APIs, so it **requires the macOS 15+ SDK to compile**; `macos-14` was simply the wrong +runner. I would rather guard `MeshGradient` than gut a real piece of UI, so the fix here was the +matrix itself: dropped `macos-14` and settled on **`[macos-15, macos-26]`**, which compiles cleanly +and still tests both the fallback path (15) and the real Liquid Glass path (26). + +**Takeaway:** the deployment target (macOS 14, via `#available`) and the *build* SDK are different +things. You back-deploy at runtime, but you must compile against an SDK new enough to contain every +symbol you reference.