Skip to content

About

Stream Deck-style iOS app that types saved phrases into the focused window on Mac or Linux

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

130 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

phrasedeck

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.

The helper (phrased)

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).

Build (Mac)

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 test

The 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).

Install on the Mac (runs at login)

cd helper
make install-mac

This 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 phrased in a new place, so grant Accessibility to ~/.local/bin/phrased once and run the firewall commands that make install-mac prints. The stable signature keeps both through later installs.
  • It reads ~/.config/phrased, the same as running ./phrased by 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.

Install with Homebrew (Mac)

Not available yet: this needs the first signed release and the ericdahl-dev/homebrew-tap repository.

brew install --cask ericdahl-dev/tap/phrased

This 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.plist

brew 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.

Let it through the macOS firewall

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 phrased from 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.

Grant Accessibility

macOS only lets phrased type after you allow it once:

  1. Start it: ./phrased. If it can't type yet, it logs can't type yet: grant Accessibility.
  2. Open System Settings > Privacy & Security > Accessibility, click +, and add helper/phrased (or the terminal app you run it from, if macOS lists that instead).
  3. curl http://<tailnet-name>:47811/health from a tailnet device shows "accessibility_ok": true.

Install on omarchy (Hyprland)

sudo pacman -S wtype wl-clipboard
cd helper
make install-linux

This 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 phrased and journalctl --user -u phrased.
  • Typing uses wtype. Pasting serves the phrase once with wl-copy --foreground --paste-once, sends Ctrl+Shift+V (the terminal paste key), waits for wl-copy to exit (at most 2 seconds), and puts the old clipboard text back. Only text is restored: a copied image is lost.
  • /health reports accessibility_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.

Install on Ubuntu, GNOME or KDE

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 ydotoold is 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, so ydotool.service still can't open /dev/uinput. A restart (or sudo systemctl restart user@$(id -u), which ends that user's sessions) picks the group up.
  • On an X11 session, install xdotool xclip instead; 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.

Install from a package (Ubuntu, Debian, Arch)

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>.deb for Ubuntu and Debian.
  • phrased-<version>-linux-<arch>.tar.gz, plus PKGBUILD, phrased-bin.install and SRCINFO for the AUR package phrased-bin. SRCINFO is 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$v

Ubuntu 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, ydotool and libnotify-bin (for the panel's notice when an edited Phrase is first typed), so apt installs them unless you pass --no-install-recommends. It only suggests wtype.

  • On GNOME or KDE, add yourself to the input group, restart, and start ydotoold, 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 -si
  • makepkg downloads the tarball for your architecture and checks it against the SHA-256 in the PKGBUILD.
  • It needs wtype and wl-clipboard. Optional: ydotool (GNOME or KDE, with the input group and ydotool.service steps above), xdotool and xclip (X11), and libnotify (the panel's notice).

Turn it on. As your user, not root:

systemctl --user enable --now phrased

It 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).

Pair a device

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 directory

It 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) /pair answers 404.
  • After 10 wrong codes, pairing stays locked (404) until you run phrased pair again.
  • /pair allows about one attempt a second, separately from /type.
  • Only hashes are saved: the token's in ~/.config/phrased/tokens.json and the code's in pairing.json, both mode 0600.

Devices and revoke

./phrased devices          # name and pairing date of each device
./phrased revoke "curl"    # that device's token gets 401 from its next request

The 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.

Type with curl

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: true presses Enter after the text; leave it out to type without sending.
  • mode is auto (the default), type or paste. auto pastes any text with a newline and anything over 300 characters, and types the rest. type types newlines as Shift+Enter.
  • phrase and edit (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-send on 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 /health needs no token and returns hostname, os and accessibility_ok.
  • GET /info needs the token and returns hostname, plus certSHA256 and lanPort when 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

Flags

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.

Home network without Tailscale (optional)

-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/16 or fc00::/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.pem and lan-key.pem (both mode 0600) in the config directory and reused after that. The app pins its SHA-256, which phrased logs at startup, and gets it only over a trusted channel: /pair over the tailnet, GET /info over the tailnet, or the QR code. If either file is unreadable or corrupt, phrased stops 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._tcp on the interfaces in those subnets, as phrased-<host>.local, with TXT v=1, ts=<tailnet name>, fp (the certificate fingerprint, for matching a paired computer) and port. TXT carries no token. If Tailscale comes up after phrased starts, ts is added without a restart.
  • /pair adds certSHA256 and lanPort to its reply, and the QR code from phrased pair adds fp, lan (this machine's LAN address) and lanPort. phrased pair also prints Pin 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 out host and port and carries only the LAN route.
  • /pair, /type, tokens, limits and lockout are shared by both listeners. Each accepts only its own Host names: tailnet names on 47811, LAN IPs and .local names on 47812.

Limits:

  • The LAN addresses and interfaces are read at startup; restart phrased after 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 ufw on) must allow the LAN port from each -lan subnet (use yours, not the example's). Bonjour still resolves, but connections time out. The firewalld rule is IPv4 only; for an fc00::/7 subnet use family="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

Using the app

  • 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 (ESP32)

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 the Pin for a panel: line that phrased pair prints (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.

The iOS app on TestFlight

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.

One-time setup (Eric, in App Store Connect)

  1. Bundle ID (done): the explicit App ID dev.ericdahl.phrasedeck is registered, with iCloud on for the sync slice (#10).
  2. 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 shows phrasedeck from CFBundleDisplayName.
  3. Internal group. In the new app, TestFlight > Internal Testing > + : name it Eric, leave "Enable automatic distribution" on, then add yourself as the only tester.
  4. 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 .p8 outside the repo, for example in ~/.appstoreconnect/private_keys/.
  5. 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).

Release by tag (the usual way)

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.0 becomes 0.2.0), and the build number is the run number plus the attempt (for example 12.1), so re-running a tag uploads a new, higher build. Both go on the xcodebuild command line; no file changes.
  • Only from main or a release/ branch: a tag on any other commit fails at "Check the tag".
  • Signing uses one fixed Apple Distribution certificate, and ios/scripts/ci_profile.py gets (or makes) the App Store profile Phrasedeck CI App Store through 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 .p8 contents), DEVELOPMENT_TEAM, DIST_CERT_P12_BASE64 (the certificate and its key as a .p12, run through base64) and DIST_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 AUR PKGBUILD) 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.

Upload a build from this Mac

ASC_KEY_ID=… ASC_ISSUER_ID=… ASC_KEY_PATH=~/.appstoreconnect/private_keys/AuthKey_….p8 \
  ios/scripts/testflight.sh

ios/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. Set BUILD_NUMBER to override it. The version is MARKETING_VERSION in ios/project.yml.
  • Export compliance is answered in the app (ITSAppUsesNonExemptEncryption is NO), so the build doesn't stop to ask.
  • --no-upload signs and writes ios/build/export/Phrasedeck.ipa without 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 higher means 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.

About

Stream Deck-style iOS app that types saved phrases into the focused window on Mac or Linux

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages