conductor-remote 1.38.0 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-BhYD1Ar2.js +42 -0
- package/dist/index.html +1 -1
- package/dist/push-sw.js +32 -7
- package/dist/sw.js +1 -1
- package/dist-node/src/conductor.applescript +155 -0
- package/dist-node/src/funnel-watchdog.js +39 -4
- package/dist-node/src/notify.js +14 -3
- package/dist-node/src/server.js +2 -2
- package/dist-node/src/writes.js +44 -2
- package/package.json +1 -1
- package/dist/assets/index-BSwT6p-R.js +0 -42
package/dist/index.html
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<title>Conductor Remote</title>
|
|
14
14
|
<!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
|
|
15
15
|
<script src="/self-heal.js"></script>
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-BhYD1Ar2.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-UopM0hHW.css">
|
|
18
18
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
|
19
19
|
<body>
|
package/dist/push-sw.js
CHANGED
|
@@ -13,6 +13,25 @@
|
|
|
13
13
|
// 2. **A tap focuses the existing window** rather than navigating it. The app is a
|
|
14
14
|
// token-gated SPA; `openWindow` on a live client would remount the whole thing and
|
|
15
15
|
// throw away in-progress composer text, so we focus and post a route instead.
|
|
16
|
+
// 3. **The route is also parked in Cache Storage**, because on iOS neither of the two
|
|
17
|
+
// direct routes survives. A backgrounded home-screen web app is resumed on whatever
|
|
18
|
+
// screen it was left on — `openWindow`'s path is ignored, and a `postMessage` to a
|
|
19
|
+
// frozen page is dropped (WebKit, reported from iOS 17.1 through 18.x and still
|
|
20
|
+
// open). The cache outlives both, so the app reads its target when it comes back to
|
|
21
|
+
// the front; see `usePushRouting` in web/src/hooks.ts.
|
|
22
|
+
|
|
23
|
+
/** One entry, overwritten per tap: only the newest tap can still be waiting to land. */
|
|
24
|
+
const ROUTE_CACHE = 'push-route'
|
|
25
|
+
const ROUTE_KEY = '/__push-route'
|
|
26
|
+
|
|
27
|
+
async function parkRoute(url) {
|
|
28
|
+
try {
|
|
29
|
+
const cache = await caches.open(ROUTE_CACHE)
|
|
30
|
+
await cache.put(ROUTE_KEY, new Response(JSON.stringify({ url, ts: Date.now() })))
|
|
31
|
+
} catch {
|
|
32
|
+
// Storage refused it (quota, a private window). The two direct routes below still stand.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
16
35
|
|
|
17
36
|
self.addEventListener('push', event => {
|
|
18
37
|
const fallback = { title: 'Conductor Remote', body: 'Something changed in a workspace.', url: '/', tag: 'conductor' }
|
|
@@ -28,8 +47,9 @@ self.addEventListener('push', event => {
|
|
|
28
47
|
event.waitUntil(
|
|
29
48
|
self.registration.showNotification(data.title, {
|
|
30
49
|
body: data.body,
|
|
31
|
-
// Tagged per
|
|
32
|
-
// instead of stacking
|
|
50
|
+
// Tagged per chat by the relay: a chatty agent replaces its own notification
|
|
51
|
+
// instead of stacking, while a sibling chat keeps its own (they open different
|
|
52
|
+
// screens). `renotify` keeps the replacement audible.
|
|
33
53
|
tag: data.tag,
|
|
34
54
|
renotify: true,
|
|
35
55
|
icon: '/icon-192.png',
|
|
@@ -45,18 +65,23 @@ self.addEventListener('notificationclick', event => {
|
|
|
45
65
|
const url = (event.notification.data && event.notification.data.url) || '/'
|
|
46
66
|
event.waitUntil(
|
|
47
67
|
(async () => {
|
|
68
|
+
// Park first. Everything below can succeed and still leave the phone on the wrong
|
|
69
|
+
// screen, and this is the copy the app reads when it wakes up.
|
|
70
|
+
await parkRoute(url)
|
|
48
71
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
|
|
49
72
|
for (const client of clients) {
|
|
50
73
|
if (new URL(client.url).origin !== self.location.origin) continue
|
|
74
|
+
// Handled in web/src/hooks.ts (usePushRouting) — an in-app route change, so the
|
|
75
|
+
// token gate and React state survive the tap. Posted before the focus, since a
|
|
76
|
+
// refused focus is no reason to skip a message the page may well receive.
|
|
77
|
+
client.postMessage({ type: 'push-navigate', url })
|
|
51
78
|
try {
|
|
52
79
|
await client.focus()
|
|
53
|
-
// Handled in web/src/hooks.ts (usePushRouting) — an in-app route change,
|
|
54
|
-
// so the token gate and React state survive the tap.
|
|
55
|
-
client.postMessage({ type: 'push-navigate', url })
|
|
56
|
-
return
|
|
57
80
|
} catch {
|
|
58
|
-
// focus() can be refused (no user activation on some platforms)
|
|
81
|
+
// focus() can be refused (no user activation on some platforms); on iOS the
|
|
82
|
+
// system foregrounds the web app on the tap regardless.
|
|
59
83
|
}
|
|
84
|
+
return
|
|
60
85
|
}
|
|
61
86
|
await self.clients.openWindow(url)
|
|
62
87
|
})()
|
package/dist/sw.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const
|
|
1
|
+
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"1dca8269fa4358508f3ca90f0c216cdb"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-UopM0hHW.css",revision:null},{url:"assets/index-BhYD1Ar2.js",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
|
|
@@ -1154,3 +1154,158 @@ on setWorkspaceStatus()
|
|
|
1154
1154
|
end tell
|
|
1155
1155
|
delay 0.6
|
|
1156
1156
|
end setWorkspaceStatus
|
|
1157
|
+
|
|
1158
|
+
(* -- Instant Hotspot ----------------------------------------------------------
|
|
1159
|
+
The funnel watchdog's last resort when this Mac has no route: press the row
|
|
1160
|
+
for a personal hotspot in Control Center's Wi-Fi popover. networksetup can
|
|
1161
|
+
only join a network that is broadcasting, and a personal hotspot usually
|
|
1162
|
+
is not - the row in the Wi-Fi menu is fed by Continuity over Bluetooth, and
|
|
1163
|
+
pressing it asks the phone to wake its hotspot, exactly like a human
|
|
1164
|
+
clicking the Wi-Fi menu. Everything here targets process "ControlCenter",
|
|
1165
|
+
not Conductor - same Accessibility grant, different process, which is why
|
|
1166
|
+
the refusal mapping below skips refusalReason's -1712 branch (its words are
|
|
1167
|
+
about a wedged Conductor).
|
|
1168
|
+
Addressed by AXIdentifier throughout ("com.apple.menuextra.wifi",
|
|
1169
|
+
"wifi-network-<name>", "wifi-header") - the items' names are missing value
|
|
1170
|
+
and any visible text is localized, the identifiers are neither (measured
|
|
1171
|
+
live on macOS 26.5, 2026-08). A "whose value of attribute ..." filter fails
|
|
1172
|
+
wholesale (-1728) when any element lacks the attribute - several menu bar
|
|
1173
|
+
items do - so every lookup here walks with a per-item try instead.
|
|
1174
|
+
The SE-terms trap at the top of CLAUDE.md's applescript section bit this
|
|
1175
|
+
code through a HANDLER PARAMETER: findAxId's first argument was once named
|
|
1176
|
+
`container`, a System Events dictionary word, so inside the tell it
|
|
1177
|
+
resolved as the term and the walk found nothing - through an error-eating
|
|
1178
|
+
try, so a provably open popover read as "no popover" for a whole debugging
|
|
1179
|
+
session while every direct read worked. osacompile accepts it, the handler
|
|
1180
|
+
check can't see it. Keep every identifier in these handlers boring and
|
|
1181
|
+
un-dictionary-like.
|
|
1182
|
+
The menu bar item is a TOGGLE: the same press opens and closes the
|
|
1183
|
+
popover, so nothing here presses it without first checking which state the
|
|
1184
|
+
popover is in (wifiPopoverWindow), and an already-open popover aborts the
|
|
1185
|
+
run - a human may be mid-gesture in it, and their next click kills an
|
|
1186
|
+
automated run anyway (contention looks exactly like the status-row menu's:
|
|
1187
|
+
fails while someone uses the Mac, clean when it is unattended). *)
|
|
1188
|
+
|
|
1189
|
+
on wifiMenuBarItem()
|
|
1190
|
+
try
|
|
1191
|
+
with timeout of 6 seconds
|
|
1192
|
+
tell application "System Events" to tell process "ControlCenter"
|
|
1193
|
+
repeat with mi in menu bar items of menu bar 1
|
|
1194
|
+
try
|
|
1195
|
+
if (value of attribute "AXIdentifier" of mi) is "com.apple.menuextra.wifi" then return contents of mi
|
|
1196
|
+
end try
|
|
1197
|
+
end repeat
|
|
1198
|
+
end tell
|
|
1199
|
+
end timeout
|
|
1200
|
+
on error errText number errNum
|
|
1201
|
+
if errNum is not -1712 then
|
|
1202
|
+
set refusal to my refusalReason({-1, errNum, errText})
|
|
1203
|
+
if refusal is not "" then error refusal
|
|
1204
|
+
end if
|
|
1205
|
+
error "reading the menu bar failed (" & errNum & "): " & errText
|
|
1206
|
+
end try
|
|
1207
|
+
error "the Wi-Fi control is not in the menu bar - turn on Show in Menu Bar for Wi-Fi in System Settings > Control Center"
|
|
1208
|
+
end wifiMenuBarItem
|
|
1209
|
+
|
|
1210
|
+
on findAxId(parentEl, wantedId, depth)
|
|
1211
|
+
-- Depth-capped even though the popover is small: the cap is what keeps a
|
|
1212
|
+
-- macOS reshuffle of this tree from turning one lookup into a full crawl.
|
|
1213
|
+
-- `parentEl` is deliberately not `container` - see the section comment.
|
|
1214
|
+
tell application "System Events"
|
|
1215
|
+
try
|
|
1216
|
+
set kids to UI elements of parentEl
|
|
1217
|
+
on error
|
|
1218
|
+
return missing value
|
|
1219
|
+
end try
|
|
1220
|
+
end tell
|
|
1221
|
+
repeat with el in kids
|
|
1222
|
+
try
|
|
1223
|
+
tell application "System Events"
|
|
1224
|
+
if (value of attribute "AXIdentifier" of el) is wantedId then return contents of el
|
|
1225
|
+
end tell
|
|
1226
|
+
end try
|
|
1227
|
+
if depth > 1 then
|
|
1228
|
+
set hit to my findAxId(el, wantedId, depth - 1)
|
|
1229
|
+
if hit is not missing value then return hit
|
|
1230
|
+
end if
|
|
1231
|
+
end repeat
|
|
1232
|
+
return missing value
|
|
1233
|
+
end findAxId
|
|
1234
|
+
|
|
1235
|
+
on wifiPopoverWindow()
|
|
1236
|
+
-- The popover is whichever ControlCenter window carries the "wifi-header"
|
|
1237
|
+
-- id. Existence is read fresh on every call: pressing a network row can
|
|
1238
|
+
-- close the popover by itself, and the menu bar item is a toggle, so a
|
|
1239
|
+
-- blind second press would reopen what just closed.
|
|
1240
|
+
tell application "System Events" to tell process "ControlCenter"
|
|
1241
|
+
try
|
|
1242
|
+
set wins to windows
|
|
1243
|
+
on error
|
|
1244
|
+
return missing value
|
|
1245
|
+
end try
|
|
1246
|
+
end tell
|
|
1247
|
+
repeat with w in wins
|
|
1248
|
+
if my findAxId(w, "wifi-header", 3) is not missing value then return contents of w
|
|
1249
|
+
end repeat
|
|
1250
|
+
return missing value
|
|
1251
|
+
end wifiPopoverWindow
|
|
1252
|
+
|
|
1253
|
+
on closeWifiPopover(wifiItem)
|
|
1254
|
+
if my wifiPopoverWindow() is not missing value then
|
|
1255
|
+
try
|
|
1256
|
+
tell application "System Events" to perform action "AXPress" of wifiItem
|
|
1257
|
+
end try
|
|
1258
|
+
end if
|
|
1259
|
+
end closeWifiPopover
|
|
1260
|
+
|
|
1261
|
+
on joinInstantHotspot()
|
|
1262
|
+
-- The name arrives via a file, like RELAY_PROMPT_FILE: do shell script
|
|
1263
|
+
-- output is decoded as UTF-8, so a name with non-ASCII letters survives
|
|
1264
|
+
-- where a system attribute read may not. AppleScript's default nonliteral
|
|
1265
|
+
-- text comparison then treats composed and decomposed accents as equal,
|
|
1266
|
+
-- which matters because the AX identifier uses whatever normalization
|
|
1267
|
+
-- macOS chose.
|
|
1268
|
+
set hotspotName to do shell script "cat " & quoted form of (system attribute "RELAY_HOTSPOT_FILE")
|
|
1269
|
+
if hotspotName is "" then error "no hotspot name provided"
|
|
1270
|
+
-- The lock screen hides the session from Accessibility, so behind it this
|
|
1271
|
+
-- press has nothing to land on. Named up front, in the same words the send
|
|
1272
|
+
-- path uses, instead of surfacing later as a popover that never opened.
|
|
1273
|
+
if my screenLocked() is "locked" then error "The Mac is locked - the Wi-Fi menu can't be pressed until it unlocks"
|
|
1274
|
+
set wifiItem to my wifiMenuBarItem()
|
|
1275
|
+
if my wifiPopoverWindow() is not missing value then error "the Wi-Fi popover is already open - someone may be using it, so nothing was pressed"
|
|
1276
|
+
tell application "System Events" to perform action "AXPress" of wifiItem
|
|
1277
|
+
-- The popover renders in its own time (measured 1-2s), and a human's click
|
|
1278
|
+
-- anywhere on the Mac closes it, so poll rather than sleep once.
|
|
1279
|
+
set pop to missing value
|
|
1280
|
+
repeat 8 times
|
|
1281
|
+
delay 0.5
|
|
1282
|
+
set pop to my wifiPopoverWindow()
|
|
1283
|
+
if pop is not missing value then exit repeat
|
|
1284
|
+
end repeat
|
|
1285
|
+
if pop is missing value then error "pressing the Wi-Fi control opened no popover - someone clicking on the Mac closes it the moment it opens (contention, not a fault); otherwise Control Center is not answering"
|
|
1286
|
+
set rowEl to missing value
|
|
1287
|
+
repeat 4 times
|
|
1288
|
+
set rowEl to my findAxId(pop, "wifi-network-" & hotspotName, 6)
|
|
1289
|
+
if rowEl is not missing value then exit repeat
|
|
1290
|
+
delay 0.5
|
|
1291
|
+
end repeat
|
|
1292
|
+
if rowEl is missing value then
|
|
1293
|
+
my closeWifiPopover(wifiItem)
|
|
1294
|
+
error quote & hotspotName & quote & " is not in the Wi-Fi menu - the iPhone is out of Bluetooth range, has Bluetooth off, or Personal Hotspot is off in its Settings"
|
|
1295
|
+
end if
|
|
1296
|
+
-- The row is a toggle too: value 1 means already connected, and pressing
|
|
1297
|
+
-- it then would DISCONNECT - the one outcome worse than doing nothing.
|
|
1298
|
+
-- Only a read of exactly 1 skips the press; an unreadable value presses.
|
|
1299
|
+
set rowValue to 0
|
|
1300
|
+
try
|
|
1301
|
+
tell application "System Events" to set rowValue to value of rowEl
|
|
1302
|
+
end try
|
|
1303
|
+
if rowValue is 1 then
|
|
1304
|
+
my closeWifiPopover(wifiItem)
|
|
1305
|
+
return "already-connected"
|
|
1306
|
+
end if
|
|
1307
|
+
tell application "System Events" to perform action "AXPress" of rowEl
|
|
1308
|
+
delay 1
|
|
1309
|
+
my closeWifiPopover(wifiItem)
|
|
1310
|
+
return "pressed"
|
|
1311
|
+
end joinInstantHotspot
|
|
@@ -31,6 +31,7 @@ import { promisify } from 'node:util';
|
|
|
31
31
|
import { readSettings } from "./settings.js";
|
|
32
32
|
import { magicDnsName, readExposeMode, relayPort, tailscaleBin } from "./tailscale.js";
|
|
33
33
|
import { hasDefaultRoute, joinNetwork, preferredNetworks } from "./wifi.js";
|
|
34
|
+
import { joinInstantHotspot } from "./writes.js";
|
|
34
35
|
const execFileP = promisify(execFile);
|
|
35
36
|
const PROBE_PATH = '/health'; // unauthenticated 200 on the relay — no token needed to prove reachability
|
|
36
37
|
const PROBE_TIMEOUT_MS = 8000;
|
|
@@ -157,6 +158,19 @@ let lastHealAt = 0;
|
|
|
157
158
|
let lastRejoinAt = 0;
|
|
158
159
|
const REJOIN_COOLDOWN_MS = 5 * 60 * 1000; // a network switch is disruptive; never churn on one
|
|
159
160
|
const REJOIN_SETTLE_MS = 12 * 1000; // DHCP + tailscaled noticing the new endpoint
|
|
161
|
+
// An Instant Hotspot press has the phone's Bluetooth wake + hotspot spin-up in front of the
|
|
162
|
+
// same DHCP wait, so it gets a longer leash than a plain join before the tick gives up on it.
|
|
163
|
+
const HOTSPOT_SETTLE_MS = 30 * 1000;
|
|
164
|
+
/** Poll for the default route instead of one fixed sleep — a join that lands early returns early. */
|
|
165
|
+
async function routeAppeared(waitMs) {
|
|
166
|
+
const until = Date.now() + waitMs;
|
|
167
|
+
while (Date.now() < until) {
|
|
168
|
+
if (await hasDefaultRoute())
|
|
169
|
+
return true;
|
|
170
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
171
|
+
}
|
|
172
|
+
return hasDefaultRoute();
|
|
173
|
+
}
|
|
160
174
|
/**
|
|
161
175
|
* Last resort when the probe is down: this Mac has no link at all, so move it onto a
|
|
162
176
|
* configured fallback (your phone's hotspot) and re-register Funnel, whose ingress a
|
|
@@ -173,6 +187,14 @@ const REJOIN_SETTLE_MS = 12 * 1000; // DHCP + tailscaled noticing the new endpoi
|
|
|
173
187
|
* or passed; an SSID that isn't in the preferred list is named in the log, not tried.
|
|
174
188
|
* - Behind a cooldown, so a Mac that is simply off the air doesn't cycle its Wi-Fi.
|
|
175
189
|
*
|
|
190
|
+
* Two ways in, tried in order. `networksetup` first — cheap, no UI — and when it answers
|
|
191
|
+
* "Could not find network" (a hotspot doesn't broadcast until asked), the Accessibility
|
|
192
|
+
* press on the Wi-Fi menu's own row (`joinInstantHotspot` in writes.ts), which wakes the
|
|
193
|
+
* phone's hotspot over Continuity exactly like clicking it. The press needs an unlocked
|
|
194
|
+
* screen — the lock hides the session from Accessibility, and the failure names that —
|
|
195
|
+
* so a lid-closed Mac still wants macOS's own Auto-Join Hotspot set to Automatic as the
|
|
196
|
+
* layer below this one.
|
|
197
|
+
*
|
|
176
198
|
* Returns true if a join reported success, meaning the caller should re-register rather
|
|
177
199
|
* than treat this tick as an ordinary failure.
|
|
178
200
|
*/
|
|
@@ -194,17 +216,30 @@ async function tryRejoin() {
|
|
|
194
216
|
lastRejoinAt = Date.now();
|
|
195
217
|
for (const ssid of candidates) {
|
|
196
218
|
log(`no default route — joining fallback network "${ssid}"`);
|
|
219
|
+
let settleMs = REJOIN_SETTLE_MS;
|
|
197
220
|
const joined = await joinNetwork(ssid);
|
|
198
221
|
if (!joined.ok) {
|
|
199
222
|
log(`join "${ssid}" failed: ${joined.error}`);
|
|
200
|
-
|
|
223
|
+
// networksetup can only join a network that is broadcasting, and a personal
|
|
224
|
+
// hotspot usually isn't — its row in the Wi-Fi menu arrives over Continuity,
|
|
225
|
+
// and pressing it wakes the hotspot the way clicking it by hand does. Tried
|
|
226
|
+
// for every failed candidate rather than only hotspot-looking names: the
|
|
227
|
+
// name heuristic never decides anything (see looksLikeHotspot), and for an
|
|
228
|
+
// ordinary network that's simply out of range the press fails in words
|
|
229
|
+
// ("not in the Wi-Fi menu") that cost one popover flash.
|
|
230
|
+
log(`pressing the Wi-Fi menu's "${ssid}" row instead (Instant Hotspot)`);
|
|
231
|
+
const pressed = await joinInstantHotspot(ssid);
|
|
232
|
+
if (!pressed.ok) {
|
|
233
|
+
log(`Instant Hotspot press for "${ssid}" failed: ${pressed.error}`);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
settleMs = HOTSPOT_SETTLE_MS;
|
|
201
237
|
}
|
|
202
|
-
|
|
203
|
-
if (await hasDefaultRoute()) {
|
|
238
|
+
if (await routeAppeared(settleMs)) {
|
|
204
239
|
log(`joined "${ssid}" and the link is up`);
|
|
205
240
|
return true;
|
|
206
241
|
}
|
|
207
|
-
log(`joined "${ssid}" but no default route appeared after ${
|
|
242
|
+
log(`joined "${ssid}" but no default route appeared after ${settleMs / 1000}s`);
|
|
208
243
|
}
|
|
209
244
|
return false;
|
|
210
245
|
}
|
package/dist-node/src/notify.js
CHANGED
|
@@ -218,6 +218,15 @@ export async function notifyDevice(id, message) {
|
|
|
218
218
|
export function deviceCount() {
|
|
219
219
|
return load().devices.length;
|
|
220
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Where a tapped notification lands. The chat id rides along because the phone
|
|
223
|
+
* otherwise picks the tab itself (Conductor's active session, else the first one)
|
|
224
|
+
* — and on a multi-chat workspace that is rarely the chat that just finished.
|
|
225
|
+
* Kept here so the notifier and the parked-prompt queue can't drift apart.
|
|
226
|
+
*/
|
|
227
|
+
export function chatRoute(workspaceId, sessionId) {
|
|
228
|
+
return `/w/${workspaceId}?session=${encodeURIComponent(sessionId)}`;
|
|
229
|
+
}
|
|
221
230
|
/** Collapse a transcript entry to one lock-screen line: no code fences, no blank runs. */
|
|
222
231
|
function oneLine(text) {
|
|
223
232
|
return clip(text
|
|
@@ -287,9 +296,11 @@ async function fire(reads, sessionId, kind, state) {
|
|
|
287
296
|
const sent = await notifyAll({
|
|
288
297
|
title: state.repoName ? `${where} — ${state.repoName}` : where,
|
|
289
298
|
body,
|
|
290
|
-
// Per
|
|
291
|
-
|
|
292
|
-
|
|
299
|
+
// Per chat, so a chatty agent replaces its own notification instead of stacking —
|
|
300
|
+
// and two chats in one workspace stay separately tappable, since each now lands
|
|
301
|
+
// somewhere different.
|
|
302
|
+
tag: sessionId,
|
|
303
|
+
url: chatRoute(state.workspaceId, sessionId),
|
|
293
304
|
kind,
|
|
294
305
|
ts: Date.now()
|
|
295
306
|
});
|
package/dist-node/src/server.js
CHANGED
|
@@ -12,7 +12,7 @@ import { workspaceDiff } from "./git.js";
|
|
|
12
12
|
import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
|
|
13
13
|
import { mergePr } from "./merge.js";
|
|
14
14
|
import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
|
|
15
|
-
import { notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
|
|
15
|
+
import { chatRoute, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
|
|
16
16
|
import { ParkedPromptQueue } from "./parked.js";
|
|
17
17
|
import { attachPrStatus } from "./pr.js";
|
|
18
18
|
import { Reads } from "./reads.js";
|
|
@@ -288,7 +288,7 @@ const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompt
|
|
|
288
288
|
body: error ? `Parked prompt failed: ${error}` : `Sent after unlock: ${preview}`,
|
|
289
289
|
// Per chat, so a second parked prompt replaces the first's notification.
|
|
290
290
|
tag: `parked-${entry.sessionId}`,
|
|
291
|
-
url:
|
|
291
|
+
url: chatRoute(entry.workspaceId, entry.sessionId),
|
|
292
292
|
kind: error ? 'error' : 'done',
|
|
293
293
|
ts: Date.now()
|
|
294
294
|
});
|
package/dist-node/src/writes.js
CHANGED
|
@@ -261,12 +261,12 @@ function targetEnv(target) {
|
|
|
261
261
|
};
|
|
262
262
|
}
|
|
263
263
|
/** osascript echoes the whole failing script back; keep just the reason for the phone. */
|
|
264
|
-
function osaError(err) {
|
|
264
|
+
function osaError(err, timeoutMsg = 'Conductor took too long to respond') {
|
|
265
265
|
const raw = err instanceof Error ? err.message : String(err);
|
|
266
266
|
// A timeout kill carries no execution error at all — its first line is
|
|
267
267
|
// "Command failed: osascript -e" plus the whole script, which is useless here.
|
|
268
268
|
if (err && typeof err === 'object' && 'killed' in err && err.killed) {
|
|
269
|
-
return
|
|
269
|
+
return timeoutMsg;
|
|
270
270
|
}
|
|
271
271
|
return raw.match(/execution error: (.+?) \(-?\d+\)/)?.[1] ?? raw.split('\n')[0];
|
|
272
272
|
}
|
|
@@ -368,6 +368,48 @@ return "ok"`.trim();
|
|
|
368
368
|
return { ok: false, strategy: 'applescript', error: osaError(err) };
|
|
369
369
|
}
|
|
370
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Press the Wi-Fi menu's row for a personal hotspot — Instant Hotspot, the same
|
|
373
|
+
* button a human clicks. `networksetup` can only join a network that is
|
|
374
|
+
* broadcasting, and a personal hotspot usually isn't; the row in Control
|
|
375
|
+
* Center's Wi-Fi popover is fed by Continuity over Bluetooth, and pressing it
|
|
376
|
+
* asks the phone to wake its hotspot. The funnel watchdog reaches for this when
|
|
377
|
+
* a plain join answered "Could not find network".
|
|
378
|
+
*
|
|
379
|
+
* The one UI write here that doesn't target Conductor, and it still takes a
|
|
380
|
+
* uiTurn: the popover steals key focus, so a palette fallback running at the
|
|
381
|
+
* same moment would type into it. The name travels via a temp file like the
|
|
382
|
+
* prompt does — same escaping-and-encoding dodge, and hotspot names ("Han
|
|
383
|
+
* høyes iPhone") are non-ASCII more often than prompts are. Success here means
|
|
384
|
+
* *pressed*, nothing more: joining takes several seconds of Bluetooth wake +
|
|
385
|
+
* DHCP, so the caller owns the wait, and it watches `hasDefaultRoute()` — the
|
|
386
|
+
* one link signal that needs no permission — not this function's word.
|
|
387
|
+
* Everything else — the lock check, the toggle-aware close, the already-open
|
|
388
|
+
* abort, the contention story — lives with the handler in conductor.applescript.
|
|
389
|
+
*/
|
|
390
|
+
export async function joinInstantHotspot(name) {
|
|
391
|
+
const script = `
|
|
392
|
+
${CONDUCTOR_HANDLERS}
|
|
393
|
+
|
|
394
|
+
my joinInstantHotspot()`.trim();
|
|
395
|
+
const os = await import('node:os');
|
|
396
|
+
const fs = await import('node:fs/promises');
|
|
397
|
+
const tmp = path.join(os.tmpdir(), `relay-hotspot-${process.pid}-${Date.now()}.txt`);
|
|
398
|
+
await fs.writeFile(tmp, name, 'utf8');
|
|
399
|
+
try {
|
|
400
|
+
await uiTurn(() => exec('osascript', ['-e', script], {
|
|
401
|
+
env: { ...process.env, RELAY_HOTSPOT_FILE: tmp },
|
|
402
|
+
timeout: 25_000
|
|
403
|
+
}));
|
|
404
|
+
return { ok: true };
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
return { ok: false, error: osaError(err, 'the Wi-Fi menu press took too long') };
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
371
413
|
/**
|
|
372
414
|
* Conductor stores the effort level as `sessions.claude_effort_level`, but the
|
|
373
415
|
* composer button is labelled with the human name and *cycles* through them in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "yarn@4.15.0",
|
|
6
6
|
"description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
|