Compare commits
302
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37a0c13319 | ||
|
|
e1648e7f31 | ||
|
|
3faf79585c | ||
|
|
7daa9c6094 | ||
|
|
99b7b370fe | ||
|
|
0676a4cdac | ||
|
|
c5bf3ca465 | ||
|
|
21675cc273 | ||
|
|
371edf3c32 | ||
|
|
c0b8391865 | ||
|
|
6b79b62d9a | ||
|
|
42715b7f9e | ||
|
|
ce41f152de | ||
|
|
a26cf6d570 | ||
|
|
82454e9bdb | ||
|
|
722cae8562 | ||
|
|
fecf416d50 | ||
|
|
9d8d66b10e | ||
|
|
0e359517f1 | ||
|
|
5fcdb66a61 | ||
|
|
29d809d7d5 | ||
|
|
3347fd9a90 | ||
|
|
6277fdb408 | ||
|
|
80054fcaca | ||
|
|
10013687b0 | ||
|
|
3a205be7a2 | ||
|
|
8fa42e5e23 | ||
|
|
4f965d05ae | ||
|
|
3a914b106d | ||
|
|
981d4d7816 | ||
|
|
bfe48260b6 | ||
|
|
6239b29a98 | ||
|
|
2c7bc82428 | ||
|
|
27a5086147 | ||
|
|
52c5f92251 | ||
|
|
93298252ad | ||
|
|
7857841837 | ||
|
|
636ae73463 | ||
|
|
24d864951f | ||
|
|
76a1d08c0b | ||
|
|
11efc10f54 | ||
|
|
189ac5b27b | ||
|
|
3a8f1156a6 | ||
|
|
1c4bd8c319 | ||
|
|
4540f16c5b | ||
|
|
9117d49b91 | ||
|
|
59dcd20a86 | ||
|
|
3821e90345 | ||
|
|
b49eb593aa | ||
|
|
bbbf26c5e6 | ||
|
|
8d4788a898 | ||
|
|
86b33c919d | ||
|
|
ea43159b2d | ||
|
|
f4263ae8b9 | ||
|
|
098b4ad007 | ||
|
|
87fb5db1b7 | ||
|
|
8f8eb05a58 | ||
|
|
d6e8d30ef6 | ||
|
|
ac8082c81d | ||
|
|
07322a4648 | ||
|
|
97c8a7cadf | ||
|
|
f0bc80ab60 | ||
|
|
0330d7a186 | ||
|
|
27dfa4a1ef | ||
|
|
3584879fce | ||
|
|
a2e7a77b62 | ||
|
|
894b4509a8 | ||
|
|
9ee56b5f34 | ||
|
|
ebdf0381ec | ||
|
|
471b0acb00 | ||
|
|
c83e67941a | ||
|
|
a0a4dfdd75 | ||
|
|
45bbf1fc51 | ||
|
|
9f0c47f8e4 | ||
|
|
d7a5b5ac0b | ||
|
|
86f8875240 | ||
|
|
a4b76aac36 | ||
|
|
b9a8e20d22 | ||
|
|
81c20891b9 | ||
|
|
fe18ae95fa | ||
|
|
e1a59bdb03 | ||
|
|
2280f9cc1e | ||
|
|
2c3f2fb5ca | ||
|
|
95ef75b4fa | ||
|
|
fb6d4c1ae5 | ||
|
|
f5cd28364f | ||
|
|
a0126d11d1 | ||
|
|
4c3325dad3 | ||
|
|
404f0108f1 | ||
|
|
43677144d1 | ||
|
|
9bf23bcf01 | ||
|
|
8527de7a4f | ||
|
|
4569dbbaf0 | ||
|
|
ce79b78632 | ||
|
|
15dfbfca59 | ||
|
|
699c27b2d6 | ||
|
|
79a3f646a9 | ||
|
|
2ac8401a37 | ||
|
|
e01238959d | ||
|
|
4665c6ea8b | ||
|
|
caf19d82fd | ||
|
|
7edde50e21 | ||
|
|
6bb1d7b5f7 | ||
|
|
ad214598a7 | ||
|
|
592f98bdaf | ||
|
|
49aca0ddc7 | ||
|
|
01ac0386c4 | ||
|
|
288315003c | ||
|
|
7fbd9d2ff7 | ||
|
|
4b7796a960 | ||
|
|
6d0d313622 | ||
|
|
2d9ba4b090 | ||
|
|
fe198fb88d | ||
|
|
81ae632de6 | ||
|
|
40ff1c1667 | ||
|
|
0447b3cb45 | ||
|
|
6e9ca01fc4 | ||
|
|
c9e66e8dc9 | ||
|
|
7ee8f64f9d | ||
|
|
d228709009 | ||
|
|
0c80c2896c | ||
|
|
ff147074bd | ||
|
|
ec0cd3308e | ||
|
|
ca1e087edf | ||
|
|
0ff3fdc2f2 | ||
|
|
3060a4054e | ||
|
|
929dee81ef | ||
|
|
f9ecd31fdc | ||
|
|
fca797fdd8 | ||
|
|
17709529d0 | ||
|
|
10eb95cbf6 | ||
|
|
0d93f18f85 | ||
|
|
0391f22bda | ||
|
|
b5a6cb640f | ||
|
|
10a04aeede | ||
|
|
9b493b8aba | ||
|
|
b3c16f3478 | ||
|
|
24f6e13fc1 | ||
|
|
e4f26e572a | ||
|
|
2f3d92281f | ||
|
|
fc5ef0d714 | ||
|
|
05419f4eba | ||
|
|
a2b3c10f11 | ||
|
|
0eecd5afea | ||
|
|
3a723ea7f1 | ||
|
|
4b7e990b3e | ||
|
|
86311bc8ab | ||
|
|
14fdf2a458 | ||
|
|
f8299cd77e | ||
|
|
4cf7cc39a6 | ||
|
|
314224f4ed | ||
|
|
c40b650276 | ||
|
|
e48438c4ef | ||
|
|
91b195c1d8 | ||
|
|
2c26320ab8 | ||
|
|
c685309bba | ||
|
|
f155b74809 | ||
|
|
d927a96d63 | ||
|
|
b99b5c2b28 | ||
|
|
00784263f0 | ||
|
|
470552b843 | ||
|
|
b3b6c65c2f | ||
|
|
f5eae784e8 | ||
|
|
3dfb475d52 | ||
|
|
2b5dc40667 | ||
|
|
28f88c819c | ||
|
|
11cab6a727 | ||
|
|
1d309236c8 | ||
|
|
05e79d3252 | ||
|
|
2e1427c499 | ||
|
|
539a1b2ad5 | ||
|
|
bb881787bd | ||
|
|
dc64d16d3a | ||
|
|
ef49d76840 | ||
|
|
e495092446 | ||
|
|
aa5e7dc9f6 | ||
|
|
ef4c5de385 | ||
|
|
96e515bad7 | ||
|
|
c90a038699 | ||
|
|
af88c26294 | ||
|
|
5e06c67e87 | ||
|
|
a0680fd439 | ||
|
|
2b6ea53707 | ||
|
|
1943a80b7a | ||
|
|
99f7e1baf6 | ||
|
|
9bdeca5d91 | ||
|
|
752f0f9eef | ||
|
|
34ad688380 | ||
|
|
bfe921f9ec | ||
|
|
0cc8445541 | ||
|
|
72aa0f0f73 | ||
|
|
6d49edf927 | ||
|
|
2d615e2b5b | ||
|
|
66798bf699 | ||
|
|
7ac4a2520a | ||
|
|
4d3e14d90d | ||
|
|
6639a2bd75 | ||
|
|
c7b47c3e93 | ||
|
|
5dd6ea231f | ||
|
|
e7c4253c71 | ||
|
|
8b8b1b0b87 | ||
|
|
f52578acb9 | ||
|
|
622654ae0b | ||
|
|
b959519136 | ||
|
|
fcc476c9ca | ||
|
|
a1726129e4 | ||
|
|
e4bd9695d1 | ||
|
|
64f7726b4f | ||
|
|
3064ef4318 | ||
|
|
97203addca | ||
|
|
85fe23c44e | ||
|
|
516ac4b4cd | ||
|
|
2ca500aa2f | ||
|
|
370a3ff944 | ||
|
|
cb2506ab61 | ||
|
|
b554509c73 | ||
|
|
4636dd9353 | ||
|
|
729b26d568 | ||
|
|
5d25a5dd35 | ||
|
|
8ef38547a1 | ||
|
|
c7374d1715 | ||
|
|
2da63bafc8 | ||
|
|
fe19c02230 | ||
|
|
54854e33a4 | ||
|
|
d806cf59d4 | ||
|
|
b53005c56f | ||
|
|
9d4bf9a990 | ||
|
|
cd643fbcfb | ||
|
|
c7dcd52bcf | ||
|
|
f6b382aaee | ||
|
|
fe88fd0ae1 | ||
|
|
c4c171bec9 | ||
|
|
9fcfb5f498 | ||
|
|
a3e99e705c | ||
|
|
2f48ef1063 | ||
|
|
c315b8cf3c | ||
|
|
8eff531da8 | ||
|
|
625712e757 | ||
|
|
c2dbb5aba3 | ||
|
|
64dc135ec3 | ||
|
|
a70bd20b78 | ||
|
|
9f88b6d71a | ||
|
|
1b2763d4ce | ||
|
|
6b54e60f76 | ||
|
|
a224b9a5d5 | ||
|
|
6d8ba314a9 | ||
|
|
ec67c509f4 | ||
|
|
db30cc93eb | ||
|
|
cd90d0b9f6 | ||
|
|
c3cc0d1522 | ||
|
|
6770f596be | ||
|
|
1c906c1a2d | ||
|
|
c5060a8f83 | ||
|
|
f170ffef77 | ||
|
|
c56ee7d57f | ||
|
|
15ca94fd16 | ||
|
|
6340fce385 | ||
|
|
d7a2c873f1 | ||
|
|
673bb9b65c | ||
|
|
a605f2eb49 | ||
|
|
5670958972 | ||
|
|
3758434f05 | ||
|
|
287529a038 | ||
|
|
cc9196c198 | ||
|
|
79f7124312 | ||
|
|
92fa684218 | ||
|
|
a5dc5afa06 | ||
|
|
6ddcc4c0e2 | ||
|
|
8358a7df40 | ||
|
|
8c30fb259a | ||
|
|
9e416c0b19 | ||
|
|
22b5e66ce6 | ||
|
|
a003053402 | ||
|
|
f821f78784 | ||
|
|
03f37f60c6 | ||
|
|
527d503432 | ||
|
|
f1667eabb6 | ||
|
|
49bc794003 | ||
|
|
8ed80bf636 | ||
|
|
947f4d8584 | ||
|
|
2b12a95cf5 | ||
|
|
25e8907da6 | ||
|
|
dbb34c1efc | ||
|
|
f584f09353 | ||
|
|
2c955f6a86 | ||
|
|
e8d36004e2 | ||
|
|
ba63392574 | ||
|
|
cdc7e50c0e | ||
|
|
26f31b7306 | ||
|
|
b9b51b2b6d | ||
|
|
7aeb4e3a60 | ||
|
|
4a11af6f08 | ||
|
|
71eea9f69f | ||
|
|
80723789d0 | ||
|
|
83b3f31a02 | ||
|
|
53880f2426 | ||
|
|
00ab332503 | ||
|
|
7776008d6a | ||
|
|
acdd03d1c1 | ||
|
|
87e8565e1a | ||
|
|
cee2d91102 | ||
|
|
6725ac6502 |
@@ -151,6 +151,18 @@ jobs:
|
||||
- name: Validate app-host xcodebuild attempt budget
|
||||
run: ./tests/test_ci_app_host_xcodebuild_attempts.sh
|
||||
|
||||
- name: Validate app-host user configuration isolation
|
||||
run: python3 tests/test_ci_app_host_home_isolation.py
|
||||
|
||||
- name: Validate app-host identity and cleanup confirmation
|
||||
run: bash tests/test_ci_app_host_identity.sh
|
||||
|
||||
- name: Validate app-host process receipts
|
||||
run: bash tests/test_ci_app_host_processes.sh
|
||||
|
||||
- name: Validate isolated app-host home cleanup
|
||||
run: bash tests/test_ci_app_host_home_cleanup.sh
|
||||
|
||||
- name: Validate cmux profiling support scripts
|
||||
run: ./tests/test_start_cmux_profiling.sh
|
||||
|
||||
@@ -259,6 +271,7 @@ jobs:
|
||||
|
||||
- name: Validate SwiftPM lockfile policy
|
||||
run: |
|
||||
python3 tests/test_package_resolved_policy_remote_inputs.py
|
||||
python3 tests/test_check_package_resolved_policy.py
|
||||
python3 scripts/check-package-resolved-policy.py
|
||||
|
||||
@@ -268,6 +281,9 @@ jobs:
|
||||
- name: Validate sidebar lazy-layout guard
|
||||
run: python3 tests/test_ci_sidebar_lazy_layout_guard.py
|
||||
|
||||
- name: Validate focused Dock shortcut routing guard
|
||||
run: python3 tests/test_dock_shortcut_routing_guard.py
|
||||
|
||||
- name: Validate bash prompt bootstrap composes with user PROMPT_COMMAND (starship)
|
||||
run: python3 tests/test_issue_5164_starship_prompt_composition.py
|
||||
|
||||
@@ -471,6 +487,10 @@ jobs:
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
# This independent job-level marker makes every app-host wrapper fail
|
||||
# closed if a setup step or environment handoff loses either redirect.
|
||||
CMUX_CI_APP_HOST_ISOLATION_REQUIRED: "1"
|
||||
CMUX_APP_HOST_SHARD: ${{ matrix.shard }}
|
||||
CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }}
|
||||
CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26"
|
||||
CMUX_SKIP_ZIG_BUILD: "1"
|
||||
@@ -544,6 +564,9 @@ jobs:
|
||||
mkdir -p "$DERIVED_DATA_PATH"
|
||||
echo "CMUX_DERIVED_DATA_PATH=$DERIVED_DATA_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare isolated app-host home
|
||||
run: scripts/ci/prepare-app-host-home.sh
|
||||
|
||||
- name: Resolve Swift packages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1089,6 +1112,10 @@ jobs:
|
||||
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_omp_extension_install.py
|
||||
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_campfire_extension_install.py
|
||||
|
||||
- name: Clean up isolated app-host home
|
||||
if: ${{ always() }}
|
||||
run: scripts/ci/run-in-console-session.sh scripts/ci/cleanup-app-host-home.sh
|
||||
|
||||
tests:
|
||||
name: tests
|
||||
# Aggregate gate for the test/build suites in this workflow. Required by
|
||||
@@ -1228,6 +1255,7 @@ jobs:
|
||||
name: cmux-ghostty-cli-helper
|
||||
path: ghostty-cli-helper/ghostty
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Retry universal Ghostty CLI helper upload
|
||||
if: steps.upload-ghostty-cli-helper.outcome == 'failure'
|
||||
@@ -1236,6 +1264,7 @@ jobs:
|
||||
name: cmux-ghostty-cli-helper
|
||||
path: ghostty-cli-helper/ghostty
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
|
||||
- name: Select Xcode
|
||||
@@ -1312,6 +1341,7 @@ jobs:
|
||||
CmuxControlSocket
|
||||
CmuxFoundation
|
||||
CmuxGit
|
||||
CmuxNotifications
|
||||
CmuxSettings
|
||||
CmuxSettingsUI
|
||||
CmuxTerminal
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y binutils clang libclang-dev pkg-config
|
||||
sudo apt-get install -y binutils clang libclang-dev musl-tools pkg-config
|
||||
|
||||
- name: Resolve Ghostty Zig version
|
||||
id: ghostty-zig-version
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: matrix.cross == true
|
||||
if: matrix.cross == true && runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: cargo install --locked [email protected]
|
||||
|
||||
@@ -145,8 +145,8 @@ jobs:
|
||||
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
|
||||
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
|
||||
|
||||
- name: Build cmux-tui (cross)
|
||||
if: matrix.cross == true
|
||||
- name: Build cmux-tui (Linux cross)
|
||||
if: matrix.cross == true && runner.os == 'Linux'
|
||||
env:
|
||||
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
|
||||
PACKAGE_NPM: ${{ inputs.package_npm }}
|
||||
@@ -166,6 +166,27 @@ jobs:
|
||||
cargo zigbuild -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
|
||||
cargo zigbuild -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
|
||||
|
||||
- name: Build cmux-tui (macOS cross)
|
||||
if: matrix.cross == true && runner.os == 'macOS'
|
||||
env:
|
||||
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
|
||||
PACKAGE_NPM: ${{ inputs.package_npm }}
|
||||
working-directory: cmux-tui
|
||||
shell: bash
|
||||
run: |
|
||||
# Xcode's macOS SDK natively supports cross-architecture builds.
|
||||
# cargo-zigbuild cannot resolve SDK frameworks when the host is arm64.
|
||||
unset CMUX_GHOSTTY_SRC
|
||||
CMUX_TUI_BUILD_COMMIT="$(git -C .. rev-parse HEAD)"
|
||||
CMUX_TUI_GHOSTTY_COMMIT="$(git -C ../ghostty rev-parse HEAD)"
|
||||
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION
|
||||
if [[ "$PACKAGE_NPM" == "true" ]]; then
|
||||
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
|
||||
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
|
||||
fi
|
||||
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
|
||||
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
|
||||
|
||||
- name: Stage binary
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
+172
-26
@@ -42,9 +42,17 @@ jobs:
|
||||
npm run build
|
||||
npm test
|
||||
|
||||
valgrind-leak-check:
|
||||
valgrind-leak-check-shard:
|
||||
name: valgrind-leak-check (${{ matrix.shard }})
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 40
|
||||
# The core, remote, and application suites are intentionally serialized
|
||||
# under instrumentation. Isolate them so none can consume another test
|
||||
# binary group's runtime budget.
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [core, remote, tui, remainder]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -76,32 +84,61 @@ jobs:
|
||||
# SIMD codegen stays within what valgrind's instruction emulation
|
||||
# supports (see crates/ghostty-vt-sys/build.rs).
|
||||
CMUX_GHOSTTY_VT_ZIG_CPU: baseline
|
||||
VALGRIND_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
mkdir -p target
|
||||
cargo test --workspace --locked --no-run --message-format=json > target/cargo-test-binaries.jsonl
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
shard = os.environ["VALGRIND_SHARD"]
|
||||
known_shards = {"core", "remote", "tui", "remainder"}
|
||||
if shard not in known_shards:
|
||||
raise SystemExit(f"unknown Valgrind shard: {shard}")
|
||||
|
||||
def shard_for(executable):
|
||||
name = os.path.basename(executable)
|
||||
if re.fullmatch(r"cmux_tui_core-[0-9a-f]+", name):
|
||||
return "core"
|
||||
if re.fullmatch(r"cmux_remote-[0-9a-f]+", name):
|
||||
return "remote"
|
||||
if re.fullmatch(r"cmux_tui-[0-9a-f]+", name):
|
||||
return "tui"
|
||||
return "remainder"
|
||||
|
||||
seen = set()
|
||||
selected = []
|
||||
with open("target/cargo-test-binaries.jsonl", "r", encoding="utf-8") as messages:
|
||||
with open("target/valgrind-test-binaries.txt", "w", encoding="utf-8") as output:
|
||||
for line in messages:
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not message.get("profile", {}).get("test"):
|
||||
continue
|
||||
executable = message.get("executable")
|
||||
if not executable or executable in seen:
|
||||
continue
|
||||
seen.add(executable)
|
||||
print(executable, file=output)
|
||||
for line in messages:
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not message.get("profile", {}).get("test"):
|
||||
continue
|
||||
executable = message.get("executable")
|
||||
if not executable or executable in seen:
|
||||
continue
|
||||
seen.add(executable)
|
||||
if shard_for(executable) == shard:
|
||||
selected.append(executable)
|
||||
|
||||
if not seen:
|
||||
raise SystemExit("cargo did not report any test binaries")
|
||||
print(f"Collected {len(seen)} test binaries", file=sys.stderr)
|
||||
if not selected:
|
||||
raise SystemExit(f"Valgrind shard {shard} selected no test binaries")
|
||||
|
||||
with open("target/valgrind-test-binaries.txt", "w", encoding="utf-8") as output:
|
||||
for executable in selected:
|
||||
print(executable, file=output)
|
||||
|
||||
print(
|
||||
f"Valgrind shard {shard} selected {len(selected)} of {len(seen)} test binaries",
|
||||
file=sys.stderr,
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Run test binaries under valgrind
|
||||
@@ -111,25 +148,99 @@ jobs:
|
||||
# otherwise-correct build; the guarded regression (events serialized
|
||||
# behind a 100ms read poll) inflates far past this bound anyway.
|
||||
CMUX_TEST_WS_LATENCY_BUDGET_MS: "2000"
|
||||
# Process-exit and PTY-reader tests also use bounded polling. Keep
|
||||
# their normal deadlines strict while allowing for instrumentation.
|
||||
# Scale test-fixture deadlines and their observation windows together
|
||||
# under instrumentation. Production defaults and normal CI remain strict.
|
||||
CMUX_TEST_TIMEOUT_SCALE: "4"
|
||||
# Retain the full 128 MiB fairness workload under Valgrind with
|
||||
# explicit instrumentation-only bounds. Normal CI stays strict.
|
||||
CMUX_TEST_PERF_INSTRUMENTED: "valgrind"
|
||||
run: |
|
||||
while IFS= read -r bin; do
|
||||
[ -n "$bin" ] || continue
|
||||
echo "Running valgrind for $bin"
|
||||
if ! valgrind \
|
||||
run_valgrind() {
|
||||
local bin="$1"
|
||||
shift
|
||||
valgrind \
|
||||
"${valgrind_args[@]}" \
|
||||
--error-exitcode=1 \
|
||||
--leak-check=full \
|
||||
--show-leak-kinds=definite \
|
||||
--errors-for-leak-kinds=definite \
|
||||
--track-origins=yes \
|
||||
-- "$bin"; then
|
||||
-- "$bin" "$@"
|
||||
}
|
||||
|
||||
while IFS= read -r bin; do
|
||||
[ -n "$bin" ] || continue
|
||||
echo "Running valgrind for $bin"
|
||||
valgrind_args=(--track-origins=yes)
|
||||
test_args=()
|
||||
case "$(basename "$bin")" in
|
||||
pty-*|cmux_tui_core-*|cmux_tui-[[:xdigit:]]*)
|
||||
# These tests own bounded worker pools, PTYs, sockets, and
|
||||
# deadline-sensitive readers. Valgrind serializes their CPU
|
||||
# work internally, so test-harness parallelism only creates
|
||||
# scheduler starvation and wall-clock timeout races.
|
||||
test_args+=(--test-threads=1)
|
||||
;;
|
||||
cmux_remote-[[:xdigit:]]*)
|
||||
# Remote-runtime tests also own real schedulers, sockets, and
|
||||
# deadline checks. Serial execution prevents the instrumented
|
||||
# harness from starving its own observation deadlines.
|
||||
test_args+=(--test-threads=1)
|
||||
;;
|
||||
terminal_host_recovery-*)
|
||||
# Valgrind instruments this client harness but not the hidden
|
||||
# terminal-host child it launches. The normal-speed child can
|
||||
# fill the socket while the instrumented reader is descheduled,
|
||||
# correctly triggering the production stalled-client timeout
|
||||
# mid-frame. Normal Linux and macOS CI retain this ordering test;
|
||||
# every other recovery case remains under Valgrind.
|
||||
test_args+=(--skip exit_follows_all_final_pty_bytes_on_the_live_stream)
|
||||
;;
|
||||
direct_wss_e2e-*|relay_wss_diagnostic-*)
|
||||
# ring's AES-GCM backend exposes initialized output through a
|
||||
# partially initialized SIMD buffer. Valgrind reports its
|
||||
# padding at Rustls writev. Keep leak and address checks for
|
||||
# TLS integration binaries while scoping undefined-value
|
||||
# suppression to those binaries.
|
||||
valgrind_args=(--undef-value-errors=no)
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$(basename "$bin")" == cmux_remote-[[:xdigit:]]* ]]; then
|
||||
# Iroh's Rustls/ring and noq UDP paths expose initialized data
|
||||
# through buffers with uninitialized SIMD or sockaddr padding.
|
||||
# Run only those tests without undefined-value diagnostics while
|
||||
# retaining address and leak checks. Every other remote test
|
||||
# keeps the complete Valgrind diagnostic set.
|
||||
if ! run_valgrind "$bin" --skip 'provider::iroh::' "${test_args[@]}"; then
|
||||
echo "Valgrind failed for $bin outside the Iroh provider" >&2
|
||||
exit 1
|
||||
fi
|
||||
valgrind_args=(--undef-value-errors=no)
|
||||
if ! run_valgrind "$bin" 'provider::iroh::' "${test_args[@]}"; then
|
||||
echo "Valgrind failed for $bin in the Iroh provider" >&2
|
||||
exit 1
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! run_valgrind "$bin" "${test_args[@]}"; then
|
||||
echo "Valgrind failed for $bin" >&2
|
||||
exit 1
|
||||
fi
|
||||
done < target/valgrind-test-binaries.txt
|
||||
|
||||
valgrind-leak-check:
|
||||
name: valgrind-leak-check
|
||||
if: always()
|
||||
needs: valgrind-leak-check-shard
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Require every Valgrind shard
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.valgrind-leak-check-shard.result }}
|
||||
run: test "$SHARD_RESULT" = success
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os == 'macos' && (vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') || 'ubuntu-latest' }}
|
||||
@@ -195,6 +306,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
cache: npm
|
||||
cache-dependency-path: cmux-tui/bindings/typescript/package-lock.json
|
||||
|
||||
- name: Install TypeScript binding dependencies
|
||||
working-directory: cmux-tui/bindings/typescript
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Init ghostty submodule
|
||||
run: git submodule update --init --depth 1 ghostty
|
||||
|
||||
@@ -226,11 +348,35 @@ jobs:
|
||||
working-directory: cmux-tui
|
||||
run: cargo build -p cmux-tui
|
||||
|
||||
- name: Resolve Zig SDK version
|
||||
id: zig-sdk-version
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(
|
||||
sed -nE 's/^[[:space:]]*\.minimum_zig_version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \
|
||||
cmux-tui/bindings/zig/build.zig.zon | head -1
|
||||
)"
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Invalid Zig SDK version: $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install Zig for SDK conformance
|
||||
env:
|
||||
ZIG_REQUIRED: ${{ steps.zig-sdk-version.outputs.version }}
|
||||
ZIG_FORCE_LOCAL_INSTALL: "1"
|
||||
run: ./scripts/install-zig-ci.sh
|
||||
|
||||
- name: Python conformance fixtures
|
||||
run: python3 cmux-tui/bindings/conformance/runner.py
|
||||
run: |
|
||||
test "$("$CMUX_ZIG" version)" = "${{ steps.zig-sdk-version.outputs.version }}"
|
||||
python3 cmux-tui/bindings/conformance/runner.py
|
||||
|
||||
- name: Binding e2e
|
||||
run: bash cmux-tui/bindings/conformance/e2e.sh --require python,typescript,rust,go,java
|
||||
run: |
|
||||
test "$("$CMUX_ZIG" version)" = "${{ steps.zig-sdk-version.outputs.version }}"
|
||||
bash cmux-tui/bindings/conformance/e2e.sh --require python,typescript,rust,go,java
|
||||
|
||||
windows-experimental:
|
||||
name: windows experimental (x86_64-gnu)
|
||||
|
||||
@@ -452,7 +452,7 @@ jobs:
|
||||
# The demo profile is fetched from the ASC API by name instead of a
|
||||
# repository secret, so regenerating it in the developer portal
|
||||
# needs no secret rotation. Same credentials the upload uses.
|
||||
PROFILE_BASE64="$(python3 ./ios/scripts/asc_download_profile.py --name "cmux Demo Distribution")"
|
||||
PROFILE_BASE64="$(python3 ./ios/scripts/asc_download_profile.py --name "cmux Demo Distribution Push")"
|
||||
elif [ "$IOS_BETA_PROFILE_TYPE" = "internal" ]; then
|
||||
PROFILE_BASE64="${IOS_BETA_PROVISIONING_PROFILE_INTERNAL_BASE64}"
|
||||
else
|
||||
@@ -475,9 +475,16 @@ jobs:
|
||||
echo "$IOS_BETA_PROFILE_TYPE provisioning profile targets unexpected app ID: $APP_ID (expected $IOS_BETA_EXPECTED_APP_ID)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# TestFlight uses production APNs. Both capabilities must be present
|
||||
# in the installed profile or export can silently strip them.
|
||||
APS_ENVIRONMENT="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$TMP_PLIST" 2>/dev/null || echo "")"
|
||||
if [ -z "$APS_ENVIRONMENT" ] || [ "$APS_ENVIRONMENT" != "production" ]; then
|
||||
echo "$IOS_BETA_PROFILE_TYPE provisioning profile aps-environment is '$APS_ENVIRONMENT', expected 'production'" >&2
|
||||
echo "$IOS_BETA_PROFILE_TYPE provisioning profile aps-environment is '${APS_ENVIRONMENT:-<absent>}', expected 'production'" >&2
|
||||
exit 1
|
||||
fi
|
||||
TIME_SENSITIVE="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.usernotifications.time-sensitive" "$TMP_PLIST" 2>/dev/null || echo "")"
|
||||
if [ "$TIME_SENSITIVE" != "true" ]; then
|
||||
echo "$IOS_BETA_PROFILE_TYPE provisioning profile com.apple.developer.usernotifications.time-sensitive is '${TIME_SENSITIVE:-<absent>}', expected 'true'" >&2
|
||||
exit 1
|
||||
fi
|
||||
PROFILE_NAME="$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST")"
|
||||
|
||||
@@ -59,6 +59,7 @@ jobs:
|
||||
name: cmux-ghostty-cli-helper
|
||||
path: ghostty-cli-helper/ghostty
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
build-sign-notarize:
|
||||
needs: build-ghostty-cli-helper
|
||||
|
||||
@@ -246,5 +246,5 @@ jobs:
|
||||
with:
|
||||
name: reload-${{ inputs.tag }}-${{ inputs.platform }}
|
||||
path: artifact/
|
||||
retention-days: 3
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -609,12 +609,16 @@ jobs:
|
||||
echo "expected one tested crate, found ${#artifacts[@]}" >&2
|
||||
exit 1
|
||||
}
|
||||
retry_missing_project=()
|
||||
if [[ "$PACKAGE" == "cmux-sidebar" ]]; then
|
||||
retry_missing_project=(--retry-missing-project)
|
||||
fi
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry crates \
|
||||
--package "$PACKAGE" \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--artifact "${artifacts[0]}" \
|
||||
--retry-missing-project \
|
||||
"${retry_missing_project[@]}" \
|
||||
--wait-seconds 300 \
|
||||
--require-match
|
||||
sleep 1
|
||||
|
||||
@@ -167,6 +167,7 @@ jobs:
|
||||
--workflow .github/workflows/sdk-bootstrap-npm.yml \
|
||||
--workflow-ref refs/heads/main \
|
||||
--dist-tag bootstrap \
|
||||
--require-dist-tag latest \
|
||||
--publisher owner \
|
||||
--artifact "${packages[0]}"
|
||||
|
||||
@@ -258,7 +259,7 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
echo "npm lifecycle scripts are disabled in the credentialed publisher"
|
||||
npm publish "${packages[0]}" \
|
||||
npm publish "$(realpath "${packages[0]}")" \
|
||||
--ignore-scripts \
|
||||
--tag bootstrap \
|
||||
--provenance \
|
||||
@@ -297,7 +298,7 @@ jobs:
|
||||
- name: Install pinned npm
|
||||
run: npm install --global --ignore-scripts [email protected]
|
||||
|
||||
- name: Verify the prerelease did not claim latest
|
||||
- name: Verify npm-required bootstrap tags
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags="$RUNNER_TEMP/cmux-sdk-bootstrap-tags.json"
|
||||
@@ -313,8 +314,13 @@ jobs:
|
||||
const fs = require("node:fs");
|
||||
const [path, expected] = process.argv.slice(2);
|
||||
const tags = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
if (tags.bootstrap !== expected || Object.hasOwn(tags, "latest")) {
|
||||
throw new Error(`unexpected cmux-sdk dist-tags: ${JSON.stringify(tags)}`);
|
||||
if (
|
||||
tags.bootstrap !== expected ||
|
||||
typeof tags.latest !== "string" ||
|
||||
tags.latest.length === 0
|
||||
) {
|
||||
console.error("cmux-sdk dist-tag validation failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
|
||||
@@ -336,5 +342,6 @@ jobs:
|
||||
--workflow .github/workflows/sdk-bootstrap-npm.yml \
|
||||
--workflow-ref refs/heads/main \
|
||||
--dist-tag bootstrap \
|
||||
--require-dist-tag latest \
|
||||
--publisher owner \
|
||||
--artifact "${packages[0]}"
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
fi
|
||||
# The conventions lint (free-function ban, namespace-type rule, ...)
|
||||
# covers every package, so it runs for any Packages/ change too.
|
||||
if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then
|
||||
if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-ios-package-conventions-baseline\.txt$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then
|
||||
echo "should_lint=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No package-owned files changed; skipping conventions lint."
|
||||
@@ -99,8 +99,8 @@ jobs:
|
||||
# namespace-enums) plus the repo-wide namespace-type rule (no
|
||||
# all-static "namespace" types in any package). Exits non-zero on any
|
||||
# unjustified ERROR; sanctioned exceptions carry a lint:allow /
|
||||
# TRANSITIONAL / carve-out marker, and pre-existing namespace-type
|
||||
# debt is grandfathered in scripts/lint-namespace-types-baseline.txt.
|
||||
# TRANSITIONAL / carve-out marker, and pre-existing debt is
|
||||
# grandfathered in the lint baseline files under scripts/.
|
||||
./scripts/lint-ios-package-conventions.sh
|
||||
|
||||
mobile-core-package:
|
||||
|
||||
@@ -188,6 +188,20 @@ extension CMUXCLI {
|
||||
}
|
||||
|
||||
static func feedHookCommandString(for def: AgentHookDef, agentEvent: String) -> String {
|
||||
if def.name == "codex",
|
||||
let injectedEvent = CodexHookInjectionSchema.current.events.first(where: {
|
||||
$0.agentEvent == agentEvent
|
||||
}) {
|
||||
let inline = codexFireAndForgetAgentHookShellCommand(
|
||||
"cmux hooks codex \(injectedEvent.cmuxSubcommand)",
|
||||
for: def
|
||||
)
|
||||
return codexPersistentHookScriptCommand(
|
||||
inline,
|
||||
eventTag: "feed-\(agentEvent)"
|
||||
)
|
||||
}
|
||||
|
||||
let inline: String
|
||||
let noOpCommand = feedHookNoOpShellCommand(for: def, agentEvent: agentEvent)
|
||||
switch def.format {
|
||||
|
||||
@@ -4,17 +4,22 @@ import Foundation
|
||||
extension CMUXCLI {
|
||||
/// Emit, NUL-separated to stdout, the exact codex arg list the wrapper must
|
||||
/// splice ahead of the user's args to enable + inject cmux's fire-and-forget
|
||||
/// hooks for one codex invocation. Returns the arg list:
|
||||
/// hooks for one codex invocation when no persistent cmux channel is
|
||||
/// installed. Returns the arg list:
|
||||
/// --enable\0hooks\0--dangerously-bypass-hook-trust\0
|
||||
/// -c\0hooks.SessionStart=[{hooks=[{type="command",command='''<ff>''',timeout=10000}]}]\0
|
||||
/// -c\0hooks.UserPromptSubmit=...\0 ... (one `-c` pair per event)
|
||||
/// where `<ff>` is `codexFireAndForgetAgentHookShellCommand(...)` so each
|
||||
/// hook returns `{}` to codex instantly and backgrounds the real cmux call.
|
||||
/// Requires no live socket: pure string construction from the agent def.
|
||||
/// Before emission, an existing cmux-owned persistent hook channel is
|
||||
/// reconciled in place and supersedes wrapper injection for this launch.
|
||||
/// No live socket is required.
|
||||
func emitCodexWrapperInjectArgs() throws {
|
||||
guard let codexDef = Self.agentDef(named: "codex") else {
|
||||
throw CLIError(message: "Codex hook integration is unavailable.")
|
||||
}
|
||||
let usesPersistentChannel = reconcileCodexPersistentHooksForWrapper()
|
||||
let eventsToInject = usesPersistentChannel ? [] : CodexHookInjectionSchema.current.events
|
||||
// Prefer a #!/bin/sh SCRIPT FILE as the hook command over an inline shell
|
||||
// snippet. Some codex-compatible runtimes (subrouters, proxies) exec the
|
||||
// `command` string directly as a program instead of via a shell, so an
|
||||
@@ -26,8 +31,15 @@ extension CMUXCLI {
|
||||
// hooks), not the user's ~/.codex. Any write failure falls back to the
|
||||
// inline snippet so the working path can never regress.
|
||||
let hooksDir = Self.codexHookScriptsDirectory()
|
||||
defer {
|
||||
Self.garbageCollectCodexHookScripts(
|
||||
retaining: Self.currentCodexWrapperHookScriptFilenames(for: codexDef)
|
||||
.union(Self.installedCodexHookScriptFilenames(for: codexDef))
|
||||
)
|
||||
}
|
||||
guard !eventsToInject.isEmpty else { return }
|
||||
var args: [String] = ["--enable", "hooks", "--dangerously-bypass-hook-trust"]
|
||||
for event in CodexHookInjectionSchema.current.events {
|
||||
for event in eventsToInject {
|
||||
let ff = Self.codexFireAndForgetAgentHookShellCommand(
|
||||
"cmux hooks codex \(event.cmuxSubcommand)", for: codexDef
|
||||
)
|
||||
@@ -114,9 +126,100 @@ extension CMUXCLI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Names that the current wrapper schema may reference from a live session.
|
||||
static func currentCodexWrapperHookScriptFilenames(for def: AgentHookDef) -> Set<String> {
|
||||
Set(CodexHookInjectionSchema.current.events.compactMap { event in
|
||||
let body = codexFireAndForgetAgentHookShellCommand(
|
||||
"cmux hooks codex \(event.cmuxSubcommand)",
|
||||
for: def
|
||||
)
|
||||
return CodexHookScriptName(
|
||||
contents: "#!/bin/sh\n\(body)\n",
|
||||
subcommand: event.cmuxSubcommand
|
||||
)?.filename
|
||||
})
|
||||
}
|
||||
|
||||
/// Cmux-generated script names referenced by the active persistent config.
|
||||
static func installedCodexHookScriptFilenames(for def: AgentHookDef) -> Set<String> {
|
||||
let fileURL = URL(fileURLWithPath: def.resolvedConfigDir(), isDirectory: true)
|
||||
.appendingPathComponent(def.configFile, isDirectory: false)
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let hooks = root["hooks"] as? [String: Any],
|
||||
let hooksDirectory = codexHookScriptsDirectory()?.standardizedFileURL else {
|
||||
return []
|
||||
}
|
||||
|
||||
var filenames = Set<String>()
|
||||
for value in hooks.values {
|
||||
guard let groups = value as? [[String: Any]] else { continue }
|
||||
for group in groups {
|
||||
guard let handlers = group["hooks"] as? [[String: Any]] else { continue }
|
||||
for handler in handlers {
|
||||
guard let command = handler["command"] as? String else { continue }
|
||||
let url = URL(fileURLWithPath: command, isDirectory: false)
|
||||
guard url.deletingLastPathComponent().standardizedFileURL == hooksDirectory,
|
||||
CodexHookScriptName(filename: url.lastPathComponent) != nil else {
|
||||
continue
|
||||
}
|
||||
filenames.insert(url.lastPathComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return filenames
|
||||
}
|
||||
|
||||
/// Removes obsolete regular files only when their names prove cmux ownership.
|
||||
/// Live Codex sessions may still hold paths from another tagged build, and
|
||||
/// concurrent launches can briefly overlap script generation, so collection
|
||||
/// waits until no Codex process is running and leaves recent files alone.
|
||||
static func garbageCollectCodexHookScripts(retaining filenames: Set<String>) {
|
||||
guard !hasRunningCodexProcess(),
|
||||
let directory = codexHookScriptsDirectory(),
|
||||
let contents = try? FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
let newestRemovableDate = Date().addingTimeInterval(-60)
|
||||
for url in contents where !filenames.contains(url.lastPathComponent) {
|
||||
let values = try? url.resourceValues(forKeys: [
|
||||
.contentModificationDateKey,
|
||||
.isRegularFileKey,
|
||||
])
|
||||
guard CodexHookScriptName(filename: url.lastPathComponent) != nil,
|
||||
values?.isRegularFile == true,
|
||||
let modificationDate = values?.contentModificationDate,
|
||||
modificationDate < newestRemovableDate else {
|
||||
continue
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Conservatively detects sessions that may still reference an older hook generation.
|
||||
private static func hasRunningCodexProcess() -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
|
||||
process.arguments = ["-x", "codex"]
|
||||
process.standardOutput = FileHandle.nullDevice
|
||||
process.standardError = FileHandle.nullDevice
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static func codexFireAndForgetAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String {
|
||||
let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command
|
||||
let runner = "payload=\"$1\"; shift; \"$@\" <\"$payload\" >/dev/null 2>&1 & child=\"$!\"; ( sleep 30; kill \"$child\" 2>/dev/null || true ) & watchdog=\"$!\"; wait \"$child\" 2>/dev/null || true; kill \"$watchdog\" 2>/dev/null || true; rm -f \"$payload\""
|
||||
let runner = "payload=\"$1\"; shift; \"$@\" <\"$payload\" >/dev/null 2>&1 & child=\"$!\"; ( timer=; trap \"kill \\$timer 2>/dev/null || true; wait \\$timer 2>/dev/null || true; exit 0\" HUP INT TERM; sleep 30 & timer=\"$!\"; wait \"$timer\" 2>/dev/null || true; timer=; kill \"$child\" 2>/dev/null || true ) & watchdog=\"$!\"; wait \"$child\" 2>/dev/null || true; kill \"$watchdog\" 2>/dev/null || true; wait \"$watchdog\" 2>/dev/null || true; rm -f \"$payload\""
|
||||
let noOp = stdinDrainingHookNoOpShellCommand
|
||||
return [
|
||||
"cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"",
|
||||
|
||||
@@ -81,6 +81,7 @@ extension CMUXCLI {
|
||||
"codex",
|
||||
"codex-hook",
|
||||
"codex-teams",
|
||||
"comments",
|
||||
"config",
|
||||
"copy-mode",
|
||||
"current-window",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
|
||||
/// `cmux comments` — read-only access to diff-viewer review comments.
|
||||
///
|
||||
/// Strings resolve through `CMUXDiffViewerLocalization`, which reads the enclosing
|
||||
/// app bundle: the CLI executable carries no string catalog of its own, so
|
||||
/// `String(localized:)` here would always fall back to its default value.
|
||||
extension CMUXCLI {
|
||||
static let commentsUsage = CMUXDiffViewerLocalization.string(
|
||||
"cli.comments.usage",
|
||||
defaultValue: """
|
||||
Usage: cmux comments <subcommand> [options]
|
||||
|
||||
Review comments saved from the diff viewer, stored per git repository.
|
||||
|
||||
Subcommands:
|
||||
list [--repo <path>] [--all] [--json]
|
||||
List review comments for a repository (default: the git repository
|
||||
containing the current directory). Lists pending comments only;
|
||||
--all includes comments already delivered to an agent through a
|
||||
TextBox submission.
|
||||
"""
|
||||
)
|
||||
|
||||
/// Runs `cmux comments <subcommand>`; `list` is the only subcommand today.
|
||||
/// Rejects anything unrecognized before it resolves a repository or calls the socket.
|
||||
func runCommentsNamespace(
|
||||
commandArgs: [String],
|
||||
client: SocketClient,
|
||||
jsonOutput: Bool,
|
||||
idFormat: CLIIDFormat
|
||||
) throws {
|
||||
if hasHelpRequest(beforeSeparator: commandArgs) {
|
||||
print(Self.commentsUsage)
|
||||
return
|
||||
}
|
||||
guard let sub = commandArgs.first?.lowercased() else {
|
||||
throw CLIError(message: CMUXDiffViewerLocalization.string(
|
||||
"cli.comments.error.subcommandRequired",
|
||||
defaultValue: "comments requires a subcommand. Try: list"
|
||||
))
|
||||
}
|
||||
let rest = Array(commandArgs.dropFirst())
|
||||
switch sub {
|
||||
case "list", "ls":
|
||||
let (repoOption, remainder) = parseOption(rest, name: "--repo")
|
||||
// `parseOption` takes the next token verbatim, so `--repo --all`
|
||||
// would resolve a repository named "--all". A path that starts with
|
||||
// a dash can still be passed as `./-name`.
|
||||
if let repoOption, repoOption.hasPrefix("--") {
|
||||
throw CLIError(message: CMUXDiffViewerLocalization.string(
|
||||
"cli.comments.error.repoRequiresPath",
|
||||
defaultValue: "--repo requires a path. For a path starting with a dash, pass it as ./-name"
|
||||
))
|
||||
}
|
||||
// Fail closed on anything unrecognized: neither a typo like `--al`
|
||||
// nor a stray positional may read as a supported request.
|
||||
if let unexpected = remainder.first(where: { $0 != "--all" }) {
|
||||
throw CLIError(message: String.localizedStringWithFormat(
|
||||
CMUXDiffViewerLocalization.string(
|
||||
"cli.comments.error.unexpectedArgument",
|
||||
defaultValue: "Unexpected argument '%@' for cmux comments list. Supported: --repo <path>, --all, --json"
|
||||
),
|
||||
unexpected
|
||||
))
|
||||
}
|
||||
let includeConsumed = remainder.contains("--all")
|
||||
let startPath = repoOption ?? FileManager.default.currentDirectoryPath
|
||||
var params: [String: Any] = ["repo_root": try commentsGitRepoRoot(startingAt: startPath)]
|
||||
if includeConsumed {
|
||||
params["include_consumed"] = true
|
||||
}
|
||||
let payload = try client.sendV2(method: "comments.list", params: params)
|
||||
printCommentsListPayload(payload, jsonOutput: jsonOutput, idFormat: idFormat)
|
||||
default:
|
||||
throw CLIError(message: String.localizedStringWithFormat(
|
||||
CMUXDiffViewerLocalization.string(
|
||||
"cli.comments.error.unknownSubcommand",
|
||||
defaultValue: "Unknown comments subcommand '%@'. Try: list"
|
||||
),
|
||||
sub
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the git top level for `--repo` (or the current directory), so the
|
||||
/// socket receives the same canonical root the store is keyed by.
|
||||
private func commentsGitRepoRoot(startingAt directory: String) throws -> String {
|
||||
let result = CLIProcessRunner.runProcess(
|
||||
executablePath: "/usr/bin/env",
|
||||
arguments: ["git", "-C", directory, "rev-parse", "--show-toplevel"],
|
||||
timeout: 10
|
||||
)
|
||||
let root = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !result.timedOut, result.status == 0, !root.isEmpty else {
|
||||
throw CLIError(message: String.localizedStringWithFormat(
|
||||
CMUXDiffViewerLocalization.string(
|
||||
"cli.comments.error.notARepository",
|
||||
defaultValue: "cmux comments requires a git repository: %@"
|
||||
),
|
||||
directory
|
||||
))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/// Builds the count line.
|
||||
///
|
||||
/// Selection stays here rather than in catalog plural variations: the count is
|
||||
/// resolved before the string is, so a `variations.plural` entry could not see
|
||||
/// it. The catalog's non-singular values therefore avoid numeral-governed
|
||||
/// nouns, keeping one form grammatical for every count above one in Slavic and
|
||||
/// Arabic locales.
|
||||
private func commentsListHeaderText(count: Int, repoRoot: String) -> String {
|
||||
if count == 1 {
|
||||
return String.localizedStringWithFormat(
|
||||
CMUXDiffViewerLocalization.string(
|
||||
"cli.comments.list.header.one",
|
||||
defaultValue: "1 review comment (repo: %@)"
|
||||
),
|
||||
repoRoot
|
||||
)
|
||||
}
|
||||
return String.localizedStringWithFormat(
|
||||
CMUXDiffViewerLocalization.string(
|
||||
"cli.comments.list.header.other",
|
||||
defaultValue: "%1$lld review comments (repo: %2$@)"
|
||||
),
|
||||
Int64(count),
|
||||
repoRoot
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders a `comments.list` reply: raw JSON when `--json` is set, otherwise one
|
||||
/// line per comment with its anchor text and message.
|
||||
private func printCommentsListPayload(
|
||||
_ payload: [String: Any],
|
||||
jsonOutput: Bool,
|
||||
idFormat: CLIIDFormat
|
||||
) {
|
||||
if jsonOutput {
|
||||
print(jsonString(formatIDs(payload, mode: idFormat)))
|
||||
return
|
||||
}
|
||||
let comments = payload["comments"] as? [[String: Any]] ?? []
|
||||
let repoRoot = payload["repo_root"] as? String ?? ""
|
||||
guard !comments.isEmpty else {
|
||||
print(String.localizedStringWithFormat(
|
||||
CMUXDiffViewerLocalization.string(
|
||||
"cli.comments.list.empty",
|
||||
defaultValue: "No review comments. (repo: %@)"
|
||||
),
|
||||
repoRoot
|
||||
))
|
||||
return
|
||||
}
|
||||
print(commentsListHeaderText(count: comments.count, repoRoot: repoRoot))
|
||||
for comment in comments {
|
||||
let filePath = comment["filePath"] as? String ?? "?"
|
||||
let startLine = intFromAny(comment["startLine"]) ?? 0
|
||||
let endLine = intFromAny(comment["endLine"]) ?? startLine
|
||||
let range = endLine > startLine ? "\(startLine)-\(endLine)" : "\(startLine)"
|
||||
let state = comment["consumedAt"] == nil
|
||||
? CMUXDiffViewerLocalization.string("cli.comments.list.statePending", defaultValue: "pending")
|
||||
: CMUXDiffViewerLocalization.string("cli.comments.list.stateConsumed", defaultValue: "consumed")
|
||||
print("- \(filePath):\(range) [\(state)]")
|
||||
if let lineText = comment["lineText"] as? String, !lineText.isEmpty {
|
||||
print(String.localizedStringWithFormat(
|
||||
CMUXDiffViewerLocalization.string(
|
||||
"cli.comments.list.anchor",
|
||||
defaultValue: " anchor: %@"
|
||||
),
|
||||
lineText
|
||||
))
|
||||
}
|
||||
if let message = comment["message"] as? String, !message.isEmpty {
|
||||
print(" \(message)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-37
@@ -3,6 +3,13 @@ import Darwin
|
||||
import Foundation
|
||||
|
||||
extension CMUXCLI {
|
||||
var restoreCommandUsageLine: String {
|
||||
String(
|
||||
localized: "cli.help.restore",
|
||||
defaultValue: "restore [--surface <id|ref>] <kind> <checkpoint-id> | restore --surface [id|ref]"
|
||||
)
|
||||
}
|
||||
|
||||
func controlAgentLaunchCommandPayload(
|
||||
_ command: AgentLaunchCommand
|
||||
) -> [String: Any] {
|
||||
@@ -193,21 +200,12 @@ extension CMUXCLI {
|
||||
)
|
||||
}
|
||||
|
||||
let resolution = AgentProcessBindingResolution.controllingTTY.rawValue
|
||||
do {
|
||||
let payload = try client.sendV2(
|
||||
method: "agent.resolve_delivery_target",
|
||||
params: [
|
||||
"pid": Int(ProcessInfo.processInfo.processIdentifier),
|
||||
"pid_resolution": resolution,
|
||||
]
|
||||
let payload = try implicitCallerIdentifyResponse(
|
||||
client: client,
|
||||
processEnvironment: processEnvironment
|
||||
)
|
||||
guard payload["source"] as? String == "pid",
|
||||
payload["pid_resolution"] as? String == resolution,
|
||||
let workspaceID = normalizedHandleValue(payload["workspace_id"] as? String),
|
||||
isUUID(workspaceID),
|
||||
let surfaceID = normalizedHandleValue(payload["surface_id"] as? String),
|
||||
isUUID(surfaceID) else {
|
||||
guard let surfaceID = identifiedCallerSurfaceID(in: payload) else {
|
||||
throw currentRestoreSurfaceUnknownError()
|
||||
}
|
||||
return surfaceID
|
||||
@@ -331,41 +329,60 @@ extension CMUXCLI {
|
||||
}
|
||||
|
||||
private func restoreSelector(_ arguments: [String]) throws -> RestoreSelector {
|
||||
if arguments.first == "--surface" {
|
||||
if arguments.count == 1 {
|
||||
return RestoreSelector(
|
||||
surface: nil,
|
||||
usesCurrentSurface: true,
|
||||
kind: nil,
|
||||
checkpointID: nil
|
||||
)
|
||||
}
|
||||
guard arguments.count == 2, !arguments[1].isEmpty else {
|
||||
if arguments == ["--surface"] {
|
||||
return RestoreSelector(
|
||||
surface: nil,
|
||||
usesCurrentSurface: true,
|
||||
kind: nil,
|
||||
checkpointID: nil
|
||||
)
|
||||
}
|
||||
|
||||
let surfaceOptionCount = arguments.filter { argument in
|
||||
argument == "--surface" || argument.hasPrefix("--surface=")
|
||||
}.count
|
||||
guard surfaceOptionCount <= 1 else {
|
||||
throw CLIError(message: String(
|
||||
localized: "cli.restore.usage.surface",
|
||||
defaultValue: "Usage: cmux restore --surface [id|ref]"
|
||||
))
|
||||
}
|
||||
let (surface, positionalArguments) = parseOption(arguments, name: "--surface")
|
||||
if surfaceOptionCount == 1 {
|
||||
guard let surface,
|
||||
!surface.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw CLIError(message: String(
|
||||
localized: "cli.restore.usage.surface",
|
||||
defaultValue: "Usage: cmux restore --surface [id|ref]"
|
||||
))
|
||||
}
|
||||
return RestoreSelector(
|
||||
surface: arguments[1],
|
||||
usesCurrentSurface: false,
|
||||
kind: nil,
|
||||
checkpointID: nil
|
||||
)
|
||||
if positionalArguments.isEmpty {
|
||||
return RestoreSelector(
|
||||
surface: surface,
|
||||
usesCurrentSurface: false,
|
||||
kind: nil,
|
||||
checkpointID: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
guard arguments.count == 2,
|
||||
!arguments[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!arguments[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
|
||||
guard positionalArguments.count == 2,
|
||||
!positionalArguments[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!positionalArguments[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw CLIError(message: String(
|
||||
localized: "cli.restore.usage.positional",
|
||||
defaultValue: "Usage: cmux restore <kind> <checkpoint-id>"
|
||||
defaultValue: """
|
||||
Usage: cmux restore [--surface <id|ref>] <kind> <checkpoint-id>
|
||||
cmux restore <kind> <checkpoint-id> --surface <id|ref>
|
||||
cmux restore --surface=<id|ref> <kind> <checkpoint-id>
|
||||
"""
|
||||
))
|
||||
}
|
||||
return RestoreSelector(
|
||||
surface: nil,
|
||||
usesCurrentSurface: true,
|
||||
kind: arguments[0],
|
||||
checkpointID: arguments[1]
|
||||
surface: surface,
|
||||
usesCurrentSurface: surface == nil,
|
||||
kind: positionalArguments[0],
|
||||
checkpointID: positionalArguments[1]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+224
-49
@@ -4552,45 +4552,51 @@ struct CMUXCLI {
|
||||
params["window_id"] = targetWindow
|
||||
}
|
||||
let includeCaller = !hasFlag(commandArgs, name: "--no-caller")
|
||||
if includeCaller {
|
||||
let idWsFlag = optionValue(commandArgs, name: "--workspace")
|
||||
let idSurfaceFlag = optionValue(commandArgs, name: "--surface")
|
||||
let workspaceArg = idWsFlag ?? (effectiveWindowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
||||
let surfaceArg = idSurfaceFlag ?? (idWsFlag == nil && effectiveWindowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
|
||||
if workspaceArg != nil || surfaceArg != nil {
|
||||
let workspaceId = try normalizeWorkspaceHandle(
|
||||
workspaceArg,
|
||||
client: client,
|
||||
windowHandle: targetWindow,
|
||||
allowCurrent: surfaceArg != nil
|
||||
)
|
||||
var caller: [String: Any] = [:]
|
||||
if let workspaceId {
|
||||
caller["workspace_id"] = workspaceId
|
||||
}
|
||||
if surfaceArg != nil {
|
||||
guard let surfaceId = try normalizeSurfaceHandle(
|
||||
surfaceArg,
|
||||
let idWsFlag = optionValue(commandArgs, name: "--workspace")
|
||||
let idSurfaceFlag = optionValue(commandArgs, name: "--surface")
|
||||
let usesImplicitCaller = includeCaller
|
||||
&& effectiveWindowRaw == nil
|
||||
&& idWsFlag == nil
|
||||
&& idSurfaceFlag == nil
|
||||
let response: [String: Any]
|
||||
if usesImplicitCaller {
|
||||
response = try implicitCallerIdentifyResponse(
|
||||
client: client,
|
||||
processEnvironment: processEnv
|
||||
)
|
||||
} else {
|
||||
if includeCaller {
|
||||
let workspaceArg = idWsFlag ?? (effectiveWindowRaw == nil ? processEnv["CMUX_WORKSPACE_ID"] : nil)
|
||||
let surfaceArg = idSurfaceFlag ?? (idWsFlag == nil && effectiveWindowRaw == nil ? processEnv["CMUX_SURFACE_ID"] : nil)
|
||||
if workspaceArg != nil || surfaceArg != nil {
|
||||
let workspaceId = try normalizeWorkspaceHandle(
|
||||
workspaceArg,
|
||||
client: client,
|
||||
workspaceHandle: workspaceId,
|
||||
windowHandle: targetWindow
|
||||
) else {
|
||||
throw CLIError(message: "Invalid surface handle")
|
||||
windowHandle: targetWindow,
|
||||
allowCurrent: surfaceArg != nil
|
||||
)
|
||||
var caller: [String: Any] = [:]
|
||||
if let workspaceId {
|
||||
caller["workspace_id"] = workspaceId
|
||||
}
|
||||
if surfaceArg != nil {
|
||||
guard let surfaceId = try normalizeSurfaceHandle(
|
||||
surfaceArg,
|
||||
client: client,
|
||||
workspaceHandle: workspaceId,
|
||||
windowHandle: targetWindow
|
||||
) else {
|
||||
throw CLIError(message: "Invalid surface handle")
|
||||
}
|
||||
caller["surface_id"] = surfaceId
|
||||
}
|
||||
if !caller.isEmpty {
|
||||
params["caller"] = caller
|
||||
}
|
||||
caller["surface_id"] = surfaceId
|
||||
}
|
||||
if !caller.isEmpty {
|
||||
params["caller"] = caller
|
||||
}
|
||||
}
|
||||
if effectiveWindowRaw == nil,
|
||||
idWsFlag == nil,
|
||||
idSurfaceFlag == nil,
|
||||
let callerTTY = resolveCallerDescriptorTTYName() {
|
||||
params["caller_tty"] = callerTTY
|
||||
}
|
||||
response = try client.sendV2(method: "system.identify", params: params)
|
||||
}
|
||||
let response = try client.sendV2(method: "system.identify", params: params)
|
||||
print(jsonString(formatIDs(response, mode: idFormat)))
|
||||
|
||||
case "list-windows":
|
||||
@@ -4729,6 +4735,14 @@ struct CMUXCLI {
|
||||
windowOverride: windowId
|
||||
)
|
||||
|
||||
case "comments":
|
||||
try runCommentsNamespace(
|
||||
commandArgs: commandArgs,
|
||||
client: client,
|
||||
jsonOutput: jsonOutput,
|
||||
idFormat: idFormat
|
||||
)
|
||||
|
||||
case "layout": try runLayoutNamespace(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)
|
||||
|
||||
case "list-workspaces":
|
||||
@@ -15585,6 +15599,8 @@ struct CMUXCLI {
|
||||
return Self.remotesUsage
|
||||
case "todo":
|
||||
return Self.todoUsage
|
||||
case "comments":
|
||||
return Self.commentsUsage
|
||||
case "ai-accounts":
|
||||
return Self.aiAccountsUsage
|
||||
case "ping":
|
||||
@@ -15825,11 +15841,13 @@ struct CMUXCLI {
|
||||
"""
|
||||
case "restore":
|
||||
return String(localized: "cli.restore.help", defaultValue: """
|
||||
Usage: cmux restore <kind> <checkpoint-id>
|
||||
Usage: cmux restore [--surface <id|ref>] <kind> <checkpoint-id>
|
||||
cmux restore <kind> <checkpoint-id> --surface <id|ref>
|
||||
cmux restore --surface=<id|ref> <kind> <checkpoint-id>
|
||||
cmux restore --surface [id|ref]
|
||||
|
||||
Replace this CLI process with the persisted surface process. New
|
||||
records preserve argv, environment, and cwd as structured values;
|
||||
records preserve launch arguments and cwd as structured values;
|
||||
command-only records from older builds use a compatibility shell.
|
||||
With no id or ref, --surface uses the calling cmux surface.
|
||||
""")
|
||||
@@ -26262,6 +26280,97 @@ struct CMUXCLI {
|
||||
return resolveCallerDescriptorTTYName()
|
||||
}
|
||||
|
||||
func implicitCallerIdentifyResponse(
|
||||
client: SocketClient,
|
||||
processEnvironment: [String: String]
|
||||
) throws -> [String: Any] {
|
||||
let callerTTY = resolveCallerDescriptorTTYName()
|
||||
?? resolveCallerTTYName(includeAmbientTTY: false)
|
||||
if let callerTTY {
|
||||
let ttyResponse = try client.sendV2(
|
||||
method: "system.identify",
|
||||
params: ["caller_tty": callerTTY]
|
||||
)
|
||||
if identifiedCallerSurfaceID(in: ttyResponse) != nil
|
||||
|| identifyResponseHasMalformedCallerSurface(ttyResponse) {
|
||||
return ttyResponse
|
||||
}
|
||||
if let environmentParams = try implicitCallerEnvironmentIdentifyParams(
|
||||
client: client,
|
||||
processEnvironment: processEnvironment
|
||||
) {
|
||||
return try client.sendV2(
|
||||
method: "system.identify",
|
||||
params: environmentParams
|
||||
)
|
||||
}
|
||||
return ttyResponse
|
||||
}
|
||||
|
||||
if let environmentParams = try implicitCallerEnvironmentIdentifyParams(
|
||||
client: client,
|
||||
processEnvironment: processEnvironment
|
||||
) {
|
||||
return try client.sendV2(
|
||||
method: "system.identify",
|
||||
params: environmentParams
|
||||
)
|
||||
}
|
||||
return try client.sendV2(method: "system.identify")
|
||||
}
|
||||
|
||||
func identifiedCallerSurfaceID(in response: [String: Any]) -> String? {
|
||||
guard let caller = response["caller"] as? [String: Any],
|
||||
let surfaceID = normalizedHandleValue(caller["surface_id"] as? String),
|
||||
isUUID(surfaceID) else {
|
||||
return nil
|
||||
}
|
||||
return surfaceID
|
||||
}
|
||||
|
||||
private func implicitCallerEnvironmentIdentifyParams(
|
||||
client: SocketClient,
|
||||
processEnvironment: [String: String]
|
||||
) throws -> [String: Any]? {
|
||||
let workspaceArg = normalizedHandleValue(processEnvironment["CMUX_WORKSPACE_ID"])
|
||||
let surfaceArg = normalizedHandleValue(processEnvironment["CMUX_SURFACE_ID"])
|
||||
guard workspaceArg != nil || surfaceArg != nil else { return nil }
|
||||
|
||||
let workspaceID = try normalizeWorkspaceHandle(
|
||||
workspaceArg,
|
||||
client: client,
|
||||
allowCurrent: surfaceArg != nil
|
||||
)
|
||||
var caller: [String: Any] = [:]
|
||||
if let workspaceID {
|
||||
caller["workspace_id"] = workspaceID
|
||||
}
|
||||
if let surfaceArg {
|
||||
guard let surfaceID = try normalizeSurfaceHandle(
|
||||
surfaceArg,
|
||||
client: client,
|
||||
workspaceHandle: workspaceID,
|
||||
windowHandle: nil
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
caller["surface_id"] = surfaceID
|
||||
}
|
||||
guard !caller.isEmpty else { return nil }
|
||||
return ["caller": caller]
|
||||
}
|
||||
|
||||
private func identifyResponseHasMalformedCallerSurface(
|
||||
_ response: [String: Any]
|
||||
) -> Bool {
|
||||
guard let caller = response["caller"] as? [String: Any],
|
||||
let surface = caller["surface_id"],
|
||||
!(surface is NSNull) else {
|
||||
return false
|
||||
}
|
||||
return identifiedCallerSurfaceID(in: response) == nil
|
||||
}
|
||||
|
||||
func resolveCallerDescriptorTTYName() -> String? {
|
||||
for fileDescriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
|
||||
if let rawTTYName = ttyname(fileDescriptor),
|
||||
@@ -29559,7 +29668,10 @@ export default CMUXSessionRestore;
|
||||
return false
|
||||
}
|
||||
|
||||
private func installAgentHooks(_ def: AgentHookDef) throws {
|
||||
private func installAgentHooks(
|
||||
_ def: AgentHookDef,
|
||||
automaticReconciliation: Bool = false
|
||||
) throws {
|
||||
if def.name == "opencode" { try installOpenCodePluginHooks(def); return }
|
||||
if def.name == "pi" { try installPiExtensionHooks(def); return }
|
||||
if def.name == "omp" { try installOmpExtensionHooks(def); return }
|
||||
@@ -29588,7 +29700,8 @@ export default CMUXSessionRestore;
|
||||
let fm = FileManager.default
|
||||
let configDir = def.resolvedConfigDir()
|
||||
let filePath = "\(configDir)/\(def.configFile)"
|
||||
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
|
||||
let skipConfirm = automaticReconciliation
|
||||
|| ProcessInfo.processInfo.arguments.contains("--yes")
|
||||
|| ProcessInfo.processInfo.arguments.contains("-y")
|
||||
|
||||
let configDirectoryFileError = String.localizedStringWithFormat(
|
||||
@@ -29604,7 +29717,9 @@ export default CMUXSessionRestore;
|
||||
if def.createConfigDirIfMissing {
|
||||
throw CLIError(message: configDirectoryFileError)
|
||||
}
|
||||
print("Required agent configuration is missing. Run `cmux hooks setup` after installing your agent CLI.")
|
||||
if !automaticReconciliation {
|
||||
print("Required agent configuration is missing. Run `cmux hooks setup` after installing your agent CLI.")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !configPathExists {
|
||||
@@ -29615,7 +29730,9 @@ export default CMUXSessionRestore;
|
||||
throw CLIError(message: configDirectoryFileError)
|
||||
}
|
||||
} else {
|
||||
print("Required agent configuration is missing. Run `cmux hooks setup` after installing your agent CLI.")
|
||||
if !automaticReconciliation {
|
||||
print("Required agent configuration is missing. Run `cmux hooks setup` after installing your agent CLI.")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -29628,6 +29745,12 @@ export default CMUXSessionRestore;
|
||||
existing = json
|
||||
}
|
||||
|
||||
let existingHooksValue: Any = existing["hooks"] ?? [String: Any]()
|
||||
if automaticReconciliation,
|
||||
!Self.jsonHookValueContainsCmuxOwnedCommand(existingHooksValue, for: def) {
|
||||
return
|
||||
}
|
||||
|
||||
var hooks = existing["hooks"] as? [String: Any] ?? [:]
|
||||
let newHooks = buildHooksDict(for: def)
|
||||
|
||||
@@ -29771,7 +29894,9 @@ export default CMUXSessionRestore;
|
||||
|
||||
if oldString == newString {
|
||||
// No-op install; skip the write and the prompt entirely.
|
||||
print("\(def.displayName) hooks already up to date at \(filePath)")
|
||||
if !automaticReconciliation {
|
||||
print("\(def.displayName) hooks already up to date at \(filePath)")
|
||||
}
|
||||
} else {
|
||||
if !skipConfirm {
|
||||
Self.printInstallPreview(
|
||||
@@ -29787,10 +29912,12 @@ export default CMUXSessionRestore;
|
||||
}
|
||||
}
|
||||
try newData.write(to: URL(fileURLWithPath: filePath), options: .atomic)
|
||||
print("\(def.displayName) hooks installed at \(filePath)")
|
||||
if !automaticReconciliation {
|
||||
print("\(def.displayName) hooks installed at \(filePath)")
|
||||
}
|
||||
}
|
||||
|
||||
if let note = def.postInstallNote {
|
||||
if !automaticReconciliation, let note = def.postInstallNote {
|
||||
print(note)
|
||||
}
|
||||
|
||||
@@ -29836,14 +29963,40 @@ export default CMUXSessionRestore;
|
||||
}
|
||||
}
|
||||
try newContent.write(toFile: configPath, atomically: true, encoding: .utf8)
|
||||
if def.name == "codex", !codexHookTrustEntries.isEmpty, trustInstall.installedTrust {
|
||||
print("Enabled hooks and approved cmux hooks in \(configPath)")
|
||||
} else {
|
||||
print("Enabled hooks in \(configPath)")
|
||||
if !automaticReconciliation {
|
||||
if def.name == "codex", !codexHookTrustEntries.isEmpty, trustInstall.installedTrust {
|
||||
print("Enabled hooks and approved cmux hooks in \(configPath)")
|
||||
} else {
|
||||
print("Enabled hooks in \(configPath)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if def.name == "codex", !automaticReconciliation {
|
||||
Self.garbageCollectCodexHookScripts(
|
||||
retaining: Self.currentCodexWrapperHookScriptFilenames(for: def)
|
||||
.union(Self.installedCodexHookScriptFilenames(for: def))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Repairs an opted-in persistent Codex channel before wrapper launch.
|
||||
func reconcileCodexPersistentHooksForWrapper() -> Bool {
|
||||
guard let def = Self.agentDef(named: "codex") else { return false }
|
||||
try? installAgentHooks(def, automaticReconciliation: true)
|
||||
|
||||
let fileURL = URL(fileURLWithPath: def.resolvedConfigDir(), isDirectory: true)
|
||||
.appendingPathComponent(def.configFile, isDirectory: false)
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let hooks = root["hooks"] as? [String: Any] else {
|
||||
return false
|
||||
}
|
||||
return hooks.values.contains {
|
||||
Self.jsonHookValueContainsCmuxOwnedCommand($0, for: def)
|
||||
}
|
||||
}
|
||||
|
||||
private func pruneLegacyGrokHookFileIfNeeded(
|
||||
@@ -32590,7 +32743,28 @@ export default CMUXSessionRestore;
|
||||
surfaceId: String? = nil,
|
||||
socketPassword: String? = nil
|
||||
) {
|
||||
let hookEventName = Self.feedEventName(forClaudeSubcommand: subcommand)
|
||||
let fallbackHookEventName = Self.feedEventName(forClaudeSubcommand: subcommand)
|
||||
let reportedHookEventName = parsedInput.object.flatMap {
|
||||
firstString(in: $0, keys: ["hook_event_name", "hookEventName", "event", "event_name"])
|
||||
} ?? parsedInput.rawObject.flatMap {
|
||||
firstString(in: $0, keys: ["hook_event_name", "hookEventName", "event", "event_name"])
|
||||
}
|
||||
let hookEventName: String
|
||||
if source == "codex",
|
||||
let reportedHookEventName,
|
||||
reportedHookEventName.replacingOccurrences(of: "_", with: "").lowercased()
|
||||
== "permissionrequest" {
|
||||
// A single notification handler now owns Codex PermissionRequest.
|
||||
// Preserve the existing non-blocking Feed classification while that
|
||||
// same handler drives the needs-input lifecycle and alert.
|
||||
hookEventName = FeedEventClassifier.classify(
|
||||
source: source,
|
||||
event: reportedHookEventName,
|
||||
toolName: ""
|
||||
).0
|
||||
} else {
|
||||
hookEventName = fallbackHookEventName
|
||||
}
|
||||
guard !hookEventName.isEmpty else { return }
|
||||
let promptText = hookEventName == "UserPromptSubmit"
|
||||
? (feedPromptText(from: parsedInput.object) ?? parsedInput.rawFallback)
|
||||
@@ -36061,7 +36235,7 @@ export default CMUXSessionRestore;
|
||||
shortcuts
|
||||
disable-browser | enable-browser | browser-status
|
||||
agent-hibernation <on|off>
|
||||
restore <kind> <checkpoint-id> | restore --surface [id|ref]
|
||||
\(restoreCommandUsageLine)
|
||||
restore-session
|
||||
open <path-or-url>... [--workspace <id|ref|index>] [--surface <id|ref|index>] [--pane <id|ref|index>] [--window <id|ref|index>] [--focus <true|false>] [--no-focus]
|
||||
diff [patch-file|-] [--source <unstaged|staged|branch|last-turn>] [--unstaged|--staged|--branch|--last-turn] [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--cwd <path>] [--base <ref>] [--focus <true|false>] [--no-focus] [--title <text>] [--layout <split|unified>] [--font-size <points>]
|
||||
@@ -36101,6 +36275,7 @@ export default CMUXSessionRestore;
|
||||
workspace-action --action <name> [--workspace <id|ref|index>] [--window <id|ref|index>] [--title <text>] [--color <name|#hex>] [--description <text>]
|
||||
workspace status [set <lane|auto>] [--workspace <id|ref|index>] [--window <id|ref|index>]
|
||||
todo <add|list|check|uncheck|start|rm|clear> [args] [--workspace <id|ref|index>] [--window <id|ref|index>]
|
||||
comments list [--repo <path>] [--all] [--json]
|
||||
move-tab-to-new-workspace [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--title <text>] [--focus <true|false>]
|
||||
list-workspaces [--window <id|ref|index>]
|
||||
new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] [--group <id|ref>] [--group-placement afterCurrent|top|end] [--group-reference <workspace>]
|
||||
|
||||
@@ -193,6 +193,26 @@ public enum DiagnosticEventCode: UInt16, Sendable, Codable, CaseIterable {
|
||||
/// `b` is ``DiagnosticPathKind`` for the affected path, and `c` is the
|
||||
/// matching positive, process-local session correlation ID.
|
||||
case transportPathEvent = 55
|
||||
/// A phone-driven browser stream session changed lifecycle state on the
|
||||
/// Mac. `a` is the stage (1 started, 2 replaced an existing session,
|
||||
/// 3 stopped, 4 first frame emitted), and `c` is the positive browser
|
||||
/// panel correlation ID derived from the panel UUID.
|
||||
case browserStreamLifecycle = 56
|
||||
/// Replayed phone input reached a streamed browser panel. `a` is the
|
||||
/// input kind (1 pointer, 2 key, 3 text, 4 suppressed no-editable
|
||||
/// backspace), `b` is the click count for pointers, 1 for keys, or the
|
||||
/// inserted character count for text, and `c` is the panel correlation ID.
|
||||
case browserInputReplayed = 57
|
||||
/// The streamed page's editable-focus state changed or a replayed click's
|
||||
/// focus assist resolved. `a` is 1 when an editable has focus (else 0),
|
||||
/// `b` is the focus-assist outcome (0 no editable at the point, 1 focus
|
||||
/// moved, 2 already focused, 3 beacon-reported transition), and `c` is
|
||||
/// the panel correlation ID.
|
||||
case browserEditableFocus = 58
|
||||
/// A phone-initiated `mobile.browser.create` request resolved on the Mac.
|
||||
/// `a` is 1 on success else 0, and `c` is the panel correlation ID of the
|
||||
/// created panel (absent on failure).
|
||||
case browserPanelCreateResolved = 59
|
||||
}
|
||||
|
||||
/// Scene phase carried by ``DiagnosticEventCode/appLifecycleChanged``.
|
||||
|
||||
+72
@@ -353,6 +353,14 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
localized("diagnostics.event.transportCloseAttribution", defaultValue: "Transport close attributed")
|
||||
case .transportPathEvent:
|
||||
localized("diagnostics.event.transportPathEvent", defaultValue: "Transport path changed")
|
||||
case .browserStreamLifecycle:
|
||||
localized("diagnostics.event.browserStreamLifecycle", defaultValue: "Browser stream lifecycle")
|
||||
case .browserInputReplayed:
|
||||
localized("diagnostics.event.browserInputReplayed", defaultValue: "Browser input replayed")
|
||||
case .browserEditableFocus:
|
||||
localized("diagnostics.event.browserEditableFocus", defaultValue: "Browser editable focus")
|
||||
case .browserPanelCreateResolved:
|
||||
localized("diagnostics.event.browserPanelCreateResolved", defaultValue: "Browser panel create resolved")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +395,14 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
return Field(key: "composer_active", value: booleanName(raw))
|
||||
case .composerKeyboardToggleWhilePresented:
|
||||
return Field(key: "terminal_input_focused", value: booleanName(raw))
|
||||
case .browserStreamLifecycle:
|
||||
return Field(key: "stage", value: browserStreamStageName(raw))
|
||||
case .browserInputReplayed:
|
||||
return Field(key: "input", value: browserInputKindName(raw))
|
||||
case .browserEditableFocus:
|
||||
return Field(key: "editable_focused", value: booleanName(raw))
|
||||
case .browserPanelCreateResolved:
|
||||
return Field(key: "created", value: booleanName(raw))
|
||||
default:
|
||||
return Field(key: "detail_1", value: String(raw))
|
||||
}
|
||||
@@ -411,6 +427,10 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
return Field(key: "draft_empty", value: booleanName(raw))
|
||||
case .composerActiveTransition, .composerKeyboardToggleWhilePresented:
|
||||
return Field(key: "first_responder", value: responderName(raw))
|
||||
case .browserInputReplayed:
|
||||
return Field(key: "count", value: String(raw))
|
||||
case .browserEditableFocus:
|
||||
return Field(key: "outcome", value: browserFocusOutcomeName(raw))
|
||||
default:
|
||||
return Field(key: "detail_2", value: String(raw))
|
||||
}
|
||||
@@ -445,6 +465,9 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
return Field(key: "session", value: String(raw))
|
||||
case .composerActiveTransition:
|
||||
return Field(key: "terminal_input_focused", value: booleanName(raw))
|
||||
case .browserStreamLifecycle, .browserInputReplayed,
|
||||
.browserEditableFocus, .browserPanelCreateResolved:
|
||||
return Field(key: "panel", value: String(raw))
|
||||
default:
|
||||
return Field(key: "detail_3", value: String(raw))
|
||||
}
|
||||
@@ -598,6 +621,48 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func browserStreamStageName(_ raw: Int) -> String {
|
||||
switch raw {
|
||||
case 1: localized("diagnostics.browserStage.started", defaultValue: "Started")
|
||||
case 2: localized("diagnostics.browserStage.replaced", defaultValue: "Replaced existing session")
|
||||
case 3: localized("diagnostics.browserStage.stopped", defaultValue: "Stopped")
|
||||
case 4: localized("diagnostics.browserStage.firstFrame", defaultValue: "First frame delivered")
|
||||
default:
|
||||
localized(
|
||||
"diagnostics.unknown.browserStage",
|
||||
defaultValue: "Unknown stage (\(raw))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func browserInputKindName(_ raw: Int) -> String {
|
||||
switch raw {
|
||||
case 1: localized("diagnostics.browserInput.pointer", defaultValue: "Pointer")
|
||||
case 2: localized("diagnostics.browserInput.key", defaultValue: "Key")
|
||||
case 3: localized("diagnostics.browserInput.text", defaultValue: "Text")
|
||||
case 4: localized("diagnostics.browserInput.keySuppressed", defaultValue: "Key suppressed")
|
||||
default:
|
||||
localized(
|
||||
"diagnostics.unknown.browserInput",
|
||||
defaultValue: "Unknown input (\(raw))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func browserFocusOutcomeName(_ raw: Int) -> String {
|
||||
switch raw {
|
||||
case 0: localized("diagnostics.browserFocus.none", defaultValue: "No editable at point")
|
||||
case 1: localized("diagnostics.browserFocus.moved", defaultValue: "Focus moved")
|
||||
case 2: localized("diagnostics.browserFocus.already", defaultValue: "Already focused")
|
||||
case 3: localized("diagnostics.browserFocus.beacon", defaultValue: "Beacon transition")
|
||||
default:
|
||||
localized(
|
||||
"diagnostics.unknown.browserFocus",
|
||||
defaultValue: "Unknown outcome (\(raw))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func pathEventName(_ raw: Int) -> String {
|
||||
switch raw {
|
||||
case 1: localized("diagnostics.pathOperation.opened", defaultValue: "Opened")
|
||||
@@ -690,6 +755,13 @@ public struct DiagnosticEventPresentation: Sendable {
|
||||
case "remote_sequence": localized("diagnostics.field.remoteSequence", defaultValue: "Remote sequence")
|
||||
case "delivered_sequence": localized("diagnostics.field.deliveredSequence", defaultValue: "Delivered sequence")
|
||||
case "next_sequence": localized("diagnostics.field.nextSequence", defaultValue: "Next sequence")
|
||||
case "stage": localized("diagnostics.field.stage", defaultValue: "Stage")
|
||||
case "input": localized("diagnostics.field.input", defaultValue: "Input")
|
||||
case "count": localized("diagnostics.field.count", defaultValue: "Count")
|
||||
case "outcome": localized("diagnostics.field.outcome", defaultValue: "Outcome")
|
||||
case "editable_focused": localized("diagnostics.field.editableFocused", defaultValue: "Editable focused")
|
||||
case "created": localized("diagnostics.field.created", defaultValue: "Created")
|
||||
case "panel": localized("diagnostics.field.panel", defaultValue: "Panel")
|
||||
case "detail_1": localized("diagnostics.field.detail1", defaultValue: "Detail 1")
|
||||
case "detail_2": localized("diagnostics.field.detail2", defaultValue: "Detail 2")
|
||||
case "detail_3": localized("diagnostics.field.detail3", defaultValue: "Detail 3")
|
||||
|
||||
+2
@@ -8,4 +8,6 @@ public struct MobileBrowserStreamCapability: Sendable {
|
||||
public static let viewportIdentifier = "browser.stream.viewport.v1"
|
||||
/// Version-one native browser dialog mirroring capability identifier.
|
||||
public static let dialogIdentifier = "browser.stream.dialog.v1"
|
||||
/// Version-one phone-initiated browser panel creation capability identifier.
|
||||
public static let createIdentifier = "browser.stream.create.v1"
|
||||
}
|
||||
|
||||
+52
@@ -159,6 +159,10 @@ import Testing
|
||||
.reachabilityChanged: "Network reachability changed",
|
||||
.transportCloseAttribution: "Transport close attributed",
|
||||
.transportPathEvent: "Transport path changed",
|
||||
.browserStreamLifecycle: "Browser stream lifecycle",
|
||||
.browserInputReplayed: "Browser input replayed",
|
||||
.browserEditableFocus: "Browser editable focus",
|
||||
.browserPanelCreateResolved: "Browser panel create resolved",
|
||||
]
|
||||
|
||||
#expect(Set(expected.keys) == Set(DiagnosticEventCode.allCases))
|
||||
@@ -235,6 +239,54 @@ import Testing
|
||||
.init(key: "remote_sequence", value: "20"),
|
||||
])
|
||||
|
||||
let browserLifecycle = englishPresentation.describe(DiagnosticEvent(
|
||||
code: .browserStreamLifecycle,
|
||||
tNanos: 1,
|
||||
a: 4,
|
||||
c: 987
|
||||
))
|
||||
#expect(browserLifecycle.fields == [
|
||||
.init(key: "stage", value: "First frame delivered"),
|
||||
.init(key: "panel", value: "987"),
|
||||
])
|
||||
|
||||
let browserInput = englishPresentation.describe(DiagnosticEvent(
|
||||
code: .browserInputReplayed,
|
||||
tNanos: 1,
|
||||
a: 4,
|
||||
b: 1,
|
||||
c: 987
|
||||
))
|
||||
#expect(browserInput.fields == [
|
||||
.init(key: "input", value: "Key suppressed"),
|
||||
.init(key: "count", value: "1"),
|
||||
.init(key: "panel", value: "987"),
|
||||
])
|
||||
|
||||
let browserFocus = englishPresentation.describe(DiagnosticEvent(
|
||||
code: .browserEditableFocus,
|
||||
tNanos: 1,
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 987
|
||||
))
|
||||
#expect(browserFocus.fields == [
|
||||
.init(key: "editable_focused", value: "Yes"),
|
||||
.init(key: "outcome", value: "Already focused"),
|
||||
.init(key: "panel", value: "987"),
|
||||
])
|
||||
|
||||
let browserCreate = englishPresentation.describe(DiagnosticEvent(
|
||||
code: .browserPanelCreateResolved,
|
||||
tNanos: 1,
|
||||
a: 1,
|
||||
c: 987
|
||||
))
|
||||
#expect(browserCreate.fields == [
|
||||
.init(key: "created", value: "Yes"),
|
||||
.init(key: "panel", value: "987"),
|
||||
])
|
||||
|
||||
for described in [recovery, endpoint, session, composer, input] {
|
||||
#expect(!described.fields.contains { ["a", "b", "c", "ms"].contains($0.key) })
|
||||
}
|
||||
|
||||
+1
@@ -155,6 +155,7 @@ extension AuthCoordinator {
|
||||
sessionCache.setHasTokens(true)
|
||||
currentUser = fixtureUser
|
||||
isAuthenticated = true
|
||||
publishAuthenticatedSessionIdentity()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+84
@@ -429,3 +429,87 @@ public struct AuthenticatedSessionSnapshot: Sendable, Equatable,
|
||||
|
||||
public var debugDescription: String { description }
|
||||
}
|
||||
|
||||
/// Credential-free identity for synchronously binding queued work to the
|
||||
/// current authenticated session.
|
||||
public struct AuthenticatedSessionIdentity: Sendable, Equatable,
|
||||
CustomStringConvertible, CustomDebugStringConvertible {
|
||||
public let generation: UInt64
|
||||
public let accountID: String
|
||||
|
||||
public init(generation: UInt64, accountID: String) {
|
||||
self.generation = generation
|
||||
self.accountID = accountID
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
"AuthenticatedSessionIdentity(generation: \(generation), accountID: <redacted>)"
|
||||
}
|
||||
|
||||
public var debugDescription: String { description }
|
||||
}
|
||||
|
||||
public extension AuthCoordinator {
|
||||
/// The current account plus session generation without either credential.
|
||||
var authenticatedSessionIdentity: AuthenticatedSessionIdentity? {
|
||||
guard isAuthenticated,
|
||||
!sessionTokenTransitionIsActive,
|
||||
let accountID = currentUser?.id,
|
||||
!accountID.isEmpty else { return nil }
|
||||
return AuthenticatedSessionIdentity(
|
||||
generation: authSessionGeneration,
|
||||
accountID: accountID
|
||||
)
|
||||
}
|
||||
|
||||
/// A credential-free lifecycle stream for consumers that must cancel work
|
||||
/// at the exact auth transition instead of discovering stale authority on
|
||||
/// their next request. The first element is always the current state.
|
||||
func authenticatedSessionIdentities()
|
||||
-> AsyncStream<AuthenticatedSessionIdentity?> {
|
||||
let continuationID = UUID()
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) {
|
||||
continuation in
|
||||
authenticatedSessionIdentityContinuations[continuationID] =
|
||||
continuation
|
||||
continuation.yield(publishedAuthenticatedSessionIdentity)
|
||||
continuation.onTermination = { @Sendable [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.authenticatedSessionIdentityContinuations[
|
||||
continuationID
|
||||
] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a credential-free identity still names the published session.
|
||||
/// This stays stable through same-account revalidation but flips false at
|
||||
/// the synchronous start of sign-out.
|
||||
func isAuthenticatedSessionIdentityCurrent(
|
||||
_ identity: AuthenticatedSessionIdentity
|
||||
) -> Bool {
|
||||
publishedAuthenticatedSessionIdentity == identity
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthCoordinator {
|
||||
private var publishedAuthenticatedSessionIdentity:
|
||||
AuthenticatedSessionIdentity? {
|
||||
guard isAuthenticated,
|
||||
!isCapturingSignOutCredentials,
|
||||
let accountID = currentUser?.id,
|
||||
!accountID.isEmpty else { return nil }
|
||||
return AuthenticatedSessionIdentity(
|
||||
generation: authSessionGeneration,
|
||||
accountID: accountID
|
||||
)
|
||||
}
|
||||
|
||||
func publishAuthenticatedSessionIdentity() {
|
||||
let identity = publishedAuthenticatedSessionIdentity
|
||||
for continuation in authenticatedSessionIdentityContinuations.values {
|
||||
continuation.yield(identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -106,6 +106,9 @@ public final class AuthCoordinator {
|
||||
@ObservationIgnored var signOutEpoch: UInt64 = 0
|
||||
/// Monotonic sign-in attempt count, allocating each flow's attempt id.
|
||||
@ObservationIgnored var signInAttemptCounter: UInt64 = 0
|
||||
@ObservationIgnored var authenticatedSessionIdentityContinuations: [
|
||||
UUID: AsyncStream<AuthenticatedSessionIdentity?>.Continuation
|
||||
] = [:]
|
||||
/// Sign-in attempts that currently own a possible write to the token store.
|
||||
///
|
||||
/// This ownership spans the whole flow, not just the credential-exchange
|
||||
@@ -155,6 +158,7 @@ public final class AuthCoordinator {
|
||||
|
||||
private func finishSignInFlow(_ flow: SignInFlowContext) {
|
||||
activeSignInFlows[flow.attempt] = nil
|
||||
publishAuthenticatedSessionIdentity()
|
||||
}
|
||||
|
||||
/// Creates an auth coordinator.
|
||||
@@ -505,6 +509,7 @@ public final class AuthCoordinator {
|
||||
// the local clear below).
|
||||
advanceSessionGeneration()
|
||||
signOutEpoch &+= 1
|
||||
publishAuthenticatedSessionIdentity()
|
||||
await phaseTimeoutRegistry.clear([.sendCode, .verifyCode, .passwordSignIn, .oauth, .validateSession])
|
||||
|
||||
// Capture the teardown credentials with raw stored reads (no refresh,
|
||||
@@ -626,6 +631,7 @@ public final class AuthCoordinator {
|
||||
currentUser = user
|
||||
isAuthenticated = true
|
||||
isRestoringSession = false
|
||||
publishAuthenticatedSessionIdentity()
|
||||
saveCachedUser(user)
|
||||
sessionCache.setHasTokens(true)
|
||||
await refreshTeams(generation: generation)
|
||||
@@ -726,6 +732,7 @@ public final class AuthCoordinator {
|
||||
currentUser = cachedUser
|
||||
isAuthenticated = cachedUser != nil
|
||||
isRestoringSession = false
|
||||
publishAuthenticatedSessionIdentity()
|
||||
}
|
||||
|
||||
func clearPersistedAuthForUITest() async {
|
||||
@@ -757,6 +764,7 @@ public final class AuthCoordinator {
|
||||
currentUser = state.currentUser
|
||||
isAuthenticated = state.isAuthenticated
|
||||
isRestoringSession = state.isRestoringSession
|
||||
publishAuthenticatedSessionIdentity()
|
||||
}
|
||||
|
||||
func loadCachedUser() -> CMUXAuthUser? {
|
||||
|
||||
+17
-1
@@ -8,6 +8,13 @@ import Foundation
|
||||
/// that talk to the web API (e.g. ``PushRegistrationService``) so they never
|
||||
/// reach for an auth singleton.
|
||||
public protocol TokenProviding: Sendable {
|
||||
/// Coherent account id + token pair pinned to one auth-session generation.
|
||||
func authenticatedSessionSnapshot() async throws
|
||||
-> AuthenticatedSessionSnapshot
|
||||
/// Whether a previously captured snapshot still names the live session.
|
||||
func isAuthenticatedSessionCurrent(
|
||||
_ snapshot: AuthenticatedSessionSnapshot
|
||||
) async -> Bool
|
||||
/// The current access token, throwing when there is no valid session.
|
||||
func accessToken() async throws -> String
|
||||
/// The currently stored access token, without refresh or auth-state mutation.
|
||||
@@ -28,4 +35,13 @@ public protocol TokenProviding: Sendable {
|
||||
func forceRefreshAccessToken() async throws -> String
|
||||
}
|
||||
|
||||
extension AuthCoordinator: TokenProviding {}
|
||||
extension AuthCoordinator: TokenProviding {
|
||||
public func isAuthenticatedSessionCurrent(
|
||||
_ snapshot: AuthenticatedSessionSnapshot
|
||||
) async -> Bool {
|
||||
isAuthenticated
|
||||
&& !sessionTokenTransitionIsActive
|
||||
&& authSessionGeneration == snapshot.generation
|
||||
&& currentUser?.id == snapshot.accountID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ public protocol PushRegistering: Sendable {
|
||||
/// Whether the user has opted into phone notifications.
|
||||
var isEnabled: Bool { get async }
|
||||
|
||||
/// The furthest locally and remotely confirmed registration stage.
|
||||
var snapshot: PushRegistrationSnapshot { get async }
|
||||
|
||||
/// A stream that immediately yields the current snapshot and every change.
|
||||
func snapshots() async -> AsyncStream<PushRegistrationSnapshot>
|
||||
|
||||
/// Persist the opt-in flag, re-uploading any cached token on enable and
|
||||
/// removing it server-side on disable.
|
||||
func setEnabled(_ enabled: Bool) async
|
||||
@@ -18,6 +24,10 @@ public protocol PushRegistering: Sendable {
|
||||
/// Cache and (when opted in) upload a freshly registered APNs device token.
|
||||
func register(deviceToken: Data) async
|
||||
|
||||
/// Records a terminal APNs token-registration callback failure without
|
||||
/// retaining or exposing the system error description.
|
||||
func deviceTokenRegistrationFailed() async
|
||||
|
||||
/// Re-upload the cached token (e.g. after sign-in). No-op unless opted in.
|
||||
func syncTokenIfPossible() async
|
||||
|
||||
@@ -32,4 +42,11 @@ public protocol PushRegistering: Sendable {
|
||||
/// live provider could authenticate as a NEXT account whose sign-in raced
|
||||
/// the bounded teardown.
|
||||
func unregisterFromServer(accessToken: String?, refreshToken: String?) async
|
||||
|
||||
/// Sign-out variant carrying the account id captured before local clear.
|
||||
func unregisterFromServer(
|
||||
accountID: String?,
|
||||
accessToken: String?,
|
||||
refreshToken: String?
|
||||
) async
|
||||
}
|
||||
|
||||
+759
-31
@@ -11,8 +11,10 @@ private let pushLog = Logger(subsystem: "ai.manaflow.cmux", category: "push")
|
||||
/// URL, bundle id, `UserDefaults(suiteName:)`, and `URLSession`, then inject it
|
||||
/// as `any PushRegistering`.
|
||||
///
|
||||
/// Privacy: notifications are **off by default**. Nothing (not even a device
|
||||
/// token) is uploaded until the user enables them via ``setEnabled(_:)``.
|
||||
/// Privacy: nothing (not even a device token) is uploaded until the app's
|
||||
/// workspace-list permission flow is accepted or the user explicitly enables
|
||||
/// notifications and the coordinator calls ``setEnabled(_:)``. An explicit
|
||||
/// app opt-out remains persisted and authoritative.
|
||||
public actor PushRegistrationService: PushRegistering {
|
||||
private let tokenProvider: any TokenProviding
|
||||
private let apiBaseURL: String
|
||||
@@ -20,9 +22,24 @@ public actor PushRegistrationService: PushRegistering {
|
||||
private let apnsEnvironment: String
|
||||
private let defaults: UserDefaults
|
||||
private let session: URLSession
|
||||
private let retryDelays: [Duration]
|
||||
private let retryJitter: @Sendable (ClosedRange<Double>) -> Double
|
||||
private let retrySleep: @Sendable (Duration) async throws -> Void
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var unregisterDrainTask: Task<Void, Never>?
|
||||
private var operationGeneration = UUID()
|
||||
private var snapshotValue: PushRegistrationSnapshot
|
||||
private var snapshotContinuations:
|
||||
[UUID: AsyncStream<PushRegistrationSnapshot>.Continuation] = [:]
|
||||
|
||||
private static let enabledKey = "cmux.notifications.pushEnabled"
|
||||
private static let cachedTokenKey = "cmux.notifications.deviceTokenHex"
|
||||
private static let registeredAccountIDKey = "cmux.notifications.registeredAccountID"
|
||||
private static let pendingUnregisterTokenKey = "cmux.notifications.pendingUnregisterToken"
|
||||
private static let pendingUnregisterAccountIDKey = "cmux.notifications.pendingUnregisterAccountID"
|
||||
private static let pendingUnregisterQueueKey =
|
||||
"cmux.notifications.pendingUnregisters.v2"
|
||||
private static let pendingUnregisterAttemptBudget = 4
|
||||
|
||||
/// Creates a push registration service.
|
||||
///
|
||||
@@ -43,7 +60,19 @@ public actor PushRegistrationService: PushRegistering {
|
||||
bundleID: String,
|
||||
apnsEnvironment: String,
|
||||
suiteName: String? = nil,
|
||||
session: sending URLSession = .shared
|
||||
session: sending URLSession = .shared,
|
||||
retryDelays: [Duration] = [
|
||||
.seconds(1),
|
||||
.seconds(4),
|
||||
.seconds(15),
|
||||
.seconds(60),
|
||||
],
|
||||
retryJitter: @escaping @Sendable (ClosedRange<Double>) -> Double = {
|
||||
Double.random(in: $0)
|
||||
},
|
||||
retrySleep: @escaping @Sendable (Duration) async throws -> Void = {
|
||||
try await ContinuousClock().sleep(for: $0)
|
||||
}
|
||||
) {
|
||||
self.tokenProvider = tokenProvider
|
||||
self.apiBaseURL = apiBaseURL
|
||||
@@ -54,35 +83,126 @@ public actor PushRegistrationService: PushRegistering {
|
||||
} else {
|
||||
self.defaults = .standard
|
||||
}
|
||||
Self.migrateLegacyPendingUnregisters(in: self.defaults)
|
||||
self.session = session
|
||||
self.retryDelays = retryDelays
|
||||
self.retryJitter = retryJitter
|
||||
self.retrySleep = retrySleep
|
||||
let enabled = self.defaults.bool(forKey: Self.enabledKey)
|
||||
let hasToken = self.defaults.string(forKey: Self.cachedTokenKey)?.isEmpty == false
|
||||
self.snapshotValue = PushRegistrationSnapshot(
|
||||
isEnabled: enabled,
|
||||
hasDeviceToken: hasToken,
|
||||
backendState: enabled
|
||||
? (hasToken ? .registrationRequired : .awaitingDeviceToken)
|
||||
: .awaitingDeviceToken
|
||||
)
|
||||
}
|
||||
|
||||
public var isEnabled: Bool { defaults.bool(forKey: Self.enabledKey) }
|
||||
public var snapshot: PushRegistrationSnapshot { snapshotValue }
|
||||
|
||||
public func snapshots() -> AsyncStream<PushRegistrationSnapshot> {
|
||||
let id = UUID()
|
||||
return AsyncStream { continuation in
|
||||
snapshotContinuations[id] = continuation
|
||||
continuation.yield(snapshotValue)
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
Task { await self?.removeSnapshotContinuation(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func setEnabled(_ enabled: Bool) async {
|
||||
let wasEnabled = isEnabled
|
||||
cancelRetry()
|
||||
defaults.set(enabled, forKey: Self.enabledKey)
|
||||
if enabled {
|
||||
await syncTokenIfPossible()
|
||||
} else {
|
||||
await unregisterFromServer()
|
||||
publish(.disabled)
|
||||
if wasEnabled {
|
||||
await unregisterFromServer()
|
||||
} else {
|
||||
await retryPendingUnregisterIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func register(deviceToken: Data) async {
|
||||
let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||
let previousToken = cachedTokenHex
|
||||
if let previousToken,
|
||||
previousToken != hex,
|
||||
let previousOwner = defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
),
|
||||
!previousOwner.isEmpty {
|
||||
// Rotation does not prove the old row disappeared. Preserve its
|
||||
// cleanup before replacing the cache, then make the new token
|
||||
// ready before attempting the old-token DELETE.
|
||||
persistPendingUnregister(
|
||||
tokenHex: previousToken,
|
||||
accountID: previousOwner
|
||||
)
|
||||
defaults.removeObject(forKey: Self.registeredAccountIDKey)
|
||||
}
|
||||
defaults.set(hex, forKey: Self.cachedTokenKey)
|
||||
guard isEnabled else { return }
|
||||
guard isEnabled else {
|
||||
publish(.disabled)
|
||||
return
|
||||
}
|
||||
cancelRetry()
|
||||
await upload(tokenHex: hex)
|
||||
if snapshotValue.backendState == .registered {
|
||||
await retryPendingUnregisterIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
public func syncTokenIfPossible() async {
|
||||
guard isEnabled, let hex = cachedTokenHex else { return }
|
||||
guard isEnabled else {
|
||||
await retryPendingUnregisterIfPossible()
|
||||
publish(.disabled)
|
||||
return
|
||||
}
|
||||
guard let hex = cachedTokenHex else {
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: false,
|
||||
backendState: .awaitingDeviceToken
|
||||
))
|
||||
// There is no current registration to prioritize, so an
|
||||
// owner-matching privacy cleanup can proceed immediately.
|
||||
await retryPendingUnregisterIfPossible()
|
||||
return
|
||||
}
|
||||
cancelRetry()
|
||||
await upload(tokenHex: hex)
|
||||
// Current-account registration is the readiness-critical operation.
|
||||
// Historical cleanup follows it, with its own bounded attempt budget.
|
||||
if snapshotValue.backendState == .registered {
|
||||
await retryPendingUnregisterIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
public func unregisterFromServer() async {
|
||||
cancelRetry()
|
||||
guard let hex = cachedTokenHex else { return }
|
||||
await sendDelete(tokenHex: hex)
|
||||
let session = try? await tokenProvider.authenticatedSessionSnapshot()
|
||||
let ownerID = defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
) ?? session?.accountID
|
||||
guard let ownerID, !ownerID.isEmpty else { return }
|
||||
// Persist before requiring live auth. This is the privacy guarantee for
|
||||
// an offline or signed-out opt-out.
|
||||
persistPendingUnregister(tokenHex: hex, accountID: ownerID)
|
||||
// A token acknowledged for account A must never be deleted using
|
||||
// account B credentials. Its tombstone waits for A to return.
|
||||
guard let session, session.accountID == ownerID else { return }
|
||||
if await sendDelete(tokenHex: hex, sessionSnapshot: session) {
|
||||
clearPendingUnregister(tokenHex: hex, accountID: ownerID)
|
||||
clearRegisteredOwner(accountID: ownerID, tokenHex: hex)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the device token from the server at sign-out, authenticating
|
||||
@@ -93,7 +213,39 @@ public actor PushRegistrationService: PushRegistering {
|
||||
/// - accessToken: The captured (or teardown-minted) access token.
|
||||
/// - refreshToken: The captured refresh token.
|
||||
public func unregisterFromServer(accessToken: String?, refreshToken: String?) async {
|
||||
await unregisterFromServer(
|
||||
accountID: nil,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken
|
||||
)
|
||||
}
|
||||
|
||||
/// Sign-out variant with the account id captured before local auth clear.
|
||||
public func unregisterFromServer(
|
||||
accountID capturedAccountID: String?,
|
||||
accessToken: String?,
|
||||
refreshToken: String?
|
||||
) async {
|
||||
cancelRetry()
|
||||
guard let hex = cachedTokenHex else { return }
|
||||
let registeredOwnerID = defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
)
|
||||
let ownerID = registeredOwnerID ?? capturedAccountID
|
||||
if let ownerID, !ownerID.isEmpty {
|
||||
// Persist the recovery record before validating credentials.
|
||||
// Offline sign-out commonly has only the refresh token, but a
|
||||
// later sign-in to this same account can safely finish the DELETE.
|
||||
persistPendingUnregister(tokenHex: hex, accountID: ownerID)
|
||||
}
|
||||
if let registeredOwnerID,
|
||||
capturedAccountID != registeredOwnerID {
|
||||
// The legacy overload has no account identity, and a caller
|
||||
// explicitly carrying B must never apply B's credentials to A's
|
||||
// acknowledged token. Keep A's tombstone until A returns.
|
||||
pushLog.info("Skipping push-token unregister: captured account does not prove registered ownership")
|
||||
return
|
||||
}
|
||||
// Sign-out path: never fall back to the live token provider. The
|
||||
// local-first sign-out cleared it, and a sign-in racing the bounded
|
||||
// teardown can repopulate it with the NEXT account's tokens; the
|
||||
@@ -104,7 +256,21 @@ public actor PushRegistrationService: PushRegistering {
|
||||
pushLog.info("Skipping push-token unregister at sign-out: captured credentials incomplete")
|
||||
return
|
||||
}
|
||||
await sendDelete(tokenHex: hex, capturedAccessToken: accessToken, capturedRefreshToken: refreshToken)
|
||||
if await sendDelete(
|
||||
tokenHex: hex,
|
||||
capturedAccessToken: accessToken,
|
||||
capturedRefreshToken: refreshToken
|
||||
), let ownerID {
|
||||
clearPendingUnregister(tokenHex: hex, accountID: ownerID)
|
||||
clearRegisteredOwner(accountID: ownerID, tokenHex: hex)
|
||||
}
|
||||
if isEnabled {
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registrationRequired
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedTokenHex: String? {
|
||||
@@ -113,7 +279,28 @@ public actor PushRegistrationService: PushRegistering {
|
||||
}
|
||||
|
||||
private func upload(tokenHex: String) async {
|
||||
guard let request = await makeRequest(
|
||||
operationGeneration = UUID()
|
||||
let generation = operationGeneration
|
||||
await attemptUpload(
|
||||
tokenHex: tokenHex,
|
||||
generation: generation,
|
||||
remainingDelays: retryDelays
|
||||
)
|
||||
}
|
||||
|
||||
private func attemptUpload(
|
||||
tokenHex: String,
|
||||
generation: UUID,
|
||||
remainingDelays: [Duration]
|
||||
) async {
|
||||
guard isEnabled, generation == operationGeneration,
|
||||
cachedTokenHex == tokenHex else { return }
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registering
|
||||
))
|
||||
let request = await makeRequest(
|
||||
method: "POST",
|
||||
path: "/api/device-tokens",
|
||||
body: [
|
||||
@@ -122,23 +309,199 @@ public actor PushRegistrationService: PushRegistering {
|
||||
"environment": apnsEnvironment,
|
||||
"platform": "ios",
|
||||
]
|
||||
) else { return }
|
||||
await perform(request, label: "register")
|
||||
)
|
||||
let result: RegistrationResult
|
||||
let requestSession: AuthenticatedSessionSnapshot?
|
||||
switch request {
|
||||
case let .success(context):
|
||||
requestSession = context.session
|
||||
result = await performRegistration(context.request)
|
||||
case let .failure(failure):
|
||||
requestSession = nil
|
||||
result = .failure(failure, retryAfter: nil)
|
||||
}
|
||||
let operationIsCurrent = isEnabled
|
||||
&& generation == operationGeneration
|
||||
&& cachedTokenHex == tokenHex
|
||||
let sessionIsCurrent: Bool
|
||||
if let requestSession {
|
||||
sessionIsCurrent = await tokenProvider
|
||||
.isAuthenticatedSessionCurrent(requestSession)
|
||||
} else {
|
||||
sessionIsCurrent = false
|
||||
}
|
||||
if case .success = result,
|
||||
let requestSession,
|
||||
(!operationIsCurrent || !sessionIsCurrent) {
|
||||
await reconcileStaleSuccessfulRegistration(
|
||||
tokenHex: tokenHex,
|
||||
staleSession: requestSession
|
||||
)
|
||||
return
|
||||
}
|
||||
guard operationIsCurrent else { return }
|
||||
if requestSession != nil, !sessionIsCurrent {
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .failed(.authenticationRequired)
|
||||
))
|
||||
return
|
||||
}
|
||||
switch result {
|
||||
case let .success(pushServiceConfigured):
|
||||
if let requestSession {
|
||||
defaults.set(
|
||||
requestSession.accountID,
|
||||
forKey: Self.registeredAccountIDKey
|
||||
)
|
||||
}
|
||||
// The token is globally unique. A successful upsert onto the
|
||||
// current account also removes any old-account association, so a
|
||||
// pending tombstone for this token is fulfilled without applying
|
||||
// old credentials.
|
||||
for pending in pendingUnregisters where pending.tokenHex == tokenHex {
|
||||
clearPendingUnregister(
|
||||
tokenHex: pending.tokenHex,
|
||||
accountID: pending.accountID
|
||||
)
|
||||
}
|
||||
if pushServiceConfigured {
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .registered
|
||||
))
|
||||
} else {
|
||||
// The API committed ownership before reporting its provider
|
||||
// readiness. Retain that cleanup identity while failing the
|
||||
// user-facing readiness check closed and retrying recovery.
|
||||
let failure = PushRegistrationFailure.serviceUnavailable
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .failed(failure)
|
||||
))
|
||||
scheduleUploadRetry(
|
||||
failure: failure,
|
||||
retryAfter: nil,
|
||||
tokenHex: tokenHex,
|
||||
generation: generation,
|
||||
remainingDelays: remainingDelays
|
||||
)
|
||||
}
|
||||
case let .failure(failure, retryAfter):
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: true,
|
||||
backendState: .failed(failure)
|
||||
))
|
||||
scheduleUploadRetry(
|
||||
failure: failure,
|
||||
retryAfter: retryAfter,
|
||||
tokenHex: tokenHex,
|
||||
generation: generation,
|
||||
remainingDelays: remainingDelays
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleUploadRetry(
|
||||
failure: PushRegistrationFailure,
|
||||
retryAfter: Duration?,
|
||||
tokenHex: String,
|
||||
generation: UUID,
|
||||
remainingDelays: [Duration]
|
||||
) {
|
||||
guard failure.isRecoverable, !remainingDelays.isEmpty else { return }
|
||||
let fallbackDelay = remainingDelays[0]
|
||||
let delay = retryAfter ?? Self.jittered(
|
||||
fallbackDelay,
|
||||
multiplier: retryJitter(0.8...1.2)
|
||||
)
|
||||
let laterDelays = Array(remainingDelays.dropFirst())
|
||||
retryTask = Task { [weak self, retrySleep] in
|
||||
do {
|
||||
try await retrySleep(delay)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.attemptUpload(
|
||||
tokenHex: tokenHex,
|
||||
generation: generation,
|
||||
remainingDelays: laterDelays
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Repairs the backend after an invalidated POST still succeeds.
|
||||
///
|
||||
/// URLSession cancellation cannot prove that the server did not commit the
|
||||
/// request. Delete with the exact stale account credentials after its
|
||||
/// acknowledgement, then re-upsert the token for whichever account is
|
||||
/// current now. This orders A POST, A DELETE, B POST and therefore makes B
|
||||
/// the final owner even when A's response arrives last.
|
||||
private func reconcileStaleSuccessfulRegistration(
|
||||
tokenHex: String,
|
||||
staleSession: AuthenticatedSessionSnapshot
|
||||
) async {
|
||||
let currentSession = try? await tokenProvider
|
||||
.authenticatedSessionSnapshot()
|
||||
if isEnabled,
|
||||
cachedTokenHex == tokenHex,
|
||||
currentSession?.accountID == staleSession.accountID {
|
||||
// A newer operation for the same account and token already
|
||||
// represents the same backend ownership. Do not disturb it.
|
||||
return
|
||||
}
|
||||
|
||||
persistPendingUnregister(
|
||||
tokenHex: tokenHex,
|
||||
accountID: staleSession.accountID
|
||||
)
|
||||
if await sendDelete(
|
||||
tokenHex: tokenHex,
|
||||
capturedAccessToken: staleSession.accessToken,
|
||||
capturedRefreshToken: staleSession.refreshToken
|
||||
) {
|
||||
clearPendingUnregister(
|
||||
tokenHex: tokenHex,
|
||||
accountID: staleSession.accountID
|
||||
)
|
||||
clearRegisteredOwner(
|
||||
accountID: staleSession.accountID,
|
||||
tokenHex: tokenHex
|
||||
)
|
||||
}
|
||||
|
||||
guard isEnabled, let currentToken = cachedTokenHex,
|
||||
let currentSession = try? await tokenProvider
|
||||
.authenticatedSessionSnapshot(),
|
||||
await tokenProvider.isAuthenticatedSessionCurrent(currentSession)
|
||||
else { return }
|
||||
await upload(tokenHex: currentToken)
|
||||
}
|
||||
|
||||
private func sendDelete(
|
||||
tokenHex: String,
|
||||
capturedAccessToken: String? = nil,
|
||||
capturedRefreshToken: String? = nil
|
||||
) async {
|
||||
guard let request = await makeRequest(
|
||||
capturedRefreshToken: String? = nil,
|
||||
sessionSnapshot: AuthenticatedSessionSnapshot? = nil
|
||||
) async -> Bool {
|
||||
guard case let .success(context) = await makeRequest(
|
||||
method: "DELETE",
|
||||
path: "/api/device-tokens",
|
||||
body: ["deviceToken": tokenHex],
|
||||
capturedAccessToken: capturedAccessToken,
|
||||
capturedRefreshToken: capturedRefreshToken
|
||||
) else { return }
|
||||
await perform(request, label: "unregister")
|
||||
capturedRefreshToken: capturedRefreshToken,
|
||||
sessionSnapshot: sessionSnapshot
|
||||
) else { return false }
|
||||
guard await performDelete(context.request) else { return false }
|
||||
if let session = context.session {
|
||||
return await tokenProvider.isAuthenticatedSessionCurrent(session)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func makeRequest(
|
||||
@@ -146,42 +509,407 @@ public actor PushRegistrationService: PushRegistering {
|
||||
path: String,
|
||||
body: [String: String],
|
||||
capturedAccessToken: String? = nil,
|
||||
capturedRefreshToken: String? = nil
|
||||
) async -> URLRequest? {
|
||||
capturedRefreshToken: String? = nil,
|
||||
sessionSnapshot: AuthenticatedSessionSnapshot? = nil
|
||||
) async -> Result<PushRequest, PushRegistrationFailure> {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
if let capturedAccessToken, let capturedRefreshToken {
|
||||
let authenticatedSession: AuthenticatedSessionSnapshot?
|
||||
if let sessionSnapshot {
|
||||
accessToken = sessionSnapshot.accessToken
|
||||
refreshToken = sessionSnapshot.refreshToken
|
||||
authenticatedSession = sessionSnapshot
|
||||
} else if let capturedAccessToken, let capturedRefreshToken {
|
||||
// Sign-out path: the live provider is already cleared by the
|
||||
// local-first sign-out; the captured pair is the only credential.
|
||||
accessToken = capturedAccessToken
|
||||
refreshToken = capturedRefreshToken
|
||||
authenticatedSession = nil
|
||||
} else {
|
||||
do {
|
||||
accessToken = try await tokenProvider.accessToken()
|
||||
let session = try await tokenProvider
|
||||
.authenticatedSessionSnapshot()
|
||||
accessToken = session.accessToken
|
||||
refreshToken = session.refreshToken
|
||||
authenticatedSession = session
|
||||
} catch {
|
||||
return nil
|
||||
return .failure(.authenticationRequired)
|
||||
}
|
||||
guard let liveRefreshToken = await tokenProvider.refreshToken() else { return nil }
|
||||
refreshToken = liveRefreshToken
|
||||
}
|
||||
guard let url = URL(string: apiBaseURL + path) else { return nil }
|
||||
guard let url = URL(string: apiBaseURL + path) else {
|
||||
return .failure(.invalidConfiguration)
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
return request
|
||||
request.timeoutInterval = 15
|
||||
return .success(PushRequest(
|
||||
request: request,
|
||||
session: authenticatedSession
|
||||
))
|
||||
}
|
||||
|
||||
private func perform(_ request: URLRequest, label: String) async {
|
||||
private func performRegistration(_ request: URLRequest) async -> RegistrationResult {
|
||||
let redirectDelegate = RedirectMethodPreservingDelegate()
|
||||
do {
|
||||
let (_, response) = try await session.data(for: request)
|
||||
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||
pushLog.error("\(label, privacy: .public) failed status=\(http.statusCode, privacy: .public)")
|
||||
let (data, response) = try await session.data(
|
||||
for: request,
|
||||
delegate: redirectDelegate
|
||||
)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
return .failure(.invalidServerResponse, retryAfter: nil)
|
||||
}
|
||||
guard (200...299).contains(http.statusCode) else {
|
||||
return Self.failureResult(statusCode: http.statusCode, response: http, data: data)
|
||||
}
|
||||
guard let acknowledgement = try? JSONDecoder().decode(
|
||||
RegistrationAcknowledgement.self,
|
||||
from: data
|
||||
), acknowledgement.ok else {
|
||||
return .failure(.invalidServerResponse, retryAfter: nil)
|
||||
}
|
||||
return .success(
|
||||
pushServiceConfigured:
|
||||
acknowledgement.pushServiceConfigured != false
|
||||
)
|
||||
} catch {
|
||||
pushLog.error("\(label, privacy: .public) error=\(error.localizedDescription, privacy: .private)")
|
||||
if redirectDelegate.refusedRedirect {
|
||||
return .failure(.invalidServerResponse, retryAfter: nil)
|
||||
}
|
||||
pushLog.error("register transport failure")
|
||||
return .failure(.networkUnavailable, retryAfter: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func performDelete(_ request: URLRequest) async -> Bool {
|
||||
let redirectDelegate = RedirectMethodPreservingDelegate()
|
||||
do {
|
||||
let (data, response) = try await session.data(
|
||||
for: request,
|
||||
delegate: redirectDelegate
|
||||
)
|
||||
if let http = response as? HTTPURLResponse,
|
||||
!(200...299).contains(http.statusCode) {
|
||||
pushLog.error(
|
||||
"unregister failed status=\(http.statusCode, privacy: .public)"
|
||||
)
|
||||
return false
|
||||
}
|
||||
guard response is HTTPURLResponse,
|
||||
let acknowledgement = try? JSONDecoder().decode(
|
||||
RegistrationAcknowledgement.self,
|
||||
from: data
|
||||
),
|
||||
acknowledgement.ok
|
||||
else {
|
||||
pushLog.error("unregister acknowledgement invalid")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
pushLog.error("unregister transport failure")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func retryPendingUnregisterIfPossible() async {
|
||||
guard let session = try? await tokenProvider
|
||||
.authenticatedSessionSnapshot() else { return }
|
||||
let currentAccountID = session.accountID
|
||||
let matching = pendingUnregisters.filter {
|
||||
$0.accountID == currentAccountID
|
||||
}
|
||||
let batch = Array(
|
||||
matching.prefix(Self.pendingUnregisterAttemptBudget)
|
||||
)
|
||||
let results = await withTaskGroup(
|
||||
of: (PendingUnregister, Bool).self,
|
||||
returning: [(PendingUnregister, Bool)].self
|
||||
) { group in
|
||||
for pending in batch {
|
||||
group.addTask { [self] in
|
||||
(
|
||||
pending,
|
||||
await sendDelete(
|
||||
tokenHex: pending.tokenHex,
|
||||
sessionSnapshot: session
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
var results: [(PendingUnregister, Bool)] = []
|
||||
for await result in group {
|
||||
results.append(result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
for (pending, succeeded) in results where succeeded {
|
||||
clearPendingUnregister(
|
||||
tokenHex: pending.tokenHex,
|
||||
accountID: pending.accountID
|
||||
)
|
||||
clearRegisteredOwner(
|
||||
accountID: pending.accountID,
|
||||
tokenHex: pending.tokenHex
|
||||
)
|
||||
}
|
||||
if matching.count > batch.count,
|
||||
results.contains(where: { $0.1 }) {
|
||||
schedulePendingUnregisterContinuation()
|
||||
}
|
||||
}
|
||||
|
||||
private func persistPendingUnregister(tokenHex: String, accountID: String) {
|
||||
let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID)
|
||||
var queue = pendingUnregisters
|
||||
if !queue.contains(entry) {
|
||||
queue.append(entry)
|
||||
}
|
||||
// Never evict a privacy cleanup obligation merely to enforce a local
|
||||
// storage cap. The set is deduplicated by (account, token), and drains
|
||||
// in bounded network batches so size cannot stall current readiness.
|
||||
storePendingUnregisters(queue)
|
||||
}
|
||||
|
||||
private func schedulePendingUnregisterContinuation() {
|
||||
guard unregisterDrainTask == nil else { return }
|
||||
unregisterDrainTask = Task { [weak self] in
|
||||
await Task.yield()
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
await self.runPendingUnregisterContinuation()
|
||||
}
|
||||
}
|
||||
|
||||
private func runPendingUnregisterContinuation() async {
|
||||
unregisterDrainTask = nil
|
||||
await retryPendingUnregisterIfPossible()
|
||||
}
|
||||
|
||||
private func clearPendingUnregister(
|
||||
tokenHex: String,
|
||||
accountID: String
|
||||
) {
|
||||
let filtered = pendingUnregisters.filter { entry in
|
||||
entry.tokenHex != tokenHex || entry.accountID != accountID
|
||||
}
|
||||
storePendingUnregisters(filtered)
|
||||
}
|
||||
|
||||
private var pendingUnregisters: [PendingUnregister] {
|
||||
let entries: [PendingUnregister]
|
||||
if let data = defaults.data(forKey: Self.pendingUnregisterQueueKey),
|
||||
let decoded = try? JSONDecoder().decode(
|
||||
[PendingUnregister].self,
|
||||
from: data
|
||||
) {
|
||||
entries = decoded
|
||||
} else {
|
||||
entries = []
|
||||
}
|
||||
var seen = Set<PendingUnregister>()
|
||||
return entries.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
private static func migrateLegacyPendingUnregisters(
|
||||
in defaults: UserDefaults
|
||||
) {
|
||||
guard let tokenHex = defaults.string(
|
||||
forKey: pendingUnregisterTokenKey
|
||||
), let accountID = defaults.string(
|
||||
forKey: pendingUnregisterAccountIDKey
|
||||
), !tokenHex.isEmpty, !accountID.isEmpty else { return }
|
||||
var entries = (defaults.data(forKey: pendingUnregisterQueueKey)
|
||||
.flatMap { try? JSONDecoder().decode(
|
||||
[PendingUnregister].self,
|
||||
from: $0
|
||||
) }) ?? []
|
||||
let legacy = PendingUnregister(
|
||||
tokenHex: tokenHex,
|
||||
accountID: accountID
|
||||
)
|
||||
if !entries.contains(legacy) { entries.append(legacy) }
|
||||
if let data = try? JSONEncoder().encode(entries) {
|
||||
defaults.set(data, forKey: pendingUnregisterQueueKey)
|
||||
}
|
||||
defaults.removeObject(forKey: pendingUnregisterTokenKey)
|
||||
defaults.removeObject(forKey: pendingUnregisterAccountIDKey)
|
||||
}
|
||||
|
||||
private func storePendingUnregisters(_ entries: [PendingUnregister]) {
|
||||
if entries.isEmpty {
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterQueueKey)
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
|
||||
return
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(entries) {
|
||||
defaults.set(data, forKey: Self.pendingUnregisterQueueKey)
|
||||
}
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
|
||||
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
|
||||
}
|
||||
|
||||
private func clearRegisteredOwner(
|
||||
accountID: String,
|
||||
tokenHex: String
|
||||
) {
|
||||
guard cachedTokenHex == tokenHex,
|
||||
defaults.string(
|
||||
forKey: Self.registeredAccountIDKey
|
||||
) == accountID else {
|
||||
return
|
||||
}
|
||||
defaults.removeObject(forKey: Self.registeredAccountIDKey)
|
||||
}
|
||||
|
||||
public func deviceTokenRegistrationFailed() {
|
||||
cancelRetry()
|
||||
guard isEnabled else {
|
||||
publish(.disabled)
|
||||
return
|
||||
}
|
||||
publish(PushRegistrationSnapshot(
|
||||
isEnabled: true,
|
||||
hasDeviceToken: cachedTokenHex != nil,
|
||||
backendState: .deviceTokenRegistrationFailed
|
||||
))
|
||||
}
|
||||
|
||||
private func cancelRetry() {
|
||||
operationGeneration = UUID()
|
||||
retryTask?.cancel()
|
||||
retryTask = nil
|
||||
}
|
||||
|
||||
private func publish(_ snapshot: PushRegistrationSnapshot) {
|
||||
guard snapshotValue != snapshot else { return }
|
||||
snapshotValue = snapshot
|
||||
for continuation in snapshotContinuations.values {
|
||||
continuation.yield(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeSnapshotContinuation(_ id: UUID) {
|
||||
snapshotContinuations.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
private static func failureResult(
|
||||
statusCode: Int,
|
||||
response: HTTPURLResponse,
|
||||
data: Data
|
||||
) -> RegistrationResult {
|
||||
switch statusCode {
|
||||
case 300...399:
|
||||
return .failure(.invalidServerResponse, retryAfter: nil)
|
||||
case 408, 425:
|
||||
let seconds = retryAfterSeconds(response: response, body: data)
|
||||
return .failure(
|
||||
.serviceUnavailable,
|
||||
retryAfter: seconds.map(Duration.seconds)
|
||||
)
|
||||
case 401:
|
||||
return .failure(.authenticationRequired, retryAfter: nil)
|
||||
case 409:
|
||||
let body = try? JSONDecoder().decode(
|
||||
RegistrationErrorResponse.self,
|
||||
from: data
|
||||
)
|
||||
if body?.error == "push_delivery_in_progress" {
|
||||
let seconds = retryAfterSeconds(
|
||||
response: response,
|
||||
body: data
|
||||
)
|
||||
return .failure(
|
||||
.serviceUnavailable,
|
||||
retryAfter: seconds.map(Duration.seconds)
|
||||
)
|
||||
}
|
||||
return .failure(.accountDeletionInProgress, retryAfter: nil)
|
||||
case 429:
|
||||
let body = try? JSONDecoder().decode(
|
||||
RegistrationErrorResponse.self,
|
||||
from: data
|
||||
)
|
||||
if body?.error == "too_many_devices" {
|
||||
return .failure(
|
||||
.deviceLimitReached(limit: max(1, body?.limit ?? 200)),
|
||||
retryAfter: nil
|
||||
)
|
||||
}
|
||||
let seconds = retryAfterSeconds(
|
||||
response: response,
|
||||
body: data
|
||||
)
|
||||
return .failure(
|
||||
.rateLimited(retryAfterSeconds: seconds),
|
||||
retryAfter: seconds.map(Duration.seconds)
|
||||
)
|
||||
case 500...599:
|
||||
return .failure(.serviceUnavailable, retryAfter: nil)
|
||||
default:
|
||||
return .failure(.rejected(statusCode: statusCode), retryAfter: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func retryAfterSeconds(
|
||||
response: HTTPURLResponse,
|
||||
body: Data
|
||||
) -> Int? {
|
||||
let headerDelay = response.value(forHTTPHeaderField: "Retry-After")
|
||||
.flatMap(Int.init)
|
||||
let bodyDelay = try? JSONDecoder().decode(
|
||||
RegistrationErrorResponse.self,
|
||||
from: body
|
||||
).retryAfterSeconds
|
||||
guard let raw = headerDelay ?? bodyDelay else { return nil }
|
||||
return min(max(raw, 0), 600)
|
||||
}
|
||||
|
||||
private static func jittered(_ duration: Duration, multiplier: Double) -> Duration {
|
||||
let components = duration.components
|
||||
let seconds = Double(components.seconds)
|
||||
+ Double(components.attoseconds) / 1_000_000_000_000_000_000
|
||||
let nanoseconds = seconds * multiplier * 1_000_000_000
|
||||
guard nanoseconds.isFinite else {
|
||||
return .nanoseconds(nanoseconds.sign == .minus
|
||||
? Int64.min
|
||||
: Int64.max)
|
||||
}
|
||||
if nanoseconds >= Double(Int64.max) {
|
||||
return .nanoseconds(Int64.max)
|
||||
}
|
||||
if nanoseconds <= Double(Int64.min) {
|
||||
return .nanoseconds(Int64.min)
|
||||
}
|
||||
return .nanoseconds(Int64(nanoseconds))
|
||||
}
|
||||
}
|
||||
|
||||
private enum RegistrationResult {
|
||||
case success(pushServiceConfigured: Bool)
|
||||
case failure(PushRegistrationFailure, retryAfter: Duration?)
|
||||
}
|
||||
|
||||
private struct PushRequest {
|
||||
let request: URLRequest
|
||||
let session: AuthenticatedSessionSnapshot?
|
||||
}
|
||||
|
||||
private struct RegistrationAcknowledgement: Decodable {
|
||||
let ok: Bool
|
||||
let pushServiceConfigured: Bool?
|
||||
}
|
||||
|
||||
private struct RegistrationErrorResponse: Decodable {
|
||||
let error: String?
|
||||
let retryAfterSeconds: Int?
|
||||
let limit: Int?
|
||||
}
|
||||
|
||||
private struct PendingUnregister: Codable, Hashable {
|
||||
let tokenHex: String
|
||||
let accountID: String
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/// A backend device-token registration failure safe to expose in UI and logs.
|
||||
public enum PushRegistrationFailure: Error, Sendable, Equatable {
|
||||
/// The native session could not supply a valid access/refresh token pair.
|
||||
case authenticationRequired
|
||||
/// Account deletion currently blocks user-scoped mutations.
|
||||
case accountDeletionInProgress
|
||||
/// The server asked the client to wait before retrying.
|
||||
case rateLimited(retryAfterSeconds: Int?)
|
||||
/// The account already has the maximum number of unpruned device tokens.
|
||||
case deviceLimitReached(limit: Int)
|
||||
/// The request could not reach the API.
|
||||
case networkUnavailable
|
||||
/// The API or APNs relay is temporarily unavailable.
|
||||
case serviceUnavailable
|
||||
/// The configured API base URL is invalid.
|
||||
case invalidConfiguration
|
||||
/// A successful HTTP response did not contain the registration acknowledgement.
|
||||
case invalidServerResponse
|
||||
/// The API permanently rejected the registration request.
|
||||
case rejected(statusCode: Int)
|
||||
|
||||
/// Whether repeating the same registration later can reasonably succeed.
|
||||
public var isRecoverable: Bool {
|
||||
switch self {
|
||||
case .rateLimited, .networkUnavailable, .serviceUnavailable:
|
||||
true
|
||||
case .authenticationRequired, .accountDeletionInProgress, .deviceLimitReached,
|
||||
.invalidConfiguration, .invalidServerResponse, .rejected:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The furthest backend stage a push-enabled iOS installation has confirmed.
|
||||
public enum PushRegistrationBackendState: Sendable, Equatable {
|
||||
/// The OS has not supplied an APNs device token yet.
|
||||
case awaitingDeviceToken
|
||||
/// A cached APNs token exists but has not been acknowledged this launch.
|
||||
case registrationRequired
|
||||
/// iOS failed to acquire a current APNs token. A user-triggered retry can
|
||||
/// call `registerForRemoteNotifications()` again.
|
||||
case deviceTokenRegistrationFailed
|
||||
/// A device-token request is currently in flight.
|
||||
case registering
|
||||
/// The API acknowledged the current APNs token.
|
||||
case registered
|
||||
/// Registration stopped at a typed, user-visible failure.
|
||||
case failed(PushRegistrationFailure)
|
||||
|
||||
/// Whether the current state can recover automatically.
|
||||
public var isRecoverable: Bool {
|
||||
if case let .failed(failure) = self {
|
||||
return failure.isRecoverable
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Truthful local and backend push-registration readiness.
|
||||
public struct PushRegistrationSnapshot: Sendable, Equatable {
|
||||
/// Whether the user explicitly opted into phone notifications.
|
||||
public let isEnabled: Bool
|
||||
/// Whether this install has acquired an APNs device token.
|
||||
public let hasDeviceToken: Bool
|
||||
/// The backend acknowledgement stage for the current token.
|
||||
public let backendState: PushRegistrationBackendState
|
||||
|
||||
/// Creates a push-registration snapshot.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - isEnabled: Whether the user explicitly opted in.
|
||||
/// - hasDeviceToken: Whether APNs supplied a token.
|
||||
/// - backendState: The furthest confirmed backend stage.
|
||||
public init(
|
||||
isEnabled: Bool,
|
||||
hasDeviceToken: Bool,
|
||||
backendState: PushRegistrationBackendState
|
||||
) {
|
||||
self.isEnabled = isEnabled
|
||||
self.hasDeviceToken = hasDeviceToken
|
||||
self.backendState = backendState
|
||||
}
|
||||
|
||||
/// The canonical disabled snapshot.
|
||||
public static let disabled = PushRegistrationSnapshot(
|
||||
isEnabled: false,
|
||||
hasDeviceToken: false,
|
||||
backendState: .awaitingDeviceToken
|
||||
)
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
public import Foundation
|
||||
import OSLog
|
||||
|
||||
nonisolated private let pushRedirectLog = Logger(
|
||||
subsystem: "ai.manaflow.cmux",
|
||||
category: "push.redirect"
|
||||
)
|
||||
|
||||
/// Preserves mutating push requests across safe redirects and rejects unsafe hops.
|
||||
///
|
||||
/// Foundation normally rewrites `POST` and `DELETE` to a body-less `GET` for
|
||||
/// 301/302 responses. That turns a successful redirect target into a false
|
||||
/// registration/send acknowledgement. This delegate restores the original
|
||||
/// method, body, and headers for same-origin 301/302 responses. Same-origin
|
||||
/// 307/308 requests already preserve the method, but their credential headers
|
||||
/// are re-applied defensively.
|
||||
///
|
||||
/// Every cross-origin redirect is refused before credentials or notification
|
||||
/// data reach the target. A 303 from a mutating request is refused because its
|
||||
/// body-less GET cannot acknowledge that the original mutation completed.
|
||||
public final class RedirectMethodPreservingDelegate:
|
||||
NSObject,
|
||||
URLSessionTaskDelegate,
|
||||
@unchecked Sendable
|
||||
{
|
||||
// URLSession's redirect delegate callback and the public refusal read are
|
||||
// both synchronous on different executors, so an actor cannot own this
|
||||
// single-bit handoff without changing either API to async.
|
||||
private let refusalLock = NSLock()
|
||||
private var refusedRedirectValue = false
|
||||
|
||||
/// Creates a stateless per-owner redirect delegate.
|
||||
public override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Whether this request was stopped by the redirect policy.
|
||||
public var refusedRedirect: Bool {
|
||||
refusalLock.withLock { refusedRedirectValue }
|
||||
}
|
||||
|
||||
/// Applies the redirect policy to one URL loading task.
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest proposedRequest: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void
|
||||
) {
|
||||
guard let original = task.originalRequest,
|
||||
Self.sameOrigin(original.url, proposedRequest.url) else {
|
||||
markRefused()
|
||||
pushRedirectLog.error(
|
||||
"Refused cross-origin push redirect status=\(response.statusCode, privacy: .public)"
|
||||
)
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let originalMethod = original.httpMethod?.uppercased() ?? "GET"
|
||||
let isMutation = Self.mutatingMethods.contains(originalMethod)
|
||||
if response.statusCode == 303, isMutation {
|
||||
markRefused()
|
||||
pushRedirectLog.error("Refused mutating push 303 redirect")
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard isMutation, (301...302).contains(response.statusCode)
|
||||
|| (307...308).contains(response.statusCode) else {
|
||||
completionHandler(proposedRequest)
|
||||
return
|
||||
}
|
||||
guard original.httpBodyStream == nil else {
|
||||
markRefused()
|
||||
pushRedirectLog.error("Refused non-replayable push redirect body")
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
|
||||
var preserved = proposedRequest
|
||||
preserved.httpMethod = originalMethod
|
||||
preserved.httpBody = original.httpBody
|
||||
for (field, value) in original.allHTTPHeaderFields ?? [:] {
|
||||
preserved.setValue(value, forHTTPHeaderField: field)
|
||||
}
|
||||
pushRedirectLog.info(
|
||||
"Preserved push \(originalMethod, privacy: .public) across status=\(response.statusCode, privacy: .public)"
|
||||
)
|
||||
completionHandler(preserved)
|
||||
}
|
||||
|
||||
private func markRefused() {
|
||||
refusalLock.withLock {
|
||||
refusedRedirectValue = true
|
||||
}
|
||||
}
|
||||
|
||||
private static let mutatingMethods = Set(["POST", "PUT", "PATCH", "DELETE"])
|
||||
static func sameOrigin(_ lhs: URL?, _ rhs: URL?) -> Bool {
|
||||
guard let lhs, let rhs else { return false }
|
||||
guard let lhsScheme = lhs.scheme?.lowercased(),
|
||||
let rhsScheme = rhs.scheme?.lowercased(),
|
||||
["http", "https"].contains(lhsScheme),
|
||||
["http", "https"].contains(rhsScheme),
|
||||
let lhsHost = lhs.host?.lowercased(),
|
||||
let rhsHost = rhs.host?.lowercased(),
|
||||
!lhsHost.isEmpty,
|
||||
!rhsHost.isEmpty
|
||||
else { return false }
|
||||
return lhsScheme == rhsScheme
|
||||
&& lhsHost == rhsHost
|
||||
&& effectivePort(lhs) == effectivePort(rhs)
|
||||
}
|
||||
|
||||
private static func effectivePort(_ url: URL) -> Int? {
|
||||
if let port = url.port { return port }
|
||||
switch url.scheme?.lowercased() {
|
||||
case "https", "wss":
|
||||
return 443
|
||||
case "http", "ws":
|
||||
return 80
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -206,17 +206,16 @@ import Testing
|
||||
}
|
||||
await client.storedAccessDidPark()
|
||||
|
||||
// The late exchange's write is what sign-out's credential capture has to
|
||||
// race, so wait for the store to hold exchange 2's tokens (or to have
|
||||
// been cleared) before releasing the capture. The client resumes this
|
||||
// from the write, so the wait does not compete with it for CPU.
|
||||
await client.releaseParkedCredential()
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .seconds(2))
|
||||
while true {
|
||||
let refresh = await client.refreshToken()
|
||||
if refresh == "refresh-2" || refresh == nil { break }
|
||||
if clock.now >= deadline {
|
||||
preconditionFailure("Timed out waiting for late exchange cleanup to reach the token store")
|
||||
}
|
||||
await Task.yield()
|
||||
let cleanupWatchdog = failAfterDeadline(.seconds(30)) {
|
||||
"Timed out waiting for late exchange cleanup to reach the token store"
|
||||
}
|
||||
await client.tokensDidSettle(afterExchange: 2)
|
||||
cleanupWatchdog.cancel()
|
||||
|
||||
await client.releaseParkedStoredAccess()
|
||||
await signOut.value
|
||||
|
||||
@@ -36,6 +36,36 @@ import Testing
|
||||
#expect(coordinator.currentUser == nil)
|
||||
}
|
||||
|
||||
@Test(.timeLimit(.minutes(1)))
|
||||
func sessionIdentityStreamPublishesSignInAndImmediateSignOut() async throws {
|
||||
let user = CMUXAuthUser(
|
||||
id: "u1",
|
||||
primaryEmail: "[email protected]",
|
||||
displayName: "A"
|
||||
)
|
||||
let (coordinator, _) = makeCoordinator(
|
||||
client: FakeAuthClient(user: user)
|
||||
)
|
||||
var identities = coordinator.authenticatedSessionIdentities()
|
||||
.makeAsyncIterator()
|
||||
|
||||
let initial = await identities.next()
|
||||
#expect(initial != nil)
|
||||
#expect(initial! == nil)
|
||||
|
||||
try await coordinator.signInWithPassword(
|
||||
email: "[email protected]",
|
||||
password: "pw"
|
||||
)
|
||||
let signedIn = try #require(await identities.next())
|
||||
#expect(signedIn?.accountID == user.id)
|
||||
|
||||
await coordinator.signOut()
|
||||
let signedOut = await identities.next()
|
||||
#expect(signedOut != nil)
|
||||
#expect(signedOut! == nil)
|
||||
}
|
||||
|
||||
@Test func passwordSignInAuthenticatesAndCaches() async throws {
|
||||
let user = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
|
||||
let client = FakeAuthClient(user: user)
|
||||
@@ -50,6 +80,31 @@ import Testing
|
||||
#expect(recorded?.email == "[email protected]")
|
||||
}
|
||||
|
||||
@Test func emptyAccountIDNeverPublishesAnAuthenticatedIdentity() async throws {
|
||||
let user = CMUXAuthUser(
|
||||
id: "",
|
||||
primaryEmail: "[email protected]",
|
||||
displayName: "A"
|
||||
)
|
||||
let (coordinator, _) = makeCoordinator(
|
||||
client: FakeAuthClient(user: user)
|
||||
)
|
||||
|
||||
try await coordinator.signInWithPassword(
|
||||
email: "[email protected]",
|
||||
password: "pw"
|
||||
)
|
||||
|
||||
#expect(coordinator.isAuthenticated)
|
||||
#expect(coordinator.authenticatedSessionIdentity == nil)
|
||||
#expect(!coordinator.isAuthenticatedSessionIdentityCurrent(
|
||||
AuthenticatedSessionIdentity(
|
||||
generation: coordinator.authSessionGeneration,
|
||||
accountID: ""
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
@Test func everyAuthSessionTransitionClosesBeforeTheNextSessionPublishes() async throws {
|
||||
let first = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
|
||||
let second = CMUXAuthUser(id: "u2", primaryEmail: "[email protected]", displayName: "B")
|
||||
|
||||
+29
@@ -28,6 +28,8 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
/// so tests can tell WHICH exchange's write the store currently holds:
|
||||
/// exchange N stores `"access-N"` / `"refresh-N"` in write order.
|
||||
private var exchangeCounter = 0
|
||||
/// Tests awaiting a settled token store after a late exchange.
|
||||
private var tokenWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
|
||||
private var currentUserStartCount = 0
|
||||
private let validationGate = Gate()
|
||||
private let teamsGate = Gate()
|
||||
@@ -63,6 +65,29 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
gate.parked.removeFirst().resume()
|
||||
}
|
||||
|
||||
// MARK: - Token-store settling
|
||||
|
||||
/// Suspends until exchange `count` has written its tokens, or until a clear
|
||||
/// emptied the store. Those are the two outcomes a late exchange racing
|
||||
/// sign-out can produce, and both are written inside this actor, so the
|
||||
/// waiter is resumed by the write instead of polling for it.
|
||||
func tokensDidSettle(afterExchange count: Int) async {
|
||||
if tokensSettled(afterExchange: count) { return }
|
||||
await withCheckedContinuation { tokenWaiters.append((count, $0)) }
|
||||
}
|
||||
|
||||
private func tokensSettled(afterExchange count: Int) -> Bool {
|
||||
exchangeCounter >= count || refresh == nil
|
||||
}
|
||||
|
||||
private func resumeSettledTokenWaiters() {
|
||||
tokenWaiters.removeAll { waiter in
|
||||
guard tokensSettled(afterExchange: waiter.count) else { return false }
|
||||
waiter.continuation.resume()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func parkIfArmed(_ gate: Gate) async {
|
||||
guard gate.armed else { return }
|
||||
gate.armed = false
|
||||
@@ -146,6 +171,7 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
exchangeCounter += 1
|
||||
access = "access-\(exchangeCounter)"
|
||||
refresh = "refresh-\(exchangeCounter)"
|
||||
resumeSettledTokenWaiters()
|
||||
}
|
||||
|
||||
func accessToken() async -> String? { access }
|
||||
@@ -159,6 +185,7 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
exchangeCounter += 1
|
||||
access = "access-\(exchangeCounter)"
|
||||
refresh = "refresh-\(exchangeCounter)"
|
||||
resumeSettledTokenWaiters()
|
||||
}
|
||||
func signInWithOAuth(provider: String, anchor: any AuthPresentationAnchoring) async throws {}
|
||||
|
||||
@@ -171,6 +198,7 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
await parkIfArmed(clearGate)
|
||||
access = nil
|
||||
refresh = nil
|
||||
resumeSettledTokenWaiters()
|
||||
}
|
||||
|
||||
func clearLocalSession(ifRefreshTokenMatches refreshToken: String) async {
|
||||
@@ -181,6 +209,7 @@ actor GateableValidationAuthClient: AuthClient {
|
||||
guard refresh == refreshToken else { return }
|
||||
access = nil
|
||||
refresh = nil
|
||||
resumeSettledTokenWaiters()
|
||||
}
|
||||
|
||||
func revokeSession(accessToken: String?, refreshToken: String?) async throws {}
|
||||
|
||||
+32
-1
@@ -17,6 +17,7 @@ actor FlowFakeAuthClient: AuthClient {
|
||||
private var currentUserError: (any Error)?
|
||||
private var userGateClosed = false
|
||||
private var userGateWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var pendingUserRequestWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var storedAccessGateArmed = false
|
||||
private var storedAccessParked: [CheckedContinuation<Void, Never>] = []
|
||||
private var storedAccessParkWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
@@ -38,6 +39,12 @@ actor FlowFakeAuthClient: AuthClient {
|
||||
for waiter in waiters { waiter.resume() }
|
||||
}
|
||||
|
||||
/// Suspends until a `currentUser` read is parked on the closed user gate.
|
||||
func pendingUserRequestDidPark() async {
|
||||
if pendingUserRequests > 0 { return }
|
||||
await withCheckedContinuation { pendingUserRequestWaiters.append($0) }
|
||||
}
|
||||
|
||||
func armStoredAccessTokenGate() { storedAccessGateArmed = true }
|
||||
|
||||
/// Suspends until a `storedAccessToken` read is parked on the armed gate.
|
||||
@@ -59,7 +66,12 @@ actor FlowFakeAuthClient: AuthClient {
|
||||
func currentUser(throwOnMissing: Bool) async throws -> CMUXAuthUser? {
|
||||
if userGateClosed {
|
||||
pendingUserRequests += 1
|
||||
await withCheckedContinuation { userGateWaiters.append($0) }
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
userGateWaiters.append(continuation)
|
||||
let waiters = pendingUserRequestWaiters
|
||||
pendingUserRequestWaiters = []
|
||||
for waiter in waiters { waiter.resume() }
|
||||
}
|
||||
pendingUserRequests -= 1
|
||||
}
|
||||
if let currentUserError {
|
||||
@@ -140,6 +152,14 @@ actor FlowInMemoryTokenStore: StackAuthTokenStoreProtocol {
|
||||
final class FakeBrowserAuthSessionFactory: HostBrowserAuthSessionFactory {
|
||||
private(set) var sessions: [FakeBrowserAuthSession] = []
|
||||
var nextStartResult = true
|
||||
private var sessionWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
|
||||
|
||||
/// Suspends until the attempt has created at least `count` sessions, so a
|
||||
/// test acts on a session that exists rather than polling for one.
|
||||
func sessionsDidReach(_ count: Int) async {
|
||||
if sessions.count >= count { return }
|
||||
await withCheckedContinuation { sessionWaiters.append((count, $0)) }
|
||||
}
|
||||
|
||||
func makeSession(
|
||||
signInURL: URL,
|
||||
@@ -153,8 +173,19 @@ final class FakeBrowserAuthSessionFactory: HostBrowserAuthSessionFactory {
|
||||
)
|
||||
sessions.append(session)
|
||||
nextStartResult = true
|
||||
for waiter in takeSatisfiedSessionWaiters() { waiter.resume() }
|
||||
return session
|
||||
}
|
||||
|
||||
private func takeSatisfiedSessionWaiters() -> [CheckedContinuation<Void, Never>] {
|
||||
var satisfied: [CheckedContinuation<Void, Never>] = []
|
||||
sessionWaiters.removeAll { waiter in
|
||||
guard sessions.count >= waiter.count else { return false }
|
||||
satisfied.append(waiter.continuation)
|
||||
return true
|
||||
}
|
||||
return satisfied
|
||||
}
|
||||
}
|
||||
|
||||
/// Delivers its completion exactly once, mirroring `ASWebAuthenticationSession`.
|
||||
|
||||
+36
-27
@@ -1,5 +1,6 @@
|
||||
import CMUXAuthCore
|
||||
import Foundation
|
||||
import Observation
|
||||
@testable import CmuxAuthRuntime
|
||||
|
||||
@MainActor
|
||||
@@ -94,40 +95,48 @@ struct HostBrowserSignInFlowHarness {
|
||||
.value ?? ""
|
||||
}
|
||||
|
||||
func waitForSession(count: Int = 1, timeout: Duration = .seconds(2)) async {
|
||||
// The attempt task runs on the same main actor; yielding lets it reach
|
||||
// the browser-session continuation deterministically.
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
while factory.sessions.count < count {
|
||||
if clock.now >= deadline {
|
||||
preconditionFailure(
|
||||
"Timed out waiting for \(count) host-browser session(s); got \(factory.sessions.count)"
|
||||
)
|
||||
}
|
||||
await Task.yield()
|
||||
/// Waits for the attempt to create `count` browser sessions. The factory
|
||||
/// resumes this from the session it creates, so the wait costs no CPU while
|
||||
/// the attempt runs.
|
||||
func waitForSession(count: Int = 1, timeout: Duration = .seconds(30)) async {
|
||||
let watchdog = failAfterDeadline(timeout) { [factory] in
|
||||
"Timed out waiting for \(count) host-browser session(s); got \(factory.sessions.count)"
|
||||
}
|
||||
await factory.sessionsDidReach(count)
|
||||
watchdog.cancel()
|
||||
}
|
||||
|
||||
func waitForCondition(timeout: Duration = .seconds(2), until condition: @MainActor () -> Bool) async {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
/// Waits until `condition` holds.
|
||||
///
|
||||
/// `condition` has to read observable state on the flow or the coordinator
|
||||
/// (both are `@Observable` and main-actor isolated). The wait registers with
|
||||
/// the observation system and suspends until one of the properties the
|
||||
/// condition read is written, then re-checks; a condition over unobserved
|
||||
/// state would never be woken and would hit the deadline below.
|
||||
func waitForCondition(timeout: Duration = .seconds(30), until condition: @MainActor () -> Bool) async {
|
||||
let watchdog = failAfterDeadline(timeout) {
|
||||
"Timed out waiting for host-browser condition; it must read observable flow or coordinator state"
|
||||
}
|
||||
while !condition() {
|
||||
if clock.now >= deadline {
|
||||
preconditionFailure("Timed out waiting for host-browser condition")
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
withObservationTracking {
|
||||
_ = condition()
|
||||
} onChange: {
|
||||
// Fires from the write itself, before the new value lands.
|
||||
// Resuming here queues the re-check as a separate main-actor
|
||||
// job, which cannot run until the write has finished.
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
watchdog.cancel()
|
||||
}
|
||||
|
||||
func waitForPendingUserRequest(timeout: Duration = .seconds(2)) async {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
while await client.pendingUserRequests == 0 {
|
||||
if clock.now >= deadline {
|
||||
preconditionFailure("Timed out waiting for a pending user request")
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
/// Waits for a `currentUser` read to park on the closed user gate. The fake
|
||||
/// client resumes this as it parks.
|
||||
func waitForPendingUserRequest(timeout: Duration = .seconds(30)) async {
|
||||
let watchdog = failAfterDeadline(timeout) { "Timed out waiting for a pending user request" }
|
||||
await client.pendingUserRequestDidPark()
|
||||
watchdog.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// End-to-end URL loading probe for mutating redirect behavior.
|
||||
final class PushRedirectURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
enum Scenario: Sendable {
|
||||
case sameOrigin301
|
||||
case sameOrigin302
|
||||
case sameOrigin303
|
||||
case sameOrigin307
|
||||
case sameOrigin308
|
||||
case schemeDowngrade307
|
||||
case crossHost308
|
||||
case portChange302
|
||||
|
||||
var statusCode: Int {
|
||||
switch self {
|
||||
case .sameOrigin301:
|
||||
301
|
||||
case .sameOrigin302, .portChange302:
|
||||
302
|
||||
case .sameOrigin303:
|
||||
303
|
||||
case .sameOrigin307, .schemeDowngrade307:
|
||||
307
|
||||
case .sameOrigin308, .crossHost308:
|
||||
308
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static let state = PushRedirectState()
|
||||
static let startHost = "push-start.test"
|
||||
static let targetHost = "push-target.test"
|
||||
static let startPath = "/api/device-tokens"
|
||||
static let targetPath = "/canonical/device-tokens"
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
Task {
|
||||
guard let url = request.url else {
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
return
|
||||
}
|
||||
let scenario = await Self.state.scenario
|
||||
if url.path == Self.startPath {
|
||||
let target: URL
|
||||
switch scenario {
|
||||
case .sameOrigin301, .sameOrigin302, .sameOrigin303,
|
||||
.sameOrigin307, .sameOrigin308:
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||
components.path = Self.targetPath
|
||||
target = components.url!
|
||||
case .schemeDowngrade307:
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||
components.scheme = "http"
|
||||
components.path = Self.targetPath
|
||||
target = components.url!
|
||||
case .crossHost308:
|
||||
target = URL(string: "https://\(Self.targetHost)\(Self.targetPath)")!
|
||||
case .portChange302:
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||
components.port = 444
|
||||
components.path = Self.targetPath
|
||||
target = components.url!
|
||||
}
|
||||
let status = scenario.statusCode
|
||||
let response = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: status,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Location": target.absoluteString]
|
||||
)!
|
||||
var proposed = URLRequest(url: target)
|
||||
proposed.httpMethod = [307, 308].contains(status) ? request.httpMethod : "GET"
|
||||
client?.urlProtocol(self, wasRedirectedTo: proposed, redirectResponse: response)
|
||||
return
|
||||
}
|
||||
|
||||
await Self.state.recordTarget(request)
|
||||
let response = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: nil
|
||||
)!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: Data(#"{"ok":true}"#.utf8))
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
actor PushRedirectState {
|
||||
private(set) var scenario: PushRedirectURLProtocol.Scenario = .sameOrigin301
|
||||
private(set) var targetRequests: [URLRequest] = []
|
||||
|
||||
func reset(_ scenario: PushRedirectURLProtocol.Scenario) {
|
||||
self.scenario = scenario
|
||||
targetRequests = []
|
||||
}
|
||||
|
||||
func recordTarget(_ request: URLRequest) {
|
||||
targetRequests.append(request)
|
||||
}
|
||||
}
|
||||
+1296
-12
File diff suppressed because it is too large
Load Diff
+211
@@ -0,0 +1,211 @@
|
||||
import Foundation
|
||||
|
||||
private struct PushRegistrationLoadingContext: @unchecked Sendable {
|
||||
let loadingProtocol: PushRegistrationURLProtocol
|
||||
}
|
||||
|
||||
/// Scripted transport for push-registration lifecycle tests.
|
||||
///
|
||||
/// `URLProtocol` is configured by type, so one actor-backed script is shared by
|
||||
/// this serialized suite. The actor owns both the response queue and request
|
||||
/// capture, keeping test mutation out of process-global unsafe variables.
|
||||
final class PushRegistrationURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
struct Stub: Sendable {
|
||||
let statusCode: Int?
|
||||
let headers: [String: String]
|
||||
let body: Data
|
||||
let error: URLError?
|
||||
let started: TestPhaseSignal?
|
||||
let blocker: TestContinuationBlocker?
|
||||
|
||||
static func response(
|
||||
_ statusCode: Int,
|
||||
headers: [String: String] = [:],
|
||||
json: String = #"{"ok":true}"#
|
||||
) -> Stub {
|
||||
Stub(
|
||||
statusCode: statusCode,
|
||||
headers: headers,
|
||||
body: Data(json.utf8),
|
||||
error: nil,
|
||||
started: nil,
|
||||
blocker: nil
|
||||
)
|
||||
}
|
||||
|
||||
static func gatedResponse(
|
||||
_ statusCode: Int,
|
||||
started: TestPhaseSignal,
|
||||
blocker: TestContinuationBlocker,
|
||||
headers: [String: String] = [:],
|
||||
json: String = #"{"ok":true}"#
|
||||
) -> Stub {
|
||||
Stub(
|
||||
statusCode: statusCode,
|
||||
headers: headers,
|
||||
body: Data(json.utf8),
|
||||
error: nil,
|
||||
started: started,
|
||||
blocker: blocker
|
||||
)
|
||||
}
|
||||
|
||||
static func failure(_ code: URLError.Code) -> Stub {
|
||||
Stub(
|
||||
statusCode: nil,
|
||||
headers: [:],
|
||||
body: Data(),
|
||||
error: URLError(code),
|
||||
started: nil,
|
||||
blocker: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static let script = PushRegistrationURLScript()
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
let capturedRequest = request
|
||||
let capturedBody = Self.bodyData(from: capturedRequest)
|
||||
let stub = Self.script.take(capturedRequest, body: capturedBody)
|
||||
let context = PushRegistrationLoadingContext(
|
||||
loadingProtocol: self
|
||||
)
|
||||
if stub.error != nil {
|
||||
Task.detached { [capturedRequest, context] in
|
||||
await Task.yield()
|
||||
context.complete(stub, request: capturedRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard stub.started != nil || stub.blocker != nil else {
|
||||
context.complete(stub, request: capturedRequest)
|
||||
return
|
||||
}
|
||||
Task.detached { [capturedRequest, context] in
|
||||
await stub.started?.markStarted()
|
||||
await stub.blocker?.wait()
|
||||
context.complete(stub, request: capturedRequest)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
|
||||
private static func bodyData(from request: URLRequest) -> Data? {
|
||||
if let body = request.httpBody {
|
||||
return body
|
||||
}
|
||||
guard let stream = request.httpBodyStream else { return nil }
|
||||
stream.open()
|
||||
defer { stream.close() }
|
||||
var data = Data()
|
||||
let bufferSize = 1_024
|
||||
let buffer = UnsafeMutablePointer<UInt8>.allocate(
|
||||
capacity: bufferSize
|
||||
)
|
||||
defer { buffer.deallocate() }
|
||||
while stream.hasBytesAvailable {
|
||||
let count = stream.read(buffer, maxLength: bufferSize)
|
||||
if count <= 0 { break }
|
||||
data.append(buffer, count: count)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private extension PushRegistrationLoadingContext {
|
||||
func complete(
|
||||
_ stub: PushRegistrationURLProtocol.Stub,
|
||||
request: URLRequest
|
||||
) {
|
||||
if let error = stub.error {
|
||||
loadingProtocol.client?.urlProtocol(
|
||||
loadingProtocol,
|
||||
didFailWithError: error
|
||||
)
|
||||
return
|
||||
}
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: stub.statusCode ?? 500,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: stub.headers
|
||||
)!
|
||||
loadingProtocol.client?.urlProtocol(
|
||||
loadingProtocol,
|
||||
didReceive: response,
|
||||
cacheStoragePolicy: .notAllowed
|
||||
)
|
||||
if !stub.body.isEmpty {
|
||||
loadingProtocol.client?.urlProtocol(
|
||||
loadingProtocol,
|
||||
didLoad: stub.body
|
||||
)
|
||||
}
|
||||
loadingProtocol.client?.urlProtocolDidFinishLoading(
|
||||
loadingProtocol
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class PushRegistrationURLScript: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var stubs: [PushRegistrationURLProtocol.Stub] = []
|
||||
private var capturedRequests: [URLRequest] = []
|
||||
private var capturedBodies: [Data?] = []
|
||||
|
||||
var requests: [URLRequest] {
|
||||
get async {
|
||||
lock.withLock { capturedRequests }
|
||||
}
|
||||
}
|
||||
|
||||
var requestBodies: [Data?] {
|
||||
get async {
|
||||
lock.withLock { capturedBodies }
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRequestCount(
|
||||
_ expectedCount: Int,
|
||||
timeout: Duration = .seconds(1)
|
||||
) async -> Bool {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
while lock.withLock({ capturedRequests.count }) < expectedCount {
|
||||
guard clock.now < deadline else { return false }
|
||||
try? await clock.sleep(for: .milliseconds(1))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func reset(
|
||||
_ nextStubs: [PushRegistrationURLProtocol.Stub]
|
||||
) async {
|
||||
lock.withLock {
|
||||
stubs = nextStubs
|
||||
capturedRequests = []
|
||||
capturedBodies = []
|
||||
}
|
||||
}
|
||||
|
||||
func take(
|
||||
_ request: URLRequest,
|
||||
body: Data?
|
||||
) -> PushRegistrationURLProtocol.Stub {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
capturedRequests.append(request)
|
||||
capturedBodies.append(body)
|
||||
guard !stubs.isEmpty else {
|
||||
return .response(
|
||||
500,
|
||||
json: #"{"error":"unscripted_request"}"#
|
||||
)
|
||||
}
|
||||
return stubs.removeFirst()
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxAuthRuntime
|
||||
|
||||
struct RedirectMethodPreservingDelegateTests {
|
||||
@Test(arguments: [
|
||||
("relative/path", "other/path"),
|
||||
("opaque:first", "opaque:second"),
|
||||
("file:///tmp/source", "file:///tmp/target"),
|
||||
("ws://example.test/source", "ws://example.test/target"),
|
||||
("wss://example.test/source", "wss://example.test/target"),
|
||||
("https://example.test/source", "https://other.test/target"),
|
||||
("https://example.test/source", "http://example.test/target"),
|
||||
("https://example.test/source", "https://example.test:8443/target"),
|
||||
("https://example.test/source", "https://sub.example.test/target"),
|
||||
])
|
||||
func nonHTTPOriginsFailClosed(
|
||||
source: String,
|
||||
target: String
|
||||
) throws {
|
||||
let sourceURL = try #require(URL(string: source))
|
||||
let targetURL = try #require(URL(string: target))
|
||||
|
||||
#expect(
|
||||
!RedirectMethodPreservingDelegate.sameOrigin(
|
||||
sourceURL,
|
||||
targetURL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
("https://example.test/source", "https://example.test/target"),
|
||||
("https://example.test:443/source", "https://example.test/target"),
|
||||
("http://example.test:80/source", "http://EXAMPLE.test/target"),
|
||||
])
|
||||
func canonicalHTTPOriginsMatch(
|
||||
source: String,
|
||||
target: String
|
||||
) throws {
|
||||
let sourceURL = try #require(URL(string: source))
|
||||
let targetURL = try #require(URL(string: target))
|
||||
|
||||
#expect(
|
||||
RedirectMethodPreservingDelegate.sameOrigin(
|
||||
sourceURL,
|
||||
targetURL
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// Aborts the run with `message` unless the caller cancels the returned task
|
||||
/// first.
|
||||
///
|
||||
/// The waits this guards are event-driven: they suspend until the fake they
|
||||
/// wait on resumes them, so a run that reaches this deadline is one where the
|
||||
/// awaited edge never arrived. Reporting that by name beats leaving the run
|
||||
/// suspended forever. The deadline is generous on purpose — it never bounds a
|
||||
/// passing run, so machine load cannot push a healthy wait past it.
|
||||
@MainActor
|
||||
func failAfterDeadline(
|
||||
_ timeout: Duration,
|
||||
_ message: @escaping @MainActor () -> String
|
||||
) -> Task<Void, Never> {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: timeout)
|
||||
guard !Task.isCancelled else { return }
|
||||
preconditionFailure(message())
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@ struct CmxAuthoritativeDiscoveryResolver: Sendable {
|
||||
knownRevision: cached?.revision
|
||||
)
|
||||
if let snapshot = response.snapshot,
|
||||
response.snapshotComplete == true {
|
||||
response.snapshotIsComplete {
|
||||
try Self.requireRevision(snapshot, atLeast: minimumRevision)
|
||||
if !response.reset {
|
||||
try Self.requireRevision(snapshot, atLeast: cached?.revision)
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
extension CmxConnectivityDiscoveryScope {
|
||||
/// The caller's own binding, which remains visible even when it does not
|
||||
/// satisfy the peer selector.
|
||||
public struct LocalBinding: Codable, Equatable, Sendable {
|
||||
/// The durable device identifier that owns the local endpoint.
|
||||
public let deviceID: String
|
||||
|
||||
/// The app installation identifier that owns the local endpoint.
|
||||
public let appInstanceID: String
|
||||
|
||||
/// The exact build tag registered by the local app.
|
||||
public let tag: String
|
||||
|
||||
/// The platform hosting the local endpoint.
|
||||
public let platform: CmxIrohPlatform
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case deviceID = "device_id"
|
||||
case appInstanceID = "app_instance_id"
|
||||
case tag
|
||||
case platform
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
extension CmxConnectivityDiscoveryScope {
|
||||
/// Opposite-platform bindings that this runtime may connect to or admit.
|
||||
public struct PeerBindings: Codable, Equatable, Sendable {
|
||||
/// The platform required for every selected peer binding.
|
||||
public let platform: CmxIrohPlatform
|
||||
|
||||
/// Canonical build tags accepted by the peer selector, or all tags when absent.
|
||||
public let tags: [String]?
|
||||
|
||||
/// The required pairing state, or either state when absent.
|
||||
public let pairingEnabled: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case platform
|
||||
case tags
|
||||
case pairingEnabled = "pairing_enabled"
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
public import Foundation
|
||||
|
||||
/// The exact local binding and bounded peer set required by one app runtime.
|
||||
public struct CmxConnectivityDiscoveryScope: Codable, Equatable, Sendable {
|
||||
/// The caller's own binding, preserved independently from the peer selector.
|
||||
public let localBinding: LocalBinding
|
||||
|
||||
/// The bounded opposite-platform bindings visible to this runtime.
|
||||
public let peerBindings: PeerBindings
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case localBinding = "local_binding"
|
||||
case peerBindings = "peer_bindings"
|
||||
}
|
||||
|
||||
/// Creates the canonical scope echoed by connectivity v3.
|
||||
///
|
||||
/// Peer tags are lowercased, deduplicated by rejection, and sorted so the
|
||||
/// case-insensitive compatibility contract is stable across implementations.
|
||||
public init(
|
||||
deviceID: String,
|
||||
appInstanceID: String,
|
||||
tag: String,
|
||||
platform: CmxIrohPlatform,
|
||||
peerPlatform: CmxIrohPlatform,
|
||||
peerTags: [String]? = nil,
|
||||
peerPairingEnabled: Bool? = nil
|
||||
) throws {
|
||||
guard isCanonicalUUID(deviceID),
|
||||
isCanonicalUUID(appInstanceID),
|
||||
isSafeTag(tag),
|
||||
platform != peerPlatform,
|
||||
peerTags.map({ (1 ... 8).contains($0.count) }) ?? true,
|
||||
peerTags?.allSatisfy(isSafeTag) ?? true else {
|
||||
throw CmxConnectivityDiscoveryScopeError.invalidScope
|
||||
}
|
||||
let canonicalPeerTags = peerTags?.map { $0.lowercased() }
|
||||
guard canonicalPeerTags.map({ Set($0).count == $0.count }) ?? true else {
|
||||
throw CmxConnectivityDiscoveryScopeError.invalidScope
|
||||
}
|
||||
let sortedTags = canonicalPeerTags?.sorted()
|
||||
localBinding = LocalBinding(
|
||||
deviceID: deviceID,
|
||||
appInstanceID: appInstanceID,
|
||||
tag: tag,
|
||||
platform: platform
|
||||
)
|
||||
peerBindings = PeerBindings(
|
||||
platform: peerPlatform,
|
||||
tags: sortedTags,
|
||||
pairingEnabled: peerPairingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
/// Decodes and validates a canonical discovery scope.
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let local = try container.decode(LocalBinding.self, forKey: .localBinding)
|
||||
let peers = try container.decode(PeerBindings.self, forKey: .peerBindings)
|
||||
try self.init(
|
||||
deviceID: local.deviceID,
|
||||
appInstanceID: local.appInstanceID,
|
||||
tag: local.tag,
|
||||
platform: local.platform,
|
||||
peerPlatform: peers.platform,
|
||||
peerTags: peers.tags,
|
||||
peerPairingEnabled: peers.pairingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func isCanonicalUUID(_ value: String) -> Bool {
|
||||
guard UUID(uuidString: value)?.uuidString.lowercased() == value,
|
||||
value.count == 36 else { return false }
|
||||
let characters = Array(value.utf8)
|
||||
guard (49 ... 56).contains(characters[14]),
|
||||
[56, 57, 97, 98].contains(characters[19]) else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func isSafeTag(_ value: String) -> Bool {
|
||||
guard (1 ... 64).contains(value.utf8.count) else { return false }
|
||||
return value.utf8.allSatisfy { byte in
|
||||
(48 ... 57).contains(byte)
|
||||
|| (65 ... 90).contains(byte)
|
||||
|| (97 ... 122).contains(byte)
|
||||
|| [45, 46, 95].contains(byte)
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
/// Validation failures for connectivity discovery scopes.
|
||||
public enum CmxConnectivityDiscoveryScopeError: Error, Equatable, Sendable {
|
||||
/// The scope contains an invalid identity, tag, platform pair, or peer selector.
|
||||
case invalidScope
|
||||
}
|
||||
+39
-2
@@ -1,7 +1,9 @@
|
||||
/// Versioned response from the authoritative connectivity reconciliation route.
|
||||
public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
|
||||
/// The only protocol version accepted by this implementation.
|
||||
/// The global-snapshot protocol used when scoped discovery is unavailable.
|
||||
public static let protocolVersion = 2
|
||||
/// The bounded discovery protocol used by current clients.
|
||||
public static let scopedProtocolVersion = 3
|
||||
|
||||
/// Backend connectivity protocol version.
|
||||
public let protocolVersion: Int
|
||||
@@ -22,6 +24,18 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
|
||||
/// Older servers omit this field, so clients fetch paginated discovery.
|
||||
public let snapshotComplete: Bool?
|
||||
|
||||
/// The bounded projection represented by a connectivity v3 snapshot.
|
||||
public let discoveryScope: CmxConnectivityDiscoveryScope?
|
||||
|
||||
/// True only when the server proves `snapshot` covers the echoed scope.
|
||||
public let snapshotScopeComplete: Bool?
|
||||
|
||||
/// Whether the snapshot carries either global or scoped completeness proof.
|
||||
public var snapshotIsComplete: Bool {
|
||||
snapshot != nil
|
||||
&& (snapshotComplete == true || snapshotScopeComplete == true)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case protocolVersion = "protocol_version"
|
||||
case revision
|
||||
@@ -29,6 +43,8 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
|
||||
case reset
|
||||
case snapshot
|
||||
case snapshotComplete = "snapshot_complete"
|
||||
case discoveryScope = "discovery_scope"
|
||||
case snapshotScopeComplete = "snapshot_scope_complete"
|
||||
}
|
||||
|
||||
/// Decodes and validates one atomic reconciliation response.
|
||||
@@ -46,10 +62,27 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
|
||||
Bool.self,
|
||||
forKey: .snapshotComplete
|
||||
)
|
||||
guard protocolVersion == Self.protocolVersion,
|
||||
let discoveryScope = try container.decodeIfPresent(
|
||||
CmxConnectivityDiscoveryScope.self,
|
||||
forKey: .discoveryScope
|
||||
)
|
||||
let snapshotScopeComplete = try container.decodeIfPresent(
|
||||
Bool.self,
|
||||
forKey: .snapshotScopeComplete
|
||||
)
|
||||
let validCompletenessContract = switch protocolVersion {
|
||||
case Self.protocolVersion:
|
||||
discoveryScope == nil && snapshotScopeComplete == nil
|
||||
case Self.scopedProtocolVersion:
|
||||
discoveryScope != nil && snapshotComplete == nil
|
||||
default:
|
||||
false
|
||||
}
|
||||
guard validCompletenessContract,
|
||||
changed == (snapshot != nil),
|
||||
!reset || changed,
|
||||
snapshot != nil || snapshotComplete == nil,
|
||||
snapshot != nil || snapshotScopeComplete == nil,
|
||||
(snapshot?.routeContractVersion ?? 1) == 1,
|
||||
(snapshot?.revision ?? revision) == revision else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
@@ -65,6 +98,8 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
|
||||
self.reset = reset
|
||||
self.snapshot = snapshot
|
||||
self.snapshotComplete = snapshotComplete
|
||||
self.discoveryScope = discoveryScope
|
||||
self.snapshotScopeComplete = snapshotScopeComplete
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -78,5 +113,7 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
|
||||
reset = false
|
||||
snapshot = legacySnapshot
|
||||
self.snapshotComplete = snapshotComplete
|
||||
discoveryScope = nil
|
||||
snapshotScopeComplete = nil
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -304,6 +304,8 @@ public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable {
|
||||
case relay
|
||||
case discovery
|
||||
case discoveryComplete = "discovery_complete"
|
||||
case discoveryScope = "discovery_scope"
|
||||
case discoveryScopeComplete = "discovery_scope_complete"
|
||||
}
|
||||
|
||||
/// Monotonic account route revision after this registration commit.
|
||||
@@ -316,6 +318,18 @@ public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable {
|
||||
/// True only when the embedded snapshot covers every active binding.
|
||||
/// Older brokers omit this proof, so clients must fetch paginated discovery.
|
||||
public let discoveryComplete: Bool?
|
||||
/// The exact bounded projection represented by embedded discovery.
|
||||
public let discoveryScope: CmxConnectivityDiscoveryScope?
|
||||
/// True only when embedded discovery covers every binding in its scope.
|
||||
public let discoveryScopeComplete: Bool?
|
||||
|
||||
/// Whether the embedded discovery is proven complete globally or for its
|
||||
/// validated scoped-registration request.
|
||||
public var embeddedDiscoveryComplete: Bool {
|
||||
discovery != nil
|
||||
&& (discoveryComplete == true
|
||||
|| (discoveryScope != nil && discoveryScopeComplete == true))
|
||||
}
|
||||
|
||||
/// Creates a registration response for alternate brokers and tests.
|
||||
public init(
|
||||
@@ -323,13 +337,17 @@ public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable {
|
||||
binding: CmxIrohBrokerBinding,
|
||||
relay: CmxIrohRegistrationRelay,
|
||||
discovery: CmxIrohDiscoveryResponse? = nil,
|
||||
discoveryComplete: Bool? = nil
|
||||
discoveryComplete: Bool? = nil,
|
||||
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
|
||||
discoveryScopeComplete: Bool? = nil
|
||||
) {
|
||||
self.revision = revision
|
||||
self.binding = binding
|
||||
self.relay = relay
|
||||
self.discovery = discovery
|
||||
self.discoveryComplete = discoveryComplete
|
||||
self.discoveryScope = discoveryScope
|
||||
self.discoveryScopeComplete = discoveryScopeComplete
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ extension CmxIrohClientRuntime {
|
||||
let discovery: CmxIrohDiscoveryResponse
|
||||
do {
|
||||
if let embedded = registration?.discovery,
|
||||
registration?.discoveryComplete == true {
|
||||
registration?.embeddedDiscoveryComplete == true {
|
||||
guard let snapshotRevision = embedded.revision,
|
||||
let registrationRevision = registration?.revision,
|
||||
snapshotRevision >= registrationRevision,
|
||||
|
||||
+43
-31
@@ -40,10 +40,49 @@ extension CmxIrohClientRuntime {
|
||||
throw CmxIrohClientRuntimeError.relayFleetMismatch
|
||||
}
|
||||
let revision = lifecycleRevision
|
||||
try await connectivityEngine.replaceRelayProfile(
|
||||
profile,
|
||||
expectedIdentity: binding.endpointID
|
||||
)
|
||||
|
||||
await relayCoordinator?.deactivate()
|
||||
relayCoordinator = nil
|
||||
if profile.source == .managed, !profile.allowedRelayURLs.isEmpty {
|
||||
let refreshSchedule = CmxIrohRelayRefreshSchedule(
|
||||
role: .client,
|
||||
endpointIdentity: binding.endpointID
|
||||
)
|
||||
let coordinator = CmxIrohRelayCredentialCoordinator(
|
||||
supervisor: connectivityEngine,
|
||||
broker: broker,
|
||||
managedRelayURLs: replacementManagedURLs,
|
||||
selectedRelayURLs: profile.allowedRelayURLs,
|
||||
jitter: { now, refreshAfter in
|
||||
refreshSchedule.deadline(now: now, refreshAfter: refreshAfter)
|
||||
},
|
||||
retrySchedule: .foregroundClient,
|
||||
automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled,
|
||||
credentialDidInstall: { [handleRelayCredential] response in
|
||||
await handleRelayCredential(response, binding)
|
||||
}
|
||||
)
|
||||
relayCoordinator = coordinator
|
||||
do {
|
||||
try await coordinator.activateManagedPolicy(
|
||||
bindingID: binding.bindingID,
|
||||
endpointIdentity: binding.endpointID,
|
||||
profile: profile,
|
||||
bootstrap: relayBootstrap
|
||||
)
|
||||
} catch {
|
||||
await coordinator.deactivate()
|
||||
if relayCoordinator === coordinator {
|
||||
relayCoordinator = nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
try await connectivityEngine.replaceRelayProfile(
|
||||
profile,
|
||||
expectedIdentity: binding.endpointID
|
||||
)
|
||||
}
|
||||
try requireCurrent(revision)
|
||||
|
||||
managedRelayURLs = replacementManagedURLs
|
||||
@@ -98,32 +137,5 @@ extension CmxIrohClientRuntime {
|
||||
}
|
||||
await contextRouter.install(provider)
|
||||
try requireCurrent(revision)
|
||||
|
||||
await relayCoordinator?.deactivate()
|
||||
relayCoordinator = nil
|
||||
guard profile.source == .managed,
|
||||
!profile.allowedRelayURLs.isEmpty else { return }
|
||||
let coordinator = CmxIrohRelayCredentialCoordinator(
|
||||
supervisor: connectivityEngine,
|
||||
broker: broker,
|
||||
managedRelayURLs: replacementManagedURLs,
|
||||
selectedRelayURLs: profile.allowedRelayURLs,
|
||||
retrySchedule: .foregroundClient,
|
||||
automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled,
|
||||
credentialDidInstall: { [handleRelayCredential] response in
|
||||
await handleRelayCredential(response, binding)
|
||||
}
|
||||
)
|
||||
relayCoordinator = coordinator
|
||||
do {
|
||||
try await coordinator.activate(
|
||||
bindingID: binding.bindingID,
|
||||
endpointIdentity: binding.endpointID,
|
||||
bootstrap: relayBootstrap
|
||||
)
|
||||
} catch {
|
||||
// The verified allowlist is already live; direct paths remain usable
|
||||
// while the coordinator retries a managed credential refresh.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-6
@@ -133,7 +133,7 @@ extension CmxIrohHostRuntime {
|
||||
let discovery: CmxIrohDiscoveryResponse
|
||||
do {
|
||||
if let embedded = registration.discovery,
|
||||
registration.discoveryComplete == true {
|
||||
registration.embeddedDiscoveryComplete {
|
||||
guard let snapshotRevision = embedded.revision,
|
||||
let registrationRevision = registration.revision,
|
||||
snapshotRevision == registrationRevision,
|
||||
@@ -427,11 +427,24 @@ extension CmxIrohHostRuntime {
|
||||
binding: CmxIrohBrokerBinding,
|
||||
now: Date
|
||||
) -> Date? {
|
||||
guard let expiry = binding.pathHints.compactMap(\.expiresAt).min(),
|
||||
expiry > now else { return nil }
|
||||
let remaining = expiry.timeIntervalSince(now)
|
||||
let safetyWindow = min(15 * 60, max(30, remaining / 4))
|
||||
return max(now, expiry.addingTimeInterval(-safetyWindow))
|
||||
// Clients accept the binding's signed direct ports for private-path
|
||||
// synthesis only while `lastSeenAt` is younger than this same window.
|
||||
// Keep that broker lease fresh even when the endpoint has no public
|
||||
// path hints, otherwise an unchanged Mac silently becomes undialable.
|
||||
let bindingFreshnessExpiry = CmxIrohISO8601Date
|
||||
.parse(binding.lastSeenAt)?
|
||||
.addingTimeInterval(CmxIrohPathHint.maximumPrivateHintTTL)
|
||||
let expiries = ([bindingFreshnessExpiry] + binding.pathHints.map(\.expiresAt))
|
||||
.compactMap { $0 }
|
||||
return expiries.compactMap { expiry -> Date? in
|
||||
let remaining = expiry.timeIntervalSince(now)
|
||||
guard remaining > 0 else { return nil }
|
||||
let safetyWindow = min(15 * 60, max(30, remaining / 4))
|
||||
let deadline = expiry.addingTimeInterval(-safetyWindow)
|
||||
// A stale or near-expiry authority cannot safely arm an immediate
|
||||
// renewal: another unchanged success would otherwise spin.
|
||||
return deadline > now ? deadline : nil
|
||||
}.min()
|
||||
}
|
||||
|
||||
func refreshRegistration(revision: UInt64) async {
|
||||
|
||||
+43
-31
@@ -42,42 +42,54 @@ extension CmxIrohHostRuntime {
|
||||
throw CmxIrohHostRuntimeError.relayFleetMismatch
|
||||
}
|
||||
let revision = lifecycleRevision
|
||||
try await connectivityEngine.replaceRelayProfile(
|
||||
profile,
|
||||
expectedIdentity: binding.endpointID
|
||||
)
|
||||
|
||||
relayActivationTask?.cancel()
|
||||
relayActivationTask = nil
|
||||
await relayCoordinator?.deactivate()
|
||||
relayCoordinator = nil
|
||||
if profile.source == .managed, !profile.allowedRelayURLs.isEmpty {
|
||||
let refreshSchedule = CmxIrohRelayRefreshSchedule(
|
||||
role: .host,
|
||||
endpointIdentity: binding.endpointID
|
||||
)
|
||||
let coordinator = CmxIrohRelayCredentialCoordinator(
|
||||
supervisor: connectivityEngine,
|
||||
broker: broker,
|
||||
managedRelayURLs: replacementManagedURLs,
|
||||
selectedRelayURLs: profile.allowedRelayURLs,
|
||||
jitter: { now, refreshAfter in
|
||||
refreshSchedule.deadline(now: now, refreshAfter: refreshAfter)
|
||||
},
|
||||
credentialDidInstall: { [handleRelayCredential] response in
|
||||
await handleRelayCredential(response, binding)
|
||||
}
|
||||
)
|
||||
relayCoordinator = coordinator
|
||||
do {
|
||||
try await coordinator.activateManagedPolicy(
|
||||
bindingID: binding.bindingID,
|
||||
endpointIdentity: binding.endpointID,
|
||||
profile: profile,
|
||||
bootstrap: relayBootstrap
|
||||
)
|
||||
} catch {
|
||||
await coordinator.deactivate()
|
||||
if relayCoordinator === coordinator {
|
||||
relayCoordinator = nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
try await connectivityEngine.replaceRelayProfile(
|
||||
profile,
|
||||
expectedIdentity: binding.endpointID
|
||||
)
|
||||
}
|
||||
try requireCurrent(revision)
|
||||
|
||||
managedRelayURLs = replacementManagedURLs
|
||||
currentEndpointRelayProfile = profile
|
||||
await admissionController?.updateManagedRelayURLs(replacementManagedURLs)
|
||||
try requireCurrent(revision)
|
||||
|
||||
relayActivationTask?.cancel()
|
||||
relayActivationTask = nil
|
||||
await relayCoordinator?.deactivate()
|
||||
relayCoordinator = nil
|
||||
guard profile.source == .managed,
|
||||
!profile.allowedRelayURLs.isEmpty else { return }
|
||||
let coordinator = CmxIrohRelayCredentialCoordinator(
|
||||
supervisor: connectivityEngine,
|
||||
broker: broker,
|
||||
managedRelayURLs: replacementManagedURLs,
|
||||
selectedRelayURLs: profile.allowedRelayURLs,
|
||||
credentialDidInstall: { [handleRelayCredential] response in
|
||||
await handleRelayCredential(response, binding)
|
||||
}
|
||||
)
|
||||
relayCoordinator = coordinator
|
||||
do {
|
||||
try await coordinator.activate(
|
||||
bindingID: binding.bindingID,
|
||||
endpointIdentity: binding.endpointID,
|
||||
bootstrap: relayBootstrap
|
||||
)
|
||||
} catch {
|
||||
// The verified allowlist is already live; direct paths remain usable
|
||||
// while the coordinator retries a managed credential refresh.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-1
@@ -8,11 +8,30 @@ public struct CmxIrohRegisterRequest: Encodable, Equatable, Sendable {
|
||||
public let payload: String
|
||||
/// Base64url Ed25519 signature over the registration transcript.
|
||||
public let signature: String
|
||||
/// Optional bounded discovery projection returned with registration.
|
||||
public let discoveryScope: CmxConnectivityDiscoveryScope?
|
||||
|
||||
init(challengeID: String, nonce: String, payload: String, signature: String) {
|
||||
init(
|
||||
challengeID: String,
|
||||
nonce: String,
|
||||
payload: String,
|
||||
signature: String,
|
||||
discoveryScope: CmxConnectivityDiscoveryScope? = nil
|
||||
) {
|
||||
challengeId = challengeID
|
||||
self.nonce = nonce
|
||||
self.payload = payload
|
||||
self.signature = signature
|
||||
self.discoveryScope = discoveryScope
|
||||
}
|
||||
|
||||
func including(discoveryScope: CmxConnectivityDiscoveryScope?) -> Self {
|
||||
Self(
|
||||
challengeID: challengeId,
|
||||
nonce: nonce,
|
||||
payload: payload,
|
||||
signature: signature,
|
||||
discoveryScope: discoveryScope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+144
-23
@@ -92,14 +92,10 @@ public actor CmxIrohRelayCredentialCoordinator {
|
||||
bootstrap: CmxIrohRelayTokenResponse? = nil,
|
||||
waitForInitialCredential: Bool = false
|
||||
) async throws {
|
||||
lifecycleRevision &+= 1
|
||||
let revision = lifecycleRevision
|
||||
refreshTask?.cancel()
|
||||
inFlightRefresh?.task.cancel()
|
||||
inFlightRefresh = nil
|
||||
let expectedBinding = Binding(id: bindingID, endpointIdentity: endpointIdentity)
|
||||
binding = expectedBinding
|
||||
installedCredential = nil
|
||||
let (expectedBinding, revision) = beginActivation(
|
||||
bindingID: bindingID,
|
||||
endpointIdentity: endpointIdentity
|
||||
)
|
||||
|
||||
if let bootstrap {
|
||||
do {
|
||||
@@ -167,6 +163,111 @@ public actor CmxIrohRelayCredentialCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces one live managed relay policy and starts credential refresh.
|
||||
///
|
||||
/// The coordinator owns the endpoint mutation so a policy bootstrap is
|
||||
/// installed exactly once. This preserves active QUIC sessions while the
|
||||
/// endpoint's relay client adopts the replacement credentials.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - bindingID: The broker binding that owns the endpoint.
|
||||
/// - endpointIdentity: The pinned endpoint identity being updated.
|
||||
/// - profile: The complete managed relay profile to install.
|
||||
/// - bootstrap: Credentials already represented by `profile`, when available.
|
||||
/// - Throws: A policy mismatch, endpoint mutation failure, or cancellation.
|
||||
public func activateManagedPolicy(
|
||||
bindingID: String,
|
||||
endpointIdentity: CmxIrohPeerIdentity,
|
||||
profile: CmxIrohEndpointRelayProfile,
|
||||
bootstrap: CmxIrohRelayTokenResponse?
|
||||
) async throws {
|
||||
guard profile.source == .managed,
|
||||
!selectedRelayURLs.isEmpty,
|
||||
selectedRelayURLs.isSubset(of: managedRelayURLs),
|
||||
profile.allowedRelayURLs == selectedRelayURLs else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
|
||||
let bootstrapInstallation: (
|
||||
response: CmxIrohRelayTokenResponse,
|
||||
configurations: [CmxIrohRelayConfiguration]
|
||||
)? = try bootstrap.map { response in
|
||||
let selectedConfigurations = try validatedSelectedConfigurations(response)
|
||||
guard profile.managedRelays.count == selectedConfigurations.count,
|
||||
profile.managedRelays.allSatisfy(selectedConfigurations.contains) else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
return (response, selectedConfigurations)
|
||||
}
|
||||
|
||||
let (expectedBinding, revision) = beginActivation(
|
||||
bindingID: bindingID,
|
||||
endpointIdentity: endpointIdentity
|
||||
)
|
||||
try await supervisor.replaceRelayProfile(
|
||||
profile,
|
||||
expectedIdentity: endpointIdentity
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
guard isCurrent(revision), binding == expectedBinding else {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
if let bootstrapInstallation {
|
||||
let installed = try recordInstallation(
|
||||
bootstrapInstallation.response,
|
||||
selectedConfigurations: bootstrapInstallation.configurations,
|
||||
binding: expectedBinding,
|
||||
revision: revision
|
||||
)
|
||||
startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await broker.issueRelayToken(
|
||||
bindingID: bindingID,
|
||||
endpointID: endpointIdentity
|
||||
)
|
||||
let installed = try await install(
|
||||
response,
|
||||
binding: expectedBinding,
|
||||
revision: revision
|
||||
)
|
||||
startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter)
|
||||
} catch {
|
||||
guard isCurrent(revision), !Task.isCancelled else {
|
||||
throw CancellationError()
|
||||
}
|
||||
let delay = retryDelay(failureCount: 0, error: error)
|
||||
startLoopIfEnabled(
|
||||
revision: revision,
|
||||
firstRefresh: retryDeadline(
|
||||
now: clock.now(),
|
||||
backoff: delay,
|
||||
honorsServerFloor: (error as? any CmxRetryAfterProviding)?
|
||||
.retryAfterSeconds != nil
|
||||
),
|
||||
initialFailureCount: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func beginActivation(
|
||||
bindingID: String,
|
||||
endpointIdentity: CmxIrohPeerIdentity
|
||||
) -> (Binding, UInt64) {
|
||||
lifecycleRevision &+= 1
|
||||
let revision = lifecycleRevision
|
||||
refreshTask?.cancel()
|
||||
inFlightRefresh?.task.cancel()
|
||||
inFlightRefresh = nil
|
||||
let expectedBinding = Binding(id: bindingID, endpointIdentity: endpointIdentity)
|
||||
binding = expectedBinding
|
||||
installedCredential = nil
|
||||
return (expectedBinding, revision)
|
||||
}
|
||||
|
||||
private func installInitialCredentialAfterRetry(
|
||||
binding: Binding,
|
||||
revision: UInt64,
|
||||
@@ -415,27 +516,14 @@ public actor CmxIrohRelayCredentialCoordinator {
|
||||
guard isCurrent(revision), binding == expectedBinding else {
|
||||
throw CancellationError()
|
||||
}
|
||||
guard response.relayFleet.count == managedRelayURLs.count,
|
||||
Set(response.relayFleet) == managedRelayURLs else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
let now = clock.now()
|
||||
let configurations = try response.relayConfigurations(now: now)
|
||||
let selectedConfigurations = configurations.filter {
|
||||
selectedRelayURLs.contains($0.url)
|
||||
}
|
||||
guard !selectedRelayURLs.isEmpty,
|
||||
selectedConfigurations.count == selectedRelayURLs.count,
|
||||
selectedRelayURLs.isSubset(of: managedRelayURLs) else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
let selectedConfigurations = try validatedSelectedConfigurations(response)
|
||||
try Task.checkCancellation()
|
||||
guard isCurrent(revision), binding == expectedBinding else {
|
||||
throw CancellationError()
|
||||
}
|
||||
if selectedRelayURLs == managedRelayURLs {
|
||||
try await supervisor.replaceRelays(
|
||||
configurations,
|
||||
selectedConfigurations,
|
||||
expectedIdentity: expectedBinding.endpointIdentity
|
||||
)
|
||||
} else {
|
||||
@@ -448,6 +536,39 @@ public actor CmxIrohRelayCredentialCoordinator {
|
||||
expectedIdentity: expectedBinding.endpointIdentity
|
||||
)
|
||||
}
|
||||
return try recordInstallation(
|
||||
response,
|
||||
selectedConfigurations: selectedConfigurations,
|
||||
binding: expectedBinding,
|
||||
revision: revision
|
||||
)
|
||||
}
|
||||
|
||||
private func validatedSelectedConfigurations(
|
||||
_ response: CmxIrohRelayTokenResponse
|
||||
) throws -> [CmxIrohRelayConfiguration] {
|
||||
guard response.relayFleet.count == managedRelayURLs.count,
|
||||
Set(response.relayFleet) == managedRelayURLs else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
let configurations = try response.relayConfigurations(now: clock.now())
|
||||
let selectedConfigurations = configurations.filter {
|
||||
selectedRelayURLs.contains($0.url)
|
||||
}
|
||||
guard !selectedRelayURLs.isEmpty,
|
||||
selectedConfigurations.count == selectedRelayURLs.count,
|
||||
selectedRelayURLs.isSubset(of: managedRelayURLs) else {
|
||||
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
|
||||
}
|
||||
return selectedConfigurations
|
||||
}
|
||||
|
||||
private func recordInstallation(
|
||||
_ response: CmxIrohRelayTokenResponse,
|
||||
selectedConfigurations: [CmxIrohRelayConfiguration],
|
||||
binding expectedBinding: Binding,
|
||||
revision: UInt64
|
||||
) throws -> InstalledCredential {
|
||||
try Task.checkCancellation()
|
||||
guard isCurrent(revision), binding == expectedBinding,
|
||||
let refreshAfter = selectedConfigurations.map(\.refreshAfter).min(),
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import CMUXMobileCore
|
||||
import Foundation
|
||||
|
||||
/// Assigns endpoint-stable, non-overlapping relay credential refresh slots.
|
||||
struct CmxIrohRelayRefreshSchedule: Sendable {
|
||||
enum Role: Sendable {
|
||||
case host
|
||||
case client
|
||||
|
||||
fileprivate var phaseStart: Int {
|
||||
switch self {
|
||||
case .host: 0
|
||||
case .client: 30
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let phaseWidth = 15
|
||||
private static let minuteDuration: TimeInterval = 60
|
||||
private static let fnvOffsetBasis: UInt64 = 14_695_981_039_346_656_037
|
||||
private static let fnvPrime: UInt64 = 1_099_511_628_211
|
||||
|
||||
private let secondWithinMinute: Int
|
||||
|
||||
init(role: Role, endpointIdentity: CmxIrohPeerIdentity) {
|
||||
var hash = Self.fnvOffsetBasis
|
||||
for byte in endpointIdentity.endpointID.utf8 {
|
||||
hash ^= UInt64(byte)
|
||||
hash &*= Self.fnvPrime
|
||||
}
|
||||
secondWithinMinute = role.phaseStart + Int(hash % UInt64(Self.phaseWidth))
|
||||
}
|
||||
|
||||
func deadline(now: Date, refreshAfter: Date) -> Date {
|
||||
let refreshEpoch = refreshAfter.timeIntervalSince1970
|
||||
let minuteStart = floor(refreshEpoch / Self.minuteDuration)
|
||||
* Self.minuteDuration
|
||||
var candidateEpoch = minuteStart + TimeInterval(secondWithinMinute)
|
||||
if candidateEpoch > refreshEpoch {
|
||||
candidateEpoch -= Self.minuteDuration
|
||||
}
|
||||
return min(
|
||||
refreshAfter,
|
||||
max(now, Date(timeIntervalSince1970: candidateEpoch))
|
||||
)
|
||||
}
|
||||
}
|
||||
+125
-23
@@ -25,6 +25,20 @@ public struct CmxIrohBrokerCredentials: Sendable, CustomStringConvertible,
|
||||
public var debugDescription: String { description }
|
||||
}
|
||||
|
||||
private func isUnsupportedRegistrationScope(
|
||||
_ error: CmxIrohTrustBrokerClientError
|
||||
) -> Bool {
|
||||
guard case let .rejected(statusCode, code) = error else { return false }
|
||||
return statusCode == 400 && code == "unknown_field"
|
||||
}
|
||||
|
||||
private func isMissingScopedDiscoveryRoute(
|
||||
_ error: CmxIrohTrustBrokerClientError
|
||||
) -> Bool {
|
||||
guard case let .rejected(statusCode, _) = error else { return false }
|
||||
return statusCode == 404
|
||||
}
|
||||
|
||||
/// One authenticated account and credential pair captured atomically.
|
||||
///
|
||||
/// Platform auth coordinators map their native session snapshot into this
|
||||
@@ -174,10 +188,12 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
private struct ConnectivitySyncRequest: Encodable {
|
||||
let protocolVersion: Int
|
||||
let knownRevision: UInt64?
|
||||
let discoveryScope: CmxConnectivityDiscoveryScope?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case protocolVersion = "protocol_version"
|
||||
case knownRevision = "known_revision"
|
||||
case discoveryScope = "discovery_scope"
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
@@ -186,12 +202,13 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
if let knownRevision {
|
||||
try container.encode(knownRevision, forKey: .knownRevision)
|
||||
} else {
|
||||
// The v2 wire contract distinguishes an initial sync (`null`)
|
||||
// The wire contract distinguishes an initial sync (`null`)
|
||||
// from an absent field. Swift's synthesized Optional encoding
|
||||
// omits nil values, which the bounded server parser correctly
|
||||
// rejects as an incomplete request.
|
||||
try container.encodeNil(forKey: .knownRevision)
|
||||
}
|
||||
try container.encodeIfPresent(discoveryScope, forKey: .discoveryScope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,17 +269,20 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
private let transport: any CmxIrohHTTPTransport
|
||||
private let requestTimeout: TimeInterval
|
||||
private let backpressureGate: CmxIrohBrokerBackpressureGate?
|
||||
private let discoveryScope: CmxConnectivityDiscoveryScope?
|
||||
|
||||
/// Creates a client that rejects cleartext non-loopback API origins.
|
||||
public init(
|
||||
baseURL: URL,
|
||||
tokenSource: CmxIrohBrokerTokenSource,
|
||||
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
|
||||
requestTimeout: TimeInterval = 10,
|
||||
backpressureMode: CmxIrohBrokerBackpressureMode = .automatic
|
||||
) throws {
|
||||
try self.init(
|
||||
baseURL: baseURL,
|
||||
tokenSource: tokenSource,
|
||||
discoveryScope: discoveryScope,
|
||||
transport: CmxIrohURLSessionTransport(),
|
||||
requestTimeout: requestTimeout,
|
||||
backpressureMode: backpressureMode
|
||||
@@ -273,6 +293,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
init(
|
||||
baseURL: URL,
|
||||
tokenSource: CmxIrohBrokerTokenSource,
|
||||
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
|
||||
transport: any CmxIrohHTTPTransport,
|
||||
requestTimeout: TimeInterval = 10,
|
||||
backpressureMode: CmxIrohBrokerBackpressureMode = .automatic
|
||||
@@ -284,6 +305,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
self.tokenSource = tokenSource
|
||||
self.transport = transport
|
||||
self.requestTimeout = requestTimeout
|
||||
self.discoveryScope = discoveryScope
|
||||
switch backpressureMode {
|
||||
case .automatic:
|
||||
backpressureGate = CmxIrohBrokerBackpressureGate()
|
||||
@@ -314,12 +336,9 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
public func register(
|
||||
_ request: CmxIrohRegisterRequest
|
||||
) async throws -> CmxIrohRegistrationResponse {
|
||||
try await send(
|
||||
path: "api/devices/iroh/register",
|
||||
method: "POST",
|
||||
body: request,
|
||||
operation: .registration
|
||||
)
|
||||
try await withBackpressure(operation: .registration) {
|
||||
try await self.registerUngated(request)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the challenge and signed registration legs without regenerating payload bytes.
|
||||
@@ -334,33 +353,42 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
body: prepared.challengeRequest
|
||||
)
|
||||
let request = try signer.sign(prepared: prepared, challenge: challenge)
|
||||
return try await self.sendUngated(
|
||||
path: "api/devices/iroh/register",
|
||||
method: "POST",
|
||||
body: request
|
||||
)
|
||||
return try await self.registerUngated(request)
|
||||
}
|
||||
}
|
||||
|
||||
public func discover() async throws -> CmxIrohDiscoveryResponse {
|
||||
try await withBackpressure(operation: .discovery) {
|
||||
try await self.discoverAllPages()
|
||||
if self.discoveryScope != nil {
|
||||
do {
|
||||
let response = try await self.syncConnectivityUngated(
|
||||
knownRevision: nil
|
||||
)
|
||||
if let snapshot = response.snapshot,
|
||||
response.snapshotIsComplete {
|
||||
return snapshot
|
||||
}
|
||||
if response.protocolVersion
|
||||
== CmxConnectivitySyncResponse.scopedProtocolVersion {
|
||||
throw CmxIrohTrustBrokerClientError.invalidResponse
|
||||
}
|
||||
} catch let error as CmxIrohTrustBrokerClientError
|
||||
where isMissingScopedDiscoveryRoute(error) {
|
||||
// Older servers have only paginated global discovery.
|
||||
}
|
||||
}
|
||||
return try await self.discoverAllPages()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconciles one completely installed route revision with connectivity v2.
|
||||
/// Reconciles one completely installed route revision with connectivity v3,
|
||||
/// falling back to global connectivity v2 on older servers.
|
||||
public func syncConnectivity(
|
||||
knownRevision: UInt64?
|
||||
) async throws -> CmxConnectivitySyncResponse {
|
||||
try await send(
|
||||
path: "api/connectivity/v2/sync",
|
||||
method: "POST",
|
||||
body: ConnectivitySyncRequest(
|
||||
protocolVersion: CmxConnectivitySyncResponse.protocolVersion,
|
||||
knownRevision: knownRevision
|
||||
),
|
||||
operation: .discovery
|
||||
)
|
||||
try await withBackpressure(operation: .discovery) {
|
||||
try await self.syncConnectivityUngated(knownRevision: knownRevision)
|
||||
}
|
||||
}
|
||||
|
||||
public func issuePairGrant(
|
||||
@@ -472,6 +500,80 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
}
|
||||
}
|
||||
|
||||
private func registerUngated(
|
||||
_ request: CmxIrohRegisterRequest
|
||||
) async throws -> CmxIrohRegistrationResponse {
|
||||
guard let discoveryScope else {
|
||||
return try await sendUngated(
|
||||
path: "api/devices/iroh/register",
|
||||
method: "POST",
|
||||
body: request.including(discoveryScope: nil)
|
||||
)
|
||||
}
|
||||
do {
|
||||
let response: CmxIrohRegistrationResponse = try await sendUngated(
|
||||
path: "api/devices/iroh/register",
|
||||
method: "POST",
|
||||
body: request.including(discoveryScope: discoveryScope)
|
||||
)
|
||||
guard response.discovery != nil,
|
||||
response.discoveryScope == discoveryScope,
|
||||
response.discoveryScopeComplete == true,
|
||||
response.discoveryComplete != true else {
|
||||
throw CmxIrohTrustBrokerClientError.invalidResponse
|
||||
}
|
||||
return response
|
||||
} catch let error as CmxIrohTrustBrokerClientError
|
||||
where isUnsupportedRegistrationScope(error) {
|
||||
// Registration parsing happens before challenge consumption, so
|
||||
// retrying the identical signature without the optional field is
|
||||
// safe against older strict servers.
|
||||
return try await sendUngated(
|
||||
path: "api/devices/iroh/register",
|
||||
method: "POST",
|
||||
body: request.including(discoveryScope: nil)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncConnectivityUngated(
|
||||
knownRevision: UInt64?
|
||||
) async throws -> CmxConnectivitySyncResponse {
|
||||
if let discoveryScope {
|
||||
do {
|
||||
let response: CmxConnectivitySyncResponse = try await sendUngated(
|
||||
path: "api/connectivity/v3/sync",
|
||||
method: "POST",
|
||||
body: ConnectivitySyncRequest(
|
||||
protocolVersion: CmxConnectivitySyncResponse.scopedProtocolVersion,
|
||||
knownRevision: knownRevision,
|
||||
discoveryScope: discoveryScope
|
||||
)
|
||||
)
|
||||
guard response.protocolVersion
|
||||
== CmxConnectivitySyncResponse.scopedProtocolVersion,
|
||||
response.discoveryScope == discoveryScope,
|
||||
!response.changed
|
||||
|| response.snapshotScopeComplete == true else {
|
||||
throw CmxIrohTrustBrokerClientError.invalidResponse
|
||||
}
|
||||
return response
|
||||
} catch let error as CmxIrohTrustBrokerClientError
|
||||
where isMissingScopedDiscoveryRoute(error) {
|
||||
// Continue with connectivity v2 below.
|
||||
}
|
||||
}
|
||||
return try await sendUngated(
|
||||
path: "api/connectivity/v2/sync",
|
||||
method: "POST",
|
||||
body: ConnectivitySyncRequest(
|
||||
protocolVersion: CmxConnectivitySyncResponse.protocolVersion,
|
||||
knownRevision: knownRevision,
|
||||
discoveryScope: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func send<Response: Decodable & Sendable, Body: Encodable>(
|
||||
path: String,
|
||||
method: String,
|
||||
|
||||
+180
@@ -1,8 +1,147 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxIrohTransport
|
||||
|
||||
@Suite
|
||||
struct CmxIrohCustomRelayRuntimeTests {
|
||||
@Test
|
||||
func clientManagedPolicyRefreshMutatesEndpointExactlyOnce() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
|
||||
let runtime = try CmxIrohClientRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
broker: TestIrohClientBroker(
|
||||
binding: fixture.binding,
|
||||
discovery: fixture.discovery,
|
||||
relay: fixture.relayResponse()
|
||||
),
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { fixture.now }
|
||||
)
|
||||
try await runtime.start()
|
||||
try await Self.waitForRelayMutation(endpoint)
|
||||
let initialCredentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
|
||||
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
|
||||
response: fixture.relayResponse(),
|
||||
relayURLs: Set(ClientRuntimeTestFixture.relayURLs),
|
||||
now: fixture.now
|
||||
))
|
||||
|
||||
let credentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
- initialCredentialUpdates
|
||||
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
- initialProfileUpdates
|
||||
#expect(credentialUpdates + profileUpdates == 1)
|
||||
#expect(await endpoint.observedCloseCallCount() == 0)
|
||||
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func hostManagedPolicyRefreshMutatesEndpointExactlyOnce() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
broker: TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: fixture.discovery
|
||||
),
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { Date(timeIntervalSince1970: 1_800_000_000) },
|
||||
handleTransport: { session, _ in await session.close() }
|
||||
)
|
||||
try await runtime.start()
|
||||
try await Self.waitForRelayMutation(endpoint)
|
||||
let initialCredentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
let response = try ClientRuntimeTestFixture().relayResponse()
|
||||
|
||||
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
|
||||
response: response,
|
||||
relayURLs: fixture.managedRelays,
|
||||
now: Date(timeIntervalSince1970: 1_800_000_000)
|
||||
))
|
||||
|
||||
let credentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
- initialCredentialUpdates
|
||||
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
- initialProfileUpdates
|
||||
#expect(credentialUpdates + profileUpdates == 1)
|
||||
#expect(await endpoint.observedCloseCallCount() == 0)
|
||||
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func clientManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
|
||||
let runtime = try CmxIrohClientRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
broker: TestIrohClientBroker(
|
||||
binding: fixture.binding,
|
||||
discovery: fixture.discovery,
|
||||
relay: fixture.relayResponse()
|
||||
),
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { fixture.now }
|
||||
)
|
||||
try await runtime.start()
|
||||
try await Self.waitForRelayMutation(endpoint)
|
||||
await endpoint.setRelayUpdateShouldFail(true)
|
||||
|
||||
await #expect(throws: TestIrohTransportError.relayUpdateFailed) {
|
||||
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
|
||||
response: fixture.relayResponse(),
|
||||
relayURLs: Set(ClientRuntimeTestFixture.relayURLs),
|
||||
now: fixture.now
|
||||
))
|
||||
}
|
||||
|
||||
#expect(await runtime.relayCoordinator == nil)
|
||||
#expect(await endpoint.observedCloseCallCount() == 0)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func hostManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
broker: TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: fixture.discovery
|
||||
),
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { Date(timeIntervalSince1970: 1_800_000_000) },
|
||||
handleTransport: { session, _ in await session.close() }
|
||||
)
|
||||
try await runtime.start()
|
||||
try await Self.waitForRelayMutation(endpoint)
|
||||
await endpoint.setRelayUpdateShouldFail(true)
|
||||
let response = try ClientRuntimeTestFixture().relayResponse()
|
||||
|
||||
await #expect(throws: TestIrohTransportError.relayUpdateFailed) {
|
||||
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
|
||||
response: response,
|
||||
relayURLs: fixture.managedRelays,
|
||||
now: Date(timeIntervalSince1970: 1_800_000_000)
|
||||
))
|
||||
}
|
||||
|
||||
#expect(await runtime.relayCoordinator == nil)
|
||||
#expect(await endpoint.observedCloseCallCount() == 0)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func clientOverrideSkipsManagedTokenIssuance() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
@@ -153,4 +292,45 @@ struct CmxIrohCustomRelayRuntimeTests {
|
||||
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
private static func managedPolicy(
|
||||
response: CmxIrohRelayTokenResponse,
|
||||
relayURLs: Set<String>,
|
||||
now: Date
|
||||
) throws -> CmxIrohEffectiveRelayPolicy {
|
||||
let profile = try CmxIrohEndpointRelayProfile(
|
||||
managedRelayURLs: relayURLs,
|
||||
relays: response.relayConfigurations(now: now)
|
||||
)
|
||||
return CmxIrohEffectiveRelayPolicy(
|
||||
endpointRelayProfile: profile,
|
||||
managedSnapshot: nil,
|
||||
managedPolicy: nil,
|
||||
requestedConfiguration: nil,
|
||||
effectivePreference: .automatic,
|
||||
source: .managed,
|
||||
usedCachedPolicy: false,
|
||||
preferenceRevision: nil,
|
||||
relayBootstrap: response
|
||||
)
|
||||
}
|
||||
|
||||
private static func waitForRelayMutation(_ endpoint: TestIrohEndpoint) async throws {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .seconds(1))
|
||||
while clock.now < deadline {
|
||||
let credentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
if credentialUpdates + profileUpdates > 0 { return }
|
||||
await Task.yield()
|
||||
}
|
||||
let credentialUpdates = await endpoint.observedRelayUpdates().count
|
||||
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
|
||||
let counts = "credential updates: \(credentialUpdates), "
|
||||
+ "profile updates: \(profileUpdates)"
|
||||
Issue.record("Timed out waiting for relay mutation (\(counts))")
|
||||
throw RelayMutationTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
private struct RelayMutationTimeout: Error {}
|
||||
|
||||
+76
@@ -6,6 +6,79 @@ import Testing
|
||||
@testable import CmuxIrohTransport
|
||||
|
||||
extension CmxIrohHostRuntimeTests {
|
||||
@Test
|
||||
func emptyPublicHintsRenewRegistrationBeforePrivatePortFreshnessExpires() async throws {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let fixture = try HostRuntimeFixture(now: now)
|
||||
let renewalDeadline = try #require(
|
||||
CmxIrohHostRuntime.registrationRenewalDeadline(
|
||||
binding: fixture.binding,
|
||||
now: now
|
||||
)
|
||||
)
|
||||
#expect(
|
||||
renewalDeadline < now.addingTimeInterval(
|
||||
CmxIrohPathHint.maximumPrivateHintTTL
|
||||
)
|
||||
)
|
||||
|
||||
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
|
||||
let broker = TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: fixture.discovery
|
||||
)
|
||||
let clock = HostRegistrationRenewalClock(now: now)
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
broker: broker,
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { clock.now() },
|
||||
registrationClock: clock,
|
||||
handleTransport: { session, _ in await session.close() }
|
||||
)
|
||||
|
||||
try await runtime.start()
|
||||
await clock.waitUntilSleeping()
|
||||
#expect(clock.observedSleepDeadlines().first == renewalDeadline)
|
||||
|
||||
clock.advance(to: renewalDeadline)
|
||||
await broker.waitForRegistrationCount(2)
|
||||
|
||||
#expect(await broker.observedRegistrationCount() == 2)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func stalePrivatePortFreshnessDoesNotScheduleImmediateRenewal() throws {
|
||||
let bindingTime = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let fixture = try HostRuntimeFixture(now: bindingTime)
|
||||
let staleNow = bindingTime.addingTimeInterval(
|
||||
CmxIrohPathHint.maximumPrivateHintTTL + 1
|
||||
)
|
||||
|
||||
#expect(CmxIrohHostRuntime.registrationRenewalDeadline(
|
||||
binding: fixture.binding,
|
||||
now: staleNow
|
||||
) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func nearExpiryPublicHintDoesNotScheduleImmediateRenewal() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let fixture = try HostRuntimeFixture(
|
||||
now: now,
|
||||
publicHintLifetime: 10
|
||||
)
|
||||
|
||||
#expect(CmxIrohHostRuntime.registrationRenewalDeadline(
|
||||
binding: fixture.binding,
|
||||
now: now
|
||||
) == now.addingTimeInterval(
|
||||
CmxIrohPathHint.maximumPrivateHintTTL - 15 * 60
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func unchangedReachabilityRenewsRegistrationBeforeHintExpiry() async throws {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
@@ -487,6 +560,9 @@ extension CmxIrohHostRuntimeTests {
|
||||
#expect(await factory.observedConfigurations().count == 1)
|
||||
#expect(clock.observedSleepDeadlines() == [
|
||||
now.addingTimeInterval(600),
|
||||
now.addingTimeInterval(
|
||||
CmxIrohPathHint.maximumPrivateHintTTL - 15 * 60
|
||||
),
|
||||
])
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
+5
-1
@@ -29,6 +29,7 @@ struct HostRuntimeFixture {
|
||||
managedRelays = Set(Self.relayURLs)
|
||||
binding = try Self.binding(
|
||||
endpointID: endpointID.endpointID,
|
||||
lastSeenAt: now,
|
||||
publicHintObservedAt: publicHintLifetime == nil ? nil : now,
|
||||
publicHintExpiresAt: publicHintLifetime.map(now.addingTimeInterval)
|
||||
)
|
||||
@@ -97,6 +98,7 @@ struct HostRuntimeFixture {
|
||||
static func binding(
|
||||
endpointID: String,
|
||||
bindingID: String = "123e4567-e89b-42d3-a456-426614174010",
|
||||
lastSeenAt: Date = Date(timeIntervalSince1970: 1_800_000_000),
|
||||
publicHintObservedAt: Date? = nil,
|
||||
publicHintExpiresAt: Date? = nil
|
||||
) throws -> CmxIrohBrokerBinding {
|
||||
@@ -105,6 +107,7 @@ struct HostRuntimeFixture {
|
||||
from: bindingJSON(
|
||||
endpointID: endpointID,
|
||||
bindingID: bindingID,
|
||||
lastSeenAt: lastSeenAt,
|
||||
publicHintObservedAt: publicHintObservedAt,
|
||||
publicHintExpiresAt: publicHintExpiresAt
|
||||
)
|
||||
@@ -154,6 +157,7 @@ struct HostRuntimeFixture {
|
||||
endpointID: String,
|
||||
bindingID: String = "123e4567-e89b-42d3-a456-426614174010",
|
||||
deviceID: String = "123e4567-e89b-42d3-a456-426614174011",
|
||||
lastSeenAt: Date = Date(timeIntervalSince1970: 1_800_000_000),
|
||||
publicHintObservedAt: Date? = nil,
|
||||
publicHintExpiresAt: Date? = nil
|
||||
) throws -> Data {
|
||||
@@ -182,7 +186,7 @@ struct HostRuntimeFixture {
|
||||
"pairing_enabled": true,
|
||||
"capabilities": ["rpc", "multistream"],
|
||||
"path_hints": pathHints,
|
||||
"last_seen_at": "2026-07-09T12:00:00.000Z",
|
||||
"last_seen_at": ISO8601DateFormatter().string(from: lastSeenAt),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
+78
@@ -5,6 +5,84 @@ import Testing
|
||||
|
||||
@Suite
|
||||
struct CmxIrohRelayCredentialCoordinatorTests {
|
||||
@Test
|
||||
func hostAndClientRefreshSlotsStaySeparatedAcrossCredentialCycles() throws {
|
||||
let hostIdentity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "1a", count: 32)
|
||||
)
|
||||
let clientIdentity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "b7", count: 32)
|
||||
)
|
||||
let hostSchedule = CmxIrohRelayRefreshSchedule(
|
||||
role: .host,
|
||||
endpointIdentity: hostIdentity
|
||||
)
|
||||
let clientSchedule = CmxIrohRelayRefreshSchedule(
|
||||
role: .client,
|
||||
endpointIdentity: clientIdentity
|
||||
)
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
var hostSeconds: [Int] = []
|
||||
var clientSeconds: [Int] = []
|
||||
|
||||
for cycle in 1 ... 8 {
|
||||
let refreshAfter = now.addingTimeInterval(TimeInterval(cycle * 240))
|
||||
let hostDeadline = hostSchedule.deadline(
|
||||
now: now,
|
||||
refreshAfter: refreshAfter
|
||||
)
|
||||
let clientDeadline = clientSchedule.deadline(
|
||||
now: now,
|
||||
refreshAfter: refreshAfter
|
||||
)
|
||||
let hostSecond = Int(hostDeadline.timeIntervalSince1970) % 60
|
||||
let clientSecond = Int(clientDeadline.timeIntervalSince1970) % 60
|
||||
hostSeconds.append(hostSecond)
|
||||
clientSeconds.append(clientSecond)
|
||||
|
||||
#expect((0 ... 14).contains(hostSecond))
|
||||
#expect((30 ... 44).contains(clientSecond))
|
||||
#expect(hostDeadline >= now)
|
||||
#expect(clientDeadline >= now)
|
||||
#expect(hostDeadline <= refreshAfter)
|
||||
#expect(clientDeadline <= refreshAfter)
|
||||
}
|
||||
|
||||
#expect(Set(hostSeconds).count == 1)
|
||||
#expect(Set(clientSeconds).count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func refreshSlotsSpreadEndpointsWithinEachRole() throws {
|
||||
let refreshAfter = Date(timeIntervalSince1970: 1_700_000_240)
|
||||
let now = refreshAfter.addingTimeInterval(-240)
|
||||
let hostSlots = try (0 ..< 16).map { index in
|
||||
let identity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(format: "%064x", index + 1)
|
||||
)
|
||||
return Int(
|
||||
CmxIrohRelayRefreshSchedule(role: .host, endpointIdentity: identity)
|
||||
.deadline(now: now, refreshAfter: refreshAfter)
|
||||
.timeIntervalSince1970
|
||||
) % 60
|
||||
}
|
||||
let clientSlots = try (0 ..< 16).map { index in
|
||||
let identity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(format: "%064x", index + 1)
|
||||
)
|
||||
return Int(
|
||||
CmxIrohRelayRefreshSchedule(role: .client, endpointIdentity: identity)
|
||||
.deadline(now: now, refreshAfter: refreshAfter)
|
||||
.timeIntervalSince1970
|
||||
) % 60
|
||||
}
|
||||
|
||||
#expect(Set(hostSlots).count > 1)
|
||||
#expect(hostSlots.allSatisfy { (0 ... 14).contains($0) })
|
||||
#expect(Set(clientSlots).count > 1)
|
||||
#expect(clientSlots.allSatisfy { (30 ... 44).contains($0) })
|
||||
}
|
||||
|
||||
@Test
|
||||
func bootstrapInstallsCompleteFleetBeforeSleepingUntilRefresh() async throws {
|
||||
let fixture = try RelayCoordinatorFixture()
|
||||
|
||||
+279
-1
@@ -5,6 +5,21 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
struct CmxIrohTrustBrokerClientTests {
|
||||
@Test
|
||||
func discoveryScopeNormalizesOnlyPeerTags() throws {
|
||||
let scope = try CmxConnectivityDiscoveryScope(
|
||||
deviceID: "123e4567-e89b-42d3-a456-426614174001",
|
||||
appInstanceID: "123e4567-e89b-42d3-a456-426614174002",
|
||||
tag: "LocalFeatureA",
|
||||
platform: .ios,
|
||||
peerPlatform: .mac,
|
||||
peerTags: ["FeatureA"]
|
||||
)
|
||||
|
||||
#expect(scope.localBinding.tag == "LocalFeatureA")
|
||||
#expect(scope.peerBindings.tags == ["featurea"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func challengeUsesNativeStackHeadersAndExactJSON() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
@@ -87,6 +102,108 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
#expect(response.discoveryComplete == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func scopedRegistrationFallsBackWithoutRegeneratingSignedPayload() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(
|
||||
status: 201,
|
||||
body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"#
|
||||
),
|
||||
.json(status: 400, body: #"{"error":"unknown_field"}"#),
|
||||
.json(status: 201, body: Self.registrationResponse),
|
||||
])
|
||||
let client = try makeClient(
|
||||
transport: transport,
|
||||
discoveryScope: iosDiscoveryScope()
|
||||
)
|
||||
let signer = try registrationSigner()
|
||||
let prepared = try signer.prepare(payload: registrationPayload())
|
||||
|
||||
_ = try await client.register(prepared: prepared, signer: signer)
|
||||
|
||||
let requests = await transport.requests()
|
||||
#expect(requests.compactMap { $0.url?.path } == [
|
||||
"/api/devices/iroh/challenge",
|
||||
"/api/devices/iroh/register",
|
||||
"/api/devices/iroh/register",
|
||||
])
|
||||
let scopedBody = try #require(requests[1].httpBody)
|
||||
let fallbackBody = try #require(requests[2].httpBody)
|
||||
var scopedObject = try #require(
|
||||
JSONSerialization.jsonObject(with: scopedBody) as? [String: Any]
|
||||
)
|
||||
let fallbackObject = try #require(
|
||||
JSONSerialization.jsonObject(with: fallbackBody) as? [String: Any]
|
||||
)
|
||||
#expect(scopedObject.removeValue(forKey: "discoveryScope") != nil)
|
||||
#expect(scopedObject as NSDictionary == fallbackObject as NSDictionary)
|
||||
}
|
||||
|
||||
@Test
|
||||
func scopedRegistrationAcceptsOnlyItsEchoedCompleteProjection() async throws {
|
||||
let scope = try iosDiscoveryScope()
|
||||
var responseObject = try #require(
|
||||
JSONSerialization.jsonObject(
|
||||
with: Data(Self.registrationResponse.utf8)
|
||||
) as? [String: Any]
|
||||
)
|
||||
responseObject["revision"] = 7
|
||||
responseObject["discovery"] = try Self.discoveryObject(revision: 7)
|
||||
responseObject["discovery_complete"] = false
|
||||
responseObject["discovery_scope"] = try scopeObject(scope)
|
||||
responseObject["discovery_scope_complete"] = true
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 201, body: try Self.jsonString(responseObject)),
|
||||
])
|
||||
let client = try makeClient(
|
||||
transport: transport,
|
||||
discoveryScope: scope
|
||||
)
|
||||
|
||||
let response = try await client.register(
|
||||
CmxIrohRegisterRequest(
|
||||
challengeID: "123e4567-e89b-42d3-a456-426614174000",
|
||||
nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
payload: "e30",
|
||||
signature: String(repeating: "A", count: 86)
|
||||
)
|
||||
)
|
||||
|
||||
#expect(response.discoveryScope == scope)
|
||||
#expect(response.discoveryScopeComplete == true)
|
||||
#expect(response.embeddedDiscoveryComplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
func scopedRegistrationCompletenessRequiresAnEchoedScope() async throws {
|
||||
var responseObject = try #require(
|
||||
JSONSerialization.jsonObject(
|
||||
with: Data(Self.registrationResponse.utf8)
|
||||
) as? [String: Any]
|
||||
)
|
||||
responseObject["revision"] = 7
|
||||
responseObject["discovery"] = try Self.discoveryObject(revision: 7)
|
||||
responseObject["discovery_complete"] = false
|
||||
responseObject["discovery_scope_complete"] = true
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 201, body: try Self.jsonString(responseObject)),
|
||||
])
|
||||
let client = try makeClient(transport: transport)
|
||||
|
||||
let response = try await client.register(
|
||||
CmxIrohRegisterRequest(
|
||||
challengeID: "123e4567-e89b-42d3-a456-426614174000",
|
||||
nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
payload: "e30",
|
||||
signature: String(repeating: "A", count: 86)
|
||||
)
|
||||
)
|
||||
|
||||
#expect(response.discoveryScope == nil)
|
||||
#expect(response.discoveryScopeComplete == true)
|
||||
#expect(!response.embeddedDiscoveryComplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
func issuedRegistrationBuildsTheExactManagedRelayFleet() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
@@ -716,6 +833,143 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
#expect(object["known_revision"] is NSNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
func connectivityV3SendsAndAcceptsOnlyTheEchoedScope() async throws {
|
||||
let scope = try iosDiscoveryScope()
|
||||
let snapshot = try Self.discoveryObject(revision: 2)
|
||||
let responseBody = try Self.jsonString([
|
||||
"protocol_version": 3,
|
||||
"revision": 2,
|
||||
"changed": true,
|
||||
"reset": false,
|
||||
"discovery_scope": try scopeObject(scope),
|
||||
"snapshot": snapshot,
|
||||
"snapshot_scope_complete": true,
|
||||
])
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 200, body: responseBody),
|
||||
])
|
||||
let client = try makeClient(
|
||||
transport: transport,
|
||||
discoveryScope: scope
|
||||
)
|
||||
|
||||
let response = try await client.syncConnectivity(knownRevision: nil)
|
||||
|
||||
#expect(response.protocolVersion == 3)
|
||||
#expect(response.discoveryScope == scope)
|
||||
#expect(response.snapshotIsComplete)
|
||||
let request = try #require(await transport.requests().first)
|
||||
#expect(request.url?.path == "/api/connectivity/v3/sync")
|
||||
let body = try #require(request.httpBody)
|
||||
let object = try #require(
|
||||
JSONSerialization.jsonObject(with: body) as? [String: Any]
|
||||
)
|
||||
#expect(object["protocol_version"] as? Int == 3)
|
||||
#expect(object["discovery_scope"] != nil)
|
||||
}
|
||||
|
||||
@Test(arguments: [Bool?.none, false])
|
||||
func connectivityV3RejectsChangedSnapshotWithoutScopedCompleteness(
|
||||
completeness: Bool?
|
||||
) async throws {
|
||||
let scope = try iosDiscoveryScope()
|
||||
let snapshot = try Self.discoveryObject(revision: 2)
|
||||
var responseObject: [String: Any] = [
|
||||
"protocol_version": 3,
|
||||
"revision": 2,
|
||||
"changed": true,
|
||||
"reset": false,
|
||||
"discovery_scope": try scopeObject(scope),
|
||||
"snapshot": snapshot,
|
||||
]
|
||||
if let completeness {
|
||||
responseObject["snapshot_scope_complete"] = completeness
|
||||
}
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(
|
||||
status: 200,
|
||||
body: try Self.jsonString(responseObject)
|
||||
),
|
||||
])
|
||||
let client = try makeClient(
|
||||
transport: transport,
|
||||
discoveryScope: scope
|
||||
)
|
||||
|
||||
await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) {
|
||||
_ = try await client.syncConnectivity(knownRevision: nil)
|
||||
}
|
||||
#expect(await transport.requests().compactMap { $0.url?.path } == [
|
||||
"/api/connectivity/v3/sync",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func scopedDiscoveryRejectsIncompleteV3WithoutFetchingGlobalBindings() async throws {
|
||||
let scope = try iosDiscoveryScope()
|
||||
let snapshot = try Self.discoveryObject(revision: 2)
|
||||
let responseBody = try Self.jsonString([
|
||||
"protocol_version": 3,
|
||||
"revision": 2,
|
||||
"changed": true,
|
||||
"reset": false,
|
||||
"discovery_scope": try scopeObject(scope),
|
||||
"snapshot": snapshot,
|
||||
"snapshot_scope_complete": false,
|
||||
])
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 200, body: responseBody),
|
||||
])
|
||||
let client = try makeClient(
|
||||
transport: transport,
|
||||
discoveryScope: scope
|
||||
)
|
||||
|
||||
await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) {
|
||||
_ = try await client.discover()
|
||||
}
|
||||
#expect(await transport.requests().compactMap { $0.url?.path } == [
|
||||
"/api/connectivity/v3/sync",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func connectivityV3FallsBackToGlobalV2OnOlderServers() async throws {
|
||||
let snapshot = try Self.discoveryObject(revision: 2)
|
||||
let v2Response = try Self.jsonString([
|
||||
"protocol_version": 2,
|
||||
"revision": 2,
|
||||
"changed": true,
|
||||
"reset": false,
|
||||
"snapshot": snapshot,
|
||||
"snapshot_complete": true,
|
||||
])
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 404, body: #"{"error":"not_found"}"#),
|
||||
.json(status: 200, body: v2Response),
|
||||
])
|
||||
let client = try makeClient(
|
||||
transport: transport,
|
||||
discoveryScope: iosDiscoveryScope()
|
||||
)
|
||||
|
||||
let response = try await client.syncConnectivity(knownRevision: nil)
|
||||
|
||||
#expect(response.protocolVersion == 2)
|
||||
#expect(response.snapshotComplete == true)
|
||||
let requests = await transport.requests()
|
||||
#expect(requests.compactMap { $0.url?.path } == [
|
||||
"/api/connectivity/v3/sync",
|
||||
"/api/connectivity/v2/sync",
|
||||
])
|
||||
let v2Body = try #require(requests[1].httpBody)
|
||||
let v2Object = try #require(
|
||||
JSONSerialization.jsonObject(with: v2Body) as? [String: Any]
|
||||
)
|
||||
#expect(v2Object["discovery_scope"] == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func connectivitySyncRequiresAnAtomicSnapshotAtTheEnvelopeRevision() async throws {
|
||||
let snapshot = try Self.discoveryObject(revision: 42)
|
||||
@@ -772,15 +1026,39 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
}
|
||||
|
||||
private func makeClient(
|
||||
transport: RecordingBrokerTransport
|
||||
transport: RecordingBrokerTransport,
|
||||
discoveryScope: CmxConnectivityDiscoveryScope? = nil
|
||||
) throws -> CmxIrohTrustBrokerClient {
|
||||
try CmxIrohTrustBrokerClient(
|
||||
baseURL: #require(URL(string: "https://cmux.example")),
|
||||
tokenSource: Self.tokenSource,
|
||||
discoveryScope: discoveryScope,
|
||||
transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
private func iosDiscoveryScope() throws -> CmxConnectivityDiscoveryScope {
|
||||
try CmxConnectivityDiscoveryScope(
|
||||
deviceID: "123e4567-e89b-42d3-a456-426614174001",
|
||||
appInstanceID: "123e4567-e89b-42d3-a456-426614174002",
|
||||
tag: "stable",
|
||||
platform: .ios,
|
||||
peerPlatform: .mac,
|
||||
peerTags: ["nightly", "default"],
|
||||
peerPairingEnabled: true
|
||||
)
|
||||
}
|
||||
|
||||
private func scopeObject(
|
||||
_ scope: CmxConnectivityDiscoveryScope
|
||||
) throws -> [String: Any] {
|
||||
try #require(
|
||||
JSONSerialization.jsonObject(
|
||||
with: JSONEncoder().encode(scope)
|
||||
) as? [String: Any]
|
||||
)
|
||||
}
|
||||
|
||||
private func registrationSigner() throws -> CmxIrohRegistrationSigner {
|
||||
let secret = try CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init)))
|
||||
let material = try CmxIrohIdentityMaterial(
|
||||
|
||||
+8
-22
@@ -26,6 +26,7 @@ final class BrowserStreamContentView: UIView, UIScrollViewDelegate, UIGestureRec
|
||||
private var panStartOffset = CGPoint.zero
|
||||
private var displayLink: CADisplayLink?
|
||||
private var viewportPolicy = BrowserStreamViewportEmissionPolicy()
|
||||
private var tapClickCounter = BrowserStreamTapClickCounter()
|
||||
|
||||
private lazy var scrollMechanicsView: UIScrollView = {
|
||||
let view = UIScrollView()
|
||||
@@ -68,12 +69,12 @@ final class BrowserStreamContentView: UIView, UIScrollViewDelegate, UIGestureRec
|
||||
addSubview(scrollMechanicsView)
|
||||
addSubview(inputProxy)
|
||||
|
||||
// One tap recognizer, forwarded immediately with a rising click count
|
||||
// (see BrowserStreamTapClickCounter): double tap means Mac double
|
||||
// click, never local zoom, and single clicks never wait on a
|
||||
// double-tap recognizer to fail. Pinch owns zooming.
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
|
||||
doubleTap.numberOfTapsRequired = 2
|
||||
tap.require(toFail: doubleTap)
|
||||
addGestureRecognizer(tap)
|
||||
addGestureRecognizer(doubleTap)
|
||||
|
||||
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))
|
||||
addGestureRecognizer(pinch)
|
||||
@@ -260,34 +261,19 @@ final class BrowserStreamContentView: UIView, UIScrollViewDelegate, UIGestureRec
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
guard let point = currentTransform.pagePoint(fromViewPoint: gesture.location(in: self)) else { return }
|
||||
let viewPoint = gesture.location(in: self)
|
||||
guard let point = currentTransform.pagePoint(fromViewPoint: viewPoint) else { return }
|
||||
let input = MobileBrowserPointerInput(
|
||||
panelID: panelID,
|
||||
kind: .click,
|
||||
x: Double(point.x),
|
||||
y: Double(point.y),
|
||||
clickCount: 1,
|
||||
clickCount: tapClickCounter.register(at: viewPoint, time: CACurrentMediaTime()),
|
||||
button: .left
|
||||
)
|
||||
delegate?.browserStreamContentView(self, didProducePointer: input)
|
||||
}
|
||||
|
||||
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||||
if zoomScale > 1.001 {
|
||||
zoomScale = 1
|
||||
viewportOffset = .zero
|
||||
} else {
|
||||
zoomScale = 2
|
||||
let location = gesture.location(in: self)
|
||||
viewportOffset = CGPoint(
|
||||
x: (location.x - bounds.midX) * (zoomScale - 1),
|
||||
y: (location.y - bounds.midY) * (zoomScale - 1)
|
||||
)
|
||||
}
|
||||
updateGestureModes()
|
||||
layoutImageLayer()
|
||||
}
|
||||
|
||||
@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
|
||||
if gesture.state == .began { pinchStartScale = zoomScale }
|
||||
zoomScale = min(max(1, pinchStartScale * gesture.scale), 4)
|
||||
|
||||
+3
@@ -12,6 +12,9 @@ public protocol BrowserStreamEventReceiving: AnyObject {
|
||||
/// Marks a stream active after the Mac accepts `stream.start`.
|
||||
/// - Parameter descriptor: The descriptor returned by the start request.
|
||||
func browserStreamDidStart(_ descriptor: MobileBrowserPanelDescriptor)
|
||||
/// Registers a panel the Mac just created on the phone's behalf.
|
||||
/// - Parameter descriptor: The descriptor returned by the create request.
|
||||
func browserPanelCreated(_ descriptor: MobileBrowserPanelDescriptor)
|
||||
/// Resets subscription-local sequencing immediately before `stream.start`.
|
||||
/// - Parameter panelID: The Mac browser panel identifier.
|
||||
func browserStreamWillStart(panelID: String) async
|
||||
|
||||
+29
@@ -179,6 +179,8 @@ public struct BrowserStreamPane: View {
|
||||
symbol: "pause.circle"
|
||||
)
|
||||
.accessibilityIdentifier("BrowserStreamPausedOverlay")
|
||||
} else if state.isBlankPage {
|
||||
newPagePlaceholder
|
||||
} else if state.latestFrame == nil {
|
||||
statusOverlay(
|
||||
title: L10n.string("mobile.browserStream.waiting", defaultValue: "Waiting for Browser"),
|
||||
@@ -189,6 +191,33 @@ public struct BrowserStreamPane: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliberate empty state for a browser that has not opened a page yet.
|
||||
///
|
||||
/// A fresh pane's mirror is an empty white capture, which looks like a
|
||||
/// glitch; this opaque placeholder replaces it until the first navigation.
|
||||
private var newPagePlaceholder: some View {
|
||||
ZStack {
|
||||
Color(red: 0.055, green: 0.063, blue: 0.075)
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "globe")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(L10n.string("mobile.browserStream.newPage", defaultValue: "New Browser"))
|
||||
.font(.headline)
|
||||
Text(L10n.string(
|
||||
"mobile.browserStream.newPageDetail",
|
||||
defaultValue: "Search or enter an address in the bar below."
|
||||
))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(28)
|
||||
}
|
||||
.accessibilityIdentifier("BrowserStreamNewPagePlaceholder")
|
||||
}
|
||||
|
||||
private var disconnectedOverlay: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.82).ignoresSafeArea()
|
||||
|
||||
+18
-7
@@ -184,16 +184,17 @@ public final class BrowserStreamStore: BrowserStreamEventReceiving {
|
||||
replacePanels(in: workspaceID, with: descriptors)
|
||||
}
|
||||
|
||||
/// Registers a panel the Mac just created on the phone's behalf, so it can
|
||||
/// be activated and streamed before any discovery refresh lands.
|
||||
/// - Parameter descriptor: The descriptor returned by the create request.
|
||||
public func browserPanelCreated(_ descriptor: MobileBrowserPanelDescriptor) {
|
||||
upsertPanel(descriptor)
|
||||
}
|
||||
|
||||
/// Reconciles the descriptor returned by a successful start request.
|
||||
/// - Parameter descriptor: The descriptor accepted by the Mac.
|
||||
public func browserStreamDidStart(_ descriptor: MobileBrowserPanelDescriptor) {
|
||||
var descriptors = panels(in: descriptor.workspaceID)
|
||||
if let index = descriptors.firstIndex(where: { $0.panelID == descriptor.panelID }) {
|
||||
descriptors[index] = descriptor
|
||||
} else {
|
||||
descriptors.append(descriptor)
|
||||
}
|
||||
replacePanels(in: descriptor.workspaceID, with: descriptors)
|
||||
upsertPanel(descriptor)
|
||||
guard let state = statesByPanel[descriptor.panelID] else { return }
|
||||
state.connectionStatus = .connected
|
||||
if state.streamStatus != .streaming {
|
||||
@@ -315,6 +316,16 @@ public final class BrowserStreamStore: BrowserStreamEventReceiving {
|
||||
return event.panelID
|
||||
}
|
||||
|
||||
private func upsertPanel(_ descriptor: MobileBrowserPanelDescriptor) {
|
||||
var descriptors = panels(in: descriptor.workspaceID)
|
||||
if let index = descriptors.firstIndex(where: { $0.panelID == descriptor.panelID }) {
|
||||
descriptors[index] = descriptor
|
||||
} else {
|
||||
descriptors.append(descriptor)
|
||||
}
|
||||
replacePanels(in: descriptor.workspaceID, with: descriptors)
|
||||
}
|
||||
|
||||
private func installDialog(_ dialog: MobileBrowserDialogEvent) {
|
||||
guard lastResolvedDialogIDByPanel[dialog.panelID] != dialog.dialogID else { return }
|
||||
lastResolvedDialogIDByPanel[dialog.panelID] = nil
|
||||
|
||||
+10
@@ -160,6 +160,16 @@ public final class BrowserStreamSurfaceState: Identifiable {
|
||||
/// Whether the hidden input proxy should hold first responder.
|
||||
public var shouldFocusInput: Bool { keyboardPolicy.shouldFocusInput }
|
||||
|
||||
/// Whether the panel has never opened a page (a fresh New Browser pane).
|
||||
///
|
||||
/// A blank pane mirrors an empty white surface, which reads as a rendering
|
||||
/// glitch; the pane shows a purposeful new-page placeholder instead until
|
||||
/// the first navigation gives the panel a URL.
|
||||
public var isBlankPage: Bool {
|
||||
let trimmed = url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty || trimmed == "about:blank"
|
||||
}
|
||||
|
||||
/// Prepares sequence and status state for a new Mac stream subscription.
|
||||
public func prepareForStreamStart() {
|
||||
newestDisplayedSequence = nil
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
/// Computes Mac-style click counts from successive phone taps.
|
||||
///
|
||||
/// The mirror preserves Mac pointer semantics: a double tap is a double click
|
||||
/// (word selection), a triple tap a triple click (paragraph selection). Taps
|
||||
/// are forwarded immediately with a rising click count, exactly like a
|
||||
/// physical mouse, so single clicks never wait on a double-tap recognizer to
|
||||
/// fail. Zooming belongs to the pinch gesture alone.
|
||||
struct BrowserStreamTapClickCounter {
|
||||
/// Maximum seconds between taps that still chain the click count.
|
||||
let chainInterval: TimeInterval
|
||||
/// Maximum view-point distance between taps that still chain the count.
|
||||
let chainRadius: CGFloat
|
||||
|
||||
private var lastTime: TimeInterval?
|
||||
private var lastLocation: CGPoint?
|
||||
private var count = 0
|
||||
|
||||
/// Creates a counter with Mac-like double-click chaining thresholds.
|
||||
/// - Parameters:
|
||||
/// - chainInterval: Seconds within which a tap continues the chain.
|
||||
/// - chainRadius: View points within which a tap continues the chain.
|
||||
init(chainInterval: TimeInterval = 0.45, chainRadius: CGFloat = 28) {
|
||||
self.chainInterval = chainInterval
|
||||
self.chainRadius = chainRadius
|
||||
}
|
||||
|
||||
/// Registers one tap and returns the click count to forward to the Mac.
|
||||
/// - Parameters:
|
||||
/// - location: The tap location in view points.
|
||||
/// - time: A monotonic timestamp for the tap.
|
||||
/// - Returns: The Mac click count for this tap (1 for a lone tap, 2 for a
|
||||
/// double click, and so on).
|
||||
mutating func register(at location: CGPoint, time: TimeInterval) -> Int {
|
||||
if let lastTime, let lastLocation,
|
||||
time - lastTime <= chainInterval,
|
||||
hypot(location.x - lastLocation.x, location.y - lastLocation.y) <= chainRadius {
|
||||
count += 1
|
||||
} else {
|
||||
count = 1
|
||||
}
|
||||
lastTime = time
|
||||
lastLocation = location
|
||||
return count
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import CMUXMobileCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxMobileBrowserStream
|
||||
|
||||
/// Coverage for phone-initiated panel creation: the New Browser button relies
|
||||
/// on the created descriptor being activatable before any discovery refresh.
|
||||
@MainActor
|
||||
struct BrowserStreamStorePanelCreationTests {
|
||||
private func descriptor(
|
||||
panelID: String,
|
||||
workspaceID: String,
|
||||
title: String? = nil
|
||||
) -> MobileBrowserPanelDescriptor {
|
||||
MobileBrowserPanelDescriptor(
|
||||
panelID: panelID,
|
||||
workspaceID: workspaceID,
|
||||
url: nil,
|
||||
title: title,
|
||||
pageWidth: 800,
|
||||
pageHeight: 600,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false
|
||||
)
|
||||
}
|
||||
|
||||
@Test func createdPanelIsImmediatelyActivatable() {
|
||||
let store = BrowserStreamStore()
|
||||
store.browserPanelCreated(descriptor(panelID: "panel-new", workspaceID: "ws-1"))
|
||||
|
||||
#expect(store.panels(in: "ws-1").map(\.panelID) == ["panel-new"])
|
||||
let state = store.activate(panelID: "panel-new", in: "ws-1")
|
||||
#expect(state != nil)
|
||||
#expect(store.activeState(in: "ws-1")?.id == "panel-new")
|
||||
}
|
||||
|
||||
@Test func repeatedCreationUpdatesInsteadOfDuplicating() {
|
||||
let store = BrowserStreamStore()
|
||||
store.browserPanelCreated(descriptor(panelID: "panel-new", workspaceID: "ws-1"))
|
||||
store.browserPanelCreated(descriptor(panelID: "panel-new", workspaceID: "ws-1", title: "Example"))
|
||||
|
||||
let panels = store.panels(in: "ws-1")
|
||||
#expect(panels.count == 1)
|
||||
#expect(panels.first?.title == "Example")
|
||||
}
|
||||
|
||||
@Test func createdPanelJoinsExistingDiscovery() {
|
||||
let store = BrowserStreamStore()
|
||||
store.replacePanels(in: "ws-1", with: [descriptor(panelID: "panel-old", workspaceID: "ws-1")])
|
||||
store.browserPanelCreated(descriptor(panelID: "panel-new", workspaceID: "ws-1"))
|
||||
|
||||
#expect(store.panels(in: "ws-1").map(\.panelID) == ["panel-old", "panel-new"])
|
||||
}
|
||||
}
|
||||
+40
@@ -37,6 +37,46 @@ import Testing
|
||||
#expect(state.streamStatus == .streaming)
|
||||
}
|
||||
|
||||
@Test @MainActor func blankPageTracksNavigationState() {
|
||||
let descriptor = MobileBrowserPanelDescriptor(
|
||||
panelID: "panel-new",
|
||||
workspaceID: "workspace-1",
|
||||
url: nil,
|
||||
title: nil,
|
||||
pageWidth: 400,
|
||||
pageHeight: 300,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false
|
||||
)
|
||||
let state = BrowserStreamSurfaceState(descriptor: descriptor)
|
||||
#expect(state.isBlankPage)
|
||||
|
||||
state.apply(MobileBrowserStateEvent(
|
||||
panelID: "panel-new",
|
||||
url: "about:blank",
|
||||
title: nil,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
progress: 1,
|
||||
editableFocused: false
|
||||
))
|
||||
#expect(state.isBlankPage)
|
||||
|
||||
state.apply(MobileBrowserStateEvent(
|
||||
panelID: "panel-new",
|
||||
url: "https://example.com",
|
||||
title: "Example",
|
||||
canGoBack: true,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
progress: 1,
|
||||
editableFocused: false
|
||||
))
|
||||
#expect(!state.isBlankPage)
|
||||
}
|
||||
|
||||
private func makeImage() -> CGImage? {
|
||||
CGContext(
|
||||
data: nil,
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import CoreGraphics
|
||||
import Testing
|
||||
@testable import CmuxMobileBrowserStream
|
||||
|
||||
/// Mac click-count semantics for phone taps: a double tap must reach the Mac
|
||||
/// as a double click (word selection), never as a local zoom gesture.
|
||||
struct BrowserStreamTapClickCounterTests {
|
||||
@Test func quickSecondTapBecomesDoubleClick() {
|
||||
var counter = BrowserStreamTapClickCounter()
|
||||
#expect(counter.register(at: CGPoint(x: 100, y: 100), time: 10.0) == 1)
|
||||
#expect(counter.register(at: CGPoint(x: 104, y: 98), time: 10.3) == 2)
|
||||
}
|
||||
|
||||
@Test func thirdTapBecomesTripleClick() {
|
||||
var counter = BrowserStreamTapClickCounter()
|
||||
#expect(counter.register(at: CGPoint(x: 50, y: 50), time: 1.0) == 1)
|
||||
#expect(counter.register(at: CGPoint(x: 50, y: 50), time: 1.3) == 2)
|
||||
#expect(counter.register(at: CGPoint(x: 50, y: 50), time: 1.6) == 3)
|
||||
}
|
||||
|
||||
@Test func slowSecondTapRestartsAtSingleClick() {
|
||||
var counter = BrowserStreamTapClickCounter()
|
||||
#expect(counter.register(at: CGPoint(x: 100, y: 100), time: 10.0) == 1)
|
||||
#expect(counter.register(at: CGPoint(x: 100, y: 100), time: 10.6) == 1)
|
||||
}
|
||||
|
||||
@Test func distantSecondTapRestartsAtSingleClick() {
|
||||
var counter = BrowserStreamTapClickCounter()
|
||||
#expect(counter.register(at: CGPoint(x: 100, y: 100), time: 10.0) == 1)
|
||||
#expect(counter.register(at: CGPoint(x: 180, y: 100), time: 10.2) == 1)
|
||||
}
|
||||
|
||||
@Test func chainRestartsAfterBreak() {
|
||||
var counter = BrowserStreamTapClickCounter()
|
||||
#expect(counter.register(at: CGPoint(x: 10, y: 10), time: 1.0) == 1)
|
||||
#expect(counter.register(at: CGPoint(x: 10, y: 10), time: 1.2) == 2)
|
||||
#expect(counter.register(at: CGPoint(x: 10, y: 10), time: 5.0) == 1)
|
||||
#expect(counter.register(at: CGPoint(x: 10, y: 10), time: 5.2) == 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// Typed parameters for `mobile.browser.create`.
|
||||
struct MobileBrowserCreateParameters: Encodable, Sendable {
|
||||
/// The Mac-local workspace identifier.
|
||||
let workspaceID: String
|
||||
|
||||
/// Creates browser-create parameters.
|
||||
init(workspaceID: String) { self.workspaceID = workspaceID }
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case workspaceID = "workspace_id" }
|
||||
}
|
||||
+12
@@ -14,6 +14,18 @@ extension MobileCoreRPCClient {
|
||||
return try MobileBrowserListResponse.decode(data).panels
|
||||
}
|
||||
|
||||
/// Creates a new browser panel in one workspace for immediate streaming.
|
||||
/// - Parameter workspaceID: The Mac-local workspace identifier.
|
||||
/// - Returns: The descriptor of the freshly created panel.
|
||||
/// - Throws: A transport, authorization, RPC, or response-decoding error.
|
||||
public func createMobileBrowserPanel(workspaceID: String) async throws -> MobileBrowserPanelDescriptor {
|
||||
let data = try await sendBrowserRequest(
|
||||
method: "mobile.browser.create",
|
||||
parameters: MobileBrowserCreateParameters(workspaceID: workspaceID)
|
||||
)
|
||||
return try JSONDecoder().decode(MobileBrowserPanelDescriptor.self, from: data)
|
||||
}
|
||||
|
||||
/// Starts streaming one browser panel and returns its descriptor.
|
||||
/// - Parameters:
|
||||
/// - panelID: The Mac browser panel identifier.
|
||||
|
||||
@@ -723,7 +723,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
// token so legacy pairings cannot accidentally narrow the global
|
||||
// feed; Stack auth is still attached to every TCP request.
|
||||
return true
|
||||
case "mobile.browser.list":
|
||||
case "mobile.browser.list", "mobile.browser.create":
|
||||
return !ticketCoverage.ticketCoversWorkspaceRequest(
|
||||
ticket: ticket,
|
||||
workspaceSelection: workspaceSelection.value
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
|
||||
/// The authenticated Mac-side gate for forwarding notifications to this phone.
|
||||
///
|
||||
/// The Mac includes this only after the caller proves same-account ownership.
|
||||
/// Missing or unknown values must therefore be treated as unavailable, never as
|
||||
/// ready.
|
||||
public struct MobileHostPhonePushStatus: Decodable, Equatable, Sendable {
|
||||
/// When the Mac forwards otherwise-qualifying notifications.
|
||||
public enum Mode: String, Decodable, Equatable, Sendable {
|
||||
/// Forward only while the Mac is locked, asleep, or idle.
|
||||
case onlyWhenAway
|
||||
/// Forward regardless of Mac presence.
|
||||
case always
|
||||
}
|
||||
|
||||
/// What the authenticated status exchange proves about account ownership.
|
||||
public enum AccountScope: String, Decodable, Equatable, Sendable {
|
||||
/// The Mac verified the phone's Stack token against its own account.
|
||||
case verifiedSameAccount = "verified_same_account"
|
||||
}
|
||||
|
||||
/// The Mac's sanitized current decision for a would-be notification.
|
||||
public enum Admission: String, Decodable, Equatable, Sendable {
|
||||
case allowed
|
||||
case forwardingDisabled = "forwarding_disabled"
|
||||
case suppressedMacActive = "suppressed_mac_active"
|
||||
case unknown
|
||||
}
|
||||
|
||||
/// Durability of the Mac's bounded retry queue. Failures degrade restart
|
||||
/// reliability without claiming that the live APNs request path is down.
|
||||
public enum QueuePersistence: String, Decodable, Equatable, Sendable {
|
||||
case unknown
|
||||
case healthy
|
||||
case loadFailed = "load_failed"
|
||||
case saveFailed = "save_failed"
|
||||
case clearFailed = "clear_failed"
|
||||
}
|
||||
|
||||
/// Whether the Mac's independent forwarding privacy gate is enabled.
|
||||
public let forwardingEnabled: Bool
|
||||
/// The Mac's live forwarding mode.
|
||||
public let mode: Mode
|
||||
/// Whether the current mode and presence admit a forward right now.
|
||||
public let admission: Admission
|
||||
/// Sanitized persistence health for queued Mac-to-phone events.
|
||||
public let queuePersistence: QueuePersistence
|
||||
/// Whether terminal title/body content is redacted before upload.
|
||||
public let hideContent: Bool
|
||||
/// The API base URL the Mac will send the notification through.
|
||||
public let apiOrigin: String
|
||||
/// The account relationship proven by the authenticated RPC.
|
||||
public let accountScope: AccountScope
|
||||
|
||||
/// Creates an authenticated Mac push-status value.
|
||||
public init(
|
||||
forwardingEnabled: Bool,
|
||||
mode: Mode,
|
||||
admission: Admission = .unknown,
|
||||
queuePersistence: QueuePersistence = .unknown,
|
||||
hideContent: Bool = false,
|
||||
apiOrigin: String,
|
||||
accountScope: AccountScope
|
||||
) {
|
||||
self.forwardingEnabled = forwardingEnabled
|
||||
self.mode = mode
|
||||
self.admission = admission
|
||||
self.queuePersistence = queuePersistence
|
||||
self.hideContent = hideContent
|
||||
self.apiOrigin = apiOrigin
|
||||
self.accountScope = accountScope
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case forwardingEnabled = "forwarding_enabled"
|
||||
case mode
|
||||
case admission
|
||||
case queuePersistence = "queue_persistence"
|
||||
case hideContent = "hide_content"
|
||||
case apiOrigin = "api_origin"
|
||||
case accountScope = "account_scope"
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
forwardingEnabled = try container.decode(
|
||||
Bool.self,
|
||||
forKey: .forwardingEnabled
|
||||
)
|
||||
mode = try container.decode(Mode.self, forKey: .mode)
|
||||
admission = try container.decodeIfPresent(
|
||||
Admission.self,
|
||||
forKey: .admission
|
||||
) ?? .unknown
|
||||
queuePersistence = try container.decodeIfPresent(
|
||||
QueuePersistence.self,
|
||||
forKey: .queuePersistence
|
||||
) ?? .unknown
|
||||
hideContent = try container.decodeIfPresent(
|
||||
Bool.self,
|
||||
forKey: .hideContent
|
||||
) ?? false
|
||||
apiOrigin = try container.decode(String.self, forKey: .apiOrigin)
|
||||
accountScope = try container.decode(
|
||||
AccountScope.self,
|
||||
forKey: .accountScope
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
|
||||
/// colors. `nil` from older Macs that predate the field, in which case the
|
||||
/// phone keeps its built-in Monokai default.
|
||||
public let theme: TerminalTheme?
|
||||
/// Authenticated Mac-side phone-forwarding status. `nil` means the caller
|
||||
/// could not prove same-account ownership, the Mac predates this field, or
|
||||
/// the value was malformed. None of those states is ready.
|
||||
public let phonePush: MobileHostPhonePushStatus?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case capabilities
|
||||
@@ -49,6 +53,7 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
|
||||
case macAppVersion = "mac_app_version"
|
||||
case macAppBuild = "mac_app_build"
|
||||
case theme
|
||||
case phonePush = "phone_push"
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -69,6 +74,12 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
|
||||
// leniently: a bad theme object yields `nil` and the phone keeps its
|
||||
// built-in Monokai default, exactly like an older Mac that omits it.
|
||||
theme = (try? container.decodeIfPresent(TerminalTheme.self, forKey: .theme)) ?? nil
|
||||
// Keep an unknown future mode/account value from invalidating the
|
||||
// transport and identity fields in the same status response.
|
||||
phonePush = (try? container.decodeIfPresent(
|
||||
MobileHostPhonePushStatus.self,
|
||||
forKey: .phonePush
|
||||
)) ?? nil
|
||||
}
|
||||
|
||||
/// Decode a host-status response from raw JSON data.
|
||||
|
||||
@@ -105,6 +105,16 @@ import Testing
|
||||
#expect(frame.stackAccessToken == "test-stack-token")
|
||||
}
|
||||
|
||||
@Test func browserCreateCarriesMatchingWorkspaceTicketContext() async throws {
|
||||
let frame = try await recordedRequest(
|
||||
method: "mobile.browser.create",
|
||||
params: ["workspace_id": "workspace-main"],
|
||||
ticketWorkspaceID: "workspace-main"
|
||||
)
|
||||
#expect(frame.attachToken == "ticket-secret")
|
||||
#expect(frame.stackAccessToken == "test-stack-token")
|
||||
}
|
||||
|
||||
@Test func panelCommandUsesMacWideTicketContext() async throws {
|
||||
let frame = try await recordedRequest(
|
||||
method: "mobile.browser.stream.start",
|
||||
|
||||
+51
@@ -49,6 +49,57 @@ import Testing
|
||||
#expect(response.terminalThemeRevisionEpoch == "boot-one")
|
||||
}
|
||||
|
||||
@Test func hostStatusDecodesAuthenticatedPhonePushReadiness() throws {
|
||||
let response = try MobileHostStatusResponse.decode(Data(
|
||||
"""
|
||||
{
|
||||
"mac_device_id": "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE",
|
||||
"phone_push": {
|
||||
"forwarding_enabled": true,
|
||||
"mode": "onlyWhenAway",
|
||||
"admission": "suppressed_mac_active",
|
||||
"queue_persistence": "healthy",
|
||||
"hide_content": true,
|
||||
"api_origin": "https://cmux-staging.vercel.app",
|
||||
"account_scope": "verified_same_account"
|
||||
}
|
||||
}
|
||||
""".utf8
|
||||
))
|
||||
|
||||
#expect(response.phonePush == MobileHostPhonePushStatus(
|
||||
forwardingEnabled: true,
|
||||
mode: .onlyWhenAway,
|
||||
admission: .suppressedMacActive,
|
||||
queuePersistence: .healthy,
|
||||
hideContent: true,
|
||||
apiOrigin: "https://cmux-staging.vercel.app",
|
||||
accountScope: .verifiedSameAccount
|
||||
))
|
||||
}
|
||||
|
||||
@Test func hostStatusKeepsMissingQueueHealthDistinctFromFailure() throws {
|
||||
let missing = try MobileHostStatusResponse.decode(Data(
|
||||
#"{"phone_push":{"forwarding_enabled":true,"mode":"always","admission":"allowed","api_origin":"https://cmux.com","account_scope":"verified_same_account"}}"#.utf8
|
||||
))
|
||||
let failed = try MobileHostStatusResponse.decode(Data(
|
||||
#"{"phone_push":{"forwarding_enabled":true,"mode":"always","admission":"allowed","queue_persistence":"save_failed","api_origin":"https://cmux.com","account_scope":"verified_same_account"}}"#.utf8
|
||||
))
|
||||
|
||||
#expect(missing.phonePush?.queuePersistence == .unknown)
|
||||
#expect(failed.phonePush?.queuePersistence == .saveFailed)
|
||||
}
|
||||
|
||||
@Test func hostStatusTreatsMissingOrUnknownPhonePushStateAsUnavailable() throws {
|
||||
let missing = try MobileHostStatusResponse.decode(Data("{}".utf8))
|
||||
let unknown = try MobileHostStatusResponse.decode(Data(
|
||||
#"{"phone_push":{"forwarding_enabled":true,"mode":"future","api_origin":"x","account_scope":"future"}}"#.utf8
|
||||
))
|
||||
|
||||
#expect(missing.phonePush == nil)
|
||||
#expect(unknown.phonePush == nil)
|
||||
}
|
||||
|
||||
@Test func hostStatusCanonicalizesOnlyUUIDDeviceIDs() throws {
|
||||
let uppercaseUUID = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE"
|
||||
let uuidResponse = try MobileHostStatusResponse.decode(Data(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/// The furthest stage a user-triggered test alert has confirmed.
|
||||
///
|
||||
/// `queuedOnMac` deliberately does not claim APNs acceptance or visible iOS
|
||||
/// presentation. Those later stages remain observable through correlated Mac,
|
||||
/// backend, and device evidence rather than a false-success button label.
|
||||
public enum MobilePhonePushTestStage: String, Equatable, Sendable {
|
||||
case queuedOnMac = "queued"
|
||||
case forwardingDisabled = "forwarding_disabled"
|
||||
case macActive = "suppressed_mac_active"
|
||||
case authenticationUnavailable = "authentication_unavailable"
|
||||
case encodingFailed = "encoding_failed"
|
||||
case queueFull = "queue_full"
|
||||
case unavailable
|
||||
}
|
||||
+42
-2
@@ -1,5 +1,6 @@
|
||||
public import CMUXMobileCore
|
||||
import CmuxMobileBrowserStream
|
||||
import CmuxMobileDiagnostics
|
||||
import CmuxMobileRPC
|
||||
import Foundation
|
||||
|
||||
@@ -17,6 +18,35 @@ extension MobileShellComposite {
|
||||
browserStreamEvents?.replaceBrowserPanels(in: workspaceID, with: panels)
|
||||
}
|
||||
|
||||
/// Creates a new Mac browser panel in a workspace so the phone can stream
|
||||
/// it with the same surface as discovered panels.
|
||||
/// - Parameter workspaceID: The Mac-local workspace identifier.
|
||||
/// - Returns: The created panel's descriptor, or `nil` when creation is
|
||||
/// unsupported, disconnected, or rejected by the Mac.
|
||||
public func createMobileBrowserPanel(workspaceID: String) async -> MobileBrowserPanelDescriptor? {
|
||||
guard connectionState == .connected,
|
||||
supportsBrowserStreamCreate,
|
||||
let client = remoteClient else {
|
||||
MobileDebugLog.anchormux(
|
||||
"browser.create skipped connected=\(connectionState == .connected ? 1 : 0) supported=\(supportsBrowserStreamCreate ? 1 : 0)"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
guard let descriptor = try? await client.createMobileBrowserPanel(workspaceID: workspaceID),
|
||||
remoteClient === client else {
|
||||
// The Mac may have committed the panel even though the outcome was
|
||||
// lost (timeout, decode failure, or a client swap mid-flight).
|
||||
// Reconcile discovery so a committed panel surfaces in the picker
|
||||
// instead of becoming an orphan the phone never learns about.
|
||||
MobileDebugLog.anchormux("browser.create uncertain-failure ws=\(workspaceID.prefix(8)) reconciling")
|
||||
await refreshMobileBrowserPanels(workspaceID: workspaceID)
|
||||
return nil
|
||||
}
|
||||
MobileDebugLog.anchormux("browser.create ok panel=\(descriptor.panelID.prefix(8))")
|
||||
browserStreamEvents?.browserPanelCreated(descriptor)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
/// Starts streaming a discovered Mac browser panel.
|
||||
/// - Parameter panelID: The Mac browser panel identifier.
|
||||
public func startMobileBrowserStream(panelID: String) async {
|
||||
@@ -33,7 +63,10 @@ extension MobileShellComposite {
|
||||
let viewport = supportsBrowserStreamViewport
|
||||
? browserStreamEvents?.browserStreamViewport(for: panelID)
|
||||
: nil
|
||||
guard !supportsBrowserStreamViewport || viewport != nil else { return }
|
||||
guard !supportsBrowserStreamViewport || viewport != nil else {
|
||||
MobileDebugLog.anchormux("browser.stream start-deferred panel=\(panelID.prefix(8)) awaiting-viewport")
|
||||
return
|
||||
}
|
||||
await browserStreamEvents?.browserStreamWillStart(panelID: panelID)
|
||||
guard connectionState == .connected,
|
||||
supportsBrowserStream,
|
||||
@@ -43,8 +76,12 @@ extension MobileShellComposite {
|
||||
viewport: viewport
|
||||
),
|
||||
connectionState == .connected,
|
||||
remoteClient === client else { return }
|
||||
remoteClient === client else {
|
||||
MobileDebugLog.anchormux("browser.stream start-failed panel=\(panelID.prefix(8))")
|
||||
return
|
||||
}
|
||||
startedMobileBrowserPanelIDs.insert(panelID)
|
||||
MobileDebugLog.anchormux("browser.stream started panel=\(panelID.prefix(8))")
|
||||
browserStreamEvents?.browserStreamDidStart(descriptor)
|
||||
}
|
||||
|
||||
@@ -162,6 +199,7 @@ extension MobileShellComposite {
|
||||
func handleMobileBrowserClosedEvent(_ event: MobileEventEnvelope) {
|
||||
guard let payload = event.payloadJSON else { return }
|
||||
if let panelID = browserStreamEvents?.receiveBrowserClosedPayload(payload) {
|
||||
MobileDebugLog.anchormux("browser.stream closed-by-mac panel=\(panelID.prefix(8))")
|
||||
startedMobileBrowserPanelIDs.remove(panelID)
|
||||
}
|
||||
}
|
||||
@@ -197,6 +235,7 @@ extension MobileShellComposite {
|
||||
/// started-dedupe set must not suppress the re-arm in that case, or the
|
||||
/// mirror freezes with no path back short of closing the surface.
|
||||
func forceRestartMobileBrowserStream(panelID: String) async {
|
||||
MobileDebugLog.anchormux("browser.stream force-restart panel=\(panelID.prefix(8))")
|
||||
startedMobileBrowserPanelIDs.remove(panelID)
|
||||
await startMobileBrowserStream(panelID: panelID)
|
||||
}
|
||||
@@ -215,6 +254,7 @@ extension MobileShellComposite {
|
||||
|
||||
private func performStopMobileBrowserStream(panelID: String) async {
|
||||
startedMobileBrowserPanelIDs.remove(panelID)
|
||||
MobileDebugLog.anchormux("browser.stream stop panel=\(panelID.prefix(8))")
|
||||
guard let client = remoteClient else { return }
|
||||
_ = try? await client.stopMobileBrowserStream(panelID: panelID)
|
||||
}
|
||||
|
||||
+4
@@ -9,6 +9,10 @@ extension MobileShellComposite {
|
||||
public var supportsBrowserStreamDialogs: Bool {
|
||||
supportsBrowserStream && supportedHostCapabilities.contains(Self.browserStreamDialogCapability)
|
||||
}
|
||||
/// Whether the connected Mac can create a browser panel for the phone to stream.
|
||||
public var supportsBrowserStreamCreate: Bool {
|
||||
supportsBrowserStream && supportedHostCapabilities.contains(Self.browserStreamCreateCapability)
|
||||
}
|
||||
static let chatArtifactFoldersCapability = "chat.artifact.folders.v1"
|
||||
static let terminalArtifactListCapability = "terminal.artifact.list.v1"
|
||||
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import CmuxMobileShellModel
|
||||
|
||||
@MainActor
|
||||
extension MobileShellComposite {
|
||||
/// Observes the shared Settings/onboarding choice and replaces any live
|
||||
/// foreground connection whose route was selected under the old method.
|
||||
func startObservingConnectionMethodChanges() {
|
||||
guard connectionMethodObservationTask == nil,
|
||||
let connectionMethodStore else { return }
|
||||
let initialMethod = connectionMethodStore.method
|
||||
connectionMethodObservationTask = Task { @MainActor [weak self, connectionMethodStore] in
|
||||
var observedMethod = initialMethod
|
||||
for await method in connectionMethodStore.changes() {
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
guard method != observedMethod else { continue }
|
||||
observedMethod = method
|
||||
self.recoverMobileConnection(trigger: .connectionMethodChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -85,7 +85,7 @@ extension MobileShellComposite {
|
||||
}
|
||||
if let accountID = identityProvider?.currentUserID {
|
||||
switch trigger {
|
||||
case .manual, .networkChange, .foreground:
|
||||
case .manual, .networkChange, .foreground, .connectionMethodChanged:
|
||||
clearTransientAutomaticReconnectBackoff(accountID: accountID)
|
||||
case .presencePush:
|
||||
guard !automaticIrohReconnectIsBlocked(accountID: accountID) else {
|
||||
@@ -96,10 +96,24 @@ extension MobileShellComposite {
|
||||
break
|
||||
}
|
||||
}
|
||||
let connectionMethodChanged: Bool
|
||||
if case .connectionMethodChanged = trigger {
|
||||
connectionMethodChanged = true
|
||||
// A method change invalidates every route decision made by an
|
||||
// in-flight recovery. The replacement below owns a new generation
|
||||
// and is the only attempt allowed to publish a foreground client.
|
||||
connectionRecoveryOwner.cancel()
|
||||
applyConnectionRecoveryOwnerState()
|
||||
invalidateStoredMacReconnectAttempt()
|
||||
} else {
|
||||
connectionMethodChanged = false
|
||||
}
|
||||
beginConnectionRecovery(
|
||||
trigger: trigger,
|
||||
expectedClient: remoteClient,
|
||||
probeCurrentConnection: connectionState == .connected && remoteClient != nil,
|
||||
probeCurrentConnection: !connectionMethodChanged
|
||||
&& connectionState == .connected
|
||||
&& remoteClient != nil,
|
||||
resyncAfterHealthy: true
|
||||
)
|
||||
// A disconnected redial has cleared its foreground identity. Starting
|
||||
@@ -198,7 +212,8 @@ extension MobileShellComposite {
|
||||
markMacConnectionReconnecting()
|
||||
resyncTerminalOutput(reason: trigger.description, restartEventStream: true)
|
||||
case .manual, .presencePush, .foreground, .eventStreamEnded,
|
||||
.subscriptionStartFailed, .transportWriteTimedOut, .automaticBackoffExpired:
|
||||
.subscriptionStartFailed, .transportWriteTimedOut, .automaticBackoffExpired,
|
||||
.connectionMethodChanged:
|
||||
markMacConnectionUnavailableIfNoStore()
|
||||
}
|
||||
return
|
||||
@@ -615,8 +630,8 @@ extension MobileShellComposite {
|
||||
routes,
|
||||
supportedKinds: supportedKinds,
|
||||
preferNonLoopback: Self.prefersNonLoopbackRoutes,
|
||||
tailscalePreference: connectionMethodStore?.method == .tailscale
|
||||
? Self.TailscaleRoutePreference(
|
||||
tailscaleRequirement: connectionMethodStore?.method == .tailscale
|
||||
? Self.TailscaleRouteRequirement(
|
||||
macDeviceID: pairedMacDeviceID,
|
||||
grantRoutes: legacyTailscaleRoutes
|
||||
)
|
||||
|
||||
+133
-4
@@ -404,10 +404,45 @@ extension MobileShellComposite {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unhides one stored pairing immediately without requiring network access.
|
||||
/// Unhides one stored pairing without requiring network access.
|
||||
public func unhideMacDeviceID(
|
||||
_ macDeviceID: String,
|
||||
instanceTag: String? = nil
|
||||
) async {
|
||||
await enqueueUnhideMacDeviceID(
|
||||
macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
).value
|
||||
}
|
||||
|
||||
/// Starts an owned unhide operation for a row visibility switch.
|
||||
public func requestUnhideMacDeviceID(
|
||||
_ macDeviceID: String,
|
||||
instanceTag: String? = nil
|
||||
) {
|
||||
_ = enqueueUnhideMacDeviceID(macDeviceID, instanceTag: instanceTag)
|
||||
}
|
||||
|
||||
private func enqueueUnhideMacDeviceID(
|
||||
_ macDeviceID: String,
|
||||
instanceTag: String?
|
||||
) -> Task<Void, Never> {
|
||||
enqueueComputerVisibilityMutation(
|
||||
computerID: MobilePairedMac.pairingID(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
)
|
||||
) { store in
|
||||
await store.performUnhideMacDeviceID(
|
||||
macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func performUnhideMacDeviceID(
|
||||
_ macDeviceID: String,
|
||||
instanceTag: String?
|
||||
) async {
|
||||
guard let scope = await currentScopeSnapshot() else { return }
|
||||
await clearHiddenMacDeviceID(
|
||||
@@ -415,8 +450,11 @@ extension MobileShellComposite {
|
||||
instanceTag: instanceTag,
|
||||
scope: scope
|
||||
)
|
||||
guard await isScopeCurrent(scope) else { return }
|
||||
guard !Task.isCancelled,
|
||||
await isScopeCurrent(scope),
|
||||
!Task.isCancelled else { return }
|
||||
await loadPairedMacs()
|
||||
guard !Task.isCancelled else { return }
|
||||
await loadRegistryDevices()
|
||||
}
|
||||
|
||||
@@ -457,6 +495,45 @@ extension MobileShellComposite {
|
||||
public func hideStoredPairedMacEntries(
|
||||
representativeID: String,
|
||||
aliasIDs: [String]
|
||||
) async {
|
||||
await enqueueHideStoredPairedMacEntries(
|
||||
representativeID: representativeID,
|
||||
aliasIDs: aliasIDs,
|
||||
refreshRegistry: false
|
||||
).value
|
||||
}
|
||||
|
||||
/// Starts an owned hide operation for a row visibility switch.
|
||||
public func requestHideStoredPairedMacEntries(
|
||||
representativeID: String,
|
||||
aliasIDs: [String]
|
||||
) {
|
||||
_ = enqueueHideStoredPairedMacEntries(
|
||||
representativeID: representativeID,
|
||||
aliasIDs: aliasIDs,
|
||||
refreshRegistry: true
|
||||
)
|
||||
}
|
||||
|
||||
private func enqueueHideStoredPairedMacEntries(
|
||||
representativeID: String,
|
||||
aliasIDs: [String],
|
||||
refreshRegistry: Bool
|
||||
) -> Task<Void, Never> {
|
||||
enqueueComputerVisibilityMutation(computerID: representativeID) { store in
|
||||
await store.performHideStoredPairedMacEntries(
|
||||
representativeID: representativeID,
|
||||
aliasIDs: aliasIDs
|
||||
)
|
||||
if refreshRegistry, !Task.isCancelled {
|
||||
await store.loadRegistryDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performHideStoredPairedMacEntries(
|
||||
representativeID: String,
|
||||
aliasIDs: [String]
|
||||
) async {
|
||||
guard !representativeID.isEmpty,
|
||||
let scope = await currentScopeSnapshot() else { return }
|
||||
@@ -478,6 +555,54 @@ extension MobileShellComposite {
|
||||
await hideStoredPairedMacs(targets, scope: scope)
|
||||
}
|
||||
|
||||
private func enqueueComputerVisibilityMutation(
|
||||
computerID: String,
|
||||
operation: @escaping @MainActor (MobileShellComposite) async -> Void
|
||||
) -> Task<Void, Never> {
|
||||
let previousTask = computerVisibilityMutationTasksByID[computerID]
|
||||
let operationID = UUID()
|
||||
computerVisibilityMutationOperationIDsByID[computerID] = operationID
|
||||
computerVisibilityMutationIDs.insert(computerID)
|
||||
|
||||
let task = Task { @MainActor [weak self] in
|
||||
await previousTask?.value
|
||||
guard let self else { return }
|
||||
defer {
|
||||
self.finishComputerVisibilityMutation(
|
||||
computerID: computerID,
|
||||
operationID: operationID
|
||||
)
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
await operation(self)
|
||||
}
|
||||
computerVisibilityMutationTasksByID[computerID] = task
|
||||
return task
|
||||
}
|
||||
|
||||
func cancelComputerVisibilityMutations() {
|
||||
let tasks = Array(computerVisibilityMutationTasksByID.values)
|
||||
computerVisibilityMutationIDs = []
|
||||
for task in tasks {
|
||||
task.cancel()
|
||||
}
|
||||
// Keep each cancelled task as the serial tail until its rollback ends.
|
||||
// A request in the next account/team scope must await that cleanup before
|
||||
// writing a newer durable visibility preference for the same computer.
|
||||
}
|
||||
|
||||
private func finishComputerVisibilityMutation(
|
||||
computerID: String,
|
||||
operationID: UUID
|
||||
) {
|
||||
guard computerVisibilityMutationOperationIDsByID[computerID] == operationID else {
|
||||
return
|
||||
}
|
||||
computerVisibilityMutationTasksByID[computerID] = nil
|
||||
computerVisibilityMutationOperationIDsByID[computerID] = nil
|
||||
computerVisibilityMutationIDs.remove(computerID)
|
||||
}
|
||||
|
||||
/// Hides exactly one stored paired-Mac row.
|
||||
public func hideStoredMac(macDeviceID: String) async {
|
||||
await hideStoredPairedMacEntries(
|
||||
@@ -514,7 +639,9 @@ extension MobileShellComposite {
|
||||
includeUserWideScope: teamlessLegacyIDs.contains(mac.id)
|
||||
)
|
||||
}
|
||||
guard await isScopeCurrent(scope) else {
|
||||
guard !Task.isCancelled,
|
||||
await isScopeCurrent(scope),
|
||||
!Task.isCancelled else {
|
||||
for pairingID in targetPairingIDs {
|
||||
await clearHiddenMacDeviceID(pairingID, scope: scope)
|
||||
}
|
||||
@@ -571,7 +698,9 @@ extension MobileShellComposite {
|
||||
removeNotificationFeedSnapshot(macDeviceID: id)
|
||||
}
|
||||
|
||||
guard await isScopeCurrent(scope) else { return }
|
||||
guard !Task.isCancelled,
|
||||
await isScopeCurrent(scope),
|
||||
!Task.isCancelled else { return }
|
||||
await loadPairedMacs()
|
||||
clearSavedMacHintWhenNoStoredMacsRemainIfNeeded()
|
||||
}
|
||||
|
||||
+15
-21
@@ -110,9 +110,9 @@ extension MobileShellComposite {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The Tailscale ordering preference for one paired Mac: which grant routes
|
||||
/// may promote an exact stored Tailscale route ahead of the Iroh pin.
|
||||
struct TailscaleRoutePreference {
|
||||
/// The strict Tailscale policy for one paired Mac: only exact grant routes
|
||||
/// remain dialable while the user has selected Tailscale.
|
||||
struct TailscaleRouteRequirement {
|
||||
let macDeviceID: String
|
||||
let grantRoutes: [CmxAttachRoute]
|
||||
}
|
||||
@@ -127,16 +127,15 @@ extension MobileShellComposite {
|
||||
/// or revocation failure could silently downgrade around the Iroh device
|
||||
/// grant. Pairings without an authenticated Iroh identity remain fail-closed.
|
||||
///
|
||||
/// `tailscalePreference` (the user's explicit Tailscale connection-method
|
||||
/// choice) relaxes only the ORDER of that pin: stored Tailscale routes that
|
||||
/// carry a device-local grant dial first, and the Iroh routes stay as the
|
||||
/// fallback instead of being exclusive. Unauthorized Tailscale routes are
|
||||
/// still never dialable, so a preference flip alone grants nothing.
|
||||
/// `tailscaleRequirement` represents the user's explicit Tailscale-only
|
||||
/// connection method. Only stored Tailscale routes carrying a device-local
|
||||
/// grant remain; Iroh is not retained as a fallback, and a method change
|
||||
/// alone grants nothing.
|
||||
static func storedReconnectRoutes(
|
||||
_ routes: [CmxAttachRoute],
|
||||
supportedKinds: [CmxAttachTransportKind],
|
||||
preferNonLoopback: Bool = false,
|
||||
tailscalePreference: TailscaleRoutePreference? = nil
|
||||
tailscaleRequirement: TailscaleRouteRequirement? = nil
|
||||
) -> [CmxAttachRoute] {
|
||||
let supportedKinds = Set(supportedKinds)
|
||||
var ordered = CmxAttachRoute.addingIrohPrivatePaths(
|
||||
@@ -149,20 +148,15 @@ extension MobileShellComposite {
|
||||
ordered.removeAll { $0.kind == .debugLoopback }
|
||||
}
|
||||
let irohRoutes = ordered.filter { $0.kind == .iroh }
|
||||
if let tailscalePreference {
|
||||
if let tailscaleRequirement {
|
||||
let authorizedTailscale = ordered.filter { route in
|
||||
legacyTailscaleAuthorizationEvidence(
|
||||
for: route,
|
||||
macDeviceID: tailscalePreference.macDeviceID,
|
||||
persistedRoutes: tailscalePreference.grantRoutes
|
||||
macDeviceID: tailscaleRequirement.macDeviceID,
|
||||
persistedRoutes: tailscaleRequirement.grantRoutes
|
||||
) != nil
|
||||
}
|
||||
if !authorizedTailscale.isEmpty {
|
||||
let rest = ordered.filter { route in
|
||||
route.kind != .iroh && route.kind != .tailscale
|
||||
}
|
||||
return authorizedTailscale + irohRoutes + rest
|
||||
}
|
||||
return authorizedTailscale
|
||||
}
|
||||
if !irohRoutes.isEmpty {
|
||||
return irohRoutes
|
||||
@@ -172,7 +166,7 @@ extension MobileShellComposite {
|
||||
|
||||
/// The dial order for one stored Mac, honoring the user's connection-method
|
||||
/// choice. With the default automatic method this is exactly
|
||||
/// ``storedReconnectRoutes(_:supportedKinds:preferNonLoopback:tailscalePreference:)``
|
||||
/// ``storedReconnectRoutes(_:supportedKinds:preferNonLoopback:tailscaleRequirement:)``
|
||||
/// without a preference.
|
||||
func orderedReconnectRoutes(
|
||||
for mac: MobilePairedMac,
|
||||
@@ -182,8 +176,8 @@ extension MobileShellComposite {
|
||||
mac.routes,
|
||||
supportedKinds: supportedKinds,
|
||||
preferNonLoopback: Self.prefersNonLoopbackRoutes,
|
||||
tailscalePreference: connectionMethodStore?.method == .tailscale
|
||||
? TailscaleRoutePreference(
|
||||
tailscaleRequirement: connectionMethodStore?.method == .tailscale
|
||||
? TailscaleRouteRequirement(
|
||||
macDeviceID: mac.macDeviceID,
|
||||
grantRoutes: mac.legacyTailscaleRoutes ?? []
|
||||
)
|
||||
|
||||
+6
-2
@@ -414,6 +414,7 @@ extension MobileShellComposite {
|
||||
// original device-id spelling, which is what the store accepted when
|
||||
// this control connection was established.
|
||||
let macID = sub.macDeviceID
|
||||
let priorSecondaryGroups = workspacesByMac[ownerKey]?.groups ?? []
|
||||
guard let scope = await currentScopeSnapshot() else {
|
||||
await retireSecondaryPromotionCandidate(sub)
|
||||
return .unavailable
|
||||
@@ -732,7 +733,7 @@ extension MobileShellComposite {
|
||||
)
|
||||
return .unavailable
|
||||
}
|
||||
guard case let .received(authoritativePreviews) =
|
||||
guard case let .received(authoritativeSnapshot) =
|
||||
authoritativeWorkspaceAttempt else {
|
||||
stopTerminalRefreshPolling()
|
||||
await retirePromotedConnectionForFreshDial(
|
||||
@@ -776,7 +777,10 @@ extension MobileShellComposite {
|
||||
macDeviceID: macID,
|
||||
instanceTag: activeMacInstanceTag,
|
||||
displayName: displayName,
|
||||
workspaces: authoritativePreviews,
|
||||
workspaces: authoritativeSnapshot.workspaces,
|
||||
groups: authoritativeSnapshot.groups
|
||||
?? workspacesByMac[foregroundMacKey]?.groups
|
||||
?? priorSecondaryGroups,
|
||||
status: .connected,
|
||||
actionCapabilities: sub.actionCapabilities
|
||||
)
|
||||
|
||||
+11
-6
@@ -227,7 +227,7 @@ extension MobileShellComposite {
|
||||
}
|
||||
var params = workspaceMutationParams(id: id)
|
||||
if let groupID {
|
||||
params["group_id"] = groupID.rawValue
|
||||
params["group_id"] = remoteWorkspaceGroupID(for: groupID).rawValue
|
||||
}
|
||||
if let beforeWorkspaceID {
|
||||
params["before_workspace_id"] = remoteWorkspaceID(for: beforeWorkspaceID).rawValue
|
||||
@@ -429,7 +429,10 @@ extension MobileShellComposite {
|
||||
MobileDebugLog.anchormux("workspace.mutation blocked action=\(actionName) id=\(id.rawValue) reason=scope")
|
||||
return .failure(.authorizationFailed(hostDisplayName: hostDisplayName))
|
||||
}
|
||||
var params: [String: Any] = ["group_id": id.rawValue, "action": action]
|
||||
var params: [String: Any] = [
|
||||
"group_id": remoteWorkspaceGroupID(for: id).rawValue,
|
||||
"action": action,
|
||||
]
|
||||
if let title {
|
||||
params["title"] = title
|
||||
}
|
||||
@@ -626,9 +629,11 @@ extension MobileShellComposite {
|
||||
/// - id: The group to collapse or expand.
|
||||
/// - collapsed: `true` to collapse (hide members), `false` to expand.
|
||||
public func setWorkspaceGroupCollapsed(id: MobileWorkspaceGroupPreview.ID, _ collapsed: Bool) async {
|
||||
groupCollapseStore.set(id.rawValue, collapsed: collapsed)
|
||||
if let index = workspaceGroups.firstIndex(where: { $0.id == id }) {
|
||||
workspaceGroups[index].isCollapsed = collapsed
|
||||
}
|
||||
guard let index = workspaceGroups.firstIndex(where: { $0.id == id }) else { return }
|
||||
groupCollapseStore.set(
|
||||
workspaceGroups[index].collapseStateID,
|
||||
collapsed: collapsed
|
||||
)
|
||||
workspaceGroups[index].isCollapsed = collapsed
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ extension MobileShellComposite {
|
||||
do {
|
||||
var params: [String: Any] = [:]
|
||||
if let groupID {
|
||||
params["group_id"] = groupID.rawValue
|
||||
params["group_id"] = remoteWorkspaceGroupID(for: groupID).rawValue
|
||||
}
|
||||
if let title = spec?.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
params["title"] = title
|
||||
|
||||
@@ -54,6 +54,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
"workspace.updated", "mobile.sync.delta",
|
||||
"terminal.bytes", "terminal.render_grid", "terminal.set_font",
|
||||
"notification.dismissed", "notification.badge", "notification.feed.changed",
|
||||
"phone_push.status.changed",
|
||||
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
|
||||
]
|
||||
case .renderGrid:
|
||||
@@ -61,6 +62,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
"workspace.updated", "mobile.sync.delta",
|
||||
"terminal.render_grid", "terminal.set_font",
|
||||
"notification.dismissed", "notification.badge", "notification.feed.changed",
|
||||
"phone_push.status.changed",
|
||||
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
|
||||
]
|
||||
case .rawBytes:
|
||||
@@ -68,6 +70,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
"workspace.updated", "mobile.sync.delta",
|
||||
"terminal.bytes", "terminal.set_font",
|
||||
"notification.dismissed", "notification.badge", "notification.feed.changed",
|
||||
"phone_push.status.changed",
|
||||
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
|
||||
]
|
||||
}
|
||||
@@ -108,6 +111,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
static let browserStreamCapability = MobileBrowserStreamCapability.identifier
|
||||
static let browserStreamViewportCapability = MobileBrowserStreamCapability.viewportIdentifier
|
||||
static let browserStreamDialogCapability = MobileBrowserStreamCapability.dialogIdentifier
|
||||
static let browserStreamCreateCapability = MobileBrowserStreamCapability.createIdentifier
|
||||
static let terminalReplayCapability = "terminal.replay.v1"
|
||||
static let terminalInputOrderedCapability = "terminal.input.ordered.v1"
|
||||
static let maxTerminalReplayBarrierDroppedOutputBeforeFailOpen: UInt64 = 256
|
||||
@@ -130,7 +134,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
static let dogfoodFeedbackCapability = "dogfood.v1"
|
||||
static let workspaceGroupsCapability = "workspace.groups.v1"
|
||||
static let notificationFeedCapability = "notification.feed.v1"
|
||||
private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000
|
||||
static let phonePushSettingsCapability = "phone_push.settings.v1"
|
||||
static let phonePushTestCapability = "phone_push.test.v1"
|
||||
nonisolated private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000
|
||||
/// How long the render-grid stream may stay silent (no event of any topic)
|
||||
/// before the liveness watchdog suspects the push subscription is dead and
|
||||
/// runs a bounded host probe; only repeated failed probes force the
|
||||
@@ -362,10 +368,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
public private(set) var notificationFeedUnreadCount: Int = 0
|
||||
/// Last authoritative chat-session snapshots, keyed by the workspace row id the UI renders.
|
||||
var chatSessionSnapshotsByWorkspaceID: [String: [ChatSessionDescriptor]] = [:]
|
||||
/// The group sections the UI renders. A materialized derivation of
|
||||
/// ``workspacesByMac`` (currently the foreground Mac's groups). Each group's
|
||||
/// `isCollapsed` reflects this device's choice (see ``groupCollapseStore``),
|
||||
/// not the Mac's live value.
|
||||
/// The group sections the UI renders. A materialized derivation of every
|
||||
/// entry in ``workspacesByMac``. Each group's `isCollapsed` reflects this
|
||||
/// device's choice (see ``groupCollapseStore``), not the Mac's live value.
|
||||
public internal(set) var workspaceGroups: [MobileWorkspaceGroupPreview] = []
|
||||
|
||||
/// The distinct per-Mac color index map (the SAME assignment the aggregated
|
||||
@@ -446,6 +451,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Authenticated phone-forwarding readiness from the focused Mac. `nil`
|
||||
/// means no attached Mac has proved same-account ownership and exposed the
|
||||
/// independent Mac privacy gate.
|
||||
public internal(set) var phonePushMacStatus: MobileHostPhonePushStatus?
|
||||
|
||||
/// Whether the authenticated Mac supports changing its independent phone
|
||||
/// forwarding privacy gates from iOS.
|
||||
public var supportsPhonePushSettings: Bool {
|
||||
supportedHostCapabilities.contains(Self.phonePushSettingsCapability)
|
||||
}
|
||||
|
||||
/// Whether the authenticated Mac can enqueue a correlated test alert.
|
||||
public var supportsPhonePushTest: Bool {
|
||||
supportedHostCapabilities.contains(Self.phonePushTestCapability)
|
||||
}
|
||||
/// Published workspace-list chip snapshots keyed by Mac-local workspace id.
|
||||
///
|
||||
/// Like ``workspaces``, this is a materialized immutable-value surface on the
|
||||
@@ -495,6 +515,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
b: terminalInputText.isEmpty ? 1 : 0
|
||||
))
|
||||
#endif
|
||||
if !isLoadingDraft,
|
||||
!terminalInputText.isEmpty,
|
||||
terminalInputText != oldValue,
|
||||
let terminalID = selectedTerminalID?.rawValue {
|
||||
clearSettledTerminalSendStatus(forTerminalID: terminalID)
|
||||
}
|
||||
// Persist the live edit under the CURRENT terminal so it survives a
|
||||
// terminal switch. Skipped while a draft is being loaded (the load is
|
||||
// the saved value, re-saving it is redundant and would race the
|
||||
@@ -575,12 +601,66 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
/// this spans the entire image-then-text run. Not observed: it gates an
|
||||
/// async flow, not view state.
|
||||
@ObservationIgnored private var isSubmittingComposer = false
|
||||
/// The last user-visible send settlement for each terminal. Unlike the
|
||||
/// re-entrancy flags above, this is observed by both the composer button and
|
||||
/// terminal command status pill.
|
||||
private var terminalSendStatusesByTerminalID: [String: MobileTerminalSendStatus] = [:]
|
||||
/// Latest operation identity per terminal. A late result from an older send
|
||||
/// cannot overwrite the state of a newer retry.
|
||||
@ObservationIgnored private var terminalSendOperationIDsByTerminalID: [String: UUID] = [:]
|
||||
/// Raw-command operations are tracked separately so a focus/connection
|
||||
/// pipeline clear can settle only those sends without disturbing an
|
||||
/// independently in-flight composer paste pinned to the same terminal.
|
||||
@ObservationIgnored private var rawTerminalSendOperationIDsByTerminalID: [String: UUID] = [:]
|
||||
/// Pending image attachments per terminal, keyed by terminal id so switching
|
||||
/// terminals keeps each draft's own attachments (mirroring how the text draft
|
||||
/// is keyed). Observed so the composer's chip row re-renders on add/remove.
|
||||
/// Sent in order on the next submit and then cleared for that terminal.
|
||||
private var pendingAttachmentsByTerminalID: [String: [MobilePendingAttachment]] = [:]
|
||||
|
||||
public func terminalSendStatus(forTerminalID terminalID: String) -> MobileTerminalSendStatus {
|
||||
terminalSendStatusesByTerminalID[terminalID] ?? .idle
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func beginTerminalSend(forTerminalID terminalID: String) -> UUID {
|
||||
let operationID = UUID()
|
||||
terminalSendOperationIDsByTerminalID[terminalID] = operationID
|
||||
terminalSendStatusesByTerminalID[terminalID] = .sending
|
||||
return operationID
|
||||
}
|
||||
|
||||
private func finishTerminalSend(
|
||||
_ operationID: UUID?,
|
||||
forTerminalID terminalID: String,
|
||||
succeeded: Bool
|
||||
) {
|
||||
guard let operationID,
|
||||
terminalSendOperationIDsByTerminalID[terminalID] == operationID else { return }
|
||||
terminalSendStatusesByTerminalID[terminalID] = succeeded ? .sent : .failed
|
||||
}
|
||||
|
||||
private func clearSettledTerminalSendStatus(forTerminalID terminalID: String) {
|
||||
guard terminalSendStatusesByTerminalID[terminalID] != .sending else { return }
|
||||
terminalSendStatusesByTerminalID[terminalID] = nil
|
||||
terminalSendOperationIDsByTerminalID[terminalID] = nil
|
||||
}
|
||||
|
||||
private func finishRawTerminalSend(
|
||||
_ operationID: UUID?,
|
||||
forTerminalID terminalID: String,
|
||||
succeeded: Bool
|
||||
) {
|
||||
guard let operationID,
|
||||
rawTerminalSendOperationIDsByTerminalID[terminalID] == operationID else { return }
|
||||
rawTerminalSendOperationIDsByTerminalID[terminalID] = nil
|
||||
finishTerminalSend(
|
||||
operationID,
|
||||
forTerminalID: terminalID,
|
||||
succeeded: succeeded
|
||||
)
|
||||
}
|
||||
|
||||
/// Max number of staged attachments per terminal. Enforced in
|
||||
/// ``addPendingAttachment(_:format:forTerminalID:)`` against the CURRENT
|
||||
/// staged set at mutation time so the check+insert is atomic on the main
|
||||
@@ -717,8 +797,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
|
||||
let runtime: (any MobileSyncRuntime)?
|
||||
let pairedMacStore: (any MobilePairedMacStoring)?
|
||||
/// The user's connection-method choice. `nil` (previews/tests without one)
|
||||
/// behaves like the default automatic method.
|
||||
/// The user's connection-method choice. The shipping app always injects
|
||||
/// this at the composition root (`AppCompositionRoot` holds it
|
||||
/// non-optional), so a user-selected Tailscale Only choice can never be
|
||||
/// dropped at runtime. `nil` exists only for DEBUG previews, the
|
||||
/// hide-computers verifier, and unit-test fixtures, which have no user
|
||||
/// preference and behave like the default automatic method.
|
||||
let connectionMethodStore: MobileConnectionMethodStore?
|
||||
/// Single compatibility authority shared by registry, persistence, and live connections.
|
||||
let buildCompatibilityPolicy: MobileMacBuildCompatibilityPolicy?
|
||||
@@ -1252,6 +1336,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
workspaces.first { $0.id == id }?.rpcWorkspaceID ?? id
|
||||
}
|
||||
|
||||
/// Resolve an aggregate group id back to the Mac-local id expected by RPC.
|
||||
func remoteWorkspaceGroupID(
|
||||
for id: MobileWorkspaceGroupPreview.ID
|
||||
) -> MobileWorkspaceGroupPreview.ID {
|
||||
workspaceGroups.first { $0.id == id }?.rpcGroupID ?? id
|
||||
}
|
||||
|
||||
/// Resolve a Mac-local workspace id to the current UI row id.
|
||||
func rowWorkspaceID(
|
||||
forRemoteWorkspaceID remoteID: MobileWorkspacePreview.ID,
|
||||
@@ -1511,6 +1602,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
browserStreamEvents?.configureBrowserStreamRestart { [weak self] panelID in
|
||||
await self?.forceRestartMobileBrowserStream(panelID: panelID)
|
||||
}
|
||||
startObservingConnectionMethodChanges()
|
||||
}
|
||||
|
||||
isolated deinit {
|
||||
@@ -1518,6 +1610,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
automaticReconnectRetryTask?.cancel()
|
||||
presenceTask?.cancel()
|
||||
networkPathObservationTask?.cancel()
|
||||
connectionMethodObservationTask?.cancel()
|
||||
terminalEventListenerTask?.cancel()
|
||||
terminalSubscriptionStartTask?.cancel()
|
||||
renderGridLivenessTimer?.cancel()
|
||||
@@ -1532,6 +1625,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
workspaceChangesSummaryTrailingTask?.cancel()
|
||||
pullToRefreshTask?.cancel()
|
||||
foregroundWorkspaceMutationRefreshTask?.cancel()
|
||||
for task in computerVisibilityMutationTasksByID.values {
|
||||
task.cancel()
|
||||
}
|
||||
foregroundWorkspaceMutationRefreshPending = false
|
||||
foregroundWorkspaceMutationRefreshGeneration = UUID()
|
||||
notificationFeedOpenTask?.cancel()
|
||||
@@ -1577,6 +1673,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
}
|
||||
|
||||
public func signOut() {
|
||||
cancelComputerVisibilityMutations()
|
||||
// Reset analytics identity to anonymous on the signed-in→signed-out edge
|
||||
// only (this is called on every unauthenticated auth-state sync).
|
||||
if isSignedIn {
|
||||
@@ -1733,6 +1830,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
/// never drops the terminal the user is in (the chosen "keep session, re-scope
|
||||
/// lists" behavior).
|
||||
public func currentTeamDidChange() {
|
||||
cancelComputerVisibilityMutations()
|
||||
secondaryAggregationScopeGeneration &+= 1
|
||||
let teamScopeGeneration = secondaryAggregationScopeGeneration
|
||||
// Presence: cancel + re-subscribe so the online dots reflect the new team
|
||||
@@ -1975,6 +2073,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
|
||||
var networkPathObservationStarted = false
|
||||
var networkPathObservationTask: Task<Void, Never>?
|
||||
var connectionMethodObservationTask: Task<Void, Never>?
|
||||
let connectionRecoveryOwner = MobileConnectionRecoveryOwner()
|
||||
var lastReconnectStackUserID: String?
|
||||
/// Whether the scene is in the active phase. Set by
|
||||
@@ -1999,6 +2098,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
case subscriptionStartFailed
|
||||
case transportWriteTimedOut
|
||||
case automaticBackoffExpired
|
||||
case connectionMethodChanged
|
||||
|
||||
var reschedulesSecondaryAggregation: Bool { self != .presencePush }
|
||||
|
||||
@@ -2016,6 +2116,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
case .subscriptionStartFailed: 7
|
||||
case .transportWriteTimedOut: 8
|
||||
case .automaticBackoffExpired: 9
|
||||
case .connectionMethodChanged: 10
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2030,6 +2131,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
case .subscriptionStartFailed: return "subscriptionStartFailed"
|
||||
case .transportWriteTimedOut: return "transportWriteTimedOut"
|
||||
case .automaticBackoffExpired: return "automaticBackoffExpired"
|
||||
case .connectionMethodChanged: return "connectionMethodChanged"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2450,7 +2552,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
if hasKnownStoredMac {
|
||||
setHasKnownPairedMac(true, generation: generation)
|
||||
}
|
||||
let irohReconnectIsBlocked = automaticIrohReconnectIsBlocked(accountID: scope.userID)
|
||||
let tailscaleOnly = connectionMethodStore?.method == .tailscale
|
||||
let irohReconnectIsBlocked = tailscaleOnly
|
||||
|| automaticIrohReconnectIsBlocked(accountID: scope.userID)
|
||||
// Capture one coherent post-request view of the registry and paired-Mac
|
||||
// store. The store read happens after the registry await, so an
|
||||
// authenticated Presence write that lands during the request wins. The
|
||||
@@ -2480,7 +2584,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
instanceTag: mac.instanceTag,
|
||||
scope: scope
|
||||
) else { break }
|
||||
let irohReconnectIsBlocked = automaticIrohReconnectIsBlocked(accountID: scope.userID)
|
||||
let irohReconnectIsBlocked = tailscaleOnly
|
||||
|| automaticIrohReconnectIsBlocked(accountID: scope.userID)
|
||||
let localRoutes = storedReconnectRoutes(mac).filter {
|
||||
!irohReconnectIsBlocked || $0.kind != .iroh
|
||||
}
|
||||
@@ -2514,7 +2619,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
}
|
||||
)
|
||||
}
|
||||
if connectionState != .connected,
|
||||
if connectionState != .connected, !tailscaleOnly,
|
||||
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
|
||||
switch await freshReconnectRoutesAfterLocalFailure(
|
||||
for: mac,
|
||||
@@ -2553,7 +2658,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
// saved candidate failed. This keeps a healthy saved Mac from sitting
|
||||
// behind an unrelated account-wide discovery request.
|
||||
var zeroTouchCandidates: [MobilePairedMac] = []
|
||||
if connectionState != .connected,
|
||||
if connectionState != .connected, !tailscaleOnly,
|
||||
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
|
||||
zeroTouchCandidates = await discoverZeroTouchIrohCandidates(
|
||||
scope: scope,
|
||||
@@ -2680,6 +2785,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
public internal(set) var hiddenComputers: [MobileHiddenComputer] = []
|
||||
/// True when the current account/team scope has at least one hidden computer.
|
||||
public internal(set) var hasHiddenComputers = false
|
||||
/// Computer identities whose visibility preference is being persisted.
|
||||
public internal(set) var computerVisibilityMutationIDs: Set<String> = []
|
||||
@ObservationIgnored var computerVisibilityMutationTasksByID: [String: Task<Void, Never>] = [:]
|
||||
@ObservationIgnored var computerVisibilityMutationOperationIDsByID: [String: UUID] = [:]
|
||||
|
||||
var pairedMacsForIdentityMatching: [MobilePairedMac] {
|
||||
storedPairedMacs.isEmpty ? pairedMacs : storedPairedMacs
|
||||
@@ -4461,11 +4570,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
)
|
||||
return .permanentFailure
|
||||
}
|
||||
return .received(response.workspaces.map { remote in
|
||||
let workspaces = response.workspaces.map { remote in
|
||||
var workspace = MobileWorkspacePreview(remote: remote)
|
||||
workspace.macDeviceID = macDeviceID
|
||||
return workspace
|
||||
})
|
||||
}
|
||||
let groups = Self.remoteWorkspaceGroups(
|
||||
from: response,
|
||||
acceptsEmptyGroupSnapshot: !response.workspaces.contains { workspace in
|
||||
workspace.groupID?.isEmpty == false
|
||||
}
|
||||
)
|
||||
return .received(SecondaryWorkspaceSnapshot(
|
||||
workspaces: workspaces,
|
||||
groups: groups
|
||||
))
|
||||
}
|
||||
|
||||
/// Ensure a live read-only subscription exists for every signed-in paired Mac
|
||||
@@ -6072,13 +6191,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
let wasSuperseded =
|
||||
subscription.workspaceRefreshGeneration
|
||||
!= requestGeneration
|
||||
let previews: [MobileWorkspacePreview]
|
||||
let snapshot: SecondaryWorkspaceSnapshot
|
||||
switch attempt {
|
||||
case let .received(value):
|
||||
// Publish every successful snapshot. Discarding a leading
|
||||
// success lets sustained event churn starve the aggregate
|
||||
// forever while requests keep completing.
|
||||
previews = value
|
||||
snapshot = value
|
||||
case .transientFailure:
|
||||
if wasSuperseded, completedPassCount < 2 {
|
||||
continue refreshLoop
|
||||
@@ -6097,7 +6216,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
macDeviceID: macID,
|
||||
instanceTag: subscription.storedInstanceTag,
|
||||
displayName: displayName ?? subscription.displayName,
|
||||
workspaces: previews,
|
||||
workspaces: snapshot.workspaces,
|
||||
groups: snapshot.groups
|
||||
?? self.workspacesByMac[ownerKey]?.groups
|
||||
?? [],
|
||||
status: .connected,
|
||||
actionCapabilities: subscription.actionCapabilities
|
||||
)
|
||||
@@ -6439,8 +6561,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
let statesByAggregateKey = Dictionary(
|
||||
uniqueKeysWithValues: workspacesByMac.map { ($0.key.pairingID, $0.value) }
|
||||
)
|
||||
let macIDsInDisplayOrder = workspaceAggregation.orderedMacIDs(
|
||||
statesByMac: statesByAggregateKey,
|
||||
foregroundMacDeviceID: foregroundKey
|
||||
)
|
||||
var derived = workspaceAggregation.derivedWorkspaces(
|
||||
statesByMac: statesByAggregateKey, foregroundMacDeviceID: foregroundKey, machineColorIndex: stableMacColorSlots)
|
||||
statesByMac: statesByAggregateKey,
|
||||
foregroundMacDeviceID: foregroundKey,
|
||||
machineColorIndex: stableMacColorSlots,
|
||||
macIDsInDisplayOrder: macIDsInDisplayOrder
|
||||
)
|
||||
// Stamp per-Mac user color/icon overrides from pairedMacs so every
|
||||
// workspace avatar matches its computer's customization (same place the
|
||||
// aggregation already assigned the automatic color index).
|
||||
@@ -6481,8 +6611,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
self.selectedWorkspaceID = remapped?.id ?? derived.first?.id
|
||||
}
|
||||
if selectedWorkspaceID != nil { syncSelectedTerminalForWorkspace() }
|
||||
workspaceGroups = workspaceAggregation.derivedGroups(
|
||||
statesByMac: statesByAggregateKey, foregroundMacDeviceID: foregroundKey)
|
||||
let derivedGroups = workspaceAggregation.derivedGroups(
|
||||
statesByMac: statesByAggregateKey,
|
||||
foregroundMacDeviceID: foregroundKey,
|
||||
macIDsInDisplayOrder: macIDsInDisplayOrder
|
||||
)
|
||||
workspaceGroups = groupCollapseStore.apply(to: derivedGroups)
|
||||
}
|
||||
|
||||
private func pruneChatSessionSnapshots(to visibleWorkspaces: [MobileWorkspacePreview]) {
|
||||
@@ -7289,10 +7423,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
/// send fails (no connection, or an older host that does not implement
|
||||
/// `terminal.paste` and answers `method_not_found`), the composed text is
|
||||
/// kept so the user can retry instead of silently losing the message.
|
||||
public func submitComposerInput() async {
|
||||
@discardableResult
|
||||
public func submitComposerInput() async -> Bool {
|
||||
guard let workspaceID = selectedWorkspace?.id,
|
||||
let terminalID = selectedTerminalID else { return }
|
||||
await submitComposerInput(workspaceID: workspaceID, terminalID: terminalID)
|
||||
let terminalID = selectedTerminalID else { return false }
|
||||
return await submitComposerInput(
|
||||
workspaceID: workspaceID,
|
||||
terminalID: terminalID
|
||||
)
|
||||
}
|
||||
|
||||
/// Submit the composer's text to an explicitly captured terminal. Used by
|
||||
@@ -7364,22 +7502,33 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
/// attachments staged AND keeps the text unsent, so the user can retry
|
||||
/// instead of silently losing photos (matching the text-keep-on-failure
|
||||
/// semantics of ``submitComposerInput()``).
|
||||
public func submitComposer() async {
|
||||
@discardableResult
|
||||
public func submitComposer() async -> Bool {
|
||||
// Reject a re-entrant submit (e.g. a double tap on Send): the button
|
||||
// stays enabled while the first image RPC awaits, and a second submit
|
||||
// would capture the same still-staged attachments and re-upload them.
|
||||
// Set/cleared on the main actor around the awaits, so no second call can
|
||||
// slip past. A failed send keeps the attachments staged (below), so the
|
||||
// user can retry once this flag clears.
|
||||
guard !isSubmittingComposer else { return }
|
||||
guard !isSubmittingComposer else { return false }
|
||||
isSubmittingComposer = true
|
||||
defer { isSubmittingComposer = false }
|
||||
guard let workspaceID = selectedWorkspace?.id,
|
||||
let submittedTerminalID = selectedTerminalID else {
|
||||
// No target: fall back to the text-only path, which is itself a no-op
|
||||
// without a selected terminal.
|
||||
await submitComposerInput()
|
||||
return
|
||||
return await submitComposerInput()
|
||||
}
|
||||
let sendOperationID = beginTerminalSend(
|
||||
forTerminalID: submittedTerminalID.rawValue
|
||||
)
|
||||
var sendSucceeded = false
|
||||
defer {
|
||||
finishTerminalSend(
|
||||
sendOperationID,
|
||||
forTerminalID: submittedTerminalID.rawValue,
|
||||
succeeded: sendSucceeded
|
||||
)
|
||||
}
|
||||
// Snapshot the text BEFORE any await (the image sends below). Threaded
|
||||
// through the text submit + the post-send reconcile so a terminal switch
|
||||
@@ -7419,7 +7568,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
signIn: submitSignInGeneration,
|
||||
connection: submitConnectionGeneration,
|
||||
client: submitClient
|
||||
) else { return }
|
||||
) else { return false }
|
||||
// Re-check the attachment is still staged for the captured terminal
|
||||
// before uploading it. The user can delete a not-yet-acked chip while
|
||||
// an earlier image's send is in flight; that removes it from
|
||||
@@ -7436,7 +7585,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
workspaceID: workspaceID,
|
||||
terminalID: submittedTerminalID
|
||||
)
|
||||
guard sent else { return }
|
||||
guard sent else { return false }
|
||||
removePendingAttachment(id: attachment.id, forTerminalID: submittedTerminalID.rawValue)
|
||||
}
|
||||
// Re-check the captured identity one last time before the text send. The
|
||||
@@ -7448,16 +7597,17 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
signIn: submitSignInGeneration,
|
||||
connection: submitConnectionGeneration,
|
||||
client: submitClient
|
||||
) else { return }
|
||||
) else { return false }
|
||||
// Submit the captured text to the captured terminal (a no-op when empty,
|
||||
// e.g. an images-only send). All images acked by here, so the text
|
||||
// follows. Passing the snapshot (not the live field) keeps this immune to
|
||||
// a switch/edit that happened during the image awaits above.
|
||||
await submitComposerInput(
|
||||
sendSucceeded = await submitComposerInput(
|
||||
workspaceID: workspaceID,
|
||||
terminalID: submittedTerminalID,
|
||||
capturedText: submittedText
|
||||
)
|
||||
return sendSucceeded
|
||||
}
|
||||
|
||||
/// Whether the session + connection identity captured at the start of a
|
||||
@@ -7539,11 +7689,23 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
#endif
|
||||
return
|
||||
}
|
||||
let sendStatusOperationID = prepareTerminalSendStatusForRawInput(
|
||||
text,
|
||||
terminalID: terminalID.rawValue
|
||||
)
|
||||
let enqueueResult = rawTerminalInputBuffer.enqueue(
|
||||
text,
|
||||
workspaceID: workspaceID,
|
||||
terminalID: terminalID
|
||||
terminalID: terminalID,
|
||||
sendStatusOperationID: sendStatusOperationID
|
||||
)
|
||||
if enqueueResult == .rejected {
|
||||
finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: terminalID.rawValue,
|
||||
succeeded: false
|
||||
)
|
||||
}
|
||||
handleSynchronousRawTerminalInputEnqueueResult(enqueueResult)
|
||||
}
|
||||
|
||||
@@ -7553,11 +7715,23 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
return
|
||||
}
|
||||
guard let workspaceID = workspaceID(forTerminalID: surfaceID) else { return }
|
||||
let sendStatusOperationID = prepareTerminalSendStatusForRawInput(
|
||||
text,
|
||||
terminalID: surfaceID
|
||||
)
|
||||
let enqueueResult = rawTerminalInputBuffer.enqueue(
|
||||
text,
|
||||
workspaceID: workspaceID,
|
||||
terminalID: MobileTerminalPreview.ID(rawValue: surfaceID)
|
||||
terminalID: MobileTerminalPreview.ID(rawValue: surfaceID),
|
||||
sendStatusOperationID: sendStatusOperationID
|
||||
)
|
||||
if enqueueResult == .rejected {
|
||||
finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: surfaceID,
|
||||
succeeded: false
|
||||
)
|
||||
}
|
||||
handleSynchronousRawTerminalInputEnqueueResult(enqueueResult)
|
||||
}
|
||||
|
||||
@@ -7576,6 +7750,28 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareTerminalSendStatusForRawInput(
|
||||
_ text: String,
|
||||
terminalID: String
|
||||
) -> UUID? {
|
||||
if Self.containsTerminalSubmission(text) {
|
||||
let operationID = beginTerminalSend(
|
||||
forTerminalID: terminalID
|
||||
)
|
||||
rawTerminalSendOperationIDsByTerminalID[terminalID] = operationID
|
||||
return operationID
|
||||
} else {
|
||||
clearSettledTerminalSendStatus(forTerminalID: terminalID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func containsTerminalSubmission(_ text: String) -> Bool {
|
||||
text.unicodeScalars.contains { scalar in
|
||||
scalar.value == 0x0D || scalar.value == 0x0A
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit raw text to the currently selected terminal when one is available.
|
||||
public func submitTerminalRawInput(_ text: String) async {
|
||||
guard !text.isEmpty else { return }
|
||||
@@ -7666,7 +7862,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
chunk.text,
|
||||
workspaceID: chunk.workspaceID,
|
||||
terminalID: chunk.terminalID,
|
||||
latencyBatchNumber: latencyBatchNumberForSend
|
||||
latencyBatchNumber: latencyBatchNumberForSend,
|
||||
sendStatusOperationID: chunk.sendStatusOperationID
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7681,6 +7878,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
func clearPendingTerminalInputForFocusChange() {
|
||||
rawTerminalInputBuffer.clear()
|
||||
terminalInputRPCPipeline.clear()
|
||||
let pendingRawSends = rawTerminalSendOperationIDsByTerminalID
|
||||
for (terminalID, operationID) in pendingRawSends {
|
||||
finishRawTerminalSend(
|
||||
operationID,
|
||||
forTerminalID: terminalID,
|
||||
succeeded: false
|
||||
)
|
||||
}
|
||||
resumeRawTerminalInputDrainWaiters()
|
||||
}
|
||||
|
||||
@@ -8272,6 +8477,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
foregroundMacDeviceID = resolvedForegroundMacID
|
||||
}
|
||||
supportedHostCapabilities = authenticatedCapabilities
|
||||
phonePushMacStatus = status.phonePush
|
||||
// Publish transport selection with the authenticated
|
||||
// capability snapshot before exposing `.connected`.
|
||||
// The listener reuses this same status below, but starts in
|
||||
@@ -8423,10 +8629,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
let irohRoutes = supportedRoutes.filter { route in
|
||||
route.kind == .iroh
|
||||
}
|
||||
// The user's explicit Tailscale method relaxes only the Iroh pin's
|
||||
// ORDER: authorized Tailscale routes dial first and Iroh remains the
|
||||
// fallback. Routes without a grant or a user-entered code stay
|
||||
// undialable regardless of the preference.
|
||||
// The explicit Tailscale method is strict: only authorized Tailscale
|
||||
// destinations may be dialed, and an unavailable route leaves the app
|
||||
// disconnected instead of silently switching to Iroh.
|
||||
if connectionMethodStore?.method == .tailscale {
|
||||
let authorizedTailscale = supportedRoutes.filter { route in
|
||||
Self.legacyTailscaleAuthorizationEvidence(
|
||||
@@ -8439,12 +8644,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
authorizations: userTailscalePairingAuthorizations
|
||||
) != nil
|
||||
}
|
||||
if !authorizedTailscale.isEmpty {
|
||||
let rest = supportedRoutes.filter { route in
|
||||
route.kind != .iroh && route.kind != .tailscale
|
||||
}
|
||||
return authorizedTailscale + irohRoutes + rest
|
||||
}
|
||||
return authorizedTailscale
|
||||
}
|
||||
return irohRoutes.isEmpty ? supportedRoutes : irohRoutes
|
||||
}
|
||||
@@ -9002,6 +9202,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
terminalOutputTransport = .rawBytes
|
||||
deactivateAllTerminalLanes()
|
||||
supportedHostCapabilities = []
|
||||
phonePushMacStatus = nil
|
||||
clearMacUpdateHint()
|
||||
terminalSubscriptionRefreshTask?.cancel()
|
||||
terminalSubscriptionRefreshTask = nil
|
||||
@@ -9633,13 +9834,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
_ text: String,
|
||||
workspaceID: MobileWorkspacePreview.ID,
|
||||
terminalID: MobileTerminalPreview.ID,
|
||||
latencyBatchNumber: UInt64? = nil
|
||||
latencyBatchNumber: UInt64? = nil,
|
||||
sendStatusOperationID: UUID? = nil
|
||||
) async {
|
||||
guard let client = remoteClient else {
|
||||
#if DEBUG
|
||||
mobileShellLog.info("skip remote terminal input remoteClient=0")
|
||||
#endif
|
||||
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: false)
|
||||
finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: terminalID.rawValue,
|
||||
succeeded: false
|
||||
)
|
||||
return
|
||||
}
|
||||
let generation = connectionGeneration
|
||||
@@ -9674,6 +9881,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
latencyBatchNumber,
|
||||
succeeded: false
|
||||
)
|
||||
finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: terminalID.rawValue,
|
||||
succeeded: false
|
||||
)
|
||||
return
|
||||
}
|
||||
if terminalInputRPCPipeline.hasAmbiguousFailure(
|
||||
@@ -9700,12 +9912,22 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
switch laneResult {
|
||||
case .sent:
|
||||
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: true)
|
||||
finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: terminalID.rawValue,
|
||||
succeeded: true
|
||||
)
|
||||
return
|
||||
case .failed:
|
||||
mobileShellLog.error(
|
||||
"independent terminal input failed surface=\(terminalID.rawValue, privacy: .public)"
|
||||
)
|
||||
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: false)
|
||||
finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: terminalID.rawValue,
|
||||
succeeded: false
|
||||
)
|
||||
return
|
||||
case .unavailable:
|
||||
break
|
||||
@@ -9738,6 +9960,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
latencyBatchNumber,
|
||||
succeeded: true
|
||||
)
|
||||
self?.finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: terminalID.rawValue,
|
||||
succeeded: true
|
||||
)
|
||||
guard let self, let client else { return }
|
||||
guard self.isCurrentRemoteOperation(
|
||||
client: client,
|
||||
@@ -9752,6 +9979,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
latencyBatchNumber,
|
||||
succeeded: false
|
||||
)
|
||||
self?.finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: terminalID.rawValue,
|
||||
succeeded: false
|
||||
)
|
||||
guard let self, let client else { return }
|
||||
self.handleTerminalInputFailure(
|
||||
error,
|
||||
@@ -9768,6 +10000,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
// operational failure, regardless of whether the caller also
|
||||
// rotated connectionGeneration.
|
||||
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: false)
|
||||
finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: terminalID.rawValue,
|
||||
succeeded: false
|
||||
)
|
||||
if error is CancellationError { return }
|
||||
handleTerminalInputFailure(
|
||||
error,
|
||||
@@ -9789,12 +10026,27 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
)
|
||||
guard isCurrentRemoteOperation(client: client, generation: generation) else {
|
||||
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: false)
|
||||
finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: terminalID.rawValue,
|
||||
succeeded: false
|
||||
)
|
||||
return
|
||||
}
|
||||
handleTerminalInputResponse(responseData, surfaceID: terminalID.rawValue)
|
||||
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: true)
|
||||
finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: terminalID.rawValue,
|
||||
succeeded: true
|
||||
)
|
||||
} catch {
|
||||
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: false)
|
||||
finishRawTerminalSend(
|
||||
sendStatusOperationID,
|
||||
forTerminalID: terminalID.rawValue,
|
||||
succeeded: false
|
||||
)
|
||||
handleTerminalInputFailure(
|
||||
error,
|
||||
client: client,
|
||||
@@ -10221,6 +10473,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
return .rawBytes
|
||||
}
|
||||
supportedHostCapabilities = Set(payload.capabilities)
|
||||
phonePushMacStatus = payload.phonePush
|
||||
restartActiveMobileBrowserStreams()
|
||||
refreshVisibleMobileBrowserPanels()
|
||||
prepareTerminalThemeRevisionAuthority(
|
||||
@@ -10441,6 +10694,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
macDeviceID: macDeviceID
|
||||
)
|
||||
)
|
||||
} else if event.topic == "phone_push.status.changed" {
|
||||
await self.refreshPhonePushStatus(
|
||||
client: client,
|
||||
generation: self.connectionGeneration
|
||||
)
|
||||
} else if event.topic == "browser.frame" {
|
||||
self.handleMobileBrowserFrameEvent(event)
|
||||
} else if event.topic == "browser.state" {
|
||||
@@ -10467,6 +10725,152 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPhonePushStatus(
|
||||
client: MobileCoreRPCClient,
|
||||
generation: UUID
|
||||
) async {
|
||||
let exchange: (response: Data, hostStatusResponse: Data)
|
||||
do {
|
||||
exchange = try await client.sendRequestAndAuthenticatedHostStatus(
|
||||
MobileCoreRPCClient.requestData(
|
||||
method: "phone_push.status.get",
|
||||
params: [:]
|
||||
),
|
||||
timeoutNanoseconds: Self.terminalOutputCapabilityTimeoutNanoseconds,
|
||||
hostStatusTimeoutNanoseconds: {
|
||||
Self.terminalOutputCapabilityTimeoutNanoseconds
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
guard isCurrentRemoteConnection(
|
||||
client: client,
|
||||
generation: generation
|
||||
) else { return }
|
||||
// This status probe is authenticated: a definitive authorization
|
||||
// failure here means the session itself is revoked or mismatched,
|
||||
// not merely that push readiness is unknown. Route it to the
|
||||
// shared reauth disconnect instead of staying connected with a
|
||||
// silently cleared readiness.
|
||||
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
|
||||
return
|
||||
}
|
||||
phonePushMacStatus = nil
|
||||
return
|
||||
}
|
||||
guard isCurrentRemoteConnection(
|
||||
client: client,
|
||||
generation: generation
|
||||
) else { return }
|
||||
guard let status = try? MobileHostStatusResponse.decode(
|
||||
exchange.hostStatusResponse
|
||||
) else {
|
||||
phonePushMacStatus = nil
|
||||
return
|
||||
}
|
||||
phonePushMacStatus = status.phonePush
|
||||
}
|
||||
|
||||
/// Applies one or more Mac-owned phone-forwarding settings over the current
|
||||
/// authenticated attach channel, then reads the authoritative status back.
|
||||
///
|
||||
/// The mutation fails closed when the Mac is unavailable, predates the
|
||||
/// capability, rejects the caller, or the connection changes mid-flight.
|
||||
/// Local UI never writes a speculative Mac value into this store.
|
||||
@discardableResult
|
||||
public func updatePhonePushSettings(
|
||||
forwardingEnabled: Bool? = nil,
|
||||
mode: MobileHostPhonePushStatus.Mode? = nil,
|
||||
hideContent: Bool? = nil
|
||||
) async -> Bool {
|
||||
guard supportsPhonePushSettings,
|
||||
let client = remoteClient,
|
||||
forwardingEnabled != nil || mode != nil || hideContent != nil
|
||||
else { return false }
|
||||
|
||||
var params: [String: Any] = [:]
|
||||
if let forwardingEnabled {
|
||||
params["forwarding_enabled"] = forwardingEnabled
|
||||
}
|
||||
if let mode {
|
||||
params["mode"] = mode.rawValue
|
||||
}
|
||||
if let hideContent {
|
||||
params["hide_content"] = hideContent
|
||||
}
|
||||
|
||||
let generation = connectionGeneration
|
||||
do {
|
||||
let exchange = try await client.sendRequestAndAuthenticatedHostStatus(
|
||||
MobileCoreRPCClient.requestData(
|
||||
method: "phone_push.settings.update",
|
||||
params: params
|
||||
),
|
||||
hostStatusTimeoutNanoseconds: {
|
||||
Self.terminalOutputCapabilityTimeoutNanoseconds
|
||||
}
|
||||
)
|
||||
let status = try MobileHostStatusResponse.decode(
|
||||
exchange.hostStatusResponse
|
||||
)
|
||||
guard isCurrentRemoteConnection(
|
||||
client: client,
|
||||
generation: generation
|
||||
) else { return false }
|
||||
phonePushMacStatus = status.phonePush
|
||||
return true
|
||||
} catch {
|
||||
guard generation == connectionGeneration else { return false }
|
||||
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
|
||||
return false
|
||||
}
|
||||
handleMacAvailabilityFailureIfCurrent(
|
||||
after: error,
|
||||
expectedClient: client,
|
||||
expectedGeneration: generation
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests a test alert through the Mac's real durable queue and returns
|
||||
/// only the furthest stage the synchronous RPC can prove.
|
||||
public func sendPhonePushTest() async -> MobilePhonePushTestStage {
|
||||
guard supportsPhonePushTest, let client = remoteClient else {
|
||||
return .unavailable
|
||||
}
|
||||
let generation = connectionGeneration
|
||||
do {
|
||||
let data = try await client.sendRequest(
|
||||
MobileCoreRPCClient.requestData(
|
||||
method: "phone_push.test",
|
||||
params: [:]
|
||||
)
|
||||
)
|
||||
guard isCurrentRemoteConnection(
|
||||
client: client,
|
||||
generation: generation
|
||||
), let object = try JSONSerialization.jsonObject(with: data)
|
||||
as? [String: Any],
|
||||
let rawStage = object["stage"] as? String,
|
||||
let stage = MobilePhonePushTestStage(rawValue: rawStage)
|
||||
else { return .unavailable }
|
||||
return stage
|
||||
} catch {
|
||||
guard generation == connectionGeneration else {
|
||||
return .unavailable
|
||||
}
|
||||
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
|
||||
return .unavailable
|
||||
}
|
||||
handleMacAvailabilityFailureIfCurrent(
|
||||
after: error,
|
||||
expectedClient: client,
|
||||
expectedGeneration: generation
|
||||
)
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the `mobile.events.subscribe` (reason `start`) handshake for one
|
||||
/// listener generation, concurrently with that generation's consumer loop.
|
||||
///
|
||||
@@ -12041,13 +12445,22 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
groupsAreAuthoritative: Bool
|
||||
) -> [MobileWorkspaceGroupPreview]? {
|
||||
guard !mergeExistingWorkspaces, groupsAreAuthoritative else { return nil }
|
||||
return Self.remoteWorkspaceGroups(
|
||||
from: response,
|
||||
acceptsEmptyGroupSnapshot: canAcceptEmptyGroupSnapshot(from: response)
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve one response's group-field completeness into update-or-preserve
|
||||
/// semantics shared by foreground and secondary workspace snapshots.
|
||||
private static func remoteWorkspaceGroups(
|
||||
from response: MobileSyncWorkspaceListResponse,
|
||||
acceptsEmptyGroupSnapshot: Bool
|
||||
) -> [MobileWorkspaceGroupPreview]? {
|
||||
guard response.groupsFieldWasPresent else { return nil }
|
||||
let groups = response.groups.map { MobileWorkspaceGroupPreview(remote: $0) }
|
||||
guard groups.isEmpty else {
|
||||
return groupCollapseStore.apply(to: groups)
|
||||
}
|
||||
guard canAcceptEmptyGroupSnapshot(from: response) else { return nil }
|
||||
return []
|
||||
guard groups.isEmpty else { return groups }
|
||||
return acceptsEmptyGroupSnapshot ? [] : nil
|
||||
}
|
||||
|
||||
private func canAcceptEmptyGroupSnapshot(
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
import CmuxMobileShellModel
|
||||
|
||||
enum SecondaryWorkspaceFetchAttempt {
|
||||
case received([MobileWorkspacePreview])
|
||||
case received(SecondaryWorkspaceSnapshot)
|
||||
case transientFailure
|
||||
case permanentFailure
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import CmuxMobileShellModel
|
||||
|
||||
/// One successfully decoded secondary Mac workspace snapshot.
|
||||
///
|
||||
/// A `nil` group list means the response did not contain authoritative group
|
||||
/// metadata, so the caller preserves that Mac's prior group snapshot. An empty
|
||||
/// list is authoritative and clears its prior groups.
|
||||
struct SecondaryWorkspaceSnapshot: Equatable, Sendable {
|
||||
let workspaces: [MobileWorkspacePreview]
|
||||
let groups: [MobileWorkspaceGroupPreview]?
|
||||
}
|
||||
+3
-1
@@ -28,6 +28,7 @@ func makeRoutingConnectedStore(
|
||||
pairedMacStore: (any MobilePairedMacStoring)? = nil,
|
||||
routeKind: CmxAttachTransportKind = .debugLoopback,
|
||||
terminalLaneProvider: MobileTerminalLaneProvider? = nil,
|
||||
draftStore: (any TerminalDraftStoring)? = nil,
|
||||
rpcRequestTimeoutNanoseconds: UInt64 = 30 * 1_000_000_000
|
||||
) async throws -> MobileShellComposite {
|
||||
let runtime = RoutingTestRuntime(
|
||||
@@ -53,7 +54,8 @@ func makeRoutingConnectedStore(
|
||||
],
|
||||
pairedMacStore: pairedMacStore,
|
||||
identityProvider: StaticIdentityProvider(userID: "routing-user"),
|
||||
pendingDismissQueue: pendingDismissQueue
|
||||
pendingDismissQueue: pendingDismissQueue,
|
||||
draftStore: draftStore
|
||||
)
|
||||
// 127.0.0.1 is a Stack-auth-trusted route, so authorized requests carry the
|
||||
// Stack token and do not throw insecureManualRoute before reaching the
|
||||
|
||||
+57
@@ -12,6 +12,63 @@ import Testing
|
||||
@Suite struct ComposerSubmitRoutingTests {
|
||||
private static func bytes(_ s: String) -> Data { Data(s.utf8) }
|
||||
|
||||
@Test func exposesComposerSendProgressAndSettlement() async throws {
|
||||
let router = RoutingHostRouter()
|
||||
let store = try await makeRoutingConnectedStore(router: router)
|
||||
let terminalID = RoutingHostRouter.terminalA
|
||||
store.selectTerminal(MobileTerminalPreview.ID(rawValue: terminalID))
|
||||
store.addPendingAttachment(Self.bytes("held"), format: "png", forTerminalID: terminalID)
|
||||
store.terminalInputText = "hello"
|
||||
|
||||
await router.setHoldFirstPasteImage(true)
|
||||
let submit = Task { await store.submitComposer() }
|
||||
await router.awaitFirstPasteImageReached()
|
||||
|
||||
#expect(store.terminalSendStatus(forTerminalID: terminalID) == .sending)
|
||||
|
||||
await router.releaseFirstPasteImage()
|
||||
await submit.value
|
||||
|
||||
#expect(store.terminalSendStatus(forTerminalID: terminalID) == .sent)
|
||||
}
|
||||
|
||||
@Test func exposesComposerSendFailure() async throws {
|
||||
let router = RoutingHostRouter()
|
||||
let store = try await makeRoutingConnectedStore(router: router)
|
||||
let terminalID = RoutingHostRouter.terminalA
|
||||
store.selectTerminal(MobileTerminalPreview.ID(rawValue: terminalID))
|
||||
store.addPendingAttachment(Self.bytes("rejected"), format: "png", forTerminalID: terminalID)
|
||||
|
||||
await router.setRejectPasteImage(true)
|
||||
await store.submitComposer()
|
||||
|
||||
#expect(store.terminalSendStatus(forTerminalID: terminalID) == .failed)
|
||||
}
|
||||
|
||||
@Test func restoredFailedDraftKeepsFailureSettlement() async throws {
|
||||
let router = RoutingHostRouter()
|
||||
let drafts = InMemoryTerminalDraftStore()
|
||||
let store = try await makeRoutingConnectedStore(router: router, draftStore: drafts)
|
||||
let termA = RoutingHostRouter.terminalA
|
||||
let termB = RoutingHostRouter.terminalB
|
||||
store.selectTerminal(MobileTerminalPreview.ID(rawValue: termA))
|
||||
await store.drainDraftOperationsForTesting()
|
||||
store.terminalInputText = "retry me"
|
||||
store.addPendingAttachment(Self.bytes("rejected"), format: "png", forTerminalID: termA)
|
||||
|
||||
await router.setRejectPasteImage(true)
|
||||
await store.submitComposer()
|
||||
#expect(store.terminalSendStatus(forTerminalID: termA) == .failed)
|
||||
|
||||
store.selectTerminal(MobileTerminalPreview.ID(rawValue: termB))
|
||||
await store.drainDraftOperationsForTesting()
|
||||
store.selectTerminal(MobileTerminalPreview.ID(rawValue: termA))
|
||||
await store.drainDraftOperationsForTesting()
|
||||
|
||||
#expect(store.terminalInputText == "retry me")
|
||||
#expect(store.terminalSendStatus(forTerminalID: termA) == .failed)
|
||||
}
|
||||
|
||||
/// Images and text both go to the selected terminal when nothing switches.
|
||||
@Test func sendsAttachmentsAndTextToSelectedTerminal() async throws {
|
||||
let router = RoutingHostRouter()
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
@testable import CmuxMobileShell
|
||||
|
||||
actor DelayedFirstHidePairedMacHiddenStore: PairedMacHiddenStoring {
|
||||
private var idsByScope: [String: Set<String>] = [:]
|
||||
private var didDelayHideSave = false
|
||||
private var hideSaveStarted = false
|
||||
private var hideSaveStartWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var hideSaveRelease: CheckedContinuation<Void, Never>?
|
||||
private var didSaveEmpty = false
|
||||
private var emptySaveWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func load(scope: String) async -> Set<String> {
|
||||
idsByScope[scope] ?? []
|
||||
}
|
||||
|
||||
func save(_ ids: Set<String>, scope: String) async {
|
||||
if !didDelayHideSave, !ids.isEmpty {
|
||||
didDelayHideSave = true
|
||||
hideSaveStarted = true
|
||||
let waiters = hideSaveStartWaiters
|
||||
hideSaveStartWaiters = []
|
||||
for waiter in waiters {
|
||||
waiter.resume()
|
||||
}
|
||||
await withCheckedContinuation { continuation in
|
||||
hideSaveRelease = continuation
|
||||
}
|
||||
}
|
||||
if ids.isEmpty {
|
||||
idsByScope.removeValue(forKey: scope)
|
||||
didSaveEmpty = true
|
||||
let waiters = emptySaveWaiters
|
||||
emptySaveWaiters = []
|
||||
for waiter in waiters {
|
||||
waiter.resume()
|
||||
}
|
||||
} else {
|
||||
idsByScope[scope] = ids
|
||||
}
|
||||
}
|
||||
|
||||
func removeAll() async {
|
||||
idsByScope.removeAll()
|
||||
}
|
||||
|
||||
func waitForDelayedHideSave() async {
|
||||
guard !hideSaveStarted else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
hideSaveStartWaiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseDelayedHideSave() {
|
||||
hideSaveRelease?.resume()
|
||||
hideSaveRelease = nil
|
||||
}
|
||||
|
||||
func waitForEmptySave() async {
|
||||
guard !didSaveEmpty else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
emptySaveWaiters.append(continuation)
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -69,6 +69,157 @@ import Testing
|
||||
#expect(store.workspaceListConnectionStatus == .connected)
|
||||
}
|
||||
|
||||
@Test func laterUnhideWinsWhenEarlierHidePersistenceFinishesLast() async throws {
|
||||
let hiddenStore = DelayedFirstHidePairedMacHiddenStore()
|
||||
let pairedStore = DelayedTeamPairedMacStore(
|
||||
recordsByTeam: [
|
||||
"team-a": [
|
||||
try Self.pairedMac(
|
||||
id: "mac-a",
|
||||
displayName: "Desk Mac",
|
||||
host: "100.82.214.112",
|
||||
lastSeenAt: Date(timeIntervalSince1970: 10),
|
||||
isActive: false
|
||||
),
|
||||
],
|
||||
],
|
||||
blockedTeams: []
|
||||
)
|
||||
let store = MobileShellComposite(
|
||||
isSignedIn: true,
|
||||
pairedMacStore: pairedStore,
|
||||
identityProvider: StaticIdentityProvider(userID: "user-1"),
|
||||
teamIDProvider: { "team-a" },
|
||||
hiddenMacStore: hiddenStore
|
||||
)
|
||||
await store.loadPairedMacs()
|
||||
let computer = try #require(store.pairedMacs.first)
|
||||
let scope = try #require(await store.currentScopeSnapshot())
|
||||
let scopeKey = store.pairedMacScopeKey(scope)
|
||||
|
||||
let hideTask = Task { @MainActor in
|
||||
await store.hideStoredPairedMacEntries(
|
||||
representativeID: computer.id,
|
||||
aliasIDs: [computer.id]
|
||||
)
|
||||
}
|
||||
await hiddenStore.waitForDelayedHideSave()
|
||||
|
||||
store.requestUnhideMacDeviceID(
|
||||
computer.macDeviceID,
|
||||
instanceTag: computer.instanceTag
|
||||
)
|
||||
await hiddenStore.releaseDelayedHideSave()
|
||||
await hiddenStore.waitForEmptySave()
|
||||
await hideTask.value
|
||||
|
||||
#expect(
|
||||
await hiddenStore.load(scope: scopeKey).isEmpty,
|
||||
"The later show request must remain durable after relaunch."
|
||||
)
|
||||
}
|
||||
|
||||
@Test func signOutCancelsInFlightComputerVisibilityMutation() async throws {
|
||||
let hiddenStore = DelayedFirstHidePairedMacHiddenStore()
|
||||
let pairedStore = DelayedTeamPairedMacStore(
|
||||
recordsByTeam: [
|
||||
"team-a": [
|
||||
try Self.pairedMac(
|
||||
id: "mac-a",
|
||||
displayName: "Desk Mac",
|
||||
host: "100.82.214.112",
|
||||
lastSeenAt: Date(timeIntervalSince1970: 10),
|
||||
isActive: false
|
||||
),
|
||||
],
|
||||
],
|
||||
blockedTeams: []
|
||||
)
|
||||
let store = MobileShellComposite(
|
||||
isSignedIn: true,
|
||||
pairedMacStore: pairedStore,
|
||||
identityProvider: StaticIdentityProvider(userID: "user-1"),
|
||||
teamIDProvider: { "team-a" },
|
||||
hiddenMacStore: hiddenStore
|
||||
)
|
||||
await store.loadPairedMacs()
|
||||
let computer = try #require(store.pairedMacs.first)
|
||||
let scope = try #require(await store.currentScopeSnapshot())
|
||||
let scopeKey = store.pairedMacScopeKey(scope)
|
||||
|
||||
store.requestHideStoredPairedMacEntries(
|
||||
representativeID: computer.id,
|
||||
aliasIDs: [computer.id]
|
||||
)
|
||||
await hiddenStore.waitForDelayedHideSave()
|
||||
let task = try #require(store.computerVisibilityMutationTasksByID[computer.id])
|
||||
|
||||
store.signOut()
|
||||
|
||||
#expect(task.isCancelled)
|
||||
#expect(store.computerVisibilityMutationIDs.isEmpty)
|
||||
#expect(store.computerVisibilityMutationTasksByID[computer.id] != nil)
|
||||
#expect(store.computerVisibilityMutationOperationIDsByID[computer.id] != nil)
|
||||
await hiddenStore.releaseDelayedHideSave()
|
||||
await task.value
|
||||
#expect(store.computerVisibilityMutationTasksByID.isEmpty)
|
||||
#expect(store.computerVisibilityMutationOperationIDsByID.isEmpty)
|
||||
#expect(store.hiddenComputers.isEmpty)
|
||||
#expect(await hiddenStore.load(scope: scopeKey).isEmpty)
|
||||
}
|
||||
|
||||
@Test func teamChangeCancelsInFlightComputerVisibilityMutation() async throws {
|
||||
let hiddenStore = DelayedFirstHidePairedMacHiddenStore()
|
||||
let pairedStore = DelayedTeamPairedMacStore(
|
||||
recordsByTeam: [
|
||||
"team-a": [
|
||||
try Self.pairedMac(
|
||||
id: "mac-a",
|
||||
displayName: "Desk Mac",
|
||||
host: "100.82.214.112",
|
||||
lastSeenAt: Date(timeIntervalSince1970: 10),
|
||||
isActive: false
|
||||
),
|
||||
],
|
||||
],
|
||||
blockedTeams: []
|
||||
)
|
||||
let selectedTeam = MutableTeamID("team-a")
|
||||
let store = MobileShellComposite(
|
||||
isSignedIn: true,
|
||||
connectionState: .connected,
|
||||
pairedMacStore: pairedStore,
|
||||
identityProvider: StaticIdentityProvider(userID: "user-1"),
|
||||
teamIDProvider: { await selectedTeam.value },
|
||||
hiddenMacStore: hiddenStore
|
||||
)
|
||||
await store.loadPairedMacs()
|
||||
let computer = try #require(store.pairedMacs.first)
|
||||
let scope = try #require(await store.currentScopeSnapshot())
|
||||
let scopeKey = store.pairedMacScopeKey(scope)
|
||||
|
||||
store.requestHideStoredPairedMacEntries(
|
||||
representativeID: computer.id,
|
||||
aliasIDs: [computer.id]
|
||||
)
|
||||
await hiddenStore.waitForDelayedHideSave()
|
||||
let task = try #require(store.computerVisibilityMutationTasksByID[computer.id])
|
||||
|
||||
await selectedTeam.set("team-b")
|
||||
store.currentTeamDidChange()
|
||||
|
||||
#expect(task.isCancelled)
|
||||
#expect(store.computerVisibilityMutationIDs.isEmpty)
|
||||
#expect(store.computerVisibilityMutationTasksByID[computer.id] != nil)
|
||||
#expect(store.computerVisibilityMutationOperationIDsByID[computer.id] != nil)
|
||||
await hiddenStore.releaseDelayedHideSave()
|
||||
await task.value
|
||||
#expect(store.computerVisibilityMutationTasksByID.isEmpty)
|
||||
#expect(store.computerVisibilityMutationOperationIDsByID.isEmpty)
|
||||
#expect(store.hiddenComputers.isEmpty)
|
||||
#expect(await hiddenStore.load(scope: scopeKey).isEmpty)
|
||||
}
|
||||
|
||||
@Test func rawDeviceIDMarkerMatchingExistingRowSurvivesMigration() async throws {
|
||||
let defaultsSuiteName = "hidden-marker-hint-migration-\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: defaultsSuiteName))
|
||||
|
||||
+11
-2
@@ -174,13 +174,22 @@ import Testing
|
||||
)
|
||||
let store = connected.store
|
||||
let workspaceID = try #require(store.workspaces.first?.id)
|
||||
let scopedGroupID = MobileWorkspaceGroupPreview.ID(
|
||||
rawValue: "test-mac\u{1F}group-a"
|
||||
)
|
||||
store.workspaceGroups = [
|
||||
MobileWorkspaceGroupPreview(id: "group-a", name: "Before", anchorWorkspaceID: workspaceID),
|
||||
MobileWorkspaceGroupPreview(
|
||||
id: scopedGroupID,
|
||||
remoteGroupID: "group-a",
|
||||
macDeviceID: "test-mac",
|
||||
name: "Before",
|
||||
anchorWorkspaceID: workspaceID
|
||||
),
|
||||
]
|
||||
|
||||
connected.clock.advance(by: 2)
|
||||
|
||||
guard case .success = await store.renameWorkspaceGroup(id: "group-a", title: " yu ") else {
|
||||
guard case .success = await store.renameWorkspaceGroup(id: scopedGroupID, title: " yu ") else {
|
||||
return #expect(Bool(false), "same-account group rename should outlive the route ticket")
|
||||
}
|
||||
let requests = await connected.router.groupActions()
|
||||
|
||||
+86
-13
@@ -1,6 +1,7 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobilePairedMac
|
||||
import CmuxMobileRPC
|
||||
import CmuxMobileShellModel
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxMobileShell
|
||||
@@ -541,51 +542,123 @@ import Testing
|
||||
return store
|
||||
}
|
||||
|
||||
@Test func tailscalePreferencePromotesGrantedRouteAheadOfIrohPin() throws {
|
||||
@Test func tailscaleMethodUsesOnlyGrantedTailscaleRoute() throws {
|
||||
let tailscale = try tailscale()
|
||||
let routes = MobileShellComposite.storedReconnectRoutes(
|
||||
[tailscale, try iroh()],
|
||||
supportedKinds: [.iroh, .tailscale],
|
||||
preferNonLoopback: true,
|
||||
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
|
||||
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
|
||||
macDeviceID: "test-mac",
|
||||
grantRoutes: [tailscale]
|
||||
)
|
||||
)
|
||||
|
||||
// The granted Tailscale destination dials first; Iroh stays as the
|
||||
// fallback instead of being exclusive.
|
||||
#expect(routes.map(\.kind) == [.tailscale, .iroh])
|
||||
#expect(routes.map(\.kind) == [.tailscale])
|
||||
}
|
||||
|
||||
@Test func tailscalePreferenceWithoutGrantKeepsIrohExclusivePin() throws {
|
||||
// A preference flip alone grants nothing: without a device-local grant
|
||||
// the Iroh pin still drops every raw host/port fallback.
|
||||
@Test func tailscaleMethodWithoutGrantRejectsEveryRoute() throws {
|
||||
let routes = MobileShellComposite.storedReconnectRoutes(
|
||||
[try tailscale(), try iroh()],
|
||||
supportedKinds: [.iroh, .tailscale],
|
||||
preferNonLoopback: true,
|
||||
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
|
||||
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
|
||||
macDeviceID: "test-mac",
|
||||
grantRoutes: []
|
||||
)
|
||||
)
|
||||
|
||||
#expect(routes.map(\.kind) == [.iroh])
|
||||
#expect(routes.isEmpty)
|
||||
}
|
||||
|
||||
@Test func tailscalePreferenceIgnoresGrantForDifferentDestination() throws {
|
||||
@Test func tailscaleMethodRejectsMismatchedGrantWithoutIrohFallback() throws {
|
||||
let otherDestination = try tailscale(50907)
|
||||
let routes = MobileShellComposite.storedReconnectRoutes(
|
||||
[try tailscale(), try iroh()],
|
||||
supportedKinds: [.iroh, .tailscale],
|
||||
preferNonLoopback: true,
|
||||
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
|
||||
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
|
||||
macDeviceID: "test-mac",
|
||||
grantRoutes: [otherDestination]
|
||||
)
|
||||
)
|
||||
|
||||
#expect(routes.map(\.kind) == [.iroh])
|
||||
#expect(routes.isEmpty)
|
||||
}
|
||||
|
||||
@Test func changingToUnavailableTailscaleDropsLiveIrohWithoutFallback() async throws {
|
||||
let clock = TestClock()
|
||||
let router = LivenessHostRouter()
|
||||
// The factory boxes the live Iroh transport it hands out, so the test
|
||||
// can observe physical teardown, not just the store's logical route.
|
||||
let liveTransportBox = TransportBox()
|
||||
let factory = KindRecordingTransportFactory(
|
||||
router: router,
|
||||
box: liveTransportBox,
|
||||
failingKinds: [.tailscale]
|
||||
)
|
||||
let tailscale = try tailscale()
|
||||
let iroh = try iroh()
|
||||
let (pairedStore, directory) = try makePairedMacStore()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try await pairedStore.upsert(
|
||||
macDeviceID: "test-mac",
|
||||
displayName: "Test Mac",
|
||||
routes: [tailscale, iroh],
|
||||
instanceTag: "default",
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
teamID: nil,
|
||||
now: clock.now
|
||||
)
|
||||
try await pairedStore.authorizeUserTailscaleRoutes(
|
||||
macDeviceID: "test-mac",
|
||||
instanceTag: "default",
|
||||
stackUserID: "user-1",
|
||||
teamID: nil,
|
||||
routes: [tailscale]
|
||||
)
|
||||
let methodDefaults = UserDefaults(
|
||||
suiteName: "connection-method-live-switch-\(UUID().uuidString)"
|
||||
)!
|
||||
let methodStore = MobileConnectionMethodStore(defaults: methodDefaults)
|
||||
let store = MobileShellComposite(
|
||||
runtime: LivenessTestRuntime(
|
||||
transportFactory: factory,
|
||||
now: { clock.now },
|
||||
supportedRouteKinds: [.iroh, .tailscale]
|
||||
),
|
||||
isSignedIn: true,
|
||||
pairedMacStore: pairedStore,
|
||||
connectionMethodStore: methodStore,
|
||||
identityProvider: StaticIdentityProvider(userID: "user-1"),
|
||||
reachability: AlwaysOnlineReachability(),
|
||||
pairingHintDefaults: UserDefaults(
|
||||
suiteName: "connection-method-pairing-hint-\(UUID().uuidString)"
|
||||
)!,
|
||||
hiddenMacStore: InMemoryPairedMacHiddenStore()
|
||||
)
|
||||
await store.loadPairedMacs()
|
||||
|
||||
#expect(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1"))
|
||||
#expect(store.activeRoute?.kind == .iroh)
|
||||
#expect(factory.attemptedKinds().filter { $0 == .iroh }.count == 1)
|
||||
|
||||
methodStore.method = .tailscale
|
||||
|
||||
// `activeRoute == nil` only proves the store cleared its logical
|
||||
// route; the dropped live Iroh transport must also finish closing so
|
||||
// no physical cleanup work is still pending when the test completes.
|
||||
let applied = try await pollUntil {
|
||||
let liveTransportClosed =
|
||||
await liveTransportBox.get()?.isClosedForTesting() == true
|
||||
return factory.attemptedKinds().contains(.tailscale)
|
||||
&& store.connectionState == .disconnected
|
||||
&& store.activeRoute == nil
|
||||
&& liveTransportClosed
|
||||
}
|
||||
#expect(applied)
|
||||
#expect(store.activeRoute == nil)
|
||||
#expect(factory.attemptedKinds().filter { $0 == .iroh }.count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
+90
@@ -1,10 +1,83 @@
|
||||
import CmuxMobileRPC
|
||||
import CmuxMobileShellModel
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import CmuxMobileShell
|
||||
|
||||
@Suite struct TerminalRawInputOrderingTests {
|
||||
@MainActor
|
||||
@Test func returnKeyExposesCommandSendProgressAndSettlement() async throws {
|
||||
let router = RoutingHostRouter()
|
||||
await router.setHoldFirstTerminalInput(true)
|
||||
let store = try await makeRoutingConnectedStore(router: router)
|
||||
|
||||
store.sendTerminalRawInput(
|
||||
Data("\r".utf8),
|
||||
surfaceID: RoutingHostRouter.terminalA
|
||||
)
|
||||
await router.awaitFirstTerminalInputReached()
|
||||
|
||||
#expect(
|
||||
store.terminalSendStatus(forTerminalID: RoutingHostRouter.terminalA)
|
||||
== .sending
|
||||
)
|
||||
|
||||
await router.releaseFirstTerminalInput()
|
||||
#expect(await waitForTerminalSendStatus(
|
||||
.sent,
|
||||
store: store,
|
||||
terminalID: RoutingHostRouter.terminalA
|
||||
))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func rejectedReturnKeyExposesCommandSendFailure() async throws {
|
||||
let router = RoutingHostRouter()
|
||||
await router.setRejectTerminalInput(at: 0)
|
||||
let store = try await makeRoutingConnectedStore(router: router)
|
||||
|
||||
store.sendTerminalRawInput(
|
||||
Data("\r".utf8),
|
||||
surfaceID: RoutingHostRouter.terminalA
|
||||
)
|
||||
|
||||
#expect(await waitForTerminalSendStatus(
|
||||
.failed,
|
||||
store: store,
|
||||
terminalID: RoutingHostRouter.terminalA
|
||||
))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func secondQueuedReturnOwnsItsFailureSettlement() async throws {
|
||||
let router = RoutingHostRouter()
|
||||
await router.setHoldFirstTerminalInput(true)
|
||||
await router.setRejectTerminalInput(at: 1)
|
||||
let store = try await makeRoutingConnectedStore(router: router)
|
||||
|
||||
store.sendTerminalRawInput(
|
||||
Data("first\r".utf8),
|
||||
surfaceID: RoutingHostRouter.terminalA
|
||||
)
|
||||
await router.awaitFirstTerminalInputReached()
|
||||
store.sendTerminalRawInput(
|
||||
Data("second\r".utf8),
|
||||
surfaceID: RoutingHostRouter.terminalA
|
||||
)
|
||||
|
||||
await router.releaseFirstTerminalInput()
|
||||
#expect(await waitForTerminalSendStatus(
|
||||
.failed,
|
||||
store: store,
|
||||
terminalID: RoutingHostRouter.terminalA
|
||||
))
|
||||
#expect(
|
||||
await router.recordedTerminalInputs().map(\.text)
|
||||
== ["first\r", "second\r"]
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func orderedIrohFallbackPipelinesAtMostFourRequests() async throws {
|
||||
let router = RoutingHostRouter()
|
||||
@@ -475,6 +548,23 @@ import Testing
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitForTerminalSendStatus(
|
||||
_ expected: MobileTerminalSendStatus,
|
||||
store: MobileShellComposite,
|
||||
terminalID: String
|
||||
) async -> Bool {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .seconds(2))
|
||||
while clock.now < deadline {
|
||||
if store.terminalSendStatus(forTerminalID: terminalID) == expected {
|
||||
return true
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private actor RawInputBarrierTerminalLane: MobileTerminalLaneConnection {
|
||||
private var pendingFrames: [MobileTerminalLaneOutputFrame] = []
|
||||
private var receiveContinuation:
|
||||
|
||||
+30
-7
@@ -6,18 +6,18 @@ public enum MobileConnectionMethod: String, CaseIterable, Sendable {
|
||||
/// Dial the built-in encrypted peer-to-peer transport (direct paths with
|
||||
/// managed relays as fallback). The default; no setup required.
|
||||
case automatic
|
||||
/// Prefer the user's Tailscale network. Requires entering the Tailscale
|
||||
/// pairing code shown on the Mac once, which authorizes that exact peer.
|
||||
/// Require the user's Tailscale network. Requires entering the Tailscale
|
||||
/// pairing code shown on the Mac once, which authorizes that exact peer;
|
||||
/// Iroh is never used as a fallback while this method is selected.
|
||||
case tailscale
|
||||
}
|
||||
|
||||
/// Persists the user's connection-method choice.
|
||||
///
|
||||
/// The preference only reorders dialing: `tailscale` puts authorized Tailscale
|
||||
/// routes ahead of the automatic transport instead of the default pin that
|
||||
/// dials the automatic transport exclusively. It never manufactures Tailscale
|
||||
/// authorization by itself; a pairing code entry remains the authorization
|
||||
/// event for each Mac.
|
||||
/// The choice is exclusive: `automatic` uses the built-in encrypted transport,
|
||||
/// while `tailscale` dials only an authorized Tailscale route. It never
|
||||
/// manufactures Tailscale authorization by itself; a pairing code entry remains
|
||||
/// the authorization event for each Mac.
|
||||
///
|
||||
/// The backing `UserDefaults` is injected so the store is testable without
|
||||
/// touching `.standard`; the app constructs it at the composition root.
|
||||
@@ -29,12 +29,17 @@ public final class MobileConnectionMethodStore {
|
||||
|
||||
// UserDefaults is Apple-documented thread-safe; OK to hold nonisolated.
|
||||
private nonisolated(unsafe) let defaults: UserDefaults
|
||||
@ObservationIgnored private var continuations:
|
||||
[UUID: AsyncStream<MobileConnectionMethod>.Continuation] = [:]
|
||||
|
||||
/// The user's current connection-method choice.
|
||||
public var method: MobileConnectionMethod {
|
||||
didSet {
|
||||
guard method != oldValue else { return }
|
||||
defaults.set(method.rawValue, forKey: Self.methodKey)
|
||||
for continuation in continuations.values {
|
||||
continuation.yield(method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +53,22 @@ public final class MobileConnectionMethodStore {
|
||||
self.method = .automatic
|
||||
}
|
||||
}
|
||||
|
||||
/// Observes connection-method changes, beginning with the current method.
|
||||
///
|
||||
/// Each subscriber owns an independent stream. Cancelling iteration removes
|
||||
/// that subscriber without affecting Settings or other connection owners.
|
||||
public func changes() -> AsyncStream<MobileConnectionMethod> {
|
||||
let id = UUID()
|
||||
let current = method
|
||||
return AsyncStream { continuation in
|
||||
continuations[id] = continuation
|
||||
continuation.yield(current)
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.continuations[id] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-5
@@ -1,4 +1,4 @@
|
||||
import Foundation
|
||||
public import Foundation
|
||||
|
||||
/// A coalescing, back-pressured queue of pending terminal input.
|
||||
///
|
||||
@@ -19,6 +19,8 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
|
||||
public var terminalID: MobileTerminalPreview.ID
|
||||
/// The accumulated text for this chunk.
|
||||
public var text: String
|
||||
/// The newest Return-terminated send represented by this chunk.
|
||||
public var sendStatusOperationID: UUID?
|
||||
|
||||
/// Creates a pending-input chunk.
|
||||
/// - Parameters:
|
||||
@@ -28,11 +30,13 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
|
||||
public init(
|
||||
workspaceID: MobileWorkspacePreview.ID,
|
||||
terminalID: MobileTerminalPreview.ID,
|
||||
text: String
|
||||
text: String,
|
||||
sendStatusOperationID: UUID? = nil
|
||||
) {
|
||||
self.workspaceID = workspaceID
|
||||
self.terminalID = terminalID
|
||||
self.text = text
|
||||
self.sendStatusOperationID = sendStatusOperationID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +60,8 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
|
||||
public mutating func enqueue(
|
||||
_ text: String,
|
||||
workspaceID: MobileWorkspacePreview.ID,
|
||||
terminalID: MobileTerminalPreview.ID
|
||||
terminalID: MobileTerminalPreview.ID,
|
||||
sendStatusOperationID: UUID? = nil
|
||||
) -> MobileTerminalInputEnqueueResult {
|
||||
guard !text.isEmpty else { return .queued }
|
||||
let byteCount = text.utf8.count
|
||||
@@ -67,13 +72,17 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
|
||||
last.workspaceID == workspaceID,
|
||||
last.terminalID == terminalID {
|
||||
last.text += text
|
||||
if let sendStatusOperationID {
|
||||
last.sendStatusOperationID = sendStatusOperationID
|
||||
}
|
||||
pendingChunks[pendingChunks.count - 1] = last
|
||||
} else {
|
||||
pendingChunks.append(
|
||||
Chunk(
|
||||
workspaceID: workspaceID,
|
||||
terminalID: terminalID,
|
||||
text: text
|
||||
text: text,
|
||||
sendStatusOperationID: sendStatusOperationID
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -120,7 +129,10 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
|
||||
return Chunk(
|
||||
workspaceID: pendingChunks[0].workspaceID,
|
||||
terminalID: pendingChunks[0].terminalID,
|
||||
text: prefix
|
||||
text: prefix,
|
||||
// Settle only after the final piece of a split chunk has been
|
||||
// handed to the transport.
|
||||
sendStatusOperationID: nil
|
||||
)
|
||||
}
|
||||
let chunk = pendingChunks.removeFirst()
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/// User-visible settlement state for a terminal submission.
|
||||
///
|
||||
/// The state is scoped per terminal and covers both the composer paste path and
|
||||
/// Return-terminated raw terminal commands. Ordinary keystrokes are excluded so
|
||||
/// typing does not flash transport chrome for every character.
|
||||
public enum MobileTerminalSendStatus: Equatable, Sendable {
|
||||
case idle
|
||||
case sending
|
||||
case sent
|
||||
case failed
|
||||
}
|
||||
+74
-20
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure derivations from the per-Mac state map to the flat, user-facing shapes.
|
||||
/// Pure derivations from the per-Mac state map to aggregated workspace and group shapes.
|
||||
///
|
||||
public struct MobileWorkspaceAggregation: Sendable {
|
||||
private let rowIDSeparator = "\u{1F}"
|
||||
@@ -73,15 +73,36 @@ public struct MobileWorkspaceAggregation: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
/// Stable display id for one Mac-local group inside an aggregated list.
|
||||
private func groupID(
|
||||
macDeviceID: String,
|
||||
instanceTag: String?,
|
||||
groupID: MobileWorkspaceGroupPreview.ID
|
||||
) -> MobileWorkspaceGroupPreview.ID {
|
||||
guard let instanceTag, !instanceTag.isEmpty else {
|
||||
return MobileWorkspaceGroupPreview.ID(
|
||||
rawValue: "\(macDeviceID)\(rowIDSeparator)\(groupID.rawValue)"
|
||||
)
|
||||
}
|
||||
return MobileWorkspaceGroupPreview.ID(
|
||||
rawValue: "\(macDeviceID)\(rowIDSeparator)\(instanceTag)\(rowIDSeparator)\(groupID.rawValue)"
|
||||
)
|
||||
}
|
||||
|
||||
/// Derive the flat, ordered workspace list across all Macs.
|
||||
public func derivedWorkspaces(
|
||||
statesByMac: [String: MacWorkspaceState],
|
||||
foregroundMacDeviceID: String?,
|
||||
machineColorIndex: [String: Int]
|
||||
machineColorIndex: [String: Int],
|
||||
macIDsInDisplayOrder: [String]? = nil
|
||||
) -> [MobileWorkspacePreview] {
|
||||
let shouldScopeRowIDs = statesByMac.keys.filter { !$0.isEmpty }.count > 1
|
||||
let orderedMacIDs = macIDsInDisplayOrder ?? orderedMacIDs(
|
||||
statesByMac: statesByMac,
|
||||
foregroundMacDeviceID: foregroundMacDeviceID
|
||||
)
|
||||
var result: [MobileWorkspacePreview] = []
|
||||
for macID in orderedMacIDs(statesByMac: statesByMac, foregroundMacDeviceID: foregroundMacDeviceID) {
|
||||
for macID in orderedMacIDs {
|
||||
guard let state = statesByMac[macID] else { continue }
|
||||
for workspace in state.workspaces {
|
||||
let ownerID = workspace.macDeviceID ?? state.macDeviceID
|
||||
@@ -102,6 +123,13 @@ public struct MobileWorkspaceAggregation: Sendable {
|
||||
instanceTag: stamped.macInstanceTag,
|
||||
workspaceID: remoteID
|
||||
)
|
||||
if let remoteGroupID = workspace.groupID {
|
||||
stamped.groupID = groupID(
|
||||
macDeviceID: ownerID,
|
||||
instanceTag: stamped.macInstanceTag,
|
||||
groupID: remoteGroupID
|
||||
)
|
||||
}
|
||||
}
|
||||
result.append(stamped)
|
||||
}
|
||||
@@ -109,28 +137,54 @@ public struct MobileWorkspaceAggregation: Sendable {
|
||||
return result
|
||||
}
|
||||
|
||||
/// Derive the group sections to show for the foreground Mac.
|
||||
/// Derive group sections from every Mac in the same order as workspaces.
|
||||
///
|
||||
/// Group ids are Mac-local, so a multi-Mac list namespaces both group ids
|
||||
/// and anchor workspace ids. The original group id remains available through
|
||||
/// ``MobileWorkspaceGroupPreview/rpcGroupID`` for mutations.
|
||||
public func derivedGroups(
|
||||
statesByMac: [String: MacWorkspaceState],
|
||||
foregroundMacDeviceID: String?
|
||||
foregroundMacDeviceID: String?,
|
||||
macIDsInDisplayOrder: [String]? = nil
|
||||
) -> [MobileWorkspaceGroupPreview] {
|
||||
guard let foregroundMacDeviceID, let state = statesByMac[foregroundMacDeviceID] else { return [] }
|
||||
let shouldScopeRowIDs = statesByMac.keys.filter { !$0.isEmpty }.count > 1
|
||||
guard shouldScopeRowIDs, !foregroundMacDeviceID.isEmpty else { return state.groups }
|
||||
let remoteIDByLocalID = Dictionary(
|
||||
uniqueKeysWithValues: state.workspaces.map { workspace in
|
||||
(workspace.id, workspace.remoteWorkspaceID ?? workspace.id)
|
||||
}
|
||||
let shouldScopeIDs = statesByMac.keys.filter { !$0.isEmpty }.count > 1
|
||||
let orderedMacIDs = macIDsInDisplayOrder ?? orderedMacIDs(
|
||||
statesByMac: statesByMac,
|
||||
foregroundMacDeviceID: foregroundMacDeviceID
|
||||
)
|
||||
return state.groups.map { group in
|
||||
var scoped = group
|
||||
let remoteID = remoteIDByLocalID[group.anchorWorkspaceID] ?? group.anchorWorkspaceID
|
||||
scoped.anchorWorkspaceID = rowID(
|
||||
macDeviceID: state.macDeviceID,
|
||||
instanceTag: state.instanceTag,
|
||||
workspaceID: remoteID
|
||||
var result: [MobileWorkspaceGroupPreview] = []
|
||||
for macID in orderedMacIDs {
|
||||
guard let state = statesByMac[macID] else { continue }
|
||||
let remoteWorkspaceIDByLocalID = Dictionary(
|
||||
uniqueKeysWithValues: state.workspaces.map { workspace in
|
||||
(workspace.id, workspace.remoteWorkspaceID ?? workspace.id)
|
||||
}
|
||||
)
|
||||
return scoped
|
||||
for group in state.groups {
|
||||
let remoteGroupID = group.remoteGroupID ?? group.id
|
||||
var stamped = group
|
||||
stamped.remoteGroupID = shouldScopeIDs ? remoteGroupID : group.remoteGroupID
|
||||
stamped.macDeviceID = state.macDeviceID
|
||||
stamped.macInstanceTag = state.instanceTag
|
||||
guard shouldScopeIDs, !state.macDeviceID.isEmpty else {
|
||||
result.append(stamped)
|
||||
continue
|
||||
}
|
||||
stamped.id = groupID(
|
||||
macDeviceID: state.macDeviceID,
|
||||
instanceTag: state.instanceTag,
|
||||
groupID: remoteGroupID
|
||||
)
|
||||
let remoteAnchorID = remoteWorkspaceIDByLocalID[group.anchorWorkspaceID]
|
||||
?? group.anchorWorkspaceID
|
||||
stamped.anchorWorkspaceID = rowID(
|
||||
macDeviceID: state.macDeviceID,
|
||||
instanceTag: state.instanceTag,
|
||||
workspaceID: remoteAnchorID
|
||||
)
|
||||
result.append(stamped)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
+36
-15
@@ -18,17 +18,18 @@ public import Foundation
|
||||
/// ```swift
|
||||
/// var store = MobileWorkspaceGroupCollapseStore(defaults: .standard)
|
||||
/// let shown = store.apply(to: groupsFromMac) // seeds unknown groups, applies local
|
||||
/// store.set(groupID, collapsed: true) // device-local, not sent to the Mac
|
||||
/// store.set(shown[0].collapseStateID, collapsed: true) // device-local, not sent to Mac
|
||||
/// ```
|
||||
public struct MobileWorkspaceGroupCollapseStore: Sendable {
|
||||
/// The defaults key under which the `[groupID: collapsed]` map is stored.
|
||||
/// The defaults key under which the `[collapseStateID: collapsed]` map is stored.
|
||||
public static let defaultsKey = "dev.cmux.mobile.workspaceGroup.collapse.v1"
|
||||
|
||||
// UserDefaults is Apple-documented thread-safe; OK to hold nonisolated.
|
||||
private nonisolated(unsafe) let defaults: UserDefaults
|
||||
/// groupID.rawValue -> this device's collapse decision. The map doubles as the
|
||||
/// "have I seen this group?" set: a present key means the group's collapse is
|
||||
/// device-owned; an absent key means it still inherits the Mac's seed.
|
||||
/// group.collapseStateID -> this device's collapse decision. The map doubles
|
||||
/// as the "have I seen this group?" set: a present key means the group's
|
||||
/// collapse is device-owned; an absent key means it still inherits the Mac's
|
||||
/// seed.
|
||||
private var map: [String: Bool]
|
||||
|
||||
/// Create a store backed by the given defaults.
|
||||
@@ -62,24 +63,36 @@ public struct MobileWorkspaceGroupCollapseStore: Sendable {
|
||||
/// `isCollapsed` with it; otherwise seed the device decision from the Mac's
|
||||
/// reported value (initial inheritance) and keep that. Entries for groups no
|
||||
/// longer present are dropped so the map stays bounded by the live group count.
|
||||
/// - Parameter groups: The groups as reported by the Mac.
|
||||
/// - Parameter groups: The owner-stamped groups derived from every Mac.
|
||||
/// - Returns: The same groups with `isCollapsed` reflecting this device.
|
||||
public mutating func apply(to groups: [MobileWorkspaceGroupPreview]) -> [MobileWorkspaceGroupPreview] {
|
||||
let liveIDs = Set(groups.map(\.id.rawValue))
|
||||
var changed = false
|
||||
|
||||
// Prune decisions for groups that no longer exist (renamed-away/deleted),
|
||||
// keeping the map bounded by the number of live groups.
|
||||
for key in map.keys where !liveIDs.contains(key) {
|
||||
map.removeValue(forKey: key)
|
||||
changed = true
|
||||
let liveIDs = Set(groups.map(\.collapseStateID))
|
||||
let groupsByRemoteID = Dictionary(grouping: groups, by: { $0.rpcGroupID.rawValue })
|
||||
let legacyMigrationTargetByRemoteID: [String: String] = groupsByRemoteID.compactMapValues { matchingGroups in
|
||||
guard matchingGroups.count == 1,
|
||||
let group = matchingGroups.first,
|
||||
let macDeviceID = group.macDeviceID,
|
||||
!macDeviceID.isEmpty,
|
||||
group.collapseStateID != group.rpcGroupID.rawValue else {
|
||||
return nil
|
||||
}
|
||||
return group.collapseStateID
|
||||
}
|
||||
var changed = false
|
||||
|
||||
let resolved = groups.map { group -> MobileWorkspaceGroupPreview in
|
||||
var group = group
|
||||
let key = group.id.rawValue
|
||||
let key = group.collapseStateID
|
||||
if let local = map[key] {
|
||||
group.isCollapsed = local
|
||||
} else if legacyMigrationTargetByRemoteID[group.rpcGroupID.rawValue] == key,
|
||||
let legacyLocal = map[group.rpcGroupID.rawValue] {
|
||||
// Before groups carried Mac ownership, collapse preferences were
|
||||
// keyed only by the raw group id. Migrate only when one live,
|
||||
// authoritatively owned group can claim that legacy decision.
|
||||
map[key] = legacyLocal
|
||||
group.isCollapsed = legacyLocal
|
||||
changed = true
|
||||
} else {
|
||||
// First time this device sees the group: inherit the Mac's value.
|
||||
map[key] = group.isCollapsed
|
||||
@@ -88,6 +101,14 @@ public struct MobileWorkspaceGroupCollapseStore: Sendable {
|
||||
return group
|
||||
}
|
||||
|
||||
// Prune decisions for groups that no longer exist (renamed-away/deleted),
|
||||
// keeping the map bounded by the number of live groups. This runs after
|
||||
// legacy migration so old raw-id decisions are still available above.
|
||||
for key in map.keys where !liveIDs.contains(key) {
|
||||
map.removeValue(forKey: key)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed { persist() }
|
||||
return resolved
|
||||
}
|
||||
|
||||
+37
@@ -29,6 +29,13 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
|
||||
|
||||
/// The group's stable identifier.
|
||||
public var id: ID
|
||||
/// The Mac-local group identifier when ``id`` is namespaced for an
|
||||
/// aggregated multi-Mac list. `nil` means ``id`` is already Mac-local.
|
||||
public var remoteGroupID: ID?
|
||||
/// The stable device identifier of the Mac that owns this group.
|
||||
public var macDeviceID: String?
|
||||
/// The owning cmux app instance tag, when the Mac has multiple builds.
|
||||
public var macInstanceTag: String?
|
||||
/// The group's user-facing name, shown as the section header label.
|
||||
public var name: String
|
||||
/// Whether the group is currently collapsed (members hidden, header shown).
|
||||
@@ -41,9 +48,33 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
|
||||
/// header and never rendered as a separate row.
|
||||
public var anchorWorkspaceID: MobileWorkspacePreview.ID
|
||||
|
||||
/// The group identifier to send back to the owning Mac.
|
||||
public var rpcGroupID: ID {
|
||||
remoteGroupID ?? id
|
||||
}
|
||||
|
||||
/// Stable key for this phone's device-local collapse preference.
|
||||
///
|
||||
/// Group identifiers are Mac-local. Namespacing the persistence key by the
|
||||
/// owner prevents two Macs with the same raw group id from sharing collapse
|
||||
/// state, and keeps the preference stable when another Mac joins or leaves
|
||||
/// the aggregated list.
|
||||
public var collapseStateID: String {
|
||||
guard let macDeviceID, !macDeviceID.isEmpty else {
|
||||
return rpcGroupID.rawValue
|
||||
}
|
||||
guard let macInstanceTag, !macInstanceTag.isEmpty else {
|
||||
return "\(macDeviceID)\u{1F}\(rpcGroupID.rawValue)"
|
||||
}
|
||||
return "\(macDeviceID)\u{1F}\(macInstanceTag)\u{1F}\(rpcGroupID.rawValue)"
|
||||
}
|
||||
|
||||
/// Creates a workspace group preview.
|
||||
/// - Parameters:
|
||||
/// - id: The group's stable identifier.
|
||||
/// - remoteGroupID: The Mac-local id when `id` is aggregate-namespaced.
|
||||
/// - macDeviceID: The stable device id of the owning Mac.
|
||||
/// - macInstanceTag: The owning cmux app instance tag, when present.
|
||||
/// - name: The group's user-facing name.
|
||||
/// - isCollapsed: Whether the group is collapsed. Defaults to `false`.
|
||||
/// - isPinned: Whether the group is pinned. Defaults to `false`.
|
||||
@@ -51,6 +82,9 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
|
||||
/// - anchorWorkspaceID: The anchor workspace that owns the group.
|
||||
public init(
|
||||
id: ID,
|
||||
remoteGroupID: ID? = nil,
|
||||
macDeviceID: String? = nil,
|
||||
macInstanceTag: String? = nil,
|
||||
name: String,
|
||||
isCollapsed: Bool = false,
|
||||
isPinned: Bool = false,
|
||||
@@ -58,6 +92,9 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
|
||||
anchorWorkspaceID: MobileWorkspacePreview.ID
|
||||
) {
|
||||
self.id = id
|
||||
self.remoteGroupID = remoteGroupID
|
||||
self.macDeviceID = macDeviceID
|
||||
self.macInstanceTag = macInstanceTag
|
||||
self.name = name
|
||||
self.isCollapsed = isCollapsed
|
||||
self.isPinned = isPinned
|
||||
|
||||
+26
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import CmuxMobileShellModel
|
||||
@@ -105,4 +106,29 @@ import Testing
|
||||
#expect(buffer.pendingByteCount == 0)
|
||||
#expect(buffer.nextBatch(maximumByteCount: 4) == nil)
|
||||
}
|
||||
|
||||
@Test func coalescedChunkRetainsNewestSendOperation() {
|
||||
var buffer = MobileTerminalInputSendBuffer()
|
||||
let workspaceID = MobileWorkspacePreview.ID(rawValue: "workspace-a")
|
||||
let terminalID = MobileTerminalPreview.ID(rawValue: "terminal-a")
|
||||
let firstOperationID = UUID()
|
||||
let secondOperationID = UUID()
|
||||
|
||||
#expect(buffer.enqueue(
|
||||
"first\r",
|
||||
workspaceID: workspaceID,
|
||||
terminalID: terminalID,
|
||||
sendStatusOperationID: firstOperationID
|
||||
) == .startDraining)
|
||||
#expect(buffer.enqueue(
|
||||
"second\r",
|
||||
workspaceID: workspaceID,
|
||||
terminalID: terminalID,
|
||||
sendStatusOperationID: secondOperationID
|
||||
) == .queued)
|
||||
|
||||
let batch = buffer.nextBatch()
|
||||
#expect(batch?.text == "first\rsecond\r")
|
||||
#expect(batch?.sendStatusOperationID == secondOperationID)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user