1import { deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts";
2import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
3
4export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
5
6export const remoteDeviceEnvironment = `export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
7if [ -z "$ANDROID_HOME" ]; then
8 if [ -d "$HOME/Library/Android/sdk" ]; then export ANDROID_HOME="$HOME/Library/Android/sdk";
9 elif [ -d "$HOME/Android/Sdk" ]; then export ANDROID_HOME="$HOME/Android/Sdk"; fi
10fi
11if [ -n "$ANDROID_HOME" ]; then export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"; fi
12if [ -z "$JAVA_HOME" ] && ! command -v java >/dev/null 2>&1; then
13 for device_java_home in "$HOME/.local/opt/android-studio/jbr" /opt/android-studio/jbr /Applications/Android\\ Studio.app/Contents/jbr "$HOME/Applications/Android Studio.app/Contents/jbr"; do
14 if [ -x "$device_java_home/bin/java" ]; then export JAVA_HOME="$device_java_home"; break; fi
15 done
16fi
17if [ -n "$JAVA_HOME" ]; then export PATH="$JAVA_HOME/bin:$PATH"; fi
18`;
19
20/** Runs on the host and returns host-local paths. */
21export const remoteDeviceScript = (
22 owner: string,
23 mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
24) =>
25 `
26const owner = ${JSON.stringify(owner)};
27const mode = ${JSON.stringify(mode)};
28const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)};
29const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)};
30` +
31 deviceToolMaintenanceScript +
32 String.raw`
33const fs = require('node:fs');
34const path = require('node:path');
35const os = require('node:os');
36const net = require('node:net');
37const { spawn, spawnSync } = require('node:child_process');
38const root = path.join(os.homedir(), '.t3', 'device');
39const state = path.join(root, 'hosts', owner);
40const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options });
41const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
42const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); };
43const toolVersions = (name, requiredVersion, entry, record) => {
44 const directory = path.join(root, 'tools');
45 const prefix = name + '@';
46 let names = [];
47 try { names = fs.readdirSync(directory); } catch (error) { if (error.code !== 'ENOENT') return null; }
48 let unreadable = false;
49 const installedVersions = names.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length)).filter(version => {
50 if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) return false;
51 const dir = path.join(directory, prefix + version);
52 try { return fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version && fs.existsSync(path.join(dir, 'node_modules', name, entry)); } catch (error) { if (error.code !== 'ENOENT') unreadable = true; return false; }
53 }).sort();
54 if (unreadable) return null;
55 let runningVersion = null;
56 if (record?.entryPath && record?.pid) {
57 const command = run('ps', ['-p', String(record.pid), '-o', 'command=']).stdout || '';
58 runningVersion = installedVersions.find(version => {
59 const install = path.join(directory, prefix + version);
60 return record.entryPath === path.join(install, 'node_modules', name, entry) && command.includes(install + path.sep);
61 }) ?? null;
62 }
63 return { requiredVersion, installedVersions, runningVersion };
64};
65const versions = () => {
66 const result = {
67 hub: toolVersions('expo-device-hub', hubVersion, 'dist/server/cli.mjs', read(path.join(state, 'hub.json'))),
68 agent: toolVersions('agent-device', agentVersion, 'bin/agent-device.mjs', { ...read(path.join(state, 'agent.json')), ...read(path.join(state, 'daemon.json')) }),
69 };
70 return result.hub && result.agent ? result : undefined;
71};
72const stopHub = hub => {
73 if (!hub || hub.owner !== owner) return;
74 const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || '';
75 if (command.includes(hub.entryPath) && command.includes(String(hub.port))) {
76 try { process.kill(hub.pid, 'SIGTERM'); } catch {}
77 }
78};
79const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
80const healthy = async (port, route) => { try { return (await fetch('http://127.0.0.1:' + port + route, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; } };
81const port = () => new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const value = server.address().port; server.close(() => resolve(value)); }); });
82async function acquireLock(lock, complete = () => false) {
83 const deadline = Date.now() + 600000;
84 const token = process.pid + ':' + require('node:crypto').randomUUID();
85 const owner = () => { try { return fs.readlinkSync(lock); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
86 while (true) {
87 try {
88 // Publishing the PID and token is atomic; suspension cannot leave an incomplete owner.
89 fs.symlinkSync(token, lock);
90 return () => { if (owner() === token) fs.unlinkSync(lock); };
91 } catch (error) {
92 if (error.code !== 'EEXIST') throw error;
93 if (complete()) return null;
94 const previous = owner();
95 if (previous === null) continue;
96 const pid = Number(previous.split(':')[0]);
97 if (!Number.isSafeInteger(pid) || pid <= 0) throw Error('Invalid device lock at ' + lock);
98 try { process.kill(pid, 0); } catch (error) {
99 if (error.code === 'ESRCH' && owner() === previous) {
100 try { fs.unlinkSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; }
101 continue;
102 }
103 }
104 if (Date.now() > deadline) throw Error('Device operation is locked at ' + lock + '. Check the other installer before removing the lock.');
105 await sleep(500);
106 }
107 }
108}
109async function install(name, version, entry) {
110 const dir = path.join(root, 'tools', name + '@' + version);
111 const file = path.join(dir, 'node_modules', name, entry);
112 const complete = () => fs.existsSync(file) && fs.existsSync(path.join(dir, '.install-complete')) && fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version;
113 if (complete()) return file;
114 fs.mkdirSync(path.dirname(dir), { recursive: true });
115 const lock = dir + '.lock';
116 const release = await acquireLock(lock, complete);
117 if (!release) return file;
118 let staging;
119 try {
120 if (complete()) return file;
121 staging = fs.mkdtempSync(path.join(path.dirname(dir), '.install-'));
122 const result = run('npm', ['install', '--prefix', staging, '--no-fund', '--no-audit', name + '@' + version], { timeout: 600000, maxBuffer: 8 * 1024 * 1024 });
123 if (result.status !== 0) throw Error('Installing ' + name + ': ' + (result.error?.message || result.stderr?.slice(-2000)));
124 if (!fs.existsSync(path.join(staging, 'node_modules', name, entry))) throw Error('Missing installed entry for ' + name);
125 fs.writeFileSync(path.join(staging, '.install-complete'), version);
126 fs.rmSync(dir, { recursive: true, force: true });
127 fs.renameSync(staging, dir);
128 return file;
129 } finally {
130 if (staging) fs.rmSync(staging, { recursive: true, force: true });
131 release();
132 }
133}
134(async () => {
135 const ios = process.platform === 'darwin' && run('xcrun', ['simctl', 'help']).status === 0;
136 const android = run('adb', ['version']).status === 0;
137 const platforms = [
138 { platform: 'ios', available: ios, ...(!ios ? { reason: 'iOS needs macOS with Xcode and working xcrun simctl.' } : {}) },
139 { platform: 'android', available: android, ...(!android ? { reason: 'Android SDK missing. Set ANDROID_HOME or put adb on the SSH PATH.' } : {}) },
140 ];
141 if (mode === 'probe') {
142 if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.');
143 if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.');
144 console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions() })); return;
145 }
146 fs.mkdirSync(state, { recursive: true, mode: 0o700 });
147 // Serialize starts and stops for this environment/host owner, including agent startup.
148 const hostLock = path.join(state, 'runtime.lock');
149 const releaseHost = await acquireLock(hostLock);
150 try {
151 const hubFile = path.join(state, 'hub.json');
152 const daemonFile = path.join(state, 'daemon.json');
153 const agentFile = path.join(state, 'agent.json');
154 const stopAgent = () => {
155 const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs');
156 if (fs.existsSync(entry)) return run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]).status === 0;
157 // A running daemon outlives its deleted CLI, so without one only a recorded pid that is gone counts as stopped.
158 const pid = read(daemonFile)?.pid;
159 return Number.isSafeInteger(pid) && pid > 0 && !maintenanceAlive(pid);
160 };
161 if (mode === 'stop' || mode === 'stop-agent') {
162 const hub = read(hubFile);
163 if (mode === 'stop' && hub && hub.owner === owner) {
164 stopHub(hub);
165 fs.rmSync(hubFile, { force: true });
166 }
167 stopAgent();
168 return;
169 }
170 // A start without agent tools means the server holds no claims, so a daemon still here is
171 // orphaned. Agent access was turned off while the tunnel was down, or a server crashed.
172 // Close skips the release when start returns no endpoint, so the claims must end here.
173 if (mode === 'start' && read(daemonFile)) {
174 if (!stopAgent()) throw Error('Could not stop the agent-device daemon in ' + state);
175 fs.rmSync(daemonFile, { force: true });
176 }
177 if (!ios && !android) throw Error(platforms.map(p => p.reason).join(' '));
178 fs.mkdirSync(state, { recursive: true, mode: 0o700 });
179 const hubEntry = await install('expo-device-hub', hubVersion, 'dist/server/cli.mjs');
180 // Only a Mac can list Apple simulators. Recording the filter replaces hubs started without it.
181 const hubPlatform = process.platform === 'darwin' ? undefined : 'android';
182 const cameraArgs = android ? ['front', 'back'].flatMap(facing => {
183 const emulator = process.env.ANDROID_HOME ? path.join(process.env.ANDROID_HOME, 'emulator', 'emulator') : 'emulator';
184 const help = run(emulator, ['-help-camera-' + facing], { timeout: 5000 });
185 const text = (help.stdout || '') + '\n' + (help.stderr || '');
186 // The hub's imagefile camera mode is not supported by every installed SDK emulator.
187 return help.status === 0 && /\bemulated\b/.test(text) && !/\bimagefile\b/.test(text) ? ['-camera-' + facing, 'emulated'] : [];
188 }) : [];
189 const emulatorExtraArgs = [cameraArgs.join(' '), process.env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS].filter(Boolean).join(' ');
190 let hub = read(hubFile);
191 if (!hub || hub.owner !== owner || hub.entryPath !== hubEntry || hub.platform !== hubPlatform || hub.emulatorExtraArgs !== emulatorExtraArgs || !await healthy(hub.port, '/readyz')) {
192 stopHub(hub);
193 for (let attempt = 0; attempt < 5; attempt++) {
194 const hubPort = await port();
195 const log = fs.openSync(path.join(state, 'hub.log'), 'a');
196 const child = spawn(process.execPath, [hubEntry, '--port', String(hubPort), '--host', '127.0.0.1', '--hide-sidebar', '--hide-boot-device', ...(hubPlatform ? ['--platform', hubPlatform] : [])], {
197 cwd: state, detached: true, stdio: ['ignore', log, log], env: { ...process.env, EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS: emulatorExtraArgs, FORCE_COLOR: '0', NO_COLOR: '1' },
198 });
199 try { await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); }
200 finally { fs.closeSync(log); }
201 child.unref();
202 hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry, platform: hubPlatform, emulatorExtraArgs };
203 write(hubFile, hub);
204 const deadline = Date.now() + 30000;
205 let listening = false;
206 while (child.exitCode === null && child.signalCode === null) {
207 if (await healthy(hub.port, '/readyz')) { listening = true; break; }
208 if (Date.now() > deadline) { stopHub(hub); throw Error('Device hub did not become ready. See ' + path.join(state, 'hub.log')); }
209 await sleep(200);
210 }
211 if (listening) break;
212 // Port reservation and binding happen in different processes. Retry an early exit with a fresh port.
213 fs.rmSync(hubFile, { force: true });
214 if (attempt === 4) throw Error('Device hub exited before becoming ready. See ' + path.join(state, 'hub.log'));
215 }
216 }
217 let agentResult = {};
218 if (mode === 'agent-start') {
219 const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs');
220 const previousAgent = read(agentFile)?.entryPath;
221 let daemon = read(daemonFile);
222 if (daemon && (previousAgent !== agentEntry || !await healthy(daemon.httpPort, '/health'))) {
223 const stopped = run(process.execPath, [previousAgent || agentEntry, 'daemon', 'stop', '--state-dir', state]);
224 if (stopped.status !== 0) throw Error('Could not stop the previous agent-device version.');
225 fs.rmSync(daemonFile, { force: true });
226 daemon = null;
227 }
228 if (!daemon) {
229 fs.rmSync(daemonFile, { force: true });
230 const env = { ...process.env, AGENT_DEVICE_STATE_DIR: state, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', AGENT_DEVICE_NO_UPDATE_NOTIFIER: '1' };
231 delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG;
232 run(process.execPath, [agentEntry, 'devices', '--json'], { env });
233 daemon = read(daemonFile);
234 }
235 if (!daemon || !await healthy(daemon.httpPort, '/health')) throw Error('agent-device daemon did not become ready in ' + state);
236 write(agentFile, { entryPath: agentEntry });
237 agentResult = { daemonPort: daemon.httpPort, token: daemon.token, entryPath: agentEntry };
238 }
239 const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist');
240 const optional = file => fs.existsSync(file) ? file : null;
241 await pruneTools(path.join(root, 'tools'), [['expo-device-hub', hubVersion], ...(mode === 'agent-start' ? [['agent-device', agentVersion]] : [])], true).catch(() => {});
242 console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult,
243 helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } }));
244 } finally { releaseHost(); }
245})().catch(error => { console.error(error.message); process.exitCode = 1; });
246`;