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
2 changes: 2 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github: CodesWhat
custom: https://codeswhat.com/support
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -131,6 +132,12 @@ The package app product and internal executable are named `DilatedApp` so they d

<hr>

<h2 align="center" id="support">Support</h2>

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).

<hr>

<h2 align="center" id="community-and-support">Community and Support</h2>

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.
Expand Down
12 changes: 12 additions & 0 deletions Sources/DilatedUI/Menu/AwakeMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
3 changes: 3 additions & 0 deletions Sources/DilatedUI/Settings/AboutSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
32 changes: 32 additions & 0 deletions Sources/DilatedUI/SupportLinks.swift
Original file line number Diff line number Diff line change
@@ -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(".")
}
}
50 changes: 50 additions & 0 deletions Tests/DilatedUITests/AboutSettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions Tests/DilatedUITests/MenuTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
4 changes: 2 additions & 2 deletions website/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,10 @@ <h1 id="night-title">Let it <em>finish.</em></h1>
<div class="manners"><div><h3>The screen can sleep.</h3><p>Keeping your Mac awake doesn't have to keep the display on.</p></div><div><h3>Stop is a full stop.</h3><p>Clears every hold. Automation waits until you explicitly resume it.</p></div><div><h3>Power gets a say.</h3><p>Battery and Low Power Mode guards help put a limit on late nights.</p></div></div>
</section>

<section class="availability page-width" id="availability"><div class="app-dock"><img class="availability-icon" src="./assets/icon-gas-station.svg" alt="Dilated app icon: yellow with pixel eyes" width="100" height="100"><span class="dock-indicator" aria-hidden="true"></span></div><p class="eyebrow" data-release-status aria-live="polite">PUBLIC BETA.</p><h2 data-release-heading>Try the<br>public beta.</h2><p data-release-copy>Dilated is a free public beta for macOS 26+ on Apple Silicon. From CodesWhat, under MIT.</p><p class="testing-note" data-testing-only>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.</p><a class="button solid" href="https://github.com/CodesWhat/dilated/releases" data-testing-only>Release history <span aria-hidden="true">↗</span></a><a class="button solid" data-release-link hidden><span data-release-label>Download for Mac</span> <span aria-hidden="true">↓</span></a><p class="release-caveat" data-release-caveat hidden></p><div class="release-details" data-release-details hidden><p data-release-version></p><details><summary>SHA-256 checksum</summary><code data-release-checksum></code></details></div><p class="availability-spec" data-release-summary>PUBLIC BETA · MACOS 26+ · APPLE SILICON · MIT LICENSE</p></section>
<section class="availability page-width" id="availability"><div class="app-dock"><img class="availability-icon" src="./assets/icon-gas-station.svg" alt="Dilated app icon: yellow with pixel eyes" width="100" height="100"><span class="dock-indicator" aria-hidden="true"></span></div><p class="eyebrow" data-release-status aria-live="polite">PUBLIC BETA.</p><h2 data-release-heading>Try the<br>public beta.</h2><p data-release-copy>Dilated is a free public beta for macOS 26+ on Apple Silicon. From CodesWhat, under MIT.</p><p class="testing-note" data-testing-only>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.</p><a class="button solid" href="https://github.com/CodesWhat/dilated/releases" data-testing-only>Release history <span aria-hidden="true">↗</span></a><a class="button solid" data-release-link hidden><span data-release-label>Download for Mac</span> <span aria-hidden="true">↓</span></a><p class="release-caveat" data-release-caveat hidden></p><div class="release-details" data-release-details hidden><p data-release-version></p><details><summary>SHA-256 checksum</summary><code data-release-checksum></code></details></div><p class="support-note">Free and open source. If it saves you time, you can <a href="https://github.com/sponsors/CodesWhat">sponsor it on GitHub</a> or <a href="https://codeswhat.com/support">donate directly</a>.</p><p class="availability-spec" data-release-summary>PUBLIC BETA · MACOS 26+ · APPLE SILICON · MIT LICENSE</p></section>

</main>
<footer class="page-width"><a class="footer-brand" href="./">dilated</a><p>Made by <a href="https://codeswhat.com">CodesWhat</a>.<br>small, sharp tools from one developer and a lot of agents</p><div><a href="https://mouthtype.app">Also from CodesWhat: Mouthtype ↗</a></div><span>FREE SOFTWARE<br>MIT LICENSE</span></footer>
<footer class="page-width"><a class="footer-brand" href="./">dilated</a><p>Made by <a href="https://codeswhat.com">CodesWhat</a>.<br>small, sharp tools from one developer and a lot of agents</p><div><a href="https://mouthtype.app">Also from CodesWhat: Mouthtype ↗</a><a href="https://codeswhat.com/support">SUPPORT</a></div><span>FREE SOFTWARE<br>MIT LICENSE</span></footer>
<div class="toast" role="status" aria-live="polite" id="toast"></div>

<template id="awake-demo">
Expand Down
3 changes: 3 additions & 0 deletions website/dist/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions website/e2e/site.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,35 @@ test.describe('readability and usability', () => {
}
});

test('footer carries one SUPPORT link and the availability section a single support sentence', async ({ page, open }) => {
await open('/');
const link = page.locator('footer a', { hasText: 'SUPPORT' });
await expect(link).toHaveCount(1);
await expect(link).toHaveAttribute('href', 'https://codeswhat.com/support');
await expect(page.locator('footer a[href*="sponsors"]')).toHaveCount(0);
const note = page.locator('#availability .support-note');
await expect(note).toHaveCount(1);
await expect(note.locator('a')).toHaveCount(2);
await expect(note.locator('a').nth(0)).toHaveAttribute('href', 'https://github.com/sponsors/CodesWhat');
await expect(note.locator('a').nth(1)).toHaveAttribute('href', 'https://codeswhat.com/support');
});

for (const width of [390, 320]) {
test(`footer SUPPORT link is a tall target and does not overflow at ${width}px`, async ({ page, open }) => {
await page.setViewportSize({ width, height: 844 });
await open('/');
await page.evaluate(() => document.fonts.ready);
const box = await page.locator('footer a', { hasText: 'SUPPORT' }).evaluate(el => {
const r = el.getBoundingClientRect();
return { w: r.width, h: r.height, right: r.right };
});
expect(box.h).toBeGreaterThanOrEqual(44);
expect(box.right).toBeLessThanOrEqual(width);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
});
}

test('listed controls are at least 44px at 390px', async ({ page, open }) => {
await page.setViewportSize({ width: 390, height: 844 });
await open('/');
Expand Down
2 changes: 1 addition & 1 deletion website/social/render-card.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Renders social/card.html to dist/assets/social-card.png (1200x630).
// Run from website/ after `npm ci`: node social/render-card.mjs
// Run from website/ after `npm ci` and `npx playwright install chromium`: node social/render-card.mjs
import { chromium } from '@playwright/test';
import { fileURLToPath } from 'node:url';

Expand Down
Loading