diff --git a/.gitignore b/.gitignore index 15bd289..cc9f718 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target/ +/dist/ /.DS_Store *.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2701f70..79ef483 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,12 @@ cargo clippy --all-targets -- -D warnings cargo run -- inspect 'https://example.com/?utm_source=newsletter&id=42' ``` +For macOS menu bar app changes: + +```sh +scripts/test-macos-app.sh +``` + ## Rule Contributions Add default rules in [rules/base.plainlink](rules/base.plainlink). Keep them conservative. @@ -52,6 +58,8 @@ PlainLink preserves unknown parameters by default because invite links, signed d - Keep the engine platform-independent. - Put OS-specific clipboard behavior behind small adapters. +- Keep the macOS menu bar app thin; it should call the CLI instead of duplicating core logic. - Prefer explicit tests over clever matching. - Add or update fixtures for rule behavior changes. +- Add or update app bundle smoke tests when changing menu app packaging. - Do not add network behavior without a privacy-focused design note. diff --git a/README.md b/README.md index be0fad3..50b52c2 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ 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 and a macOS clipboard watcher. The project goal is to become a community-maintained, ad-blocker-list-style ruleset for removing tracking parameters from copied URLs. +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. ```mermaid flowchart LR Copy["User copies a URL"] --> Clipboard["macOS clipboard"] - Clipboard --> Watcher["plainlink watch"] + Menu["PlainLink menu bar app"] --> Watcher["plainlink watch"] + Clipboard --> Watcher Watcher --> Engine["plainlink-core"] Engine --> Rules["rules/base.plainlink"] Rules --> Engine @@ -23,6 +24,8 @@ PlainLink is early MVP software. - Explains removed parameters with `plainlink inspect`. - Restores the last cleaned original URL with `plainlink restore`. - 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. - Installs PlainLink to a stable user path with `plainlink install`. - Installs PlainLink as a user LaunchAgent with `plainlink agent install`. - Validates community rule behavior with fixture-backed tests. @@ -34,6 +37,7 @@ PlainLink is early MVP software. cargo test cargo run -- clean 'https://youtu.be/LYa_ReqRlcs?si=VC4qVB_EUC90uwbo' cargo run -- inspect 'https://example.com/read?utm_source=newsletter&id=42' +cargo run -- clean-clipboard cargo run -- restore cargo run -- doctor cargo run -- agent status @@ -57,9 +61,19 @@ To install PlainLink to a stable user path and start the watcher: cargo run -- install --interval-ms 500 ``` +To build and smoke-test the native macOS menu bar app: + +```sh +scripts/test-macos-app.sh +``` + +This creates `dist/PlainLink.app`. + ## Project Layout ```text +app/ + macos/PlainLinkMenu Swift/AppKit menu bar app src/ agent.rs macOS LaunchAgent management cleaner.rs URL cleaning engine @@ -76,6 +90,10 @@ docs/ ARCHITECTURE.md System design and data flow RULES.md Rule syntax and contribution guidance MACOS.md LaunchAgent notes + MENUBAR.md Native menu bar app notes +scripts/ + build-macos-app.sh Build dist/PlainLink.app + test-macos-app.sh Build and smoke-test the app bundle ``` ## Contributing diff --git a/app/macos/PlainLinkMenu/Sources/PlainLinkMenu.swift b/app/macos/PlainLinkMenu/Sources/PlainLinkMenu.swift new file mode 100644 index 0000000..d058269 --- /dev/null +++ b/app/macos/PlainLinkMenu/Sources/PlainLinkMenu.swift @@ -0,0 +1,559 @@ +import AppKit +import Darwin +import Foundation + +private enum WatcherStatus: Equatable { + case running + case installed + case notInstalled + case unknown(String) + + var title: String { + switch self { + case .running: + return "Cleaning is on" + case .installed: + return "Installed, not running" + case .notInstalled: + return "Cleaning is off" + case .unknown: + return "Status unavailable" + } + } + + var detail: String { + switch self { + case .running: + return "PlainLink is watching your clipboard." + case .installed: + return "LaunchAgent exists but is not loaded." + case .notInstalled: + return "Enable cleaning to start the watcher." + case .unknown(let message): + return message + } + } + + var symbolName: String { + switch self { + case .running: + return "link.circle.fill" + case .installed: + return "pause.circle" + case .notInstalled: + return "link.circle" + case .unknown: + return "exclamationmark.circle" + } + } + + static func parse(_ output: String) -> WatcherStatus { + let lowercased = output.lowercased() + + if lowercased.contains("is running") { + return .running + } + + if lowercased.contains("installed but not loaded") { + return .installed + } + + if lowercased.contains("not installed") { + return .notInstalled + } + + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + return .unknown(trimmed.isEmpty ? "No status output." : trimmed) + } +} + +private struct CommandResult { + let exitCode: Int32 + let stdout: String + let stderr: String + + var succeeded: Bool { + exitCode == 0 + } + + var combinedOutput: String { + let parts = [stdout, stderr] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + return parts.isEmpty ? "(no output)" : parts.joined(separator: "\n\n") + } +} + +private final class PlainLinkCommand { + private enum Executable { + case direct(URL) + case pathLookup + } + + private let executable: Executable + + init(bundle: Bundle = .main, environment: [String: String] = ProcessInfo.processInfo.environment) { + executable = Self.resolveExecutable(bundle: bundle, environment: environment) + } + + var displayPath: String { + switch executable { + case .direct(let url): + return url.path + case .pathLookup: + return "plainlink from PATH" + } + } + + func run(_ arguments: [String]) throws -> CommandResult { + let process = Process() + + switch executable { + case .direct(let url): + process.executableURL = url + process.arguments = arguments + case .pathLookup: + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["plainlink"] + arguments + } + + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = [ + environment["PATH"], + "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ] + .compactMap { $0 } + .joined(separator: ":") + process.environment = environment + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + process.waitUntilExit() + + return CommandResult( + exitCode: process.terminationStatus, + stdout: String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", + stderr: String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + ) + } + + private static func resolveExecutable( + bundle: Bundle, + environment: [String: String] + ) -> Executable { + let fileManager = FileManager.default + + if let override = environment["PLAINLINK_BIN"], fileManager.isExecutableFile(atPath: override) { + return .direct(URL(fileURLWithPath: override)) + } + + if let bundled = bundle.executableURL? + .deletingLastPathComponent() + .appendingPathComponent("plainlink"), + fileManager.isExecutableFile(atPath: bundled.path) { + return .direct(bundled) + } + + if let home = environment["HOME"] { + let installed = URL(fileURLWithPath: home) + .appendingPathComponent("Library") + .appendingPathComponent("Application Support") + .appendingPathComponent("PlainLink") + .appendingPathComponent("bin") + .appendingPathComponent("plainlink") + + if fileManager.isExecutableFile(atPath: installed.path) { + return .direct(installed) + } + } + + return .pathLookup + } +} + +private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { + private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + private let runner = PlainLinkCommand() + private var watcherStatus: WatcherStatus = .unknown("Checking status...") + private var isWorking = false + private var refreshTimer: Timer? + + private var selectedInterval: Int { + get { + let stored = UserDefaults.standard.integer(forKey: "PlainLinkIntervalMilliseconds") + return stored == 0 ? 500 : stored + } + set { + UserDefaults.standard.set(newValue, forKey: "PlainLinkIntervalMilliseconds") + } + } + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + configureStatusButton() + rebuildMenu() + refreshStatus() + + refreshTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in + self?.refreshStatus() + } + } + + func applicationWillTerminate(_ notification: Notification) { + refreshTimer?.invalidate() + } + + func menuNeedsUpdate(_ menu: NSMenu) { + refreshStatus() + } + + private func configureStatusButton() { + guard let button = statusItem.button else { + return + } + + button.toolTip = "PlainLink" + updateStatusButton() + } + + private func updateStatusButton() { + guard let button = statusItem.button else { + return + } + + if #available(macOS 11.0, *), + let image = NSImage(systemSymbolName: watcherStatus.symbolName, accessibilityDescription: "PlainLink") { + image.isTemplate = true + button.image = image + button.title = "" + } else { + button.image = nil + button.title = "PL" + } + } + + private func rebuildMenu() { + let menu = NSMenu(title: "PlainLink") + menu.delegate = self + + menu.addItem(makeHeaderItem()) + menu.addItem(NSMenuItem.separator()) + + let status = NSMenuItem(title: watcherStatus.title, action: nil, keyEquivalent: "") + status.isEnabled = false + menu.addItem(status) + + let toggleTitle: String + switch watcherStatus { + case .running: + toggleTitle = "Pause Cleaning" + case .installed: + toggleTitle = "Start Cleaning" + case .notInstalled, .unknown: + toggleTitle = "Enable Cleaning" + } + + menu.addItem(makeItem(toggleTitle, action: #selector(toggleCleaning), isEnabled: !isWorking)) + menu.addItem(makeItem("Restart Watcher", action: #selector(restartWatcher), isEnabled: !isWorking && canRestartWatcher)) + menu.addItem(NSMenuItem.separator()) + + menu.addItem(makeItem("Clean Current Clipboard", action: #selector(cleanCurrentClipboard), isEnabled: !isWorking)) + menu.addItem(makeItem("Restore Last Original", action: #selector(restoreLastOriginal), isEnabled: !isWorking)) + menu.addItem(makeIntervalMenu()) + menu.addItem(NSMenuItem.separator()) + + menu.addItem(makeItem("Run Doctor", action: #selector(runDoctor), isEnabled: !isWorking)) + menu.addItem(makeItem("Copy Diagnostics", action: #selector(copyDiagnostics), isEnabled: !isWorking)) + menu.addItem(makeItem("Open Support Folder", action: #selector(openSupportFolder))) + menu.addItem(makeItem("Open Logs", action: #selector(openLogsFolder))) + menu.addItem(NSMenuItem.separator()) + + menu.addItem(makeItem("About PlainLink", action: #selector(showAbout))) + menu.addItem(makeItem("Quit PlainLink", action: #selector(quit), keyEquivalent: "q")) + + statusItem.menu = menu + } + + private func makeHeaderItem() -> NSMenuItem { + let item = NSMenuItem() + let container = NSStackView() + container.orientation = .vertical + container.spacing = 3 + container.edgeInsets = NSEdgeInsets(top: 9, left: 14, bottom: 9, right: 14) + container.frame = NSRect(x: 0, y: 0, width: 280, height: 58) + + let title = NSTextField(labelWithString: "PlainLink") + title.font = NSFont.boldSystemFont(ofSize: 14) + title.textColor = .labelColor + + let detail = NSTextField(labelWithString: watcherStatus.detail) + detail.font = NSFont.systemFont(ofSize: 11) + detail.textColor = .secondaryLabelColor + detail.lineBreakMode = .byTruncatingTail + + container.addArrangedSubview(title) + container.addArrangedSubview(detail) + item.view = container + + return item + } + + private func makeItem( + _ title: String, + action: Selector?, + keyEquivalent: String = "", + isEnabled: Bool = true + ) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) + item.target = self + item.isEnabled = isEnabled + return item + } + + private func makeIntervalMenu() -> NSMenuItem { + let parent = NSMenuItem(title: "Watcher Interval", action: nil, keyEquivalent: "") + let submenu = NSMenu(title: "Watcher Interval") + + for interval in [250, 500, 1000, 2000] { + let item = NSMenuItem( + title: "\(interval) ms", + action: #selector(setInterval(_:)), + keyEquivalent: "" + ) + item.target = self + item.representedObject = interval + item.state = selectedInterval == interval ? .on : .off + submenu.addItem(item) + } + + parent.submenu = submenu + return parent + } + + private var canRestartWatcher: Bool { + watcherStatus == .running || watcherStatus == .installed + } + + private func refreshStatus() { + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let self else { + return + } + + let nextStatus: WatcherStatus + do { + let result = try self.runner.run(["agent", "status"]) + nextStatus = result.succeeded ? .parse(result.combinedOutput) : .unknown(result.combinedOutput) + } catch { + nextStatus = .unknown(error.localizedDescription) + } + + DispatchQueue.main.async { + guard self.watcherStatus != nextStatus else { + return + } + + self.watcherStatus = nextStatus + self.updateStatusButton() + self.rebuildMenu() + } + } + } + + @objc private func toggleCleaning() { + switch watcherStatus { + case .running: + runCommand(title: "Pause Cleaning", arguments: ["agent", "uninstall"]) { [weak self] result in + self?.showResultIfFailed(title: "Pause Cleaning", result: result) + } + case .installed: + runCommand(title: "Start Cleaning", arguments: ["agent", "restart"]) { [weak self] result in + self?.showResultIfFailed(title: "Start Cleaning", result: result) + } + case .notInstalled, .unknown: + runCommand( + title: "Enable Cleaning", + arguments: ["install", "--interval-ms", "\(selectedInterval)"] + ) { [weak self] result in + self?.showResultIfFailed(title: "Enable Cleaning", result: result) + } + } + } + + @objc private func restartWatcher() { + runCommand(title: "Restart Watcher", arguments: ["agent", "restart"]) { [weak self] result in + self?.showResultIfFailed(title: "Restart Watcher", result: result) + } + } + + @objc private func cleanCurrentClipboard() { + runCommand(title: "Clean Current Clipboard", arguments: ["clean-clipboard"]) { [weak self] result in + self?.showCommandResult(title: "Clipboard", result: result) + } + } + + @objc private func restoreLastOriginal() { + runCommand(title: "Restore Last Original", arguments: ["restore"]) { [weak self] result in + self?.showCommandResult(title: "Restore", result: result) + } + } + + @objc private func runDoctor() { + runCommand(title: "PlainLink Doctor", arguments: ["doctor"]) { [weak self] result in + self?.showCommandResult(title: "PlainLink Doctor", result: result) + } + } + + @objc private func copyDiagnostics() { + runCommand(title: "Copy Diagnostics", arguments: ["doctor"]) { result in + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(result.combinedOutput, forType: .string) + } + } + + @objc private func openSupportFolder() { + openUserFolder(["Library", "Application Support", "PlainLink"]) + } + + @objc private func openLogsFolder() { + openUserFolder(["Library", "Logs", "PlainLink"]) + } + + @objc private func showAbout() { + let alert = NSAlert() + alert.messageText = "PlainLink" + alert.informativeText = [ + "System-level copied-link cleaning for macOS.", + "", + "Engine: \(runner.displayPath)", + "Cleaning interval: \(selectedInterval) ms" + ].joined(separator: "\n") + alert.addButton(withTitle: "OK") + showAlert(alert) + } + + @objc private func quit() { + NSApp.terminate(nil) + } + + @objc private func setInterval(_ sender: NSMenuItem) { + guard let interval = sender.representedObject as? Int else { + return + } + + selectedInterval = interval + + if watcherStatus == .running { + runCommand( + title: "Update Interval", + arguments: ["install", "--interval-ms", "\(interval)"] + ) { [weak self] result in + self?.showResultIfFailed(title: "Update Interval", result: result) + } + } else { + rebuildMenu() + } + } + + private func runCommand( + title: String, + arguments: [String], + completion: @escaping (CommandResult) -> Void + ) { + guard !isWorking else { + return + } + + isWorking = true + rebuildMenu() + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let result: CommandResult + + do { + result = try self?.runner.run(arguments) ?? CommandResult( + exitCode: 1, + stdout: "", + stderr: "PlainLink menu controller disappeared." + ) + } catch { + result = CommandResult(exitCode: 1, stdout: "", stderr: error.localizedDescription) + } + + DispatchQueue.main.async { + self?.isWorking = false + completion(result) + self?.refreshStatus() + self?.rebuildMenu() + } + } + } + + private func showResultIfFailed(title: String, result: CommandResult) { + if !result.succeeded { + showCommandResult(title: title, result: result) + } + } + + private func showCommandResult(title: String, result: CommandResult) { + let alert = NSAlert() + alert.messageText = result.succeeded ? title : "\(title) failed" + alert.informativeText = result.succeeded ? "" : "Exit code \(result.exitCode)" + alert.alertStyle = result.succeeded ? .informational : .warning + alert.accessoryView = makeOutputView(result.combinedOutput) + alert.addButton(withTitle: "OK") + showAlert(alert) + } + + private func makeOutputView(_ output: String) -> NSView { + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 520, height: 240)) + scrollView.hasVerticalScroller = true + scrollView.borderType = .bezelBorder + + let textView = NSTextView(frame: scrollView.bounds) + textView.isEditable = false + textView.isSelectable = true + textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + textView.string = output + textView.textContainerInset = NSSize(width: 8, height: 8) + + scrollView.documentView = textView + return scrollView + } + + private func showAlert(_ alert: NSAlert) { + NSApp.activate(ignoringOtherApps: true) + alert.runModal() + } + + private func openUserFolder(_ components: [String]) { + guard let home = ProcessInfo.processInfo.environment["HOME"] else { + return + } + + let url = components.reduce(URL(fileURLWithPath: home)) { partial, component in + partial.appendingPathComponent(component) + } + + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + NSWorkspace.shared.open(url) + } +} + +if CommandLine.arguments.contains("--smoke-test") { + let runner = PlainLinkCommand() + print("PlainLinkMenu smoke OK") + print("plainlink: \(runner.displayPath)") + exit(0) +} + +let application = NSApplication.shared +private let delegate = AppDelegate() +application.delegate = delegate +application.run() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4e5297d..6700a89 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,10 +1,11 @@ # 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 clipboard code. +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. ```mermaid flowchart TB subgraph Platform["Platform Adapter"] + Menu["macOS menu bar app"] Install["Stable installer"] Agent["LaunchAgent manager"] Watch["Clipboard watcher"] @@ -26,11 +27,18 @@ flowchart TB Tests["Fixture-backed tests"] end + Menu --> Install + Menu --> Agent + Menu --> Restore + Menu --> Doctor["Doctor checks"] + Menu --> OneShot["Clean current clipboard"] Install --> Agent Agent --> Watch Watch --> Read --> Parse --> Match --> Rebuild --> State --> Write + OneShot --> Read Restore --> State Restore --> Write + Doctor --> Agent Base --> Match Fixtures --> Tests Tests --> Core @@ -41,11 +49,14 @@ flowchart TB ```mermaid sequenceDiagram participant User + participant Menu as PlainLink.app participant Clipboard participant Watcher as plainlink watch participant Core as URL cleaner participant Rules as RuleSet + User->>Menu: Enable cleaning + Menu->>Watcher: Install/start LaunchAgent User->>Clipboard: Copy tracked URL Watcher->>Clipboard: Poll text Watcher->>Core: clean_url(text, rules) @@ -59,14 +70,16 @@ sequenceDiagram ## Design Choices - 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 macOS adapter only reads and writes clipboard text. - Unknown parameters are kept by default. - The original URL is stored before PlainLink rewrites the clipboard. - The stable installer copies the binary before pointing LaunchAgent at it. - LaunchAgent commands install and control the user-level watcher process. +- System-level clipboard cleaning is the product surface; browser extensions are not required for the core app. - Community rule examples live as fixtures and run through `cargo test`. - Root is not required; clipboard access belongs to the logged-in user session. -- The MVP uses `pbpaste` and `pbcopy` for a small macOS adapter. A future native menu bar app can reuse the same core. +- The MVP uses `pbpaste` and `pbcopy` for a small macOS adapter. ## Future Shape @@ -76,5 +89,7 @@ flowchart LR Core --> Mac["macOS menu bar app"] Core --> Win["Windows tray app"] Core --> Linux["Linux tray app"] - Core --> WASM["Browser/WASM experiments"] + Mac --> Agent["macOS LaunchAgent"] + Win --> WinWatch["Windows clipboard watcher"] + Linux --> LinuxWatch["Linux clipboard watcher"] ``` diff --git a/docs/MACOS.md b/docs/MACOS.md index fca812b..739a16d 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -2,6 +2,42 @@ 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. +## Menu Bar App + +Build and smoke-test the native menu bar app: + +```sh +scripts/test-macos-app.sh +``` + +The generated app lives at: + +```text +dist/PlainLink.app +``` + +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 +flowchart LR + App["PlainLink.app"] --> CLI["Embedded plainlink"] + CLI --> Agent["LaunchAgent"] + CLI --> Doctor["Doctor"] + CLI --> Clipboard["Clean / restore clipboard"] +``` + +Run it from Finder after building, or from the terminal during development: + +```sh +dist/PlainLink.app/Contents/MacOS/PlainLinkMenu +``` + +The app has a smoke-test mode that does not open the UI: + +```sh +dist/PlainLink.app/Contents/MacOS/PlainLinkMenu --smoke-test +``` + ## Run Manually ```sh @@ -24,7 +60,7 @@ flowchart LR ## Restore -When `plainlink watch` cleans a URL, it stores the original at: +When `plainlink watch` or `plainlink clean-clipboard` cleans a URL, it stores the original at: ```text ~/Library/Application Support/PlainLink/last-cleaned.json @@ -36,6 +72,12 @@ Restore the last original URL to the clipboard: cargo run -- restore ``` +Clean the current clipboard once: + +```sh +cargo run -- clean-clipboard +``` + ## LaunchAgent Example Build the binary, then install the watcher as a user LaunchAgent: @@ -67,4 +109,18 @@ PLAINLINK_BIN="$HOME/Library/Application Support/PlainLink/bin/plainlink" "$PLAINLINK_BIN" uninstall ``` -The generated plist is based on [packaging/macos/com.plainlink.agent.example.plist](../packaging/macos/com.plainlink.agent.example.plist). For lower-level control, use `plainlink agent help`. For a real release, the app should provide a menu bar shell with an enable/disable toggle and restore-last-original action. +The generated plist is based on [packaging/macos/com.plainlink.agent.example.plist](../packaging/macos/com.plainlink.agent.example.plist). For lower-level control, use `plainlink agent help`. + +## App Controls + +The menu bar app provides: + +- enable, pause, start, and restart watcher controls, +- interval selection, +- one-shot current clipboard cleaning, +- restore-last-original, +- doctor diagnostics, +- diagnostics copy, +- support and log folder shortcuts. + +Signing and notarization are not part of this MVP. See [MENUBAR.md](MENUBAR.md) for app internals. diff --git a/docs/MENUBAR.md b/docs/MENUBAR.md new file mode 100644 index 0000000..fab2f49 --- /dev/null +++ b/docs/MENUBAR.md @@ -0,0 +1,70 @@ +# macOS Menu Bar App + +PlainLink includes a native macOS menu bar shell built with Swift and AppKit. It does not need Xcode; Apple Command Line Tools are enough. + +The menu bar app is intentionally thin. The Rust CLI remains the engine for cleaning, LaunchAgent management, restore, and diagnostics. + +## Build + +```sh +scripts/build-macos-app.sh +``` + +The script creates: + +```text +dist/PlainLink.app +``` + +The app bundle contains: + +```text +Contents/MacOS/PlainLinkMenu Native AppKit status bar app +Contents/MacOS/plainlink Release Rust CLI used by the app +Contents/Info.plist LSUIElement menu bar app metadata +``` + +## Smoke Test + +```sh +scripts/test-macos-app.sh +``` + +This builds the bundle, validates `Info.plist`, confirms both executables exist, runs the menu app smoke test, and checks the embedded CLI version command. + +## Runtime Flow + +```mermaid +flowchart TB + Menu["PlainLink.app menu bar UI"] --> Runner["PlainLink command runner"] + Runner --> CLI["Embedded plainlink CLI"] + CLI --> Install["install / agent commands"] + CLI --> Doctor["doctor"] + CLI --> Clean["clean-clipboard"] + CLI --> Restore["restore"] + Install --> Agent["User LaunchAgent"] + Agent --> Watch["plainlink watch"] + Watch --> Clipboard["macOS clipboard"] + Clean --> Clipboard + Restore --> Clipboard +``` + +## Menu Actions + +- Enable, pause, start, and restart clipboard cleaning. +- Select watcher interval. +- Clean the current clipboard once. +- Restore the last original URL. +- Run doctor diagnostics. +- Copy diagnostics to the clipboard. +- Open support and log folders. +- Quit the menu bar app without stopping the LaunchAgent. + +## Design Notes + +- The app is a user-level status bar app with `LSUIElement`. +- 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. +- Full uninstall remains available through the CLI with `plainlink uninstall`. +- Signing and notarization are intentionally out of scope for the current MVP. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 068b2c1..7f8e450 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -5,6 +5,7 @@ - Rust URL cleaning core. - CLI commands for cleaning and inspection. - macOS clipboard watcher. +- Native macOS menu bar app. - Restore last original URL. - User LaunchAgent install/status/restart/uninstall. - Stable user install/uninstall and doctor checks. @@ -14,8 +15,9 @@ ## Next -- Add a native macOS menu bar shell. - Add signed release builds. +- Add a downloadable app artifact. +- Add clearer first-run onboarding inside the menu bar app. - Add optional remote ruleset updates with clear privacy controls. ## Later @@ -23,4 +25,4 @@ - Windows and Linux clipboard adapters. - Community rules registry. - Rule linter for pull requests. -- Browser/WASM experiment using the same Rust core. +- Native tray apps for supported desktop platforms. diff --git a/packaging/macos/PlainLink.app/Contents/Info.plist b/packaging/macos/PlainLink.app/Contents/Info.plist new file mode 100644 index 0000000..680fde7 --- /dev/null +++ b/packaging/macos/PlainLink.app/Contents/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + PlainLink + CFBundleExecutable + PlainLinkMenu + CFBundleIdentifier + au.hexcode.plainlink + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + PlainLink + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + NSHumanReadableCopyright + Copyright HexCodeYT. Released under the MIT license. + + diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh new file mode 100755 index 0000000..85bc303 --- /dev/null +++ b/scripts/build-macos-app.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +APP_NAME="PlainLink" +APP_DIR="$ROOT_DIR/dist/$APP_NAME.app" +CONTENTS_DIR="$APP_DIR/Contents" +MACOS_DIR="$CONTENTS_DIR/MacOS" +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" + +if [ "$(uname -s)" != "Darwin" ]; then + echo "PlainLink.app can only be built on macOS." >&2 + exit 1 +fi + +command -v cargo >/dev/null 2>&1 || { + echo "cargo is required to build PlainLink.app." >&2 + exit 1 +} + +command -v swiftc >/dev/null 2>&1 || { + echo "swiftc is required to build PlainLink.app. Install Apple Command Line Tools." >&2 + exit 1 +} + +cargo build --release --manifest-path "$ROOT_DIR/Cargo.toml" + +rm -rf "$APP_DIR" +mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" "$SWIFT_MODULE_CACHE" + +cp "$INFO_PLIST" "$CONTENTS_DIR/Info.plist" +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" + +chmod 755 "$MACOS_DIR/PlainLinkMenu" "$MACOS_DIR/plainlink" + +echo "Built $APP_DIR" diff --git a/scripts/test-macos-app.sh b/scripts/test-macos-app.sh new file mode 100755 index 0000000..c96200e --- /dev/null +++ b/scripts/test-macos-app.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +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" + +"$ROOT_DIR/scripts/build-macos-app.sh" + +test -d "$APP_DIR" +test -x "$MENU_BIN" +test -x "$CLI_BIN" +test -f "$INFO_PLIST" + +plutil -lint "$INFO_PLIST" >/dev/null +"$MENU_BIN" --smoke-test >/dev/null +"$CLI_BIN" --version >/dev/null + +echo "PlainLink.app smoke tests passed." diff --git a/src/clipboard.rs b/src/clipboard.rs index 7eb5be6..a1f02e0 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,6 +1,6 @@ use crate::RuleSet; #[cfg(target_os = "macos")] -use crate::{clean_url, save_last_cleaned}; +use crate::{CleanResult, clean_url, save_last_cleaned}; use std::time::Duration; #[derive(Debug, Clone, Copy)] @@ -18,6 +18,20 @@ impl Default for WatchOptions { } } +#[cfg(target_os = "macos")] +pub fn clean_clipboard_once(rules: &RuleSet) -> std::io::Result> { + let current = read_clipboard_text()?; + write_cleaned_clipboard_text(¤t, rules) +} + +#[cfg(not(target_os = "macos"))] +pub fn clean_clipboard_once(_rules: &RuleSet) -> std::io::Result> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "clipboard cleaning is currently implemented for macOS only", + )) +} + #[cfg(target_os = "macos")] pub fn watch_clipboard(rules: &RuleSet, options: WatchOptions) -> std::io::Result<()> { use std::thread; @@ -25,9 +39,9 @@ pub fn watch_clipboard(rules: &RuleSet, options: WatchOptions) -> std::io::Resul let mut last_seen = read_clipboard_text().unwrap_or_default(); if options.clean_current - && let Some(cleaned) = clean_clipboard_text(&last_seen, rules)? + && let Some(result) = write_cleaned_clipboard_text(&last_seen, rules)? { - last_seen = cleaned; + last_seen = result.cleaned; } loop { @@ -38,8 +52,8 @@ pub fn watch_clipboard(rules: &RuleSet, options: WatchOptions) -> std::io::Resul continue; } - if let Some(cleaned) = clean_clipboard_text(¤t, rules)? { - last_seen = cleaned; + if let Some(result) = write_cleaned_clipboard_text(¤t, rules)? { + last_seen = result.cleaned; } else { last_seen = current; } @@ -55,7 +69,10 @@ pub fn watch_clipboard(_rules: &RuleSet, _options: WatchOptions) -> std::io::Res } #[cfg(target_os = "macos")] -fn clean_clipboard_text(input: &str, rules: &RuleSet) -> std::io::Result> { +fn write_cleaned_clipboard_text( + input: &str, + rules: &RuleSet, +) -> std::io::Result> { let result = clean_url(input, rules); if result.removed.is_empty() { @@ -70,7 +87,7 @@ fn clean_clipboard_text(input: &str, rules: &RuleSet) -> std::io::Result Result<(), String> { } Some("agent") => agent_command(args.collect()), Some("clean") => clean_command(args.collect()), + Some("clean-clipboard") => clean_clipboard_command(), Some("doctor") => doctor_command(), Some("install") => install_command(args.collect()), Some("inspect") => inspect_command(args.collect()), @@ -47,6 +48,25 @@ fn clean_command(args: Vec) -> Result<(), String> { Ok(()) } +fn clean_clipboard_command() -> Result<(), String> { + let rules = RuleSet::default_rules(); + let result = clean_clipboard_once(&rules) + .map_err(|error| format!("could not clean current clipboard: {error}"))?; + + match result { + Some(result) => { + println!( + "Cleaned clipboard URL and removed {} parameter(s):", + result.removed.len() + ); + println!("{}", result.cleaned); + } + None => println!("Clipboard already clean."), + } + + Ok(()) +} + fn inspect_command(args: Vec) -> Result<(), String> { let input = positional_text(args)?; let rules = RuleSet::default_rules(); @@ -297,6 +317,7 @@ fn print_help() { Usage: plainlink agent Manage the macOS LaunchAgent plainlink clean Print a cleaned URL + plainlink clean-clipboard Clean the current clipboard once plainlink doctor Check install health plainlink install [options] Install PlainLink to a stable user path plainlink inspect Show what PlainLink removed and why @@ -321,6 +342,7 @@ Install options: Examples: plainlink install --interval-ms 500 plainlink doctor + plainlink clean-clipboard plainlink agent install --interval-ms 500 plainlink clean 'https://youtu.be/LYa_ReqRlcs?si=VC4qVB_EUC90uwbo' plainlink inspect 'https://example.com/?utm_source=newsletter&id=42' diff --git a/tests/macos_app_bundle.rs b/tests/macos_app_bundle.rs new file mode 100644 index 0000000..16e28f9 --- /dev/null +++ b/tests/macos_app_bundle.rs @@ -0,0 +1,39 @@ +use std::fs; + +#[test] +fn macos_menu_app_assets_are_wired() { + let source = fs::read_to_string("app/macos/PlainLinkMenu/Sources/PlainLinkMenu.swift") + .expect("menu app source should exist"); + let plist = fs::read_to_string("packaging/macos/PlainLink.app/Contents/Info.plist") + .expect("app bundle Info.plist should exist"); + let build_script = + fs::read_to_string("scripts/build-macos-app.sh").expect("build script should exist"); + let test_script = + fs::read_to_string("scripts/test-macos-app.sh").expect("test script should exist"); + + for command in [ + "install", + "agent", + "status", + "restart", + "clean-clipboard", + "restore", + "doctor", + ] { + assert!( + source.contains(command), + "menu app should call plainlink `{command}`" + ); + } + + assert!(source.contains("--smoke-test")); + assert!(source.contains("PlainLinkIntervalMilliseconds")); + assert!(plist.contains("LSUIElement")); + assert!(plist.contains("")); + assert!(plist.contains("PlainLinkMenu")); + assert!(build_script.contains("cargo build --release")); + assert!(build_script.contains("swiftc")); + assert!(build_script.contains("-module-cache-path")); + assert!(test_script.contains("--smoke-test")); + assert!(test_script.contains("plutil -lint")); +}