From ed2360b951e18fe109ba3bfdda6d33dfc445885b Mon Sep 17 00:00:00 2001 From: Pawan Sedara Date: Sun, 19 Jul 2026 22:58:46 +1000 Subject: [PATCH 1/2] feat: add macOS release readiness --- README.md | 13 ++ .../PlainLinkMenu/Sources/PlainLinkMenu.swift | 51 ++++- docs/MENUBAR.md | 8 +- docs/RELEASE.md | 62 ++++++ docs/releases/v0.1.0.md | 16 ++ .../macos/PlainLink.app/Contents/Info.plist | 2 + scripts/build-macos-app.sh | 2 + scripts/generate-macos-icon.sh | 31 +++ scripts/publish-github-release.sh | 47 +++++ scripts/release-macos-app.sh | 74 ++++++++ scripts/test-macos-app.sh | 2 + tests/macos_app_bundle.rs | 32 ++++ tools/macos/render-app-icon.swift | 176 ++++++++++++++++++ 13 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 docs/RELEASE.md create mode 100644 docs/releases/v0.1.0.md create mode 100755 scripts/generate-macos-icon.sh create mode 100755 scripts/publish-github-release.sh create mode 100755 scripts/release-macos-app.sh create mode 100644 tools/macos/render-app-icon.swift diff --git a/README.md b/README.md index 4d3f324..58a9e66 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ PlainLink is early MVP software. - Watches the macOS clipboard with `plainlink watch`. - Cleans the current clipboard once with `plainlink clean-clipboard`. - Provides a native macOS menu bar app built with Apple Command Line Tools. +- Ships menu bar app icon generation and first-run guidance. - Installs PlainLink to a stable user path with `plainlink install`. - Installs PlainLink as a user LaunchAgent with `plainlink agent install`. - Compiles conservative external rule-source subsets with reproducible manifests. @@ -98,6 +99,14 @@ cargo run --bin plainlink-rules -- verify-fixtures cargo run --bin plainlink-rules -- verify-fixtures --rules rules/generated/clearurls.plainlink ``` +To create a signed and notarized macOS release build, configure a Developer ID signing identity and notary profile, then run: + +```sh +scripts/release-macos-app.sh +``` + +See [docs/RELEASE.md](docs/RELEASE.md). + ## Project Layout ```text @@ -120,12 +129,16 @@ docs/ ARCHITECTURE.md System design and data flow RULES.md Rule syntax and contribution guidance RULE_SOURCES.md External source compiler notes + RELEASE.md Signed macOS release process MACOS.md LaunchAgent notes MENUBAR.md Native menu bar app notes scripts/ build-macos-app.sh Build dist/PlainLink.app + generate-macos-icon.sh Generate PlainLink.icns test-macos-app.sh Build and smoke-test the app bundle package-macos-app.sh Create an unsigned zip and checksum + release-macos-app.sh Sign, notarize, staple, and package + publish-github-release.sh Publish a draft GitHub Release ``` ## Contributing diff --git a/app/macos/PlainLinkMenu/Sources/PlainLinkMenu.swift b/app/macos/PlainLinkMenu/Sources/PlainLinkMenu.swift index d058269..63bdfc7 100644 --- a/app/macos/PlainLinkMenu/Sources/PlainLinkMenu.swift +++ b/app/macos/PlainLinkMenu/Sources/PlainLinkMenu.swift @@ -179,6 +179,7 @@ private final class PlainLinkCommand { private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) private let runner = PlainLinkCommand() + private let firstRunKey = "PlainLinkHasSeenFirstRun" private var watcherStatus: WatcherStatus = .unknown("Checking status...") private var isWorking = false private var refreshTimer: Timer? @@ -198,6 +199,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate configureStatusButton() rebuildMenu() refreshStatus() + showFirstRunIfNeeded() refreshTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in self?.refreshStatus() @@ -273,6 +275,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate menu.addItem(makeItem("Open Logs", action: #selector(openLogsFolder))) menu.addItem(NSMenuItem.separator()) + menu.addItem(makeItem("Getting Started", action: #selector(showGettingStarted))) menu.addItem(makeItem("About PlainLink", action: #selector(showAbout))) menu.addItem(makeItem("Quit PlainLink", action: #selector(quit), keyEquivalent: "q")) @@ -438,6 +441,10 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate showAlert(alert) } + @objc private func showGettingStarted() { + showFirstRunGuide() + } + @objc private func quit() { NSApp.terminate(nil) } @@ -501,6 +508,44 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate } } + private func showFirstRunIfNeeded() { + guard !UserDefaults.standard.bool(forKey: firstRunKey) else { + return + } + + UserDefaults.standard.set(true, forKey: firstRunKey) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.showFirstRunGuide() + } + } + + private func showFirstRunGuide() { + let alert = NSAlert() + alert.messageText = "PlainLink lives in your menu bar" + alert.informativeText = [ + "Enable cleaning when you are ready.", + "", + "PlainLink runs locally, watches copied URLs, removes known tracking parameters, and keeps the last original link so you can restore it.", + "", + "You can pause cleaning, run doctor, and copy diagnostics from the menu." + ].joined(separator: "\n") + alert.alertStyle = .informational + + let canEnable = watcherStatus != .running + alert.addButton(withTitle: canEnable ? "Enable Cleaning" : "OK") + + if canEnable { + alert.addButton(withTitle: "Not Now") + } + + let response = runAlert(alert) + + if canEnable && response == .alertFirstButtonReturn { + toggleCleaning() + } + } + private func showCommandResult(title: String, result: CommandResult) { let alert = NSAlert() alert.messageText = result.succeeded ? title : "\(title) failed" @@ -528,8 +573,12 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate } private func showAlert(_ alert: NSAlert) { + _ = runAlert(alert) + } + + private func runAlert(_ alert: NSAlert) -> NSApplication.ModalResponse { NSApp.activate(ignoringOtherApps: true) - alert.runModal() + return alert.runModal() } private func openUserFolder(_ components: [String]) { diff --git a/docs/MENUBAR.md b/docs/MENUBAR.md index be0e4d4..2820054 100644 --- a/docs/MENUBAR.md +++ b/docs/MENUBAR.md @@ -4,6 +4,10 @@ PlainLink includes a native macOS menu bar shell built with Swift and AppKit. It The menu bar app is intentionally thin. The Rust CLI remains the engine for cleaning, LaunchAgent management, restore, and diagnostics. +On first launch, the app shows a short explanation and lets the user enable cleaning from the dialog. The same copy is available later from `Getting Started` in the menu. + +The app icon is generated during `scripts/build-macos-app.sh` by `scripts/generate-macos-icon.sh`. The generated `PlainLink.icns` is bundled into `Contents/Resources` and referenced by `CFBundleIconFile`. + ## Build ```sh @@ -21,6 +25,7 @@ The app bundle contains: ```text Contents/MacOS/PlainLinkMenu Native AppKit status bar app Contents/MacOS/plainlink Release Rust CLI used by the app +Contents/Resources/PlainLink.icns App icon Contents/Info.plist LSUIElement menu bar app metadata ``` @@ -79,6 +84,7 @@ flowchart TB - Restore the last original URL. - Run doctor diagnostics. - Copy diagnostics to the clipboard. +- Show getting-started guidance. - Open support and log folders. - Quit the menu bar app without stopping the LaunchAgent. @@ -89,4 +95,4 @@ flowchart TB - `plainlink install` copies the embedded CLI to the stable user path before starting the watcher. - Pausing uses `plainlink agent uninstall`, which stops the watcher without deleting the installed binary. - Full uninstall remains available through the CLI with `plainlink uninstall`. -- Signing and notarization are intentionally out of scope for the current MVP. +- Signed and notarized release builds are documented in [RELEASE.md](RELEASE.md). diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..e69f307 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,62 @@ +# Release Process + +PlainLink release builds need to be boring and verifiable. Regular users should receive a signed, notarized macOS app zip attached to a GitHub Release. + +## Requirements + +- Apple Developer Program membership. +- A `Developer ID Application` certificate installed in the signing keychain. +- A stored notary profile created with `xcrun notarytool store-credentials`. +- Authenticated GitHub CLI access for publishing the release. + +Apple's notarization flow requires Developer ID signing, hardened runtime, secure timestamps, submission through `notarytool`, and stapling the accepted ticket before distribution. + +## Store Notary Credentials + +Create a keychain profile once on the release machine: + +```sh +xcrun notarytool store-credentials plainlink-notary \ + --apple-id you@example.com \ + --team-id TEAMID12345 \ + --password app-specific-password +``` + +## Build Signed And Notarized App + +Set the Developer ID Application identity exactly as it appears in `security find-identity -v -p codesigning`: + +```sh +export PLAINLINK_DEVELOPER_ID_APPLICATION="Developer ID Application: Example Name (TEAMID12345)" +export PLAINLINK_NOTARY_PROFILE="plainlink-notary" +scripts/release-macos-app.sh +``` + +The script: + +- builds and smoke-tests `dist/PlainLink.app`, +- generates the app icon, +- signs the bundled CLI, menu binary, and app bundle with hardened runtime, +- packages a zip for notarization, +- submits the zip with `xcrun notarytool submit --wait`, +- staples and validates the app, +- verifies Gatekeeper assessment, +- repackages the stapled app and writes a SHA-256 checksum. + +## Publish GitHub Release + +After the signed zip and checksum exist: + +```sh +git tag -a v0.1.0 -m "PlainLink v0.1.0" +git push origin v0.1.0 +scripts/publish-github-release.sh v0.1.0 +``` + +The publish script creates a draft GitHub Release with: + +- `dist/packages/PlainLink-0.1.0-macos-.zip` +- `dist/packages/PlainLink-0.1.0-macos-.zip.sha256` +- release notes from `docs/releases/v0.1.0.md` + +Review the draft release, confirm the checksum, then publish it. diff --git a/docs/releases/v0.1.0.md b/docs/releases/v0.1.0.md new file mode 100644 index 0000000..aadfd16 --- /dev/null +++ b/docs/releases/v0.1.0.md @@ -0,0 +1,16 @@ +# PlainLink v0.1.0 + +PlainLink is an early macOS release for local-first copied-link cleaning. + +## Highlights + +- Native macOS menu bar app. +- User-level LaunchAgent clipboard watcher. +- One-shot clipboard cleaning and restore-last-original support. +- Conservative `.plainlink` rule engine that preserves unknown parameters by default. +- Rule fixtures for safer community contributions. +- External rule-source compiler for conservative ClearURLs-compatible imports. + +## Distribution Notes + +This release should ship only after the macOS app zip has been signed with a Developer ID Application certificate, submitted to Apple's notary service, stapled, and verified with Gatekeeper. diff --git a/packaging/macos/PlainLink.app/Contents/Info.plist b/packaging/macos/PlainLink.app/Contents/Info.plist index 680fde7..f653fd6 100644 --- a/packaging/macos/PlainLink.app/Contents/Info.plist +++ b/packaging/macos/PlainLink.app/Contents/Info.plist @@ -10,6 +10,8 @@ PlainLinkMenu CFBundleIdentifier au.hexcode.plainlink + CFBundleIconFile + PlainLink.icns CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 85bc303..aa0e96f 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -10,6 +10,7 @@ RESOURCES_DIR="$CONTENTS_DIR/Resources" SWIFT_SOURCE="$ROOT_DIR/app/macos/PlainLinkMenu/Sources/PlainLinkMenu.swift" INFO_PLIST="$ROOT_DIR/packaging/macos/PlainLink.app/Contents/Info.plist" SWIFT_MODULE_CACHE="$ROOT_DIR/target/swift-module-cache" +ICON_FILE="$RESOURCES_DIR/PlainLink.icns" if [ "$(uname -s)" != "Darwin" ]; then echo "PlainLink.app can only be built on macOS." >&2 @@ -32,6 +33,7 @@ rm -rf "$APP_DIR" mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" "$SWIFT_MODULE_CACHE" cp "$INFO_PLIST" "$CONTENTS_DIR/Info.plist" +"$ROOT_DIR/scripts/generate-macos-icon.sh" "$ICON_FILE" swiftc -O -framework AppKit -module-cache-path "$SWIFT_MODULE_CACHE" "$SWIFT_SOURCE" -o "$MACOS_DIR/PlainLinkMenu" cp "$ROOT_DIR/target/release/plainlink" "$MACOS_DIR/plainlink" diff --git a/scripts/generate-macos-icon.sh b/scripts/generate-macos-icon.sh new file mode 100755 index 0000000..f63d85a --- /dev/null +++ b/scripts/generate-macos-icon.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +OUTPUT_PATH="${1:-$ROOT_DIR/dist/PlainLink.app/Contents/Resources/PlainLink.icns}" +ICONSET_PARENT="$ROOT_DIR/target/macos-icon" +ICONSET_DIR="$ICONSET_PARENT/PlainLink.iconset" +SWIFT_MODULE_CACHE="$ROOT_DIR/target/swift-module-cache" + +if [ "$(uname -s)" != "Darwin" ]; then + echo "PlainLink.app icons can only be generated on macOS." >&2 + exit 1 +fi + +command -v swift >/dev/null 2>&1 || { + echo "swift is required to generate PlainLink.app icons. Install Apple Command Line Tools." >&2 + exit 1 +} + +command -v iconutil >/dev/null 2>&1 || { + echo "iconutil is required to generate PlainLink.app icons. Install Apple Command Line Tools." >&2 + exit 1 +} + +rm -rf "$ICONSET_DIR" +mkdir -p "$ICONSET_DIR" "$SWIFT_MODULE_CACHE" "$(dirname -- "$OUTPUT_PATH")" + +swift -module-cache-path "$SWIFT_MODULE_CACHE" "$ROOT_DIR/tools/macos/render-app-icon.swift" "$ICONSET_DIR" +iconutil -c icns "$ICONSET_DIR" -o "$OUTPUT_PATH" + +echo "Generated $OUTPUT_PATH" diff --git a/scripts/publish-github-release.sh b/scripts/publish-github-release.sh new file mode 100755 index 0000000..2f5a89c --- /dev/null +++ b/scripts/publish-github-release.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +TAG="${1:-v0.1.0}" +VERSION="${TAG#v}" +APP_NAME="PlainLink" +ARCH=$(uname -m) +PACKAGE_DIR="$ROOT_DIR/dist/packages" +ZIP_PATH="$PACKAGE_DIR/$APP_NAME-$VERSION-macos-$ARCH.zip" +SHA_PATH="$ZIP_PATH.sha256" +NOTES_PATH="$ROOT_DIR/docs/releases/$TAG.md" + +command -v gh >/dev/null 2>&1 || { + echo "gh is required to publish a GitHub Release." >&2 + exit 1 +} + +test -f "$ZIP_PATH" || { + echo "missing release artifact: $ZIP_PATH" >&2 + exit 1 +} + +test -f "$SHA_PATH" || { + echo "missing release checksum: $SHA_PATH" >&2 + exit 1 +} + +test -f "$NOTES_PATH" || { + echo "missing release notes: $NOTES_PATH" >&2 + exit 1 +} + +gh auth status >/dev/null +git diff --quiet +git diff --cached --quiet +git rev-parse "$TAG" >/dev/null + +gh release create "$TAG" \ + "$ZIP_PATH" \ + "$SHA_PATH" \ + --draft \ + --verify-tag \ + --title "PlainLink $TAG" \ + --notes-file "$NOTES_PATH" + +echo "Created draft GitHub Release $TAG" diff --git a/scripts/release-macos-app.sh b/scripts/release-macos-app.sh new file mode 100755 index 0000000..9994ac1 --- /dev/null +++ b/scripts/release-macos-app.sh @@ -0,0 +1,74 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +APP_NAME="PlainLink" +APP_DIR="$ROOT_DIR/dist/$APP_NAME.app" +PACKAGE_DIR="$ROOT_DIR/dist/packages" +VERSION=$(awk -F '=' '$1 ~ /^[[:space:]]*version[[:space:]]*$/ { gsub(/[ "]/, "", $2); print $2; exit }' "$ROOT_DIR/Cargo.toml") +ARCH=$(uname -m) +ARTIFACT_NAME="$APP_NAME-$VERSION-macos-$ARCH.zip" +ZIP_PATH="$PACKAGE_DIR/$ARTIFACT_NAME" +SHA_PATH="$ZIP_PATH.sha256" +SIGNING_IDENTITY="${PLAINLINK_DEVELOPER_ID_APPLICATION:-}" +NOTARY_PROFILE="${PLAINLINK_NOTARY_PROFILE:-plainlink-notary}" + +if [ "$(uname -s)" != "Darwin" ]; then + echo "PlainLink release builds can only be produced on macOS." >&2 + exit 1 +fi + +if [ -z "$VERSION" ]; then + echo "could not read package version from Cargo.toml" >&2 + exit 1 +fi + +if [ -z "$SIGNING_IDENTITY" ]; then + echo "set PLAINLINK_DEVELOPER_ID_APPLICATION to a Developer ID Application signing identity." >&2 + exit 1 +fi + +command -v codesign >/dev/null 2>&1 || { + echo "codesign is required to sign PlainLink.app." >&2 + exit 1 +} + +command -v ditto >/dev/null 2>&1 || { + echo "ditto is required to package PlainLink.app." >&2 + exit 1 +} + +command -v shasum >/dev/null 2>&1 || { + echo "shasum is required to create package checksums." >&2 + exit 1 +} + +xcrun --find notarytool >/dev/null +xcrun --find stapler >/dev/null + +"$ROOT_DIR/scripts/test-macos-app.sh" + +codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$APP_DIR/Contents/MacOS/plainlink" +codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$APP_DIR/Contents/MacOS/PlainLinkMenu" +codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$APP_DIR" +codesign --verify --deep --strict --verbose=2 "$APP_DIR" + +mkdir -p "$PACKAGE_DIR" +rm -f "$ZIP_PATH" "$SHA_PATH" + +cd "$ROOT_DIR/dist" +ditto -c -k --sequesterRsrc --keepParent "$APP_NAME.app" "$ZIP_PATH" + +xcrun notarytool submit "$ZIP_PATH" --keychain-profile "$NOTARY_PROFILE" --wait +xcrun stapler staple "$APP_DIR" +xcrun stapler validate "$APP_DIR" +spctl --assess --type execute --verbose "$APP_DIR" + +rm -f "$ZIP_PATH" "$SHA_PATH" +ditto -c -k --sequesterRsrc --keepParent "$APP_NAME.app" "$ZIP_PATH" + +cd "$PACKAGE_DIR" +shasum -a 256 "$ARTIFACT_NAME" > "$ARTIFACT_NAME.sha256" + +echo "Signed, notarized, and packaged $ZIP_PATH" +echo "Checksum $SHA_PATH" diff --git a/scripts/test-macos-app.sh b/scripts/test-macos-app.sh index c96200e..389a91c 100755 --- a/scripts/test-macos-app.sh +++ b/scripts/test-macos-app.sh @@ -6,6 +6,7 @@ APP_DIR="$ROOT_DIR/dist/PlainLink.app" MENU_BIN="$APP_DIR/Contents/MacOS/PlainLinkMenu" CLI_BIN="$APP_DIR/Contents/MacOS/plainlink" INFO_PLIST="$APP_DIR/Contents/Info.plist" +ICON_FILE="$APP_DIR/Contents/Resources/PlainLink.icns" "$ROOT_DIR/scripts/build-macos-app.sh" @@ -13,6 +14,7 @@ test -d "$APP_DIR" test -x "$MENU_BIN" test -x "$CLI_BIN" test -f "$INFO_PLIST" +test -s "$ICON_FILE" plutil -lint "$INFO_PLIST" >/dev/null "$MENU_BIN" --smoke-test >/dev/null diff --git a/tests/macos_app_bundle.rs b/tests/macos_app_bundle.rs index dd9f1b9..490bda4 100644 --- a/tests/macos_app_bundle.rs +++ b/tests/macos_app_bundle.rs @@ -12,6 +12,17 @@ fn macos_menu_app_assets_are_wired() { fs::read_to_string("scripts/test-macos-app.sh").expect("test script should exist"); let package_script = fs::read_to_string("scripts/package-macos-app.sh").expect("package script should exist"); + let release_script = + fs::read_to_string("scripts/release-macos-app.sh").expect("release script should exist"); + let publish_script = fs::read_to_string("scripts/publish-github-release.sh") + .expect("publish release script should exist"); + let release_docs = fs::read_to_string("docs/RELEASE.md").expect("release docs should exist"); + let release_notes = + fs::read_to_string("docs/releases/v0.1.0.md").expect("v0.1.0 release notes should exist"); + let icon_script = + fs::read_to_string("scripts/generate-macos-icon.sh").expect("icon script should exist"); + let icon_renderer = fs::read_to_string("tools/macos/render-app-icon.swift") + .expect("icon renderer should exist"); for command in [ "install", @@ -30,17 +41,38 @@ fn macos_menu_app_assets_are_wired() { assert!(source.contains("--smoke-test")); assert!(source.contains("PlainLinkIntervalMilliseconds")); + assert!(source.contains("PlainLinkHasSeenFirstRun")); + assert!(source.contains("Getting Started")); + assert!(source.contains("PlainLink lives in your menu bar")); assert!(plist.contains("LSUIElement")); assert!(plist.contains("")); assert!(plist.contains("PlainLinkMenu")); + assert!(plist.contains("CFBundleIconFile")); + assert!(plist.contains("PlainLink.icns")); assert!(build_script.contains("cargo build --release")); + assert!(build_script.contains("scripts/generate-macos-icon.sh")); assert!(build_script.contains("swiftc")); assert!(build_script.contains("-module-cache-path")); assert!(test_script.contains("--smoke-test")); assert!(test_script.contains("plutil -lint")); + assert!(test_script.contains("PlainLink.icns")); assert!(package_script.contains("scripts/test-macos-app.sh")); assert!(package_script.contains("ditto -c -k --sequesterRsrc --keepParent")); assert!(package_script.contains("shasum -a 256")); assert!(package_script.contains("ARTIFACT_NAME=")); assert!(package_script.contains("macos-$ARCH.zip")); + assert!(release_script.contains("PLAINLINK_DEVELOPER_ID_APPLICATION")); + assert!(release_script.contains("--options runtime")); + assert!(release_script.contains("xcrun notarytool submit")); + assert!(release_script.contains("xcrun stapler staple")); + assert!(release_script.contains("spctl --assess")); + assert!(publish_script.contains("gh release create")); + assert!(publish_script.contains("--verify-tag")); + assert!(release_docs.contains("Developer ID Application")); + assert!(release_docs.contains("notarytool store-credentials")); + assert!(release_notes.contains("PlainLink v0.1.0")); + assert!(icon_script.contains("iconutil -c icns")); + assert!(icon_script.contains("render-app-icon.swift")); + assert!(icon_script.contains("-module-cache-path")); + assert!(icon_renderer.contains("func renderIcon")); } diff --git a/tools/macos/render-app-icon.swift b/tools/macos/render-app-icon.swift new file mode 100644 index 0000000..68c1127 --- /dev/null +++ b/tools/macos/render-app-icon.swift @@ -0,0 +1,176 @@ +import AppKit +import Foundation +import ImageIO + +struct IconVariant { + let points: Int + let scale: Int + + var pixels: Int { + points * scale + } + + var filename: String { + scale == 1 ? "icon_\(points)x\(points).png" : "icon_\(points)x\(points)@2x.png" + } +} + +let variants = [ + IconVariant(points: 16, scale: 1), + IconVariant(points: 16, scale: 2), + IconVariant(points: 32, scale: 1), + IconVariant(points: 32, scale: 2), + IconVariant(points: 128, scale: 1), + IconVariant(points: 128, scale: 2), + IconVariant(points: 256, scale: 1), + IconVariant(points: 256, scale: 2), + IconVariant(points: 512, scale: 1), + IconVariant(points: 512, scale: 2) +] + +guard CommandLine.arguments.count == 2 else { + fputs("usage: render-app-icon.swift \n", stderr) + exit(1) +} + +let iconsetURL = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) +try FileManager.default.createDirectory(at: iconsetURL, withIntermediateDirectories: true) + +for variant in variants { + let image = renderIcon(size: CGFloat(variant.pixels)) + let outputURL = iconsetURL.appendingPathComponent(variant.filename) + try writePNG(image: image, to: outputURL) +} + +func renderIcon(size: CGFloat) -> NSImage { + let image = NSImage(size: NSSize(width: size, height: size)) + image.lockFocus() + defer { image.unlockFocus() } + + let rect = NSRect(x: 0, y: 0, width: size, height: size) + NSGraphicsContext.current?.imageInterpolation = .high + NSColor.clear.setFill() + rect.fill() + + let inset = size * 0.055 + let iconRect = rect.insetBy(dx: inset, dy: inset) + let cornerRadius = size * 0.225 + let background = NSBezierPath(roundedRect: iconRect, xRadius: cornerRadius, yRadius: cornerRadius) + + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.24) + shadow.shadowBlurRadius = size * 0.035 + shadow.shadowOffset = NSSize(width: 0, height: -size * 0.018) + shadow.set() + + let gradient = NSGradient(colors: [ + NSColor(calibratedRed: 0.06, green: 0.18, blue: 0.24, alpha: 1), + NSColor(calibratedRed: 0.05, green: 0.45, blue: 0.43, alpha: 1), + NSColor(calibratedRed: 0.70, green: 0.96, blue: 0.77, alpha: 1) + ])! + gradient.draw(in: background, angle: 42) + + NSGraphicsContext.saveGraphicsState() + background.addClip() + drawTopGlow(in: iconRect, size: size) + drawDiagonalCleanBand(in: iconRect, size: size) + NSGraphicsContext.restoreGraphicsState() + + drawLink(in: iconRect, size: size) + drawCheck(in: iconRect, size: size) + + return image +} + +func drawTopGlow(in rect: NSRect, size: CGFloat) { + let glow = NSBezierPath(ovalIn: NSRect( + x: rect.minX - size * 0.10, + y: rect.midY + size * 0.05, + width: size * 0.92, + height: size * 0.58 + )) + NSColor.white.withAlphaComponent(0.16).setFill() + glow.fill() +} + +func drawDiagonalCleanBand(in rect: NSRect, size: CGFloat) { + let band = NSBezierPath() + band.move(to: NSPoint(x: rect.minX + size * 0.08, y: rect.minY + size * 0.20)) + band.line(to: NSPoint(x: rect.maxX - size * 0.10, y: rect.maxY - size * 0.19)) + band.line(to: NSPoint(x: rect.maxX - size * 0.02, y: rect.maxY - size * 0.10)) + band.line(to: NSPoint(x: rect.minX + size * 0.17, y: rect.minY + size * 0.29)) + band.close() + NSColor.white.withAlphaComponent(0.13).setFill() + band.fill() +} + +func drawLink(in rect: NSRect, size: CGFloat) { + let strokeWidth = max(size * 0.072, 1.6) + let linkSize = NSSize(width: size * 0.38, height: size * 0.20) + + drawLinkSegment( + center: NSPoint(x: rect.midX - size * 0.105, y: rect.midY + size * 0.045), + size: linkSize, + angle: -28, + lineWidth: strokeWidth, + color: NSColor.white.withAlphaComponent(0.94) + ) + + drawLinkSegment( + center: NSPoint(x: rect.midX + size * 0.105, y: rect.midY - size * 0.045), + size: linkSize, + angle: -28, + lineWidth: strokeWidth, + color: NSColor(calibratedRed: 0.91, green: 1.00, blue: 0.78, alpha: 0.98) + ) +} + +func drawLinkSegment(center: NSPoint, size: NSSize, angle: CGFloat, lineWidth: CGFloat, color: NSColor) { + let origin = NSPoint(x: center.x - size.width / 2, y: center.y - size.height / 2) + let path = NSBezierPath(roundedRect: NSRect(origin: origin, size: size), xRadius: size.height / 2, yRadius: size.height / 2) + var transform = AffineTransform() + transform.translate(x: center.x, y: center.y) + transform.rotate(byDegrees: angle) + transform.translate(x: -center.x, y: -center.y) + path.transform(using: transform) + + color.setStroke() + path.lineWidth = lineWidth + path.stroke() +} + +func drawCheck(in rect: NSRect, size: CGFloat) { + let check = NSBezierPath() + check.move(to: NSPoint(x: rect.minX + size * 0.60, y: rect.minY + size * 0.30)) + check.line(to: NSPoint(x: rect.minX + size * 0.70, y: rect.minY + size * 0.20)) + check.line(to: NSPoint(x: rect.minX + size * 0.84, y: rect.minY + size * 0.40)) + check.lineCapStyle = .round + check.lineJoinStyle = .round + check.lineWidth = max(size * 0.045, 1.2) + NSColor.white.withAlphaComponent(0.92).setStroke() + check.stroke() +} + +func writePNG(image: NSImage, to url: URL) throws { + var proposedRect = NSRect(origin: .zero, size: image.size) + + guard let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) else { + throw NSError(domain: "PlainLinkIcon", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "could not create CGImage for \(url.lastPathComponent)" + ]) + } + + guard let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else { + throw NSError(domain: "PlainLinkIcon", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "could not create PNG destination for \(url.lastPathComponent)" + ]) + } + + CGImageDestinationAddImage(destination, cgImage, nil) + + if !CGImageDestinationFinalize(destination) { + throw NSError(domain: "PlainLinkIcon", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "could not encode \(url.lastPathComponent)" + ]) + } +} From 3159464b3c68288f59cb767887b171b5f4a56e2c Mon Sep 17 00:00:00 2001 From: Pawan Sedara Date: Mon, 20 Jul 2026 23:38:09 +1000 Subject: [PATCH 2/2] docs: refresh project status --- .github/ISSUE_TEMPLATE/rule_request.md | 2 ++ .github/PULL_REQUEST_TEMPLATE.md | 12 +++++++++++ CONTRIBUTING.md | 12 +++++++++++ README.md | 20 ++++++++++++++--- docs/ARCHITECTURE.md | 22 +++++++++++++++++-- docs/MACOS.md | 17 ++++++++++++--- docs/MENUBAR.md | 6 +++++- docs/RELEASE.md | 30 ++++++++++++++++++++++++-- docs/ROADMAP.md | 20 ++++++++++++----- docs/releases/v0.1.0.md | 8 +++++-- 10 files changed, 131 insertions(+), 18 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/rule_request.md b/.github/ISSUE_TEMPLATE/rule_request.md index 920c27b..b456214 100644 --- a/.github/ISSUE_TEMPLATE/rule_request.md +++ b/.github/ISSUE_TEMPLATE/rule_request.md @@ -11,4 +11,6 @@ labels: rule ## Why The Removed Parameters Are Tracking +## Required Parameters That Must Stay + ## Notes diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a5cd7d4..143242d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,6 +5,9 @@ - [ ] `cargo fmt --check` - [ ] `cargo test` - [ ] `cargo clippy --all-targets -- -D warnings` +- [ ] `cargo run --bin plainlink-rules -- verify-fixtures` if rules or importers changed +- [ ] `scripts/test-macos-app.sh` if macOS app or packaging changed +- [ ] `scripts/package-macos-app.sh` if release packaging changed ## Rule Changes @@ -15,3 +18,12 @@ After: Why this is safe: Fixture added/updated: + +## Release Changes + +Artifact status, if applicable: + +- [ ] unsigned developer preview +- [ ] signed and notarized regular-user build + +Notes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e70ac24..9386c7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,7 @@ cargo fmt --check cargo test cargo clippy --all-targets -- -D warnings cargo run -- inspect 'https://example.com/?utm_source=newsletter&id=42' +cargo run --bin plainlink-rules -- verify-fixtures ``` For macOS menu bar app changes: @@ -18,6 +19,8 @@ scripts/test-macos-app.sh scripts/package-macos-app.sh ``` +`scripts/package-macos-app.sh` creates an unsigned testing zip. Use `scripts/release-macos-app.sh` only on a release machine with a Developer ID Application certificate and a configured notary profile. + ## Rule Contributions Add default rules in [rules/base.plainlink](rules/base.plainlink). Keep them conservative. @@ -67,6 +70,15 @@ External rule importers are welcome, but keep generated third-party output out o - Run `plainlink-rules verify-fixtures` with native rules and again with generated rules merged in. - Document license and attribution requirements in [docs/RULE_SOURCES.md](docs/RULE_SOURCES.md). +## Release Contributions + +Release changes should keep developer-preview and regular-user distribution separate. + +- Unsigned zips are acceptable for testing and CI artifacts. +- Regular-user downloads should be Developer ID-signed, notarized, stapled, and checksummed. +- Do not imply that unsigned preview builds are notarized or Gatekeeper-clean. +- Keep release notes in [docs/releases](docs/releases) aligned with the actual artifact status. + ## Code Contributions - Keep the engine platform-independent. diff --git a/README.md b/README.md index 58a9e66..d72f24a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Clean copied links before you share them. -PlainLink is a local-first URL cleaner. The MVP is a Rust command line tool with a portable cleaning engine, a macOS clipboard watcher, and a native macOS menu bar app. The project goal is to become a community-maintained, ad-blocker-list-style ruleset for removing tracking parameters from copied URLs at the system clipboard level. +PlainLink is a local-first copied-link cleaner for macOS. It ships as a Rust CLI, a user-level clipboard watcher, and a native Swift/AppKit menu bar app. The project goal is to become a community-maintained, ad-blocker-list-style ruleset for removing tracking parameters from copied URLs at the system clipboard level. ```mermaid flowchart LR @@ -16,9 +16,9 @@ flowchart LR Cleaned --> Clipboard ``` -## Status +## Current Status -PlainLink is early MVP software. +PlainLink is functional developer-preview software. It is ready for technical testers who are comfortable with source builds or unsigned macOS apps, but it is not yet a regular-user notarized release. - Cleans URLs from the CLI with `plainlink clean`. - Explains removed parameters with `plainlink inspect`. @@ -31,6 +31,8 @@ PlainLink is early MVP software. - Installs PlainLink as a user LaunchAgent with `plainlink agent install`. - Compiles conservative external rule-source subsets with reproducible manifests. - Verifies native and imported rule behavior with `plainlink-rules verify-fixtures`. +- Builds unsigned macOS zip packages for testing and CI artifacts. +- Has signed/notarized release automation ready, but no Developer ID certificate is configured. - Validates community rule behavior with fixture-backed tests. - Uses conservative rules that preserve unknown parameters by default. @@ -82,6 +84,8 @@ scripts/package-macos-app.sh This creates `dist/packages/PlainLink--macos-.zip` and a `.sha256` checksum. +This zip is for developer-preview testing. macOS Gatekeeper will warn because the app is not signed with a Developer ID certificate or notarized by Apple. + To compile a safe subset from an external source and write a manifest: ```sh @@ -107,6 +111,16 @@ scripts/release-macos-app.sh See [docs/RELEASE.md](docs/RELEASE.md). +## Distribution + +Current recommended distribution path: + +- Technical testers: build from source or use an explicitly unsigned preview zip. +- Regular users: wait for a Developer ID-signed and notarized release. +- GitHub Release: publish only when the artifact is clearly labeled as unsigned preview, or when the signed/notarized release script has produced the final zip. + +Developer ID signing and notarization require Apple Developer Program membership. PlainLink does not currently assume that cost is worth paying before there is enough tester demand. + ## Project Layout ```text diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 84b2b10..90b63f2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,11 +1,13 @@ # PlainLink Architecture -PlainLink has two jobs: detect copied URLs and clean them without breaking useful links. The MVP keeps those jobs separate so contributors can improve rules and engine behavior without touching macOS UI or clipboard code. +PlainLink has two jobs: detect copied URLs and clean them without breaking useful links. The current design keeps platform, core engine, community rules, and release packaging separate so contributors can improve one area without touching the others. ```mermaid flowchart TB subgraph Platform["Platform Adapter"] Menu["macOS menu bar app"] + Icon["Generated app icon"] + FirstRun["First-run guide"] Install["Stable installer"] Agent["LaunchAgent manager"] Watch["Clipboard watcher"] @@ -32,6 +34,15 @@ flowchart TB Tests["cargo test"] end + subgraph Release["Release Packaging"] + Unsigned["Unsigned tester zip"] + Signed["Developer ID signed zip"] + Notary["Apple notarization"] + GitHub["GitHub Release"] + end + + Menu --> Icon + Menu --> FirstRun Menu --> Install Menu --> Agent Menu --> Restore @@ -53,6 +64,10 @@ flowchart TB Fixtures --> Gate Gate --> Tests Tests --> Core + Menu --> Unsigned + Unsigned --> Signed + Signed --> Notary + Notary --> GitHub ``` ## Data Flow @@ -82,6 +97,7 @@ sequenceDiagram - The Rust core owns URL cleaning, rule parsing, and tests. - The menu bar app owns user-facing controls and shells out to the CLI. +- The menu bar app includes generated icon assets and first-run guidance. - The macOS adapter only reads and writes clipboard text. - Unknown parameters are kept by default. - The original URL is stored before PlainLink rewrites the clipboard. @@ -92,8 +108,10 @@ sequenceDiagram - Import manifests record upstream revision, input hash, output hash, and skip-reason counts. - Generated rules must pass the fixture verifier before they are considered safe to ship. - Community rule examples live as fixtures and run through `cargo test`. +- Unsigned app zips are for technical testers and CI artifacts. +- Regular-user macOS releases require Developer ID signing, notarization, stapling, and checksum publication. - Root is not required; clipboard access belongs to the logged-in user session. -- The MVP uses `pbpaste` and `pbcopy` for a small macOS adapter. +- The current macOS watcher uses `pbpaste` and `pbcopy` for a small adapter. ## Future Shape diff --git a/docs/MACOS.md b/docs/MACOS.md index 0620ce6..3817cab 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -2,6 +2,12 @@ PlainLink does not need to run as root. The clipboard belongs to the logged-in user session, so the right macOS shape is a user-level process or LaunchAgent. +PlainLink currently supports macOS as a developer-preview app: + +- the CLI, clipboard watcher, LaunchAgent install flow, menu bar app, first-run guide, and app icon are implemented, +- unsigned zip packaging is available for technical testers and CI artifacts, +- signed and notarized release automation exists, but it requires a Developer ID certificate and notary credentials. + ## Menu Bar App Build and smoke-test the native menu bar app: @@ -16,7 +22,7 @@ The generated app lives at: dist/PlainLink.app ``` -Create an unsigned zip and checksum for testing: +Create an unsigned zip and checksum for technical testing: ```sh scripts/package-macos-app.sh @@ -28,6 +34,10 @@ Packages are written to: dist/packages/ ``` +The unsigned zip is not a regular-user release. Gatekeeper will warn when a user opens it. + +For a signed and notarized release build, see [RELEASE.md](RELEASE.md). + The app is built with Swift/AppKit and Apple Command Line Tools. It embeds the release Rust CLI, then uses that CLI for all product actions: ```mermaid @@ -56,7 +66,7 @@ dist/PlainLink.app/Contents/MacOS/PlainLinkMenu --smoke-test cargo run -- watch --interval-ms 500 ``` -The MVP uses macOS `pbpaste` and `pbcopy` from a Rust watcher loop: +The watcher uses macOS `pbpaste` and `pbcopy` from a Rust loop: ```mermaid flowchart LR @@ -133,6 +143,7 @@ The menu bar app provides: - restore-last-original, - doctor diagnostics, - diagnostics copy, +- first-run and getting-started guidance, - support and log folder shortcuts. -Signing and notarization are not part of this MVP. See [MENUBAR.md](MENUBAR.md) for app internals. +See [MENUBAR.md](MENUBAR.md) for app internals and [RELEASE.md](RELEASE.md) for signed/notarized distribution. diff --git a/docs/MENUBAR.md b/docs/MENUBAR.md index 2820054..c414141 100644 --- a/docs/MENUBAR.md +++ b/docs/MENUBAR.md @@ -50,7 +50,7 @@ dist/packages/PlainLink--macos-.zip dist/packages/PlainLink--macos-.zip.sha256 ``` -The zip is unsigned and not notarized. It is meant for MVP testing and GitHub Actions artifacts. +The zip is unsigned and not notarized. It is meant for technical testers and GitHub Actions artifacts, not regular-user distribution. Verify a downloaded package: @@ -59,6 +59,8 @@ cd dist/packages shasum -a 256 -c PlainLink--macos-.zip.sha256 ``` +Signed and notarized release builds are produced by `scripts/release-macos-app.sh` on a machine with Developer ID credentials. See [RELEASE.md](RELEASE.md). + ## Runtime Flow ```mermaid @@ -91,6 +93,8 @@ flowchart TB ## Design Notes - The app is a user-level status bar app with `LSUIElement`. +- The first-run dialog is intentionally short and can enable cleaning directly. +- The app icon is generated from reviewable Swift drawing code instead of a checked-in binary source asset. - The app shells out to the embedded `plainlink` binary instead of duplicating core logic. - `plainlink install` copies the embedded CLI to the stable user path before starting the watcher. - Pausing uses `plainlink agent uninstall`, which stops the watcher without deleting the installed binary. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index e69f307..fd6cff2 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -2,8 +2,34 @@ PlainLink release builds need to be boring and verifiable. Regular users should receive a signed, notarized macOS app zip attached to a GitHub Release. +Current status: release automation exists, but this repository does not currently have Developer ID credentials configured. Until that changes, any downloadable app artifact should be described as an unsigned developer preview. + +## Release Modes + +### Unsigned Preview + +Use this for technical testers: + +```sh +scripts/package-macos-app.sh +``` + +This produces an unsigned zip and checksum in `dist/packages/`. macOS Gatekeeper will warn users because the app is not signed with a Developer ID certificate and is not notarized. + +### Signed Regular-User Release + +Use this only when a release machine has Apple Developer Program credentials: + +```sh +scripts/release-macos-app.sh +``` + +This produces the signed, notarized, stapled zip that is suitable for regular users. + ## Requirements +Signed releases require: + - Apple Developer Program membership. - A `Developer ID Application` certificate installed in the signing keychain. - A stored notary profile created with `xcrun notarytool store-credentials`. @@ -45,7 +71,7 @@ The script: ## Publish GitHub Release -After the signed zip and checksum exist: +After the signed zip and checksum exist, create and push the tag: ```sh git tag -a v0.1.0 -m "PlainLink v0.1.0" @@ -59,4 +85,4 @@ The publish script creates a draft GitHub Release with: - `dist/packages/PlainLink-0.1.0-macos-.zip.sha256` - release notes from `docs/releases/v0.1.0.md` -Review the draft release, confirm the checksum, then publish it. +Review the draft release, confirm the checksum, confirm whether the artifact is signed/notarized or explicitly labeled unsigned preview, then publish it. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4089191..735859e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,27 +1,37 @@ # Roadmap -## MVP +## Done - Rust URL cleaning core. - CLI commands for cleaning and inspection. - macOS clipboard watcher. - Native macOS menu bar app. +- App icon generation. +- First-run menu bar onboarding. - Restore last original URL. - User LaunchAgent install/status/restart/uninstall. - Stable user install/uninstall and doctor checks. -- Unsigned macOS release packaging. +- Unsigned macOS release packaging for testers and CI artifacts. +- Signed/notarized release automation. - Conservative ClearURLs rule importer. +- Import manifests and fixture verification for generated rules. - Human-readable default rules. - Fixture-backed rule QA. - Contributor docs with Mermaid diagrams. +## Current Distribution Status + +- Technical testers can build from source or use unsigned preview artifacts. +- Regular-user distribution is blocked on Apple Developer Program membership, a Developer ID Application certificate, and notarization credentials. +- A GitHub Release should be published only when the artifact is clearly labeled as an unsigned preview or when the signed/notarized zip exists. + ## Next -- Add signed release builds. -- Publish downloadable app artifacts from GitHub Actions. -- Add clearer first-run onboarding inside the menu bar app. +- Collect tester signal before paying for Developer ID distribution. +- Improve the menu bar app's visible status and failure states. - Expand external source importers for EasyList/uBO removeparam and Firefox query stripping. - Add optional ruleset updates with clear privacy and licensing controls. +- Add release provenance notes for any generated external rules included in an artifact. ## Later diff --git a/docs/releases/v0.1.0.md b/docs/releases/v0.1.0.md index aadfd16..d398189 100644 --- a/docs/releases/v0.1.0.md +++ b/docs/releases/v0.1.0.md @@ -1,16 +1,20 @@ # PlainLink v0.1.0 -PlainLink is an early macOS release for local-first copied-link cleaning. +PlainLink v0.1.0 is a developer-preview macOS release for local-first copied-link cleaning. ## Highlights - Native macOS menu bar app. +- App icon and first-run getting-started flow. - User-level LaunchAgent clipboard watcher. - One-shot clipboard cleaning and restore-last-original support. - Conservative `.plainlink` rule engine that preserves unknown parameters by default. - Rule fixtures for safer community contributions. - External rule-source compiler for conservative ClearURLs-compatible imports. +- Import manifests and fixture verification for generated rule files. ## Distribution Notes -This release should ship only after the macOS app zip has been signed with a Developer ID Application certificate, submitted to Apple's notary service, stapled, and verified with Gatekeeper. +Current preview builds are unsigned unless produced by `scripts/release-macos-app.sh` on a machine with Developer ID credentials. If an unsigned zip is attached to this release, label it as a developer preview and expect macOS Gatekeeper warnings. + +A regular-user v0.1.0 release should ship only after the macOS app zip has been signed with a Developer ID Application certificate, submitted to Apple's notary service, stapled, and verified with Gatekeeper.