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.

-
Dilated app icon: yellow with pixel eyes

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.

Release history

PUBLIC BETA · MACOS 26+ · APPLE SILICON · MIT LICENSE

+
Dilated app icon: yellow with pixel eyes

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.

Release history

Free and open source. If it saves you time, you can sponsor it on GitHub or donate directly.

PUBLIC BETA · MACOS 26+ · APPLE SILICON · MIT LICENSE

- +