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/ISSUE_TEMPLATE/rule_request.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ labels: rule

## Why The Removed Parameters Are Tracking

## Required Parameters That Must Stay

## Notes
12 changes: 12 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,20 +16,23 @@ 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`.
- 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.
- 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.
- 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.

Expand Down Expand Up @@ -81,6 +84,8 @@ scripts/package-macos-app.sh

This creates `dist/packages/PlainLink-<version>-macos-<arch>.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
Expand All @@ -98,6 +103,24 @@ 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).

## 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
Expand All @@ -120,12 +143,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
Expand Down
51 changes: 50 additions & 1 deletion app/macos/PlainLinkMenu/Sources/PlainLinkMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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()
Expand Down Expand Up @@ -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"))

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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]) {
Expand Down
22 changes: 20 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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"]
Expand All @@ -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
Expand All @@ -53,6 +64,10 @@ flowchart TB
Fixtures --> Gate
Gate --> Tests
Tests --> Core
Menu --> Unsigned
Unsigned --> Signed
Signed --> Notary
Notary --> GitHub
```

## Data Flow
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
17 changes: 14 additions & 3 deletions docs/MACOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Loading
Loading