diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..6582996
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,2 @@
+github: CodesWhat
+custom: https://codeswhat.com/support
diff --git a/README.md b/README.md
index 1a694f2..a168444 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ Timed holds, triggers, a CLI for agents, and an opt-in closed-lid mode.
- [Privacy and Permissions](#privacy-and-permissions)
- [Roadmap](#roadmap)
- [Build and Contribute](#build-and-contribute)
+- [Support](#support)
- [Community and Support](#community-and-support)
- [Star History](#star-history)
- [CodesWhat Ecosystem](#codeswhat-ecosystem)
@@ -131,6 +132,12 @@ The package app product and internal executable are named `DilatedApp` so they d
+
Support
+
+Dilated is free and open source. If it saves you time, you can support it on [GitHub Sponsors](https://github.com/sponsors/CodesWhat) or [donate directly](https://codeswhat.com/support).
+
+
+
Community and Support
Open an [issue](https://github.com/CodesWhat/dilated/issues) for bugs. Include the Dilated and macOS versions and steps to reproduce. Report security problems through GitHub's [private vulnerability reporting](https://github.com/CodesWhat/dilated/security/advisories/new), not in a public issue.
diff --git a/Sources/DilatedUI/Menu/AwakeMenu.swift b/Sources/DilatedUI/Menu/AwakeMenu.swift
index 2da49dc..f9168f3 100644
--- a/Sources/DilatedUI/Menu/AwakeMenu.swift
+++ b/Sources/DilatedUI/Menu/AwakeMenu.swift
@@ -119,10 +119,22 @@ import DilatedCore
menu.addItem(updates)
}
menu.addItem(action("About Dilated", body: openAbout))
+ menu.addItem(supportItem())
menu.addItem(action("Quit Dilated", key: "q") { NSApp.terminate(nil) })
return menu
}
+ private static func supportItem() -> NSMenuItem {
+ let submenu = NSMenu(title: SupportLinks.menuTitle)
+ submenu.autoenablesItems = false
+ for destination in SupportLinks.all {
+ submenu.addItem(action(destination.title) { NSWorkspace.shared.open(destination.url) })
+ }
+ let item = NSMenuItem(title: SupportLinks.menuTitle, action: nil, keyEquivalent: "")
+ item.submenu = submenu
+ return item
+ }
+
static func refreshStatus(in menu: NSMenu, model: AppModel) {
menu.items.first?.title = model.statusText
menu.items.first { $0.keyEquivalent == "d" }?.title =
diff --git a/Sources/DilatedUI/Settings/AboutSettings.swift b/Sources/DilatedUI/Settings/AboutSettings.swift
index e57d3b0..572e018 100644
--- a/Sources/DilatedUI/Settings/AboutSettings.swift
+++ b/Sources/DilatedUI/Settings/AboutSettings.swift
@@ -68,6 +68,9 @@ enum AboutInfo {
}
}
}
+ Text(SupportLinks.credits).font(DesignFont.sans(11.5)).foregroundStyle(DesignPalette.ink2)
+ .multilineTextAlignment(.center).frame(maxWidth: .infinity)
+ .fixedSize(horizontal: false, vertical: true)
}
}
}
diff --git a/Sources/DilatedUI/SupportLinks.swift b/Sources/DilatedUI/SupportLinks.swift
new file mode 100644
index 0000000..ac13f05
--- /dev/null
+++ b/Sources/DilatedUI/SupportLinks.swift
@@ -0,0 +1,32 @@
+import Foundation
+
+/// Where people can chip in. Dilated is free and open source; these two links live in the menu bar menu, the About page, the
+/// website footer and the README, and nowhere else. Nothing here prompts, nags or runs at launch.
+enum SupportLinks {
+ struct Destination: Equatable {
+ let title: String
+ let url: URL
+ }
+
+ static let gitHubSponsors = Destination(
+ title: "GitHub Sponsors", url: URL(string: "https://github.com/sponsors/CodesWhat")!)
+ static let donate = Destination(
+ title: "Donate", url: URL(string: "https://codeswhat.com/support")!)
+ static let all = [gitHubSponsors, donate]
+
+ static let menuTitle = "Support Dilated"
+
+ /// The About page's credits line, split around its two links.
+ static let creditsLead =
+ "Dilated is free and open source. If it saves you time, you can support it on "
+ static let creditsDonateText = "donate directly"
+
+ static var credits: AttributedString {
+ let lead = AttributedString(creditsLead)
+ var sponsors = AttributedString(gitHubSponsors.title)
+ sponsors.link = gitHubSponsors.url
+ var donateLink = AttributedString(creditsDonateText)
+ donateLink.link = donate.url
+ return lead + sponsors + AttributedString(" or ") + donateLink + AttributedString(".")
+ }
+}
diff --git a/Tests/DilatedUITests/AboutSettingsTests.swift b/Tests/DilatedUITests/AboutSettingsTests.swift
index 66dbfc2..fec848d 100644
--- a/Tests/DilatedUITests/AboutSettingsTests.swift
+++ b/Tests/DilatedUITests/AboutSettingsTests.swift
@@ -82,6 +82,56 @@ import ViewInspector
}
#endif
+ @Test func creditsLineLinksBothDestinations() {
+ let credits = SupportLinks.credits
+ #expect(
+ String(credits.characters)
+ == "Dilated is free and open source. If it saves you time, you can support it on GitHub Sponsors or donate directly."
+ )
+ let links = credits.runs.compactMap { run in
+ run.link.map { (String(credits[run.range].characters), $0) }
+ }
+ #expect(links.map(\.0) == ["GitHub Sponsors", "donate directly"])
+ #expect(
+ links.map(\.1.absoluteString) == [
+ "https://github.com/sponsors/CodesWhat", "https://codeswhat.com/support",
+ ])
+ }
+
+ @Test func aboutPageShowsTheCreditsLine() throws {
+ _ = NSApplication.shared
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let model = AppModel(endpoint: .init(stateDirectory: directory), power: { .init() })
+ defer { model.shutdown() }
+ let text = try AboutSettings(model: model).inspect().find(ViewType.Text.self) {
+ (try? String($0.attributedString().characters).hasPrefix("Dilated is free and open source"))
+ == true
+ }
+ #expect(String(try text.attributedString().characters).hasSuffix("or donate directly."))
+ }
+
+ /// Writes a PNG of the About page when `SUPPORT_RENDER_DIR` is set. A no-op otherwise.
+ @Test func rendersAboutPage() throws {
+ guard let directory = ProcessInfo.processInfo.environment["SUPPORT_RENDER_DIR"] else { return }
+ _ = NSApplication.shared
+ FontRegistry.registerBundledFonts()
+ let state = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: state) }
+ let model = AppModel(endpoint: .init(stateDirectory: state), power: { .init() })
+ defer { model.shutdown() }
+ try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
+ let renderer = ImageRenderer(
+ content: AboutSettings(model: model).frame(width: 560).padding(20).background(
+ DesignPalette.bg))
+ renderer.scale = 2
+ let image = try #require(renderer.nsImage)
+ let tiff = try #require(image.tiffRepresentation)
+ let png = try #require(
+ NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]))
+ try png.write(to: URL(fileURLWithPath: directory).appendingPathComponent("about.png"))
+ }
+
@Test func aboutMenuItemInvokesTheHandlerInsteadOfTheStockPanel() throws {
_ = NSApplication.shared
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
diff --git a/Tests/DilatedUITests/MenuTests.swift b/Tests/DilatedUITests/MenuTests.swift
index 1f16948..b57c258 100644
--- a/Tests/DilatedUITests/MenuTests.swift
+++ b/Tests/DilatedUITests/MenuTests.swift
@@ -118,6 +118,35 @@ import Testing
#expect(menu.items.contains { $0.title == model.attention && !$0.isEnabled })
}
+ @Test func supportSubmenuSitsBetweenAboutAndQuit() throws {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let model = AppModel(endpoint: .init(stateDirectory: directory), power: { .init() })
+ defer { model.shutdown() }
+ let menu = AwakeMenu.make(model: model, openSettings: {})
+ let titles = menu.items.map(\.title)
+ let about = try #require(titles.firstIndex(of: "About Dilated"))
+ #expect(titles[about + 1] == "Support Dilated")
+ #expect(titles[about + 2] == "Quit Dilated")
+ let support = menu.items[about + 1]
+ #expect(support.image == nil)
+ let submenu = try #require(support.submenu)
+ #expect(submenu.items.map(\.title) == ["GitHub Sponsors", "Donate"])
+ #expect(
+ SupportLinks.all.map(\.url.absoluteString) == [
+ "https://github.com/sponsors/CodesWhat", "https://codeswhat.com/support",
+ ])
+ #expect(submenu.items.allSatisfy { $0.image == nil && $0.action != nil && $0.target != nil })
+ if let directory = ProcessInfo.processInfo.environment["SUPPORT_RENDER_DIR"] {
+ try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
+ let dump = (titles + ["Support Dilated >"] + submenu.items.map { " " + $0.title })
+ .joined(separator: "\n")
+ try dump.write(
+ to: URL(fileURLWithPath: directory).appendingPathComponent("menu.txt"), atomically: true,
+ encoding: .utf8)
+ }
+ }
+
@Test func checkForUpdatesSitsBetweenSettingsAndAboutOnlyWhenAvailable() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
diff --git a/website/dist/index.html b/website/dist/index.html
index b57dfee..c286318 100644
--- a/website/dist/index.html
+++ b/website/dist/index.html
@@ -118,10 +118,10 @@
Let it finish.
The screen can sleep.
Keeping your Mac awake doesn't have to keep the display on.
Stop is a full stop.
Clears every hold. Automation waits until you explicitly resume it.
Power gets a say.
Battery and Low Power Mode guards help put a limit on late nights.
-
PUBLIC BETA.
Try the public beta.
Dilated is a free public beta for macOS 26+ on Apple Silicon. From CodesWhat, under MIT.
Closed-lid mode is experimental. Hardware recovery checks remain incomplete. If the download details don't load, see the release history for published builds and their verification status.
PUBLIC BETA · MACOS 26+ · APPLE SILICON · MIT LICENSE
+
PUBLIC BETA.
Try the public beta.
Dilated is a free public beta for macOS 26+ on Apple Silicon. From CodesWhat, under MIT.
Closed-lid mode is experimental. Hardware recovery checks remain incomplete. If the download details don't load, see the release history for published builds and their verification status.