diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 0000000..8248234
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,51 @@
+name: Bug report
+description: Something behaves differently from how it is documented
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Please check **Known limits** in the README first. The 60 Hz ceiling, the
+ ~1920×1200 geometry ceiling, and protected video refusing to play are
+ properties of the private display API rather than defects, and are not
+ going to change.
+
+ If the receiver cannot connect at all, the *It will not connect*
+ template asks better questions than this one.
+ - type: input
+ id: versions
+ attributes:
+ label: Versions
+ placeholder: "Mac 0.16.1, receiver 0.16.1, macOS 26.2, Windows 11"
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: What did you expect to happen?
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: What happened instead?
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: How can it be reproduced?
+ description: >
+ Including what was on the second display at the time. An idle desktop
+ legitimately sends almost no frames, so "0 fps" on an empty screen is
+ correct rather than a fault.
+ validations:
+ required: true
+ - type: textarea
+ id: hud
+ attributes:
+ label: The receiver's HUD, if the stream was running
+ description: >
+ Press H to show it. The per-stage rows say far more than a description
+ of the symptom can.
+ render: text
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..b85310e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Why the app is unsigned, and what the warnings mean
+ url: https://github.com/nbkdoesntknowcoding/display-share/blob/main/docs/distribution.md
+ about: The macOS and SmartScreen warnings on first launch are expected and explained here.
+ - name: Security vulnerability
+ url: https://github.com/nbkdoesntknowcoding/display-share/security/advisories/new
+ about: Please report these privately rather than as a public issue.
diff --git a/.github/ISSUE_TEMPLATE/connection.yml b/.github/ISSUE_TEMPLATE/connection.yml
new file mode 100644
index 0000000..66fb438
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/connection.yml
@@ -0,0 +1,76 @@
+name: It will not connect
+description: The receiver cannot find the Mac, or connects and immediately drops
+labels: ["connection"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Most connection reports come down to one of a handful of causes, and the
+ questions below are the ones that separate them. Answering them saves a
+ round trip each.
+
+ **Two worth ruling out before filing**, because they look like faults and
+ are not:
+
+ - **Is the Mac actually sharing?** Running is not the same as sharing.
+ The menu bar panel has to say it is sharing — if it says *Press Start
+ to begin*, the receiver has nothing to connect to.
+ - **Is another receiver already attached?** Only one at a time is
+ allowed. A second one is refused, and the receiver will keep retrying.
+ Close any other receiver, including a browser tab open on the Mac's
+ own viewer page.
+ - type: input
+ id: versions
+ attributes:
+ label: Versions
+ description: Both halves. The receiver shows its version on the connect card.
+ placeholder: "Mac 0.16.1, receiver 0.16.1"
+ validations:
+ required: true
+ - type: dropdown
+ id: stage
+ attributes:
+ label: How far does it get?
+ options:
+ - The receiver never lists the Mac at all
+ - It lists the Mac, but connecting fails
+ - It connects, then drops straight away
+ - It connects and then drops after a while
+ - It asks for a PIN and the PIN is refused
+ validations:
+ required: true
+ - type: textarea
+ id: message
+ attributes:
+ label: What does the receiver say, word for word?
+ description: >
+ The exact text matters. "Disconnected. Reconnecting…" and a named
+ refusal are different problems with different causes.
+ validations:
+ required: true
+ - type: textarea
+ id: mac-state
+ attributes:
+ label: What does the Mac's menu bar panel say?
+ placeholder: "Sharing, or Not sharing — and anything under it"
+ validations:
+ required: true
+ - type: dropdown
+ id: network
+ attributes:
+ label: How are the two machines connected?
+ options:
+ - Same Wi-Fi network
+ - Same router, one or both on Ethernet
+ - A cable straight between the two machines
+ - Different networks, or not sure
+ validations:
+ required: true
+ - type: textarea
+ id: extra
+ attributes:
+ label: Anything else
+ description: >
+ Guest or workplace Wi-Fi often blocks the discovery protocol, which
+ shows up as the Mac never being listed. Typing the Mac's address by
+ hand tells the two cases apart — please say whether that works.
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..3bfa566
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,21 @@
+## What changes, and why
+
+
+
+## How it was verified
+
+
+
+- [ ] `xcodebuild -scheme DisplayShareCore -derivedDataPath ./.build test` (from `mac/`)
+- [ ] `cargo test --manifest-path src-tauri/Cargo.toml` (from `windows/`)
+- [ ] The `node --experimental-strip-types scripts/verify-*.mjs` checks (from `windows/`)
+- [ ] Run against a real Mac and receiver
+
+## Anything a reviewer should be suspicious of
+
+
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..e5f25b0
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,152 @@
+# Working in this repository
+
+Notes for coding agents. [CONTRIBUTING.md](CONTRIBUTING.md) covers the licensing
+rules; this covers how to build, what to run, and the specific ways this codebase
+will mislead you if you assume the usual conventions.
+
+## The shape of it
+
+Two applications that talk over a documented wire protocol.
+
+| Path | What it is |
+|---|---|
+| `mac/DisplayShare/` | SwiftUI menu bar app — no window, `LSUIElement` |
+| `mac/DisplayShareCore/` | capture, encode, transport, pairing, input. Most logic lives here |
+| `mac/vd_helper/` | separate process that owns the `CGVirtualDisplay` |
+| `mac/Shared/` | wire protocol and helper IPC, compiled into both |
+| `windows/src/` | TypeScript frontend: WebCodecs decode, canvas, input |
+| `windows/src-tauri/` | Rust backend that owns the WebSocket |
+| `protocol/` | `SPEC.md` and golden vectors |
+
+## Four things that will catch you out
+
+**The Xcode project is generated.** Edit `mac/project.yml`, then run
+`xcodegen generate`. Changes made directly to `DisplayShare.xcodeproj` are
+overwritten, including new files — a test file added only to the `.xcodeproj`
+silently never runs.
+
+**The protocol has golden vectors.** The Swift and TypeScript parsers are each
+tested against the same bytes in `protocol/vectors/`, never against each other,
+so a shared misunderstanding cannot pass. Changing the wire format means
+revising `protocol/SPEC.md` and both implementations. Do not add a field to the
+header for convenience.
+
+**macOS permissions cannot be granted by a script.** Screen Recording and
+Accessibility are granted by a human in System Settings, and a freshly built
+copy is a *new identity* even if permission was granted to a previous build. If
+a task needs them, stop and ask. There is no API, entitlement or `sudo` that
+does it.
+
+**`mac/build/` is hundreds of megabytes** and must never be committed. It is
+ignored; do not add it back with `git add -f`.
+
+## Commands
+
+```bash
+# Mac
+brew install xcodegen
+cd mac && xcodegen generate
+xcodebuild -scheme DisplayShareCore -derivedDataPath ./.build test
+
+# Windows receiver — the Rust half builds and tests on macOS too
+cd windows && npm ci
+cargo test --manifest-path src-tauri/Cargo.toml
+npm run build
+
+# Frontend checks, all run in CI
+node scripts/verify-vectors.mjs
+node --experimental-strip-types scripts/verify-window-states.mjs
+node --experimental-strip-types scripts/verify-timing.mjs
+node --experimental-strip-types scripts/verify-refusals.mjs
+```
+
+## How this codebase tests things that need two machines
+
+Almost every defect that ever reached a user here passed a green build, because
+the failing path needed a Mac, a receiver and a network to reach. The response
+has been consistent, and new work should follow it:
+
+**Extract the decision as a pure value, then assert it.** `SendGate` decides
+what to shed without a socket. `Cadence` picks a frame rate without a display.
+`PopoverLayout.ordered` returns the sections without a view. `DisplayPlacement`
+answers which edge without a second monitor. Frontend equivalents live in
+`windows/src/*.ts` with a `scripts/verify-*.mjs` runner wired into both CI jobs.
+
+**Then break it on purpose.** An assertion that still passes with the fix
+reverted is worth nothing, and this project has written a few. Delete the guard,
+watch a named test fail, put it back. If nothing fails, the test is decoration —
+say so rather than keeping it.
+
+## The failure this project keeps repeating
+
+Worth knowing before writing anything user-facing, because it has shipped
+repeatedly in different clothes: **the app knows a specific fact and displays a
+generic one.**
+
+A refused session showed "Reconnecting…". A display placed to the left said
+nothing at all. An idle desktop was read as a dead capture and as a congested
+link, and both times something was restarted or degraded for it. In every case
+the information existed and the interface declined to say it, and in every case
+the user reasonably concluded the app was broken — because from outside there is
+no difference.
+
+When adding a state, the question is not "what should this show" but "what does
+the app already know here that it is not saying".
+
+## Setting it up on a machine
+
+
+A prompt that knows where the human steps are
+
+```text
+Set up Display Share on this machine. It turns a Windows laptop into a real
+second display for a Mac. Repo: https://github.com/nbkdoesntknowcoding/display-share
+
+MAC SENDER (only runs on macOS 14+):
+1. git clone https://github.com/nbkdoesntknowcoding/display-share.git && cd display-share
+2. Run ./install.sh and show me its output. It installs xcodegen if needed,
+ builds a Release build, and installs to /Applications.
+3. If it fails, read the build log path it prints and fix or report the error.
+ Do NOT skip failures or fall back to a Debug build.
+
+THEN STOP AND ASK ME. You cannot do the next part:
+macOS permissions are granted by a human in System Settings — no command,
+script, or API can grant them, and a freshly built copy counts as a NEW app
+identity even if I granted them before. Tell me to:
+ - System Settings > Privacy & Security > Screen Recording > enable Display Share
+ - (only if I want to control the Mac from the laptop)
+ Privacy & Security > Accessibility > enable Display Share
+Then wait for me to confirm.
+
+VERIFY (after I confirm):
+4. Launch /Applications/DisplayShare.app. It is a MENU BAR app — no Dock icon,
+ no window. Click its icon and press Start.
+5. Confirm a virtual display exists: `system_profiler SPDisplaysDataType | grep -i display`
+ should show one more display than the physical monitors.
+6. Open http://localhost:8787 in a browser on the Mac. Expect a black canvas and
+ a HUD. It stays black until something is actually on that display — drag a
+ window onto the new display, then the HUD should show frames arriving.
+ A near-black screen with capture near 0 fps is CORRECT for an empty desktop.
+ NOTE: this browser page counts as a receiver, and only one is allowed at a
+ time. Close it before connecting the real receiver, or that one is refused.
+
+WINDOWS RECEIVER (run this part on the Windows laptop):
+7. Install Rust (https://rustup.rs) and Node 22+.
+8. cd windows && npm ci && npx tauri build
+9. Run the installer from windows/src-tauri/target/release/bundle/nsis/
+ SmartScreen will warn because it is unsigned: More info > Run anyway.
+10. The app finds the Mac over Bonjour. Enter the 4-digit PIN the Mac shows.
+ Both machines must be on the same network, on 5 GHz Wi-Fi or Ethernet.
+
+USEFUL TO KNOW:
+- Ports 8787 and 8788 must not be blocked.
+- Keys in the receiver: F fullscreen, H toggle HUD, K force a keyframe,
+ A cycle decode mode, F8 forward input to the Mac.
+- macOS decides where the second display goes and usually puts it on the LEFT.
+ Push the cursor that way to reach it; System Settings > Displays moves it.
+- Read README.md "Known limits" before reporting a bug — the 60 Hz cap, the
+ ~1920x1200 geometry ceiling and no-HDCP are properties of Apple's private
+ API, not defects.
+```
+
+
diff --git a/README.md b/README.md
index 11ebeda..e25cf9d 100644
--- a/README.md
+++ b/README.md
@@ -159,64 +159,11 @@ xcodebuild -scheme DisplayShare -configuration Release -derivedDataPath ./.build
cd windows && npm ci && npx tauri build
```
-
-Or hand it to a coding agent — a prompt that knows where the human steps are
-
-Paste this into Claude Code, Cursor, or any agent with shell access. The
-important part is that it stops before the permission grant, because no script
-can grant macOS permissions:
-
-````text
-Set up Display Share on this machine. It turns a Windows laptop into a real
-second display for a Mac. Repo: https://github.com/nbkdoesntknowcoding/display-share
-
-MAC SENDER (only runs on macOS 14+):
-1. git clone https://github.com/nbkdoesntknowcoding/display-share.git && cd display-share
-2. Run ./install.sh and show me its output. It installs xcodegen if needed,
- builds a Release build, and installs to /Applications.
-3. If it fails, read the build log path it prints and fix or report the error.
- Do NOT skip failures or fall back to a Debug build.
-
-THEN STOP AND ASK ME. You cannot do the next part:
-macOS permissions are granted by a human in System Settings — no command,
-script, or API can grant them, and a freshly built copy counts as a NEW app
-identity even if I granted them before. Tell me to:
- - System Settings > Privacy & Security > Screen Recording > enable Display Share
- - (only if I want to control the Mac from the laptop)
- Privacy & Security > Accessibility > enable Display Share
-Then wait for me to confirm.
-
-VERIFY (after I confirm):
-4. Launch /Applications/DisplayShare.app. It is a MENU BAR app — no Dock icon,
- no window. Click its icon and press Start.
-5. Confirm a virtual display exists: `system_profiler SPDisplaysDataType | grep -i display`
- should show one more display than the physical monitors.
-6. Open http://localhost:8787 in a browser on the Mac. Expect a black canvas and
- a HUD. It stays black until something is actually on that display — drag a
- window onto the new display, then the HUD should show ~58 fps.
- A near-black screen with capture near 0 fps is CORRECT for an empty desktop
- — that is not a bug.
-
-WINDOWS RECEIVER (run this part on the Windows laptop):
-7. Install Rust (https://rustup.rs) and Node 22+.
-8. cd windows && npm ci && npx tauri build
-9. Run the installer from windows/src-tauri/target/release/bundle/nsis/
- SmartScreen will warn because it is unsigned: More info > Run anyway.
-10. The app finds the Mac over Bonjour. Enter the 4-digit PIN the Mac shows.
- Both machines must be on the same network, on 5 GHz Wi-Fi or Ethernet.
-
-USEFUL TO KNOW:
-- Ports 8787 (viewer page) and 8788 (video + control) must not be blocked.
-- Keys in the receiver: F fullscreen, H toggle HUD, K force a keyframe,
- A cycle decode mode, F8 forward input to the Mac.
-- Read README.md "Known limits" before reporting a bug — the 60 Hz cap, the
- ~1920x1200 geometry ceiling and no-HDCP are properties of Apple's private
- API, not defects.
-- If anything is ambiguous, read docs/distribution.md and protocol/SPEC.md
- rather than guessing.
-````
-
-
+### Or hand it to a coding agent
+
+[AGENTS.md](AGENTS.md) has a setup prompt written for agents with shell access.
+It stops before the permission grant, because no script can grant macOS
+permissions — that part is always a human in System Settings.
---
@@ -538,8 +485,9 @@ see [What isn't proven yet](#what-isnt-proven-yet). A HUD screenshot from a real
Mac-to-Windows session over a real LAN would settle several open questions at
once.
-Found a security issue? Please open a private advisory through GitHub's
-**Security** tab rather than a public issue.
+Found a security issue? [SECURITY.md](SECURITY.md) says how to report it
+privately, and what counts — the unsigned binaries and the private API use are
+deliberate, documented trade-offs rather than vulnerabilities.
---
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..1091023
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,56 @@
+# Security
+
+## Reporting
+
+Please report vulnerabilities **privately**, through this repository's
+**Security** tab → *Report a vulnerability*. That opens a draft advisory only
+maintainers can see.
+
+Please do not open a public issue for anything in the "in scope" list below.
+
+This is a small project with no security team and no paid support. A realistic
+expectation is a first reply within a week. If something is being actively
+exploited, say so in the title and it will be looked at sooner.
+
+## In scope
+
+Display Share puts a video stream and an input channel on your local network, so
+the interesting failures are about who is allowed to reach them:
+
+- **Pairing bypass** — receiving video, or having input accepted, without
+ completing the PIN exchange. Input forwarding in particular is refused until a
+ device is paired, and a way around that is the highest-severity report here.
+- **Token handling** — pairing tokens are stored hashed. A way to recover a
+ usable token from what is written to disk, or to replay one from another
+ device, is in scope.
+- **Input injection** — the Mac injects `CGEvent`s on behalf of a paired
+ receiver. Anything that lets an unpaired party drive that is in scope.
+- **The helper process** — `vd_helper` holds the virtual display and speaks a
+ local IPC protocol. Anything that lets an unrelated local process drive it, or
+ escalate through it, is in scope.
+- **Path handling in the updater** — both apps download release assets. Anything
+ that writes outside the intended location is in scope.
+
+## Not vulnerabilities
+
+Stated plainly, because these get reported and they are deliberate:
+
+- **The binaries are unsigned.** Display Share is open source and does not buy
+ code signing certificates, so macOS and SmartScreen both warn on first launch.
+ That is a known, documented trade — see [docs/distribution.md](docs/distribution.md).
+ Build it yourself and the warning does not appear.
+- **It uses a private Apple API.** `CGVirtualDisplay` is how a real virtual
+ display is created at all. It is the project's central technique, not an
+ oversight, and it is why this cannot ship on the Mac App Store.
+- **It records the screen.** It captures the virtual display it creates, and
+ macOS shows the purple recording indicator exactly as it does for any other
+ capture app. That indicator appearing is correct behaviour.
+- **Traffic on your LAN is not encrypted.** The stream is plain WebSocket on the
+ local network, gated by pairing. If your threat model includes someone on your
+ own LAN reading it, this is not the tool for you today — but a report saying
+ so is a feature request, and a welcome one, rather than a vulnerability.
+
+## Scope of the code
+
+Only this repository. The reference projects named in the README are separate
+and should be reported to their own maintainers.