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
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,22 @@ PlainLink is the open-source, local-first URL-cleaning engine with transparent,
## Install the developer preview

> [!WARNING]
> PlainLink is early developer-preview software for technical testers. The app is ad-hoc signed, but it is not yet Developer ID-signed or notarized. macOS Gatekeeper will warn on first launch. Please expect rough edges and report incorrect URL changes.
> PlainLink is early developer-preview software for technical testers. The app is ad-hoc signed, but it is not yet Developer ID-signed or notarized. macOS may say the downloaded app is damaged until its quarantine attribute is removed. Please expect rough edges and report incorrect URL changes.

1. Open [GitHub Releases](https://github.com/HexCodeYT/PlainLink/releases) and download the ZIP for your Mac:
- The file ending in `-macos-arm64.zip` for Apple silicon (M1, M2, M3, M4, or newer).
- The file ending in `-macos-x86_64.zip` for an Intel Mac.
2. Open the ZIP and drag `PlainLink.app` into `/Applications`.
3. In Finder, open **Applications**, Control-click `PlainLink`, choose **Open**, then choose **Open** again.
4. PlainLink appears in the menu bar and starts watching copied URLs.
3. Open Terminal and remove quarantine from this app only, then launch it:

If macOS does not show the second **Open** button, try launching PlainLink once, then go to **System Settings → Privacy & Security**, find the blocked-app message, and choose **Open Anyway**.
```sh
xattr -dr com.apple.quarantine /Applications/PlainLink.app
open /Applications/PlainLink.app
```

4. PlainLink appears in the menu bar and starts watching copied URLs automatically.

The `xattr` command only removes macOS's downloaded-file quarantine marker from `PlainLink.app`. If you do not want to do that, use the source build below instead.

If the [Releases](https://github.com/HexCodeYT/PlainLink/releases) page has no ZIP for your Mac, use the source build below. Signing and notarisation are pending; the preview ZIP and its checksum will be clearly labelled on the release.

Expand Down
71 changes: 41 additions & 30 deletions app/macos/PlainLinkMenu/Sources/PlainLinkMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +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 let automaticEnableKey = "PlainLinkHasAutomaticallyEnabledWatcherV1"
private var watcherStatus: WatcherStatus = .unknown("Checking status...")
private var isWorking = false
private var refreshTimer: Timer?
Expand All @@ -199,7 +199,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
configureStatusButton()
rebuildMenu()
refreshStatus()
showFirstRunIfNeeded()
enableCleaningOnFirstLaunch()

refreshTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
self?.refreshStatus()
Expand Down Expand Up @@ -442,7 +442,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
}

@objc private func showGettingStarted() {
showFirstRunGuide()
showGettingStartedGuide(cleaningEnabled: watcherStatus == .running)
}

@objc private func quit() {
Expand Down Expand Up @@ -508,42 +508,53 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
}
}

private func showFirstRunIfNeeded() {
guard !UserDefaults.standard.bool(forKey: firstRunKey) else {
private func enableCleaningOnFirstLaunch() {
guard !UserDefaults.standard.bool(forKey: automaticEnableKey) else {
return
}

UserDefaults.standard.set(true, forKey: firstRunKey)

DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
self?.showFirstRunGuide()
guard let self else {
return
}

self.runCommand(
title: "Start PlainLink",
arguments: ["install", "--interval-ms", "\(self.selectedInterval)"]
) { [weak self] result in
guard let self else {
return
}

if result.succeeded {
UserDefaults.standard.set(true, forKey: self.automaticEnableKey)
self.showGettingStartedGuide(cleaningEnabled: true)
} else {
self.showCommandResult(title: "Could not start cleaning", result: result)
}
}
}
}

private func showFirstRunGuide() {
private func showGettingStartedGuide(cleaningEnabled: Bool) {
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.messageText = cleaningEnabled ? "PlainLink is cleaning copied links" : "PlainLink lives in your menu bar"
alert.informativeText = cleaningEnabled
? [
"Cleaning started automatically.",
"",
"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")
: [
"Cleaning is currently off. Choose Enable Cleaning from the PlainLink menu to start the watcher.",
"",
"PlainLink runs locally and preserves the last original link so you can restore it."
].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()
}
alert.addButton(withTitle: "OK")
showAlert(alert)
}

private func showCommandResult(title: String, result: CommandResult) {
Expand Down
1 change: 1 addition & 0 deletions docs/LAUNCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Secondary angles by audience:
- [ ] Publish one public GitHub Release with one obvious macOS artifact and checksum.
- [ ] Make sure the release clearly says whether the artifact is an ad-hoc-signed, unnotarized preview or a Developer ID-signed/notarized build.
- [ ] Make the GitHub repository README the HN landing page, with the release download and source-build path visible above the fold.
- [x] Document the exact `xattr` quarantine workaround required by the ad-hoc-signed preview.
- [x] Record a 10 to 15 second demo showing copy, automatic cleanup, and paste.
- [ ] Capture screenshots for the menu bar, inspection result, and restore flow.
- [ ] Enable GitHub Discussions for rule requests, testing reports, ideas, and show-and-tell posts.
Expand Down
3 changes: 2 additions & 1 deletion docs/MENUBAR.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +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.
- On first launch, the app installs and starts the watcher automatically, then confirms that cleaning is active.
- After a user pauses cleaning, later app launches preserve that choice; cleaning can be re-enabled from the menu.
- 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.
Expand Down
2 changes: 2 additions & 0 deletions docs/releases/v0.1.0-preview.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ PlainLink v0.1.0 Preview 3 is an ad-hoc-signed, unnotarized technical preview fo
- Fixture-backed rule verification.
- HN-ready installation, Gatekeeper, privacy, and technical-preview guidance.
- Multi-resolution app icon verification during packaging.
- Automatic watcher installation and startup on first launch.
- Exact recovery steps for macOS's misleading downloaded-app “damaged” warning.

## Artifact

Expand Down
4 changes: 3 additions & 1 deletion tests/macos_app_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ fn macos_menu_app_assets_are_wired() {

assert!(source.contains("--smoke-test"));
assert!(source.contains("PlainLinkIntervalMilliseconds"));
assert!(source.contains("PlainLinkHasSeenFirstRun"));
assert!(source.contains("PlainLinkHasAutomaticallyEnabledWatcherV1"));
assert!(source.contains("enableCleaningOnFirstLaunch"));
assert!(source.contains("Cleaning started automatically."));
assert!(source.contains("Getting Started"));
assert!(source.contains("PlainLink lives in your menu bar"));
assert!(plist.contains("<key>LSUIElement</key>"));
Expand Down