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