Skip to content

Add iOS-parity web client#9

Open
AppleFE wants to merge 1 commit into
NewTurn2017:mainfrom
AppleFE:applefe/ios-parity-web-client
Open

Add iOS-parity web client#9
AppleFE wants to merge 1 commit into
NewTurn2017:mainfrom
AppleFE:applefe/ios-parity-web-client

Conversation

@AppleFE

@AppleFE AppleFE commented Jul 5, 2026

Copy link
Copy Markdown

Summary

  • add the iOS-parity web client bundle for the relay server
  • keep the fork branch based on upstream main with a single AppleFE-authored commit

Notes

  • commit author email has been corrected to the AppleFE GitHub noreply address so attribution can be recognized after merge

Summary by CodeRabbit

  • New Features

    • Added a browser-based cmux Remote interface with workspace, terminal, inbox, and settings screens.
    • Relay setup now uses port 80 by default and supports running as a system service.
  • Bug Fixes

    • Improved relay startup reliability by preventing multiple instances from running at once.
    • Tightened HTTP responses to avoid connection issues.
  • Documentation

    • Updated setup, pairing, health-check, and troubleshooting instructions to match the new port and service configuration.

Web client (Sources/RelayServer/WebResources): full 4-tab UI mirroring the
iOS app — Workspaces (cards + per-workspace surfaces, create/rename/close),
Active (terminal mirror with surface chips, composer, keypad, file attach),
Inbox (event-frame notifications), Settings (guide/status/unpair). Sizes the
terminal to the surface grid (CJK-aware width), pins to bottom, and
self-heals revoked bearer tokens by re-registering on WS upgrade rejection.

Also bundles in-progress relay/iOS/script/doc updates already in the tree.
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR changes the relay server's default port from 4399 to 80, converts the macOS deployment from a per-user launchd agent to a root system daemon, adds a singleton instance lock, and embeds a browser-based web client (HTML/CSS/JS) served directly by the relay's HTTP server. Documentation, install/uninstall scripts, and iOS app defaults are updated accordingly.

Changes

Relay Server Core

Layer / File(s) Summary
Default port and config
Sources/RelayCore/ConfigLoader.swift, Sources/RelayServer/CmuxRelay.swift
Default listen address changes from 0.0.0.0:4399 to 0.0.0.0:80; path helpers refactored to use HOME env var; parseListen fallback port changed to 80.
Singleton lock
Sources/RelayServer/CmuxRelay.swift
Adds acquireSingletonLock() using non-blocking flock on ~/.cmuxremote/relay.lock, invoked at Serve.run() startup.
Static asset serving
Package.swift, Sources/RelayServer/Routes.swift, Sources/RelayServer/HTTPServer.swift
Bundles WebResources into the executable target; HTTPResponseLite gains a headers field; Routes.handle serves static files via serveStatic/contentType; HTTPServer.respond forwards custom headers while guarding Content-Length/Connection.
Web client
Sources/RelayServer/WebResources/app.js, .../index.html, .../style.css
New browser client implementing WebSocket/RPC connection, terminal rendering, workspace/surface management, inbox, settings, attachments, and full UI markup/styling.

Deployment Scripts

Layer / File(s) Summary
Install as root daemon
scripts/install-launchd.sh, scripts/relay.plist.tmpl
Rewrites install script to target system-domain launchd, resolve owner home, install to root-owned prefix with WebResources, migrate legacy agents, default config to port 80; plist template adds ThrottleInterval and HOME env.
Uninstall as root daemon
scripts/uninstall-launchd.sh
Targets system-domain plist location and requires root privileges.
Smoke test
scripts/smoke-relay.sh
Default SMOKE_PORT changes from 4399 to 80.

Documentation and iOS Defaults

Layer / File(s) Summary
README updates
README.md, README.en.md
Quickstart, pairing, configuration, and troubleshooting sections updated to port 80 and system daemon commands.
Connection guide updates
docs/connection-guide.md, docs/connection-guide.en.md
Install/health-check/pairing/troubleshooting instructions updated to port 80 and system service; adds browser access section and lock/auto-start FAQ.
iOS and misc defaults
ios/CmuxRemote/CmuxRemoteApp.swift, ios/CmuxRemote/Demo/DemoContent.swift, ios/CmuxRemote/Settings/SettingsView.swift, PRIVACY.md, scripts/generate_launch_assets.py, .gitignore
Default port fallback and demo/tutorial text updated to 80; privacy doc updated; .gjc/ ignore rule added.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant AppJS as app.js
  participant Routes as Routes.serveStatic
  participant HTTPServer
  participant CmuxRelay

  Browser->>HTTPServer: GET /
  HTTPServer->>Routes: handle(request)
  Routes->>Routes: serveStatic(path)
  Routes-->>HTTPServer: HTTPResponseLite(index.html, headers)
  HTTPServer-->>Browser: 200 OK + Content-Type + Cache headers

  Browser->>AppJS: load /app.js
  AppJS->>HTTPServer: POST /v1/devices/me/register
  HTTPServer-->>AppJS: bearer token + device id
  AppJS->>CmuxRelay: WebSocket connect (bearer.<token>)
  CmuxRelay-->>AppJS: hello ack
  AppJS->>CmuxRelay: RPC surface.subscribe
  CmuxRelay-->>AppJS: screen.full push
  AppJS->>Browser: render terminal
Loading

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding an iOS-parity web client bundle.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (5)
Sources/RelayServer/CmuxRelay.swift (1)

228-231: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

PID file not truncated before write; stale bytes can persist.

Opening with O_CREAT | O_RDWR (no truncation) and then write()-ing the PID leaves trailing bytes from a previous, longer PID string if the new PID is shorter, corrupting the debug info in the lock file.

♻️ Proposed fix
     // Record the PID for debugging — `ps`/`launchctl` can confirm ownership.
     let pidStr = "\(ProcessInfo.processInfo.processIdentifier)\n"
+    ftruncate(fd, 0)
     _ = pidStr.withCString { write(fd, $0, pidStr.utf8.count) }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RelayServer/CmuxRelay.swift` around lines 228 - 231, The PID write in
CmuxRelay’s PID-file handling leaves stale bytes because the file is opened
without truncation before `write()`. Update the PID-file setup in the same block
that writes `pidStr` to ensure the file is truncated or otherwise overwritten
from zero before writing the new PID, so shorter PIDs don’t leave trailing
characters behind.
Sources/RelayServer/Routes.swift (1)

217-224: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider ETag/Last-Modified instead of full no-store.

no-cache, no-store, must-revalidate forces a full re-download of app.js/style.css on every request. Adding a content-hash or mtime-based ETag/Last-Modified would let the browser send conditional requests, preserving freshness after reinstalls while avoiding always re-transferring the full body.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RelayServer/Routes.swift` around lines 217 - 224, The static asset
response in Routes.swift is overusing "Cache-Control: no-cache, no-store,
must-revalidate", which prevents conditional caching for app.js/style.css.
Update the response-building path in the asset-serving logic to use an ETag
and/or Last-Modified derived from the bundled asset content or timestamp, and
relax the cache policy so browsers can revalidate instead of always
re-downloading. Keep the stale-after-reinstall safety behavior, but make the
headers in the asset response conditional-cache friendly in the same code that
builds the .ok response.
Sources/RelayServer/WebResources/index.html (2)

5-5: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

user-scalable=no disables pinch-zoom.

Combined with maximum-scale=1, this blocks users who need to zoom text for readability (WCAG 1.4.4). Given this is a chat/terminal-heavy UI, consider allowing zoom.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RelayServer/WebResources/index.html` at line 5, The viewport meta tag
in index.html is preventing users from zooming because it includes
user-scalable=no and maximum-scale=1. Update the viewport settings to allow
pinch-zoom and text scaling while keeping the existing layout behavior, and
verify the change in the meta tag used by the app shell.

2-2: 🎯 Functional Correctness | 🔵 Trivial

Mixed English/Korean UI copy.

<html lang="ko"> is set, and several instructional/empty-state strings are Korean (e.g. Line 59-60, Line 134-135, the tutorial steps at Lines 149-153, Line 175), while every other label, button, and tab in the file is English. This is inconsistent and will confuse non-Korean-speaking users on exactly the screens (tutorial, empty states) meant to guide first-time setup.

Confirm the intended primary locale and either translate the whole UI or move the Korean strings behind proper i18n rather than leaving a partial mix.

Also applies to: 59-60, 134-135, 149-153, 175-175

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RelayServer/WebResources/index.html` at line 2, The UI text in
index.html is inconsistently mixed between English and Korean, so align the
locale by either translating all labels/buttons/tabs and empty-state/tutorial
copy to one language or moving the Korean strings behind proper i18n in the same
places used by the tutorial and empty-state content. Check the html lang
attribute and the affected instructional strings in the main template so the
visible copy is consistent for users across the whole page.
scripts/install-launchd.sh (1)

206-212: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Bundle discovery via find | head -1 can pick a stale/wrong build.

If .build contains more than one path matching *release* (e.g. builds for multiple architectures, or a stale artifact from an earlier build), head -1 picks an arbitrary one, potentially installing web assets that don't match the freshly built $BIN_SRC. Deriving the bundle path from $(dirname "$BIN_SRC") (same build output directory as the binary) would be more deterministic than a tree-wide find.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-launchd.sh` around lines 206 - 212, The bundle lookup in the
install script can select the wrong release artifact because the current
find/head approach searches the whole .build tree and may return a stale or
mismatched bundle. Update the Bundle discovery logic in
scripts/install-launchd.sh to derive CmuxRemote_RelayServer.bundle from the same
build output directory as BIN_SRC (for example using the binary’s dirname)
instead of scanning .build, so the installed web assets always match the freshly
built binary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ios/CmuxRemote/Settings/SettingsView.swift`:
- Line 9: The port Stepper in SettingsView currently excludes the documented
default of 80, so update the Stepper range used with the port `@AppStorage`
binding to include 80 (for example, widen it to start at 80 or 1). Locate the
Stepper/port control in SettingsView and adjust its allowed range so fresh
installs can keep the default port instead of being forced away from it.

In `@README.md`:
- Around line 399-405: The daemon command swap is incomplete in the README’s
system daemon section: the pause flow still shows an unprivileged launchctl
bootout command, which will not work for the root-owned service. Update the
daemon-management examples around the existing
SERVICE="system/com.genie.cmuxremote" block so the stop/pause path also uses the
privileged system-domain form, matching the kickstart and print examples.

In `@scripts/install-launchd.sh`:
- Around line 198-201: The install flow overwrites BIN_DEST in place before
booting out any existing launchd job, which can crash a still-running daemon.
Update install-launchd.sh so the binary replacement in the install path is
atomic: write the new binary to a temporary location and rename it into BIN_DEST
only after the old instance is safely stopped, and apply the same ordering
around the bootout/bootstrap sequence referenced in the launchd setup logic. Use
the existing install binary step and daemon lifecycle steps to locate the
affected cp/bootout/bootstrap flow.
- Around line 83-96: The dry-run fallback in install-launchd.sh still fails when
USER is unset because OWNER_USER becomes the literal "unknown" and real_home()
then triggers fail on the dscl lookup. Update the dry-run branch around
OWNER_USER and real_home() so dry-run can preview safely without requiring a
resolvable account, e.g. by skipping the OWNER_HOME lookup and any fail path
when DRY_RUN=1 and OWNER_USER is not available/resolvable, while keeping the
non-dry-run behavior unchanged.

In `@Sources/RelayServer/WebResources/app.js`:
- Around line 15-20: The HTTPS default port logic in app.js is using an
inconsistent scheme value, so the PORT fallback never selects 443 and both BASE
and WS_URL are built with the wrong port. Update the SCHEME/WS_SCHEME and PORT
setup together so the HTTPS case is detected correctly in the top-level
constants, then rebuild BASE and WS_URL from those corrected values.
- Around line 427-441: Shift-only printable input is being treated as a shortcut
in the `attachCustomKeyEventHandler` handler, so characters like capitals and
symbols get intercepted instead of reaching xterm. Update the shortcut detection
logic in this handler to ignore `shift` when deciding whether a key event should
be sent through `sendKey(...)`, while still allowing true modifier shortcuts
that include `ctrl`, `alt`, or `cmd`; keep the existing `mapDomKey(event)` and
`isSpecialKey(keyName)` flow intact.

In `@Sources/RelayServer/WebResources/index.html`:
- Line 8: The xterm.js assets in the WebResources page are still loaded from
jsDelivr, so update the HTML to serve both the xterm CSS and JS locally instead
of using the CDN. Make the change in index.html where the terminal assets are
referenced, and keep the existing xterm-related includes consistent with the
local vendored files so the page no longer depends on third-party delivery.

---

Nitpick comments:
In `@scripts/install-launchd.sh`:
- Around line 206-212: The bundle lookup in the install script can select the
wrong release artifact because the current find/head approach searches the whole
.build tree and may return a stale or mismatched bundle. Update the Bundle
discovery logic in scripts/install-launchd.sh to derive
CmuxRemote_RelayServer.bundle from the same build output directory as BIN_SRC
(for example using the binary’s dirname) instead of scanning .build, so the
installed web assets always match the freshly built binary.

In `@Sources/RelayServer/CmuxRelay.swift`:
- Around line 228-231: The PID write in CmuxRelay’s PID-file handling leaves
stale bytes because the file is opened without truncation before `write()`.
Update the PID-file setup in the same block that writes `pidStr` to ensure the
file is truncated or otherwise overwritten from zero before writing the new PID,
so shorter PIDs don’t leave trailing characters behind.

In `@Sources/RelayServer/Routes.swift`:
- Around line 217-224: The static asset response in Routes.swift is overusing
"Cache-Control: no-cache, no-store, must-revalidate", which prevents conditional
caching for app.js/style.css. Update the response-building path in the
asset-serving logic to use an ETag and/or Last-Modified derived from the bundled
asset content or timestamp, and relax the cache policy so browsers can
revalidate instead of always re-downloading. Keep the stale-after-reinstall
safety behavior, but make the headers in the asset response conditional-cache
friendly in the same code that builds the .ok response.

In `@Sources/RelayServer/WebResources/index.html`:
- Line 5: The viewport meta tag in index.html is preventing users from zooming
because it includes user-scalable=no and maximum-scale=1. Update the viewport
settings to allow pinch-zoom and text scaling while keeping the existing layout
behavior, and verify the change in the meta tag used by the app shell.
- Line 2: The UI text in index.html is inconsistently mixed between English and
Korean, so align the locale by either translating all labels/buttons/tabs and
empty-state/tutorial copy to one language or moving the Korean strings behind
proper i18n in the same places used by the tutorial and empty-state content.
Check the html lang attribute and the affected instructional strings in the main
template so the visible copy is consistent for users across the whole page.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 421e0eca-817c-443d-9813-dbf822a9d212

📥 Commits

Reviewing files that changed from the base of the PR and between 9f805eb and 63b91b4.

📒 Files selected for processing (22)
  • .gitignore
  • PRIVACY.md
  • Package.swift
  • README.en.md
  • README.md
  • Sources/RelayCore/ConfigLoader.swift
  • Sources/RelayServer/CmuxRelay.swift
  • Sources/RelayServer/HTTPServer.swift
  • Sources/RelayServer/Routes.swift
  • Sources/RelayServer/WebResources/app.js
  • Sources/RelayServer/WebResources/index.html
  • Sources/RelayServer/WebResources/style.css
  • docs/connection-guide.en.md
  • docs/connection-guide.md
  • ios/CmuxRemote/CmuxRemoteApp.swift
  • ios/CmuxRemote/Demo/DemoContent.swift
  • ios/CmuxRemote/Settings/SettingsView.swift
  • scripts/generate_launch_assets.py
  • scripts/install-launchd.sh
  • scripts/relay.plist.tmpl
  • scripts/smoke-relay.sh
  • scripts/uninstall-launchd.sh

var onTriggerTestNotification: (@MainActor () -> TestNotificationResult)? = nil
@AppStorage("cmux.host") private var host: String = ""
@AppStorage("cmux.port") private var port: Int = 4399
@AppStorage("cmux.port") private var port: Int = 80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files 'ios/CmuxRemote/Settings/SettingsView.swift' 'README.md' 'docs/*' '*.md' | sed -n '1,120p'
echo '--- SettingsView.swift ---'
cat -n ios/CmuxRemote/Settings/SettingsView.swift
echo '--- search cmux.port ---'
rg -n 'cmux\.port|port' ios/CmuxRemote README.md docs -g '!**/DerivedData/**' || true

Repository: NewTurn2017/cmux-remote

Length of output: 36026


🌐 Web query:

SwiftUI Stepper(value:in:) out of range bound value behavior clamp increment decrement Apple documentation

💡 Result:

When using the SwiftUI Stepper(value:in:step:) initializer, SwiftUI automatically manages the value within the provided ClosedRange [1][2][3]. The component exhibits the following behaviors: Clamping and Bounds Enforcement: When you provide a range (in:) to the Stepper, SwiftUI automatically clamps the bound value to ensure it never exceeds the specified minimum or maximum [4][2]. If a user attempts to increment or decrement the value beyond these bounds, the corresponding button (+ or -) is automatically disabled by the framework [4][2]. You do not need to implement manual guard logic to prevent the value from leaving the range [4][2]. Increment and Decrement Behavior: The Stepper uses the provided step value to adjust the binding [4][3]. If an increment or decrement would cause the value to exceed the boundary, SwiftUI prevents the operation and clamps the value to the boundary limit [2]. Important Distinction regarding Initializers: There are two primary ways to initialize a Stepper in SwiftUI [5]: 1. Value-based (with range): Using init(value:in:step:...), SwiftUI handles all range checking and clamping for you [4][2]. This is the recommended approach for maintaining data integrity [4]. 2. Callback-based (onIncrement/onDecrement): If you choose to use the initializers that provide onIncrement and onDecrement callbacks instead of a range, SwiftUI does not automatically enforce bounds [4]. In this case, you are responsible for manually checking the value and implementing guard statements to prevent the value from going out of your intended range [4]. In summary, for the value-based Stepper, clamping and button disabling are native, automatic features of the component [4][2]. If you require custom logic alongside range constraints, you must either manage the state manually or create a wrapper view that combines the behaviors [5].

Citations:


Include the default port in the Stepper range

The port Stepper excludes 80, so fresh installs get pushed off the documented default. Expand the range to include 80 (for example 80...65535 or 1...65535).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/CmuxRemote/Settings/SettingsView.swift` at line 9, The port Stepper in
SettingsView currently excludes the documented default of 80, so update the
Stepper range used with the port `@AppStorage` binding to include 80 (for example,
widen it to start at 80 or 1). Locate the Stepper/port control in SettingsView
and adjust its allowed range so fresh installs can keep the default port instead
of being forced away from it.

Comment thread README.md
Comment on lines +399 to +405
SERVICE="system/com.genie.cmuxremote"

# 재시작 (재빌드 없이 — 가장 자주 씀)
launchctl kickstart -k "$SERVICE"
sudo launchctl kickstart -k "$SERVICE"

# 상태 (state / pid / last exit code)
launchctl print "$SERVICE" | grep -E "state|pid|last exit"
sudo launchctl print "$SERVICE" | grep -E "state|pid|last exit"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Finish the system-daemon command swap.

The switch to system/... is right, but this section still leaves the later launchctl bootout example unprivileged, so the documented pause flow will fail under the new root daemon model.

🔧 Proposed fix
- launchctl bootout "$SERVICE"
+ sudo launchctl bootout "$SERVICE"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 399 - 405, The daemon command swap is incomplete in
the README’s system daemon section: the pause flow still shows an unprivileged
launchctl bootout command, which will not work for the root-owned service.
Update the daemon-management examples around the existing
SERVICE="system/com.genie.cmuxremote" block so the stop/pause path also uses the
privileged system-domain form, matching the kickstart and print examples.

Comment on lines +83 to +96
if [ -z "$OWNER_USER" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
OWNER_USER="${USER:-unknown}"
note "dry-run without sudo; previewing owner as '$OWNER_USER'"
else
fail "could not determine owner user; run under sudo or set CMUX_REMOTE_USER"
fi
fi

real_home() {
dscl . -read "/Users/$1" NFSHomeDirectory 2>/dev/null | awk '{print $2}'
}
OWNER_HOME="$(real_home "$OWNER_USER")"
[ -n "$OWNER_HOME" ] || fail "could not resolve home for user '$OWNER_USER' (dscl lookup failed)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

fail() can trigger even in dry-run if $USER is unset.

When run without sudo and without CMUX_REMOTE_USER, dry-run falls back to OWNER_USER="${USER:-unknown}". If $USER isn't set (common in non-interactive/CI shells), OWNER_USER becomes the literal string unknown, and the subsequent dscl lookup for /Users/unknown will fail, causing fail (exit 1) even though this is supposed to be a non-destructive dry-run preview.

🔧 Proposed fix
-    OWNER_USER="${USER:-unknown}"
+    OWNER_USER="${USER:-$(id -un)}"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if [ -z "$OWNER_USER" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
OWNER_USER="${USER:-unknown}"
note "dry-run without sudo; previewing owner as '$OWNER_USER'"
else
fail "could not determine owner user; run under sudo or set CMUX_REMOTE_USER"
fi
fi
real_home() {
dscl . -read "/Users/$1" NFSHomeDirectory 2>/dev/null | awk '{print $2}'
}
OWNER_HOME="$(real_home "$OWNER_USER")"
[ -n "$OWNER_HOME" ] || fail "could not resolve home for user '$OWNER_USER' (dscl lookup failed)"
if [ -z "$OWNER_USER" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
OWNER_USER="${USER:-$(id -un)}"
note "dry-run without sudo; previewing owner as '$OWNER_USER'"
else
fail "could not determine owner user; run under sudo or set CMUX_REMOTE_USER"
fi
fi
real_home() {
dscl . -read "/Users/$1" NFSHomeDirectory 2>/dev/null | awk '{print $2}'
}
OWNER_HOME="$(real_home "$OWNER_USER")"
[ -n "$OWNER_HOME" ] || fail "could not resolve home for user '$OWNER_USER' (dscl lookup failed)"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-launchd.sh` around lines 83 - 96, The dry-run fallback in
install-launchd.sh still fails when USER is unset because OWNER_USER becomes the
literal "unknown" and real_home() then triggers fail on the dscl lookup. Update
the dry-run branch around OWNER_USER and real_home() so dry-run can preview
safely without requiring a resolvable account, e.g. by skipping the OWNER_HOME
lookup and any fail path when DRY_RUN=1 and OWNER_USER is not
available/resolvable, while keeping the non-dry-run behavior unchanged.

Comment on lines +198 to 201
note "installing binary under $PREFIX (root-owned); config/logs under $DEST"
mkdir -p "$PREFIX/bin" "$DEST" "$LOGDIR" "$LAUNCH_DAEMONS_DIR"
cp "$BIN_SRC" "$BIN_DEST"
chmod 755 "$BIN_DEST"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Binary is overwritten in-place while a prior daemon may still be running it.

cp "$BIN_SRC" "$BIN_DEST" (line 200) truncates/rewrites the destination file's existing inode via O_TRUNC. The bootout of any prior running system-domain instance happens later, at lines 243-251 — after the binary has already been overwritten. If a previous install of this daemon is currently executing $BIN_DEST as root and bound to port 80, truncating its backing file mid-execution risks a SIGBUS/crash of the live production daemon during what's meant to be a routine upgrade, and worse, a subsequent KeepAlive crash-loop against a half-written binary before bootout/bootstrap runs.

Copy to a temp file and atomically rename into place so any running process keeps its original (now-unlinked) inode intact:

🔧 Proposed fix: atomic binary replace
-cp "$BIN_SRC" "$BIN_DEST"
-chmod 755 "$BIN_DEST"
+cp "$BIN_SRC" "$BIN_DEST.new"
+chmod 755 "$BIN_DEST.new"
+mv -f "$BIN_DEST.new" "$BIN_DEST"

Also applies to: 243-251

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-launchd.sh` around lines 198 - 201, The install flow
overwrites BIN_DEST in place before booting out any existing launchd job, which
can crash a still-running daemon. Update install-launchd.sh so the binary
replacement in the install path is atomic: write the new binary to a temporary
location and rename it into BIN_DEST only after the old instance is safely
stopped, and apply the same ordering around the bootout/bootstrap sequence
referenced in the launchd setup logic. Use the existing install binary step and
daemon lifecycle steps to locate the affected cp/bootout/bootstrap flow.

Comment on lines +15 to +20
const SCHEME = location.protocol === 'https:' ? 'https' : 'http';
const WS_SCHEME = location.protocol === 'https:' ? 'wss' : 'ws';
const HOST = location.hostname;
const PORT = location.port || (SCHEME === 'https:' ? '443' : '80');
const BASE = `${SCHEME}://${HOST}:${PORT}`;
const WS_URL = `${WS_SCHEME}://${HOST}:${PORT}/v1/ws`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

HTTPS default port resolves to 80. SCHEME is assigned 'https' (no trailing colon) on Line 15, so the check SCHEME === 'https:' on Line 18 is always false. On an HTTPS page served without an explicit port, PORT becomes '80' instead of '443', breaking both BASE and WS_URL.

🐛 Proposed fix
-const PORT = location.port || (SCHEME === 'https:' ? '443' : '80');
+const PORT = location.port || (location.protocol === 'https:' ? '443' : '80');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const SCHEME = location.protocol === 'https:' ? 'https' : 'http';
const WS_SCHEME = location.protocol === 'https:' ? 'wss' : 'ws';
const HOST = location.hostname;
const PORT = location.port || (SCHEME === 'https:' ? '443' : '80');
const BASE = `${SCHEME}://${HOST}:${PORT}`;
const WS_URL = `${WS_SCHEME}://${HOST}:${PORT}/v1/ws`;
const SCHEME = location.protocol === 'https:' ? 'https' : 'http';
const WS_SCHEME = location.protocol === 'https:' ? 'wss' : 'ws';
const HOST = location.hostname;
const PORT = location.port || (location.protocol === 'https:' ? '443' : '80');
const BASE = `${SCHEME}://${HOST}:${PORT}`;
const WS_URL = `${WS_SCHEME}://${HOST}:${PORT}/v1/ws`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RelayServer/WebResources/app.js` around lines 15 - 20, The HTTPS
default port logic in app.js is using an inconsistent scheme value, so the PORT
fallback never selects 443 and both BASE and WS_URL are built with the wrong
port. Update the SCHEME/WS_SCHEME and PORT setup together so the HTTPS case is
detected correctly in the top-level constants, then rebuild BASE and WS_URL from
those corrected values.

Comment on lines +427 to +441
term.attachCustomKeyEventHandler((event) => {
if (event.type !== 'keydown') return true;
const mods = [];
if (event.ctrlKey) mods.push('ctrl');
if (event.altKey) mods.push('alt');
if (event.shiftKey) mods.push('shift');
if (event.metaKey) mods.push('cmd');
const keyName = mapDomKey(event);
if (!keyName) return true;
if (mods.length === 0 && !isSpecialKey(keyName)) return true;
const ordered = ['ctrl', 'alt', 'shift', 'cmd'].filter(m => mods.includes(m));
const encoded = ordered.length ? ordered.join('+') + '+' + keyName : keyName;
sendKey(encoded);
return false;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

xterm.js attachCustomKeyEventHandler return false onData

💡 Result:

In xterm.js, attachCustomKeyEventHandler and onData serve different purposes, and you cannot return false from an onData handler to influence key processing. 1. attachCustomKeyEventHandler: This method is designed to intercept KeyboardEvents before they are processed by the terminal [1][2]. The callback function you provide receives a KeyboardEvent and is expected to return a boolean [3][1]. Returning false from this handler tells xterm.js that the event should not be processed further (effectively "canceling" it) [1][4]. This is the correct mechanism to use if you want to prevent specific key combinations (like shortcuts or browser hotkeys) from being handled by xterm.js [1][5][6]. 2. onData: The onData event listener fires after the terminal has already processed user input (typing, pasting, etc.) and determined what data should be sent to the backing pseudoterminal (pty) [7][8]. The callback receives a string of data [9][8]. Because onData is an event emitter listener, it does not support returning false to prevent terminal behavior; it is strictly a notification that data has been generated [8]. If your goal is to prevent xterm.js from responding to specific keys, you must use attachCustomKeyEventHandler, not onData [1][5]. Example of preventing a key combination: term.attachCustomKeyEventHandler(event => { // Return false to prevent xterm.js from processing the event if (event.ctrlKey && event.key === 'c') { return false; } // Return true (or undefined) to allow default processing return true; });

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== xterm version references ==\n'
rg -n --hidden --no-messages 'xterm|attachCustomKeyEventHandler|onData' \
  package.json package-lock.json yarn.lock pnpm-lock.yaml Sources README.md . \
  | head -n 200

printf '\n== target file context ==\n'
sed -n '400,460p' Sources/RelayServer/WebResources/app.js

printf '\n== bundling clues ==\n'
git ls-files | rg '(^|/)(package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|app\.js|xterm)'

Repository: NewTurn2017/cmux-remote

Length of output: 2417


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== relevant functions ==\n'
sed -n '1,140p' Sources/RelayServer/WebResources/app.js

printf '\n== key handler region ==\n'
sed -n '420,455p' Sources/RelayServer/WebResources/app.js

Repository: NewTurn2017/cmux-remote

Length of output: 6638


Shift-only printable keys are intercepted here. event.shiftKey makes mods.length > 0, so typing capitals or symbols like @/! falls into sendKey(...) and returns false instead of reaching xterm’s normal text input path. Exclude shift from the shortcut trigger check for printable keys.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RelayServer/WebResources/app.js` around lines 427 - 441, Shift-only
printable input is being treated as a shortcut in the
`attachCustomKeyEventHandler` handler, so characters like capitals and symbols
get intercepted instead of reaching xterm. Update the shortcut detection logic
in this handler to ignore `shift` when deciding whether a key event should be
sent through `sendKey(...)`, while still allowing true modifier shortcuts that
include `ctrl`, `alt`, or `cmd`; keep the existing `mapDomKey(event)` and
`isSpecialKey(keyName)` flow intact.

<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="color-scheme" content="dark">
<title>cmux Remote</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/css/xterm.css">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Files\n'
git ls-files Sources/RelayServer/WebResources/index.html Sources/RelayServer/Routes.swift Package.swift 2>/dev/null || true

printf '\n## index.html\n'
nl -ba Sources/RelayServer/WebResources/index.html | sed -n '1,240p'

printf '\n## Routes.swift\n'
nl -ba Sources/RelayServer/Routes.swift | sed -n '1,260p'

printf '\n## Package.swift\n'
nl -ba Package.swift | sed -n '1,260p'

Repository: NewTurn2017/cmux-remote

Length of output: 317


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find any references to xterm assets and static-file handling.
rg -n --hidden --glob '!**/.git/**' -S 'xterm|serveStatic|contentType|WebResources|cdn.jsdelivr.net' Sources Package.swift

Repository: NewTurn2017/cmux-remote

Length of output: 1743


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the surrounding HTML around the CDN references and any local resource hints.
nl -ba Sources/RelayServer/WebResources/index.html | sed -n '1,220p'

Repository: NewTurn2017/cmux-remote

Length of output: 201


Vendor xterm.js locally instead of loading it from jsDelivr. The page pulls both the terminal CSS and JS from a third-party CDN without integrity/crossorigin, which adds a failure point for a Tailscale-only deployment and exposes the app’s authenticated session to CDN tampering or MITM risk. If CDN loading must remain, add SRI plus crossorigin="anonymous".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RelayServer/WebResources/index.html` at line 8, The xterm.js assets
in the WebResources page are still loaded from jsDelivr, so update the HTML to
serve both the xterm CSS and JS locally instead of using the CDN. Make the
change in index.html where the terminal assets are referenced, and keep the
existing xterm-related includes consistent with the local vendored files so the
page no longer depends on third-party delivery.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant