A Stream Deck-style iOS app for Eric's own use. Each Button types an often-used Phrase into the focused window on a Computer, usually a Claude Code terminal. The Helper runs on macOS, on Arch/Omarchy (Hyprland), and on Ubuntu with GNOME, KDE or X11.
helper/:phrased, the Go Helper that receives a Phrase and types it.ios/: the SwiftUI app, shipped to Eric's devices through internal TestFlight.firmware/: the desk panel, an ESP32 CrowPanel that is also a Device.contract/:pairing.json, the pairing contract that both the Go and Swift tests read.design/: the app icon's source,icon.svg.docs/plans/: the design and the implementation plan.docs/launch/: the Home network route threat checklist and the product name shortlist.docs/adr/: decisions, starting with ADR 0001 on how the Mac Helper is distributed.CONTEXT.md: the glossary (Helper, Device, Phrase, Button, Page, Deck, Pin, Route and so on).CLAUDE.md: the rules for agents working here.
phrased listens on port 47811 but only answers Tailscale peers (100.64.0.0/10 and
fd7a:115c:a1e0::/48), and only for a Host that is this machine's tailnet name, MagicDNS name or
tailnet IP. It answers on the home network only when you turn that on with -lan (see
Home network without Tailscale).
The Mac Helper is planned to ship as a Developer ID, notarized direct download plus a Homebrew cask (ADR 0001). For now, build it from source:
cd helper
make build # go build, then codesign with "Apple Development: Eric Dahl (D6LCAABBEQ)"
make testThe stable signature keeps the Accessibility grant when you rebuild. Use another identity with
make build SIGN_IDENTITY="..." (list yours with security find-identity -v -p codesigning).
cd helper
make install-macThis builds and signs phrased, installs it to ~/.local/bin/phrased, and loads the LaunchAgent
~/Library/LaunchAgents/dev.ericdahl.phrased.plist, which starts it at login and restarts it if
it stops. The log is ~/Library/Logs/phrased.log. Run it again after pulling changes: it swaps in
the new binary and reloads the agent.
- The first install puts
phrasedin a new place, so grant Accessibility to~/.local/bin/phrasedonce and run the firewall commands thatmake install-macprints. The stable signature keeps both through later installs. - It reads
~/.config/phrased, the same as running./phrasedby hand. - Stop it with
launchctl bootout gui/$(id -u)/dev.ericdahl.phrased; remove the plist too to keep it from starting at the next login.
Not available yet: this needs the first signed release and the ericdahl-dev/homebrew-tap repository.
brew install --cask ericdahl-dev/tap/phrasedThis installs a universal phrased (Apple Silicon and Intel, macOS 12 or later), signed with
Developer ID and notarized, so Gatekeeper lets it run. Grant it Accessibility (see "Grant
Accessibility"). After brew upgrade, if typing stops, remove phrased from the Accessibility list
and add it again: each version lives at its own path under the Caskroom, which macOS may treat as a
new app.
Run at login. The cask installs the binary only. To start it at login and keep it running, add the LaunchAgent:
curl -fsSL https://raw.githubusercontent.com/ericdahl-dev/phrasedeck/main/helper/install/dev.ericdahl.phrased.plist |
sed -e "s|__HOME__/.local/bin/phrased|$(brew --prefix)/bin/phrased|" -e "s|__HOME__|$HOME|g" \
> ~/Library/LaunchAgents/dev.ericdahl.phrased.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ericdahl.phrased.plistbrew upgrade swaps in the new binary. Restart it with
launchctl kickstart -k gui/$(id -u)/dev.ericdahl.phrased. To uninstall, run
launchctl bootout gui/$(id -u)/dev.ericdahl.phrased, delete the plist, then
brew uninstall --cask phrased.
If the macOS firewall is on, it may drop incoming connections to phrased without asking,
especially in stealth mode. phrased logs a warning at startup when the firewall doesn't allow it.
make firewall prints the two commands to fix that; run them yourself:
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add "$PWD/phrased"
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --unblockapp "$PWD/phrased"Or add helper/phrased in System Settings > Network > Firewall > Options and set it to
"Allow incoming connections".
On a managed Mac (a work Mac with an MDM profile), neither works: the + button is grayed out,
and socketfilterfw answers "cannot be modified from command line on managed Mac computers".
phrased then logs that the firewall is managed and lists any phrased path the firewall already
allows. Either:
- run
phrasedfrom that allowed path, for example by installing there:make install-mac BIN_DIR=<the allowed folder>; or - ask IT to allow incoming connections for the path you want.
macOS only lets phrased type after you allow it once:
- Start it:
./phrased. If it can't type yet, it logscan't type yet: grant Accessibility. - Open System Settings > Privacy & Security > Accessibility, click +, and add
helper/phrased(or the terminal app you run it from, if macOS lists that instead). curl http://<tailnet-name>:47811/healthfrom a tailnet device shows"accessibility_ok": true.
sudo pacman -S wtype wl-clipboard
cd helper
make install-linuxThis builds phrased, installs it to ~/.local/bin/phrased, and runs it as the systemd user
service phrased, started with the graphical session and restarted if it fails. Run it again to
update. The unit (helper/install/phrased.service) doesn't hardcode WAYLAND_DISPLAY: it takes it
from the session environment uwsm imports at login, and skips starting without one.
- Status and log:
systemctl --user status phrasedandjournalctl --user -u phrased. - Typing uses
wtype. Pasting serves the phrase once withwl-copy --foreground --paste-once, sends Ctrl+Shift+V (the terminal paste key), waits forwl-copyto exit (at most 2 seconds), and puts the old clipboard text back. Only text is restored: a copied image is lost. /healthreportsaccessibility_ok: false(the Helper isn't Ready) when a tool is missing or there's no display, and the error says which.- Stop it with
systemctl --user disable --now phrased.
At startup the Helper picks its keyboard tool from the session (XDG_SESSION_TYPE,
XDG_CURRENT_DESKTOP):
| Session | Types with | Clipboard |
|---|---|---|
| Hyprland, Sway and other wlroots compositors | wtype |
wl-clipboard |
| GNOME or KDE on Wayland | ydotool |
wl-clipboard |
| Any desktop on X11 | xdotool |
xclip |
GNOME and KDE don't offer the virtual-keyboard protocol wtype needs, so there the Helper types
through ydotool, which goes through the kernel's /dev/uinput. It sends US keycodes, so a line
with anything beyond ASCII ("naïve café ✓") is pasted instead of typed. GNOME typing needs
Ubuntu 26.04 or later (ydotool 1.0.x); 24.04 and 25.10 ship ydotool 0.1.8, which doesn't work. On
Ubuntu with GNOME (Wayland):
sudo apt install ydotool wl-clipboard
sudo usermod -aG input "$USER" # the package's udev rule gives the input group /dev/uinput
# restart the computer so the group applies (see below), then:
systemctl --user enable --now ydotool # ydotoold, the daemon ydotool talks to
cd helper
make install-linux- The Helper isn't Ready until
ydotooldis running: it checks for its socket at$XDG_RUNTIME_DIR/.ydotool_socket(or$YDOTOOL_SOCKET). - Restart rather than log out. The user services (
systemctl --user) keep the groups you had when they started, and an ssh session or a second login keeps them running across a logout, soydotool.servicestill can't open/dev/uinput. A restart (orsudo systemctl restart user@$(id -u), which ends that user's sessions) picks the group up. - On an X11 session, install
xdotool xclipinstead; no daemon or group is needed. - Pasting works as on omarchy: it serves the phrase once, sends Ctrl+Shift+V (the terminal paste key), and puts the old clipboard text back.
Each version tag (vX.Y.Z) builds Linux packages for amd64 and arm64 and attaches them to that
tag's GitHub Release, with SHA256SUMS:
phrased_<version>_<arch>.debfor Ubuntu and Debian.phrased-<version>-linux-<arch>.tar.gz, plusPKGBUILD,phrased-bin.installandSRCINFOfor the AUR packagephrased-bin.SRCINFOis the AUR's.SRCINFO; a release asset's name can't start with a dot, so rename it when pushing to the AUR.
Either package installs /usr/bin/phrased and the user unit
/usr/lib/systemd/user/phrased.service. Installing turns nothing on; each user does that once
(below). Set the release first (the version without the v, and arm64 on an ARM machine):
v=1.2.3 arch=amd64
base=https://github.com/ericdahl-dev/phrasedeck/releases/download/v$vUbuntu or Debian. GNOME and KDE type through ydotool, which needs Ubuntu 26.04 or later (see
the table above).
curl -fLO "$base/phrased_${v}_$arch.deb" -fLO "$base/SHA256SUMS"
sha256sum --check --ignore-missing SHA256SUMS
sudo apt install "./phrased_${v}_$arch.deb"-
The .deb recommends
wl-clipboard,ydotoolandlibnotify-bin(for the panel's notice when an edited Phrase is first typed), soaptinstalls them unless you pass--no-install-recommends. It only suggestswtype. -
On GNOME or KDE, add yourself to the
inputgroup, restart, and startydotoold, as in Install on Ubuntu, GNOME or KDE:sudo usermod -aG input "$USER" # restart the computer, then: systemctl --user enable --now ydotool
-
On Sway or another wlroots compositor,
sudo apt install wtype. On X11,sudo apt install xdotool xclip.
Arch or Omarchy (phrased-bin). phrased-bin isn't on the AUR yet; until it is, build it from
the files on the release:
mkdir phrased-bin && cd phrased-bin
curl -fLO "$base/PKGBUILD" -fLO "$base/phrased-bin.install" -fLO "$base/SHA256SUMS"
sha256sum --check --ignore-missing SHA256SUMS # makepkg runs the PKGBUILD, so check it first
makepkg -simakepkgdownloads the tarball for your architecture and checks it against the SHA-256 in thePKGBUILD.- It needs
wtypeandwl-clipboard. Optional:ydotool(GNOME or KDE, with theinputgroup andydotool.servicesteps above),xdotoolandxclip(X11), andlibnotify(the panel's notice).
Turn it on. As your user, not root:
systemctl --user enable --now phrasedIt starts with your graphical session from then on. systemctl --user status phrased shows
whether it's running, and curl http://<tailnet-name>:47811/health whether it's Ready. Then pair a
phone with phrased pair (next section).
Each Device (a phone, an iPad or the panel) gets its own token. On the computer running phrased:
phrased pair # ./phrased pair from a build directoryIt prints a 6-digit code, valid for 2 minutes, and on the Mac also shows it as a notification. Enter the code in the app, or pair from curl:
curl -s http://<tailnet-name>:47811/pair -H 'Content-Type: application/json' \
-d '{"code":"123456","device":"curl"}'
# {"token":"..."}- The code works once. A wrong code gets 401; with no open window (never opened, expired or
already used)
/pairanswers 404. - After 10 wrong codes, pairing stays locked (404) until you run
phrased pairagain. /pairallows about one attempt a second, separately from/type.- Only hashes are saved: the token's in
~/.config/phrased/tokens.jsonand the code's inpairing.json, both mode 0600.
./phrased devices # name and pairing date of each device
./phrased revoke "curl" # that device's token gets 401 from its next requestThe running helper rereads tokens.json when it changes, so neither needs a restart. pair,
devices and revoke take the same -config directory as the helper.
curl -s http://<tailnet-name>:47811/type \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"what is the status of the agents? ask them if needed","enter":true}'enter: truepresses Enter after the text; leave it out to type without sending.modeisauto(the default),typeorpaste.autopastes any text with a newline and anything over 300 characters, and types the rest.typetypes newlines as Shift+Enter.phraseandedit(sent by the ESP32 panel): the Phrase's id and its edit counter. The first time the Helper types a Phrase at a new counter, it shows a notification without the Phrase's text (osascript on macOS,notify-sendon Linux), since an edit on the panel's web page could have changed what it types. The panel itself asks before the first send of an edited Phrase. The Helper remembers counters until it restarts.- On the Mac, a paste puts the phrase on the clipboard, sends Cmd+V, and about half a second later puts back everything that was on the clipboard before (images and rich text too). If you copy something in that half second, your new copy is kept instead.
GET /healthneeds no token and returnshostname,osandaccessibility_ok.GET /infoneeds the token and returnshostname, pluscertSHA256andlanPortwhen the LAN route is on. It's served over the tailnet only.
Status codes:
| Code | Meaning |
|---|---|
| 400 | bad JSON, mode or device name |
| 401 | bad token, or a wrong pairing code |
| 403 | Host not allowed |
| 404 | no pairing window open, or pairing locked |
| 409 | busy typing another phrase for over a second; nothing was typed |
| 413 | body over 64 KB, or text over 10,000 characters |
| 415 | not application/json |
| 429 | over 10 requests a second on /type, or about 1 a second on /pair |
| 500 | typing failed partway; some keys may have been typed |
| 503 | can't type yet: Accessibility not granted (macOS), or a typing tool missing, no display, or ydotoold not running (Linux); the error says which |
| Flag | Default | |
|---|---|---|
-port |
47811 |
port to listen on |
-config |
~/.config/phrased |
directory holding tokens.json and pairing.json |
-tailscale |
tailscale on PATH, else the Mac app's CLI |
used once at startup for this machine's names |
-allow-host |
none | another Host to accept; repeat for more |
-lan |
none | a home subnet to also serve over HTTPS, like 192.168.1.0/24; repeat for more |
-lan-port |
47812 |
HTTPS port for the -lan subnets |
If tailscale status --json fails, phrased exits: without the allowlist it would answer 403 to
everything. With -lan set it keeps running, serves only the LAN route, and checks again every 30 seconds;
once Tailscale is up the tailnet route works too, without a restart.
-lan 192.168.1.0/24 (or one subnet per line in ~/.config/phrased/lan-subnets; # starts a
comment) adds a second listener:
- HTTPS on port 47812, open only to those subnets and to tailnet peers. Each subnet must be
private (inside
10/8,172.16/12,192.168/16orfc00::/7), so a typo can't expose the helper to the internet. - Its certificate is self-signed ECDSA, made on first run as
lan-cert.pemandlan-key.pem(both mode 0600) in the config directory and reused after that. The app pins its SHA-256, whichphrasedlogs at startup, and gets it only over a trusted channel:/pairover the tailnet,GET /infoover the tailnet, or the QR code. If either file is unreadable or corrupt,phrasedstops with an error naming it rather than making a new one. Delete both files to make a new one, and then re-pair every device. - Bonjour advertises
_phrasedeck._tcpon the interfaces in those subnets, asphrased-<host>.local, with TXTv=1,ts=<tailnet name>,fp(the certificate fingerprint, for matching a paired computer) andport. TXT carries no token. If Tailscale comes up afterphrasedstarts,tsis added without a restart. /pairaddscertSHA256andlanPortto its reply, and the QR code fromphrased pairaddsfp,lan(this machine's LAN address) andlanPort.phrased pairalso printsPin for a panel:the first 16 hex of the fingerprint, which a panel without a camera takes with the code (#68). With Tailscale off, the QR code leaves outhostandportand carries only the LAN route./pair,/type, tokens, limits and lockout are shared by both listeners. Each accepts only its ownHostnames: tailnet names on 47811, LAN IPs and.localnames on 47812.
Limits:
-
The LAN addresses and interfaces are read at startup; restart
phrasedafter the network changes. -
On the Mac, the firewall must allow the binary (see above); on a managed Mac that means the already-allowed path.
-
On Linux, a firewall that blocks incoming connections (Omarchy turns
ufwon) must allow the LAN port from each-lansubnet (use yours, not the example's). Bonjour still resolves, but connections time out. The firewalld rule is IPv4 only; for anfc00::/7subnet usefamily="ipv6".sudo ufw allow from 192.168.1.0/24 to any port 47812 proto tcp # firewalld: sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port port="47812" protocol="tcp" accept' && sudo firewall-cmd --reload
curl -sk https://<lan-ip>:47812/health # -k: the app pins the certificate instead- Buttons on Pages. Tap a Button to type its Phrase. Swipe between Pages; pinch to change how big a Square is on this phone or iPad. "Pages" (top left) adds, renames, reorders and deletes Pages.
- Choose a Computer from the picker at the top. "Computers…" in it lists them, and its "+" pairs a new one. A dot shows whether the Helper is Ready, and a label shows the Route it last took.
- Add a Button with "+" in the top bar, at any time. Long-press a Button to edit or delete it.
- Arrange Buttons with Edit Deck in the long-press menu. Buttons jiggle and taps don't type.
Drag a Button to a new Spot; hold it at a screen edge to flip to the next Page. "−" deletes a
Button, and a "+" tile after the Buttons adds one. Done, or a tap on empty space, ends it.
For now Edit Deck only shows in Debug builds launched with
-arrangeEnabled, until PR #108 ships Arrange mode.
The panel is an Elecrow CrowPanel Advance 7" (ESP32-S3, 800×480 touch screen), hardware V1.4. It's
a Device: it pairs with a Helper over the Home network route, so the Helper needs -lan.
- To pair, run
phrased pair, open the panel's Pair screen, pick the Nearby Computer, and enter the Pairing code and thePin for a panel:line thatphrased pairprints (the panel has no camera for the QR code). The panel checks the Helper's certificate against the Pin before it sends anything. - Today it shows a fixed Page of Buttons; a tap sends its Phrase.
- In progress (#98): the panel's own Phrase list, edited from its web page.
- Planned (#94): a Bluetooth keyboard mode.
Build, flash, Wi-Fi and factory restore are in firmware/README.md.
Installs on Eric's iPhone and iPad go through internal TestFlight only: no external testers, no Beta App Review and no App Store release. Day-to-day development still runs straight from Xcode.
- Bundle ID (done): the explicit App ID
dev.ericdahl.phrasedeckis registered, with iCloud on for the sync slice (#10). - App record (done): "phrasedeck by Eric Dahl", Apple ID 6826742858, SKU
phrasedeck, primary language English (U.S.). The plain name was taken on the App Store; the home screen still showsphrasedeckfromCFBundleDisplayName. - Internal group. In the new app, TestFlight > Internal Testing > + : name it
Eric, leave "Enable automatic distribution" on, then add yourself as the only tester. - API key. Users and Access > Integrations > App Store Connect API > Team Keys > +: name
phrasedeck upload, access Admin (automatic signing creates the App Store profile, which an App Manager key can't do). Download the.p8(Apple only offers it once) and note the Key ID and the Issuer ID shown above the list. Keep the.p8outside the repo, for example in~/.appstoreconnect/private_keys/. - TestFlight app. Install TestFlight from the App Store on the iPhone and the iPad, signed in with the same Apple ID.
The Apple Distribution certificate for team 5HR8E5CWR7 must be in the login keychain (check with
security find-identity -v -p codesigning).
Tag a version on main and push the tag; .github/workflows/release.yml builds it and uploads it
to TestFlight:
git tag v0.2.0 && git push origin v0.2.0- Version and build: the tag sets the version (
v0.2.0becomes0.2.0), and the build number is the run number plus the attempt (for example12.1), so re-running a tag uploads a new, higher build. Both go on thexcodebuildcommand line; no file changes. - Only from
mainor arelease/branch: a tag on any other commit fails at "Check the tag". - Signing uses one fixed Apple Distribution certificate, and
ios/scripts/ci_profile.pygets (or makes) the App Store profilePhrasedeck CI App Storethrough the App Store Connect API. The workflow checks that the profile has CloudKit and production push before it archives. Nothing makes a new certificate, so Apple's certificate limit is never reached. - Secrets (repository secrets):
ASC_KEY_ID,ASC_ISSUER_ID,ASC_KEY_P8(the.p8contents),DEVELOPMENT_TEAM,DIST_CERT_P12_BASE64(the certificate and its key as a.p12, run throughbase64) andDIST_CERT_PASSWORD. The repository is public, but only a tag pushed here runs the workflow, never a pull request or a fork, so the secrets never reach outside code. - The same tag also runs
.github/workflows/helper-release.yml, which builds the Linux Helper packages (a .deb, a tarball per arch and the AURPKGBUILD) and attaches them to the tag's GitHub Release. - If it fails, open the run's Archive or Upload step; the logs are also attached to the run as
release-logs.
Don't mix the two upload paths within one version. A local upload's build number (for example
202610251430) is higher than any CI build number, so after one local upload at 0.2.0, every
tagged v0.2.0 run fails with bundle version must be higher. Use the local script for a version
no tag will ship, or set BUILD_NUMBER to match.
ASC_KEY_ID=… ASC_ISSUER_ID=… ASC_KEY_PATH=~/.appstoreconnect/private_keys/AuthKey_….p8 \
ios/scripts/testflight.shios/scripts/testflight.sh runs xcodegen, archives a Release build and uploads it with
ios/ExportOptions.plist (App Store Connect, automatic signing). The key is read from the
environment each run and is never committed.
- Build number: the UTC date and time to the minute (for example
202610251430), so it always grows. SetBUILD_NUMBERto override it. The version isMARKETING_VERSIONinios/project.yml. - Export compliance is answered in the app (
ITSAppUsesNonExemptEncryptionisNO), so the build doesn't stop to ask. --no-uploadsigns and writesios/build/export/Phrasedeck.ipawithout uploading.- The build shows in TestFlight after Apple processes it, usually 5 to 15 minutes. Install it from the TestFlight app on each device.
bundle version must be highermeans a build with that number exists; wait a minute and run it again.
The app icon's source is design/icon.svg; the asset catalog holds the 1024 x 1024 PNG made from it.