pixelremotecontrol 0.1.0 → 0.2.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/README.md CHANGED
@@ -22,6 +22,22 @@ If an older manual installation is still on `PATH`, check which launcher wins
22
22
  with `Get-Command pixelremotecontrol -All`. Move the old launcher out of `PATH`
23
23
  so the npm `.cmd` is selected.
24
24
 
25
+ On an Apple Silicon or Intel Mac:
26
+
27
+ ```sh
28
+ npm install -g pixelremotecontrol
29
+ pixelremotecontrol setup --host YOUR_RELAY_TAILSCALE_IP --user YOUR_SSH_USERNAME
30
+ pixelremotecontrol control local pixel8
31
+ ```
32
+
33
+ Mac `setup` downloads the official macOS scrcpy bundle for the Mac's processor,
34
+ verifies its SHA-256 checksum, and caches its `scrcpy` and `adb` executables.
35
+ The Mac needs an SSH client and Tailscale access to the relay. SSH prompts for
36
+ authentication when needed; this package does not save an SSH password.
37
+ `pixelremotecontrol status local pixel8`, `wake`, `lock`, and `restart` also
38
+ work from the Mac. `control asml pixel8` must be run in ASML's interactive
39
+ Windows desktop to put the window there.
40
+
25
41
  In Termux on the configured Pixel:
26
42
 
27
43
  ```sh
@@ -51,5 +67,14 @@ phone's key can use. This package cannot complete Tailscale sign-in or
51
67
  Android's first-time ADB pairing for you.
52
68
 
53
69
  `pixelremotecontrol wake local pixel8`, `lock`, `restart`, and `status` work on
54
- the Windows viewer. `control asml pixel8` opens a window on the ASML desktop
70
+ Windows and macOS viewers. `control asml pixel8` opens a window on the ASML desktop
55
71
  when run there. The current relay scripts support one configured Pixel 8.
72
+
73
+ The mirror can show and control ordinary Android screens, including Settings
74
+ and the lock screen, while Android, Wireless debugging, and the phone's Termux
75
+ relay stay active. Enter the phone's passcode in the scrcpy window when Android
76
+ allows input there; the package does not know or bypass it. Some protected
77
+ screens may be blank. After a reboot, a person must perform the first local
78
+ unlock and restart the Termux service. The bootloader and fastboot screens do
79
+ not run Android or scrcpy, so they require a separate USB host and an external
80
+ way to see the physical display.
@@ -22,14 +22,17 @@ function usage() {
22
22
  'Usage:\n' +
23
23
  ' pixelremotecontrol setup [--host RELAY_HOST --user SSH_USER]\n' +
24
24
  ' pixelremotecontrol startservice pixel8 asml [CONNECT_PORT] (Termux on Pixel)\n' +
25
- ' pixelremotecontrol control local pixel8 [options] (Windows viewer)\n' +
25
+ ' pixelremotecontrol control local pixel8 [options] (Windows or Mac viewer)\n' +
26
26
  ' pixelremotecontrol control asml pixel8 (ASML desktop)\n' +
27
27
  ' pixelremotecontrol <wake|lock|restart|status> local pixel8\n' +
28
- '\nRun setup once on each Windows viewer to save its relay address.\n'
28
+ '\nRun setup once on each Windows or Mac viewer to save its relay address.\n'
29
29
  );
30
30
  }
31
31
 
32
32
  function viewerConfigPath() {
33
+ if (process.platform === 'darwin') {
34
+ return path.join(os.homedir(), 'Library', 'Application Support', 'PixelRemoteControl', 'config.json');
35
+ }
33
36
  const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
34
37
  return path.join(localAppData, 'PixelRemoteControl', 'config.json');
35
38
  }
@@ -223,7 +226,17 @@ async function main() {
223
226
  process.stdout.write(`Viewer tools ready: ${tools.scrcpyPath}\n`);
224
227
  return 0;
225
228
  }
226
- throw new Error('This package currently supports Windows viewers and Termux on Android.');
229
+ if (process.platform === 'darwin') {
230
+ await setupViewerConfig(args.slice(1));
231
+ if (!commandExists('ssh')) {
232
+ throw new Error('OpenSSH is required. Install the macOS Command Line Tools, then retry setup.');
233
+ }
234
+ const { ensureMacScrcpy } = require('../lib/mac-dependencies.js');
235
+ const tools = await ensureMacScrcpy();
236
+ process.stdout.write(`Viewer tools ready: ${tools.scrcpyPath}\n`);
237
+ return 0;
238
+ }
239
+ throw new Error('This package supports Windows or Mac viewers and Termux on Android.');
227
240
  }
228
241
 
229
242
  if (action === 'startservice') {
@@ -235,8 +248,18 @@ async function main() {
235
248
  }
236
249
 
237
250
  if (actions.has(action)) {
251
+ if (process.platform === 'darwin') {
252
+ const display = args[1] || 'local';
253
+ if (action === 'control' && display.toLowerCase() === 'asml') {
254
+ throw new Error('ASML screen mode needs an interactive Windows desktop session on ASML. Use "control local pixel8" on this Mac, or open an ASML desktop through Remote Desktop.');
255
+ }
256
+ const { runMacAction } = require('../lib/mac-viewer.js');
257
+ return runMacAction(action, display, args[2] || 'pixel8', [
258
+ ...args.slice(3), ...viewerRelayArgs(args),
259
+ ]);
260
+ }
238
261
  if (process.platform !== 'win32') {
239
- throw new Error('Run screen control on a Windows viewer or ASML desktop.');
262
+ throw new Error('Run screen control on a Windows or Mac viewer, or on the ASML desktop.');
240
263
  }
241
264
  const script = path.join(assets, 'viewer', 'pixelremotecontrol-core.ps1');
242
265
  const powershellArgs = [
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const fsp = fs.promises;
5
+ const path = require('node:path');
6
+ const os = require('node:os');
7
+ const https = require('node:https');
8
+ const crypto = require('node:crypto');
9
+ const { Transform } = require('node:stream');
10
+ const { pipeline } = require('node:stream/promises');
11
+ const { spawnSync } = require('node:child_process');
12
+
13
+ // These checksums are published in scrcpy's official macOS installation guide.
14
+ // The official release archives include both scrcpy and adb.
15
+ const VERSION = '5.0.1';
16
+ const ARCHIVES = Object.freeze({
17
+ arm64: Object.freeze({
18
+ name: `scrcpy-macos-aarch64-v${VERSION}`,
19
+ sha256: '33611e51977a8289e2e124b220f727895181ef2eb9060e0987a4dc2db9cf0d6b',
20
+ }),
21
+ x64: Object.freeze({
22
+ name: `scrcpy-macos-x86_64-v${VERSION}`,
23
+ sha256: '31a5467a9e907f162b093267cbbab1276b15c6e709c2ea8159566a8d165beb50',
24
+ }),
25
+ });
26
+
27
+ const MAX_DOWNLOAD_BYTES = 250 * 1024 * 1024;
28
+ const MAX_REDIRECTS = 6;
29
+
30
+ function cacheRoot() {
31
+ return process.env.PIXELREMOTECONTROL_CACHE_DIR
32
+ ? path.resolve(process.env.PIXELREMOTECONTROL_CACHE_DIR)
33
+ : path.join(os.homedir(), 'Library', 'Caches', 'PixelRemoteControl', 'tools');
34
+ }
35
+
36
+ function isContainedChild(parent, child) {
37
+ const relative = path.relative(parent, child);
38
+ return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
39
+ }
40
+
41
+ async function existingBundle(dir) {
42
+ const scrcpyPath = path.join(dir, 'scrcpy');
43
+ const adbPath = path.join(dir, 'adb');
44
+ try {
45
+ const [scrcpy, adb] = await Promise.all([fsp.stat(scrcpyPath), fsp.stat(adbPath)]);
46
+ if (!scrcpy.isFile() || !adb.isFile()) return null;
47
+ await Promise.all([fsp.access(scrcpyPath, fs.constants.X_OK), fsp.access(adbPath, fs.constants.X_OK)]);
48
+ return { scrcpyPath, adbPath };
49
+ } catch (error) {
50
+ if (['ENOENT', 'EACCES', 'EPERM'].includes(error.code)) return null;
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ function download(url, destination, expectedSha256, redirects = 0) {
56
+ if (redirects > MAX_REDIRECTS) {
57
+ return Promise.reject(new Error('Too many redirects while downloading scrcpy.'));
58
+ }
59
+ if (new URL(url).protocol !== 'https:') {
60
+ return Promise.reject(new Error('The scrcpy download must use HTTPS.'));
61
+ }
62
+
63
+ return new Promise((resolve, reject) => {
64
+ const request = https.get(url, {
65
+ headers: { 'User-Agent': `pixelremotecontrol/${VERSION}` },
66
+ timeout: 30000,
67
+ }, async (response) => {
68
+ try {
69
+ if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
70
+ const location = response.headers.location;
71
+ response.resume();
72
+ if (!location) throw new Error('The scrcpy download redirect had no destination.');
73
+ await download(new URL(location, url).toString(), destination, expectedSha256, redirects + 1);
74
+ resolve();
75
+ return;
76
+ }
77
+ if (response.statusCode !== 200) {
78
+ response.resume();
79
+ throw new Error(`The scrcpy download failed (HTTP ${response.statusCode}).`);
80
+ }
81
+ const length = Number(response.headers['content-length']);
82
+ if (Number.isFinite(length) && length > MAX_DOWNLOAD_BYTES) {
83
+ response.resume();
84
+ throw new Error('The scrcpy archive exceeds the download size limit.');
85
+ }
86
+
87
+ const hash = crypto.createHash('sha256');
88
+ let bytes = 0;
89
+ const meter = new Transform({
90
+ transform(chunk, _encoding, callback) {
91
+ bytes += chunk.length;
92
+ if (bytes > MAX_DOWNLOAD_BYTES) {
93
+ callback(new Error('The scrcpy archive exceeds the download size limit.'));
94
+ return;
95
+ }
96
+ hash.update(chunk);
97
+ callback(null, chunk);
98
+ },
99
+ });
100
+ await pipeline(response, meter, fs.createWriteStream(destination, { flags: 'wx' }));
101
+ if (hash.digest('hex') !== expectedSha256) {
102
+ throw new Error('The scrcpy archive failed SHA-256 verification.');
103
+ }
104
+ resolve();
105
+ } catch (error) {
106
+ reject(error);
107
+ }
108
+ });
109
+ request.on('timeout', () => request.destroy(new Error('The scrcpy download timed out.')));
110
+ request.on('error', reject);
111
+ });
112
+ }
113
+
114
+ async function ensureMacScrcpy() {
115
+ if (process.platform !== 'darwin') {
116
+ throw new Error('macOS scrcpy installation is available only on macOS.');
117
+ }
118
+ const archive = ARCHIVES[process.arch];
119
+ if (!archive) {
120
+ throw new Error(`Unsupported macOS architecture: ${process.arch}.`);
121
+ }
122
+
123
+ const explicitScrcpy = process.env.PIXELREMOTECONTROL_SCRCPY;
124
+ if (explicitScrcpy && process.env.PIXELREMOTECONTROL_FORCE_DOWNLOAD !== '1') {
125
+ const executable = path.resolve(explicitScrcpy);
126
+ const bundle = await existingBundle(path.dirname(executable));
127
+ if (!bundle || bundle.scrcpyPath !== executable) {
128
+ throw new Error('PIXELREMOTECONTROL_SCRCPY must point to an executable scrcpy with an executable adb beside it.');
129
+ }
130
+ return bundle;
131
+ }
132
+
133
+ const root = cacheRoot();
134
+ const finalDir = path.join(root, archive.name);
135
+ const cached = await existingBundle(finalDir);
136
+ if (cached) return cached;
137
+
138
+ await fsp.mkdir(root, { recursive: true });
139
+ const stage = await fsp.mkdtemp(path.join(root, '.scrcpy-stage-'));
140
+ try {
141
+ const archivePath = path.join(stage, `${archive.name}.tar.gz`);
142
+ const extractedRoot = path.join(stage, 'extracted');
143
+ await fsp.mkdir(extractedRoot);
144
+ const url = `https://github.com/Genymobile/scrcpy/releases/download/v${VERSION}/${archive.name}.tar.gz`;
145
+ process.stderr.write(`Downloading scrcpy ${VERSION} for macOS ${process.arch}...\n`);
146
+ await download(url, archivePath, archive.sha256);
147
+
148
+ const extraction = spawnSync('/usr/bin/tar', ['-xzf', archivePath, '-C', extractedRoot], {
149
+ encoding: 'utf8',
150
+ maxBuffer: 2 * 1024 * 1024,
151
+ });
152
+ if (extraction.error) throw extraction.error;
153
+ if (extraction.status !== 0) {
154
+ throw new Error(`Could not extract scrcpy: ${(extraction.stderr || '').trim() || `tar exited ${extraction.status}`}`);
155
+ }
156
+
157
+ const extractedDir = path.join(extractedRoot, archive.name);
158
+ await Promise.all([
159
+ fsp.chmod(path.join(extractedDir, 'scrcpy'), 0o755),
160
+ fsp.chmod(path.join(extractedDir, 'adb'), 0o755),
161
+ ]);
162
+ if (!await existingBundle(extractedDir)) {
163
+ throw new Error('The verified scrcpy archive did not contain scrcpy and adb.');
164
+ }
165
+
166
+ try {
167
+ await fsp.rename(extractedDir, finalDir);
168
+ } catch (error) {
169
+ // Another process may have installed this exact version while we downloaded.
170
+ const winner = await existingBundle(finalDir);
171
+ if (winner) return winner;
172
+ if (error.code === 'EEXIST' || error.code === 'ENOTEMPTY') {
173
+ throw new Error(`The scrcpy cache at ${finalDir} is incomplete. Remove that directory and retry.`);
174
+ }
175
+ throw error;
176
+ }
177
+ return { scrcpyPath: path.join(finalDir, 'scrcpy'), adbPath: path.join(finalDir, 'adb') };
178
+ } finally {
179
+ if (isContainedChild(root, stage)) {
180
+ await fsp.rm(stage, { recursive: true, force: true });
181
+ }
182
+ }
183
+ }
184
+
185
+ module.exports = { ensureMacScrcpy };
@@ -0,0 +1,284 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const net = require('node:net');
5
+ const { spawn } = require('node:child_process');
6
+
7
+ const serial = '127.0.0.1:15555';
8
+ const supportedDevices = new Set(['pixel8', 'pixel_8', 'pixel-8', serial]);
9
+ const supportedActions = new Set(['control', 'wake', 'lock', 'restart', 'status']);
10
+ const defaults = Object.freeze({ sshPort: 22, adbForwardPort: 5038, videoPort: 27183, timeLimitSeconds: 0 });
11
+
12
+ function integerOption(value, name, minimum, maximum) {
13
+ if (!/^[0-9]+$/.test(value || '')) throw new Error(`${name} must be a number from ${minimum} to ${maximum}.`);
14
+ const number = Number(value);
15
+ if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
16
+ throw new Error(`${name} must be a number from ${minimum} to ${maximum}.`);
17
+ }
18
+ return number;
19
+ }
20
+
21
+ function parseMacOptions(args) {
22
+ const result = { ...defaults };
23
+ const names = new Map([
24
+ ['-centralhost', 'host'], ['-centraluser', 'user'], ['-sshport', 'sshPort'],
25
+ ['-adbforwardport', 'adbForwardPort'], ['-videoport', 'videoPort'],
26
+ ['-timelimitseconds', 'timeLimitSeconds'], ['-scrcpypath', 'scrcpyPath'], ['-adbpath', 'adbPath'],
27
+ ]);
28
+ const seen = new Set();
29
+ for (let index = 0; index < args.length; index += 2) {
30
+ const name = args[index];
31
+ const key = typeof name === 'string' ? names.get(name.toLowerCase()) : null;
32
+ const value = args[index + 1];
33
+ if (!key || !value) {
34
+ throw new Error(`Unknown or incomplete option '${name || ''}'. Run pixelremotecontrol --help.`);
35
+ }
36
+ if (seen.has(key)) throw new Error(`Specify ${name} only once.`);
37
+ seen.add(key);
38
+ result[key] = value;
39
+ }
40
+ if (!/^[A-Za-z0-9][A-Za-z0-9.:-]*$/.test(result.host || '')) {
41
+ throw new Error('Invalid relay host. Run pixelremotecontrol setup --host RELAY_HOST --user SSH_USER.');
42
+ }
43
+ if (!/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(result.user || '')) {
44
+ throw new Error('Invalid relay SSH username. Run pixelremotecontrol setup --host RELAY_HOST --user SSH_USER.');
45
+ }
46
+ result.sshPort = integerOption(result.sshPort.toString(), '-SshPort', 1, 65535);
47
+ result.adbForwardPort = integerOption(result.adbForwardPort.toString(), '-AdbForwardPort', 1024, 65535);
48
+ result.videoPort = integerOption(result.videoPort.toString(), '-VideoPort', 1024, 65535);
49
+ result.timeLimitSeconds = integerOption(result.timeLimitSeconds.toString(), '-TimeLimitSeconds', 0, 86400);
50
+ if (result.adbForwardPort === result.videoPort) {
51
+ throw new Error('-AdbForwardPort and -VideoPort must differ.');
52
+ }
53
+ return result;
54
+ }
55
+
56
+ function buildSshArgs(action, options) {
57
+ const destination = `${options.user}@${options.host}`;
58
+ if (action !== 'control') {
59
+ return ['-T', '-p', String(options.sshPort), destination, `pixelctl.cmd ${action}`];
60
+ }
61
+ return [
62
+ '-T', '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15',
63
+ '-o', 'ServerAliveCountMax=3', '-p', String(options.sshPort),
64
+ '-L', `127.0.0.1:${options.adbForwardPort}:127.0.0.1:5039`,
65
+ '-L', `127.0.0.1:${options.videoPort}:127.0.0.1:${options.videoPort}`,
66
+ destination, 'pixelctl.cmd serve',
67
+ ];
68
+ }
69
+
70
+ function buildScrcpyArgs(options) {
71
+ const args = ['--force-adb-forward', `--port=${options.videoPort}`, '--serial', serial];
72
+ if (options.timeLimitSeconds > 0) args.push(`--time-limit=${options.timeLimitSeconds}`);
73
+ return args;
74
+ }
75
+
76
+ function pause(ms) {
77
+ return new Promise((resolve) => setTimeout(resolve, ms));
78
+ }
79
+
80
+ function testPortOpen(port) {
81
+ return new Promise((resolve) => {
82
+ const socket = new net.Socket();
83
+ let done = false;
84
+ const finish = (open) => {
85
+ if (done) return;
86
+ done = true;
87
+ socket.destroy();
88
+ resolve(open);
89
+ };
90
+ socket.setTimeout(250);
91
+ socket.once('connect', () => finish(true));
92
+ socket.once('timeout', () => finish(false));
93
+ socket.once('error', () => finish(false));
94
+ socket.connect(port, '127.0.0.1');
95
+ });
96
+ }
97
+
98
+ function assertPortFree(port) {
99
+ return new Promise((resolve, reject) => {
100
+ const server = net.createServer();
101
+ server.once('error', () => reject(new Error(
102
+ `Local port ${port} is already in use. Close the other viewer or choose another forward port.`
103
+ )));
104
+ server.listen(port, '127.0.0.1', () => server.close(resolve));
105
+ });
106
+ }
107
+
108
+ function stopChild(child) {
109
+ if (!child || !child.pid || child.killed || child.exitCode !== null || child.signalCode !== null) return;
110
+ child.kill('SIGTERM');
111
+ const force = setTimeout(() => {
112
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
113
+ }, 3000);
114
+ force.unref();
115
+ child.once('close', () => clearTimeout(force));
116
+ }
117
+
118
+ function runChild(program, args, { env = process.env, stdio = 'inherit', timeoutMs = 0, tracked } = {}) {
119
+ return new Promise((resolve, reject) => {
120
+ let child;
121
+ try {
122
+ child = spawn(program, args, { env, stdio });
123
+ } catch (error) {
124
+ reject(new Error(`Cannot launch ${program}: ${error.message}`));
125
+ return;
126
+ }
127
+ if (tracked) tracked.add(child);
128
+ let output = '';
129
+ let timer;
130
+ if (stdio !== 'inherit') {
131
+ for (const stream of [child.stdout, child.stderr]) {
132
+ stream.on('data', (chunk) => { output = (output + chunk.toString()).slice(-16384); });
133
+ }
134
+ }
135
+ if (timeoutMs) {
136
+ timer = setTimeout(() => stopChild(child), timeoutMs);
137
+ timer.unref();
138
+ }
139
+ child.once('error', (error) => reject(new Error(`Cannot launch ${program}: ${error.message}`)));
140
+ child.once('close', (code, signal) => {
141
+ if (timer) clearTimeout(timer);
142
+ if (tracked) tracked.delete(child);
143
+ resolve({ code: code === null ? 1 : code, signal, output });
144
+ });
145
+ });
146
+ }
147
+
148
+ function checkSession(session) {
149
+ if (session.interrupted) throw new Error('Interrupted.');
150
+ if (session.sshError) throw new Error(`Could not open SSH: ${session.sshError.message}`);
151
+ if (session.sshClosed) {
152
+ throw new Error(`SSH ended with code ${session.sshCode === null ? 'unknown' : session.sshCode}. Check the relay login and Pixel service.`);
153
+ }
154
+ }
155
+
156
+ async function waitForForward(session, port) {
157
+ const deadline = Date.now() + 120000;
158
+ while (Date.now() < deadline) {
159
+ checkSession(session);
160
+ if (await testPortOpen(port)) return;
161
+ await pause(200);
162
+ }
163
+ throw new Error('SSH forwarding did not open within two minutes. Check the relay login and password prompt.');
164
+ }
165
+
166
+ async function deviceReady(adbPath, env, tracked) {
167
+ const connect = await runChild(adbPath, ['connect', serial], { env, stdio: 'pipe', timeoutMs: 10000, tracked });
168
+ if (connect.code !== 0) return { ready: false, message: connect.output.trim() };
169
+ const devices = await runChild(adbPath, ['devices', '-l'], { env, stdio: 'pipe', timeoutMs: 10000, tracked });
170
+ return {
171
+ ready: devices.code === 0 && new RegExp(`^${serial.replaceAll('.', '\\.')}\\s+device\\b`, 'm').test(devices.output),
172
+ message: devices.output.trim() || connect.output.trim(),
173
+ };
174
+ }
175
+
176
+ async function waitForDevice(session, adbPath, env, timeoutMs) {
177
+ const deadline = Date.now() + timeoutMs;
178
+ let lastMessage = '';
179
+ let announced = false;
180
+ while (Date.now() < deadline) {
181
+ checkSession(session);
182
+ const result = await deviceReady(adbPath, env, session.tracked);
183
+ if (result.ready) return;
184
+ lastMessage = result.message;
185
+ if (!announced) {
186
+ process.stdout.write('Waiting for the Pixel through the relay...\n');
187
+ announced = true;
188
+ }
189
+ await pause(1000);
190
+ }
191
+ const approval = /unauthorized/i.test(lastMessage)
192
+ ? ' Approve the Android debugging prompt on the Pixel.' : '';
193
+ throw new Error(`The Pixel did not become available through the relay. Ask the holder to run ` +
194
+ `'pixelremotecontrol startservice pixel8 asml' in Termux.${approval}` +
195
+ (lastMessage ? ` Last ADB response: ${lastMessage}` : ''));
196
+ }
197
+
198
+ async function runMacControl(options) {
199
+ const { ensureMacScrcpy } = require('./mac-dependencies.js');
200
+ const installed = options.scrcpyPath && options.adbPath ? null : await ensureMacScrcpy();
201
+ const scrcpyPath = options.scrcpyPath || installed.scrcpyPath;
202
+ const adbPath = options.adbPath || installed.adbPath;
203
+ for (const [name, program] of [['scrcpy', scrcpyPath], ['adb', adbPath]]) {
204
+ if (!fs.existsSync(program)) throw new Error(`${name} was not found at '${program}'.`);
205
+ }
206
+ await assertPortFree(options.adbForwardPort);
207
+ await assertPortFree(options.videoPort);
208
+
209
+ const session = { interrupted: false, sshClosed: false, sshCode: null, sshError: null, tracked: new Set() };
210
+ let interruptCode = 130;
211
+ const interrupt = (code) => {
212
+ interruptCode = code;
213
+ session.interrupted = true;
214
+ for (const child of session.tracked) stopChild(child);
215
+ };
216
+ const onSigint = () => interrupt(130);
217
+ const onSigterm = () => interrupt(143);
218
+ process.once('SIGINT', onSigint);
219
+ process.once('SIGTERM', onSigterm);
220
+ try {
221
+ const destination = `${options.user}@${options.host}`;
222
+ process.stdout.write(`Opening SSH to ${destination}. Complete any login prompt in this terminal.\n`);
223
+ const ssh = spawn('ssh', buildSshArgs('control', options), { stdio: 'inherit' });
224
+ session.tracked.add(ssh);
225
+ ssh.once('error', (error) => { session.sshError = error; });
226
+ ssh.once('close', (code) => {
227
+ session.sshClosed = true;
228
+ session.sshCode = code;
229
+ session.tracked.delete(ssh);
230
+ });
231
+
232
+ await waitForForward(session, options.adbForwardPort);
233
+ const env = {
234
+ ...process.env,
235
+ ADB_SERVER_SOCKET: `tcp:127.0.0.1:${options.adbForwardPort}`,
236
+ ADB: adbPath,
237
+ };
238
+ await waitForDevice(session, adbPath, env, 60000);
239
+ process.stdout.write('Pixel connected. Close the scrcpy window to end this session.\n');
240
+ for (let attempt = 0; attempt < 3; attempt++) {
241
+ checkSession(session);
242
+ const result = await runChild(scrcpyPath, buildScrcpyArgs(options), { env, tracked: session.tracked });
243
+ if (session.interrupted) return interruptCode;
244
+ if (result.code === 0) return 0;
245
+ checkSession(session);
246
+ if (attempt === 2) break;
247
+ const current = await deviceReady(adbPath, env, session.tracked);
248
+ // A transient scrcpy failure can leave ADB reporting the device as ready.
249
+ // Retry that case once; a persistent local error should fail promptly.
250
+ if (current.ready && attempt > 0) break;
251
+ process.stdout.write(`Screen connection dropped. Reconnecting (${attempt + 1}/2)...\n`);
252
+ if (current.ready) await pause(1500);
253
+ else await waitForDevice(session, adbPath, env, 30000);
254
+ }
255
+ throw new Error('scrcpy exited unexpectedly. Check the Pixel service and retry control local pixel8.');
256
+ } catch (error) {
257
+ if (session.interrupted) return interruptCode;
258
+ throw error;
259
+ } finally {
260
+ process.removeListener('SIGINT', onSigint);
261
+ process.removeListener('SIGTERM', onSigterm);
262
+ for (const child of session.tracked) stopChild(child);
263
+ }
264
+ }
265
+
266
+ async function runMacAction(action, display, deviceId, args) {
267
+ if (!supportedActions.has(action)) throw new Error(`Unknown action '${action}'. Run pixelremotecontrol --help.`);
268
+ const target = (display || 'local').toLowerCase();
269
+ if (!['local', 'thismachine', 'asml'].includes(target)) {
270
+ throw new Error(`Unknown display '${display}'. Use local or asml.`);
271
+ }
272
+ if (!supportedDevices.has((deviceId || 'pixel8').toLowerCase())) {
273
+ throw new Error(`Unknown device '${deviceId}'. This relay currently has one registered device: pixel8 (Pixel_8).`);
274
+ }
275
+ if (action === 'control' && target === 'asml') {
276
+ throw new Error('ASML screen mode needs an interactive Windows desktop session on ASML. Use "control local pixel8" on this Mac, or open an ASML desktop through Remote Desktop.');
277
+ }
278
+ const options = parseMacOptions(args);
279
+ if (action === 'control') return runMacControl(options);
280
+ const result = await runChild('ssh', buildSshArgs(action, options));
281
+ return result.code;
282
+ }
283
+
284
+ module.exports = { runMacAction, parseMacOptions, buildSshArgs, buildScrcpyArgs };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pixelremotecontrol",
3
- "version": "0.1.0",
4
- "description": "Control a paired Pixel through a private ASML SSH relay",
3
+ "version": "0.2.0",
4
+ "description": "Control a paired Pixel through a private SSH relay from Windows or macOS",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
7
7
  "contentPolicy": {