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.
+[](https://github.com/Ti-03/MacDirStat/actions/workflows/ci.yml)
[](https://apps.apple.com/app/dirstat/id6766033292?mt=12)
[](https://www.apple.com/macos/)
[](./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.