pixelremotecontrol 0.0.0-stage → 0.1.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/DISCLOSURE ADDED
@@ -0,0 +1,5 @@
1
+ # Authorized remote device control
2
+
3
+ This package mirrors and controls an Android device that its owner has deliberately paired for ADB Wireless debugging and connected to a trusted SSH relay. It can wake, lock, and restart that device. The phone holder starts sharing from Termux and can stop the session at any time. A viewer must authenticate to the relay and have access through its private network.
4
+
5
+ The commands are intended for development, support, and access to devices owned by the operator or used with the owner's permission. They do not bypass Android's lock screen, ADB pairing, SSH authentication, or Tailscale authorization. The package does not collect credentials or device data.
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # pixelremotecontrol
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Command line launcher for a paired Pixel 8 and a private SSH relay. Use it only
4
+ with a device you own or are authorized to control.
5
+
6
+ On a Windows viewer PC:
7
+
8
+ ```powershell
9
+ npm.cmd install -g pixelremotecontrol
10
+ pixelremotecontrol setup --host YOUR_RELAY_TAILSCALE_IP --user YOUR_SSH_USERNAME
11
+ pixelremotecontrol control local pixel8
12
+ ```
13
+
14
+ `setup` saves the relay address for this viewer and downloads the official
15
+ scrcpy Windows bundle (including `adb`). A later `control` run downloads it if
16
+ needed. Windows OpenSSH Client and Tailscale access to the relay are required.
17
+ SSH authentication is interactive; no password is stored by this package.
18
+ Use `npm.cmd` in PowerShell because Windows execution policy may block
19
+ `npm.ps1`. The `pixelremotecontrol` command uses a `.cmd` launcher and works
20
+ under a Restricted PowerShell execution policy.
21
+ If an older manual installation is still on `PATH`, check which launcher wins
22
+ with `Get-Command pixelremotecontrol -All`. Move the old launcher out of `PATH`
23
+ so the npm `.cmd` is selected.
24
+
25
+ In Termux on the configured Pixel:
26
+
27
+ ```sh
28
+ pkg install nodejs npm
29
+ npm install -g pixelremotecontrol
30
+ pixelremotecontrol setup
31
+ pixelremotecontrol startservice pixel8 asml
32
+ ```
33
+
34
+ If an older standalone `pixelremotecontrol` launcher is already in Termux's
35
+ `$PREFIX/bin`, save it before installing the npm package:
36
+
37
+ ```sh
38
+ mv "$PREFIX/bin/pixelremotecontrol" "$PREFIX/bin/pixelremotecontrol.pre-npm"
39
+ npm install -g pixelremotecontrol
40
+ ```
41
+
42
+ The command installs missing Termux `openssh`, `coreutils`, and `procps`
43
+ packages. It
44
+ uses the phone's existing relay config at `~/.config/pixel-connect/config`
45
+ and key at `~/.ssh/central_tunnel`; it never writes or replaces them. Android
46
+ Wireless debugging and relay ADB pairing must already be enabled. The command
47
+ prompts for the Android connection port when Termux cannot discover it.
48
+ The central machine must already have `pixelctl.cmd` provisioned, its ADB
49
+ service paired with the Pixel, and a restricted SSH tunnel account that the
50
+ phone's key can use. This package cannot complete Tailscale sign-in or
51
+ Android's first-time ADB pairing for you.
52
+
53
+ `pixelremotecontrol wake local pixel8`, `lock`, `restart`, and `status` work on
54
+ the Windows viewer. `control asml pixel8` opens a window on the ASML desktop
55
+ when run there. The current relay scripts support one configured Pixel 8.
@@ -0,0 +1,68 @@
1
+ #!/data/data/com.termux/files/usr/bin/bash
2
+ set -euo pipefail
3
+
4
+ config="${PIXEL_CONNECT_CONFIG:-$HOME/.config/pixel-connect/config}"
5
+ if [[ ! -r "$config" ]]; then
6
+ printf 'Missing %s. Copy config.example there and set CENTRAL_HOST and CENTRAL_USER.\n' "$config" >&2
7
+ exit 2
8
+ fi
9
+ # The config is owned and edited by the phone's Termux user.
10
+ # shellcheck source=/dev/null
11
+ source "$config"
12
+
13
+ CENTRAL_HOST="${CENTRAL_HOST:-}"
14
+ CENTRAL_USER="${CENTRAL_USER:-}"
15
+ CENTRAL_SSH_PORT="${CENTRAL_SSH_PORT:-22}"
16
+ CENTRAL_ADB_PORT="${CENTRAL_ADB_PORT:-15555}"
17
+ CENTRAL_PAIR_PORT="${CENTRAL_PAIR_PORT:-15556}"
18
+
19
+ usage() {
20
+ cat >&2 <<'USAGE'
21
+ Usage: pixel-connect CONNECT_PORT [PAIR_PORT]
22
+
23
+ Read CONNECT_PORT from Android Settings > Developer options > Wireless debugging
24
+ ("IP address & Port"). For first-time pairing, also open "Pair device with
25
+ pairing code" and pass that separate PAIR_PORT. Press Ctrl+C to disconnect.
26
+ USAGE
27
+ exit 2
28
+ }
29
+
30
+ valid_port() {
31
+ [[ "$1" =~ ^[0-9]{1,5}$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 ))
32
+ }
33
+
34
+ [[ $# -ge 1 && $# -le 2 ]] || usage
35
+ connect_port="$1"
36
+ valid_port "$connect_port" || usage
37
+ if [[ $# -eq 2 ]]; then
38
+ pair_port="$2"
39
+ valid_port "$pair_port" || usage
40
+ fi
41
+ if [[ -z "$CENTRAL_HOST" || -z "$CENTRAL_USER" ]]; then
42
+ printf 'Set CENTRAL_HOST and CENTRAL_USER in %s.\n' "$config" >&2
43
+ exit 2
44
+ fi
45
+ for port in "$CENTRAL_SSH_PORT" "$CENTRAL_ADB_PORT" "$CENTRAL_PAIR_PORT"; do
46
+ valid_port "$port" || { printf 'Invalid port in %s.\n' "$config" >&2; exit 2; }
47
+ done
48
+
49
+ key="$HOME/.ssh/central_tunnel"
50
+ [[ -r "$key" ]] || { printf 'Missing SSH key: %s\n' "$key" >&2; exit 2; }
51
+
52
+ ssh_args=(
53
+ -N -T -i "$key" -p "$CENTRAL_SSH_PORT"
54
+ -o BatchMode=yes
55
+ -o ExitOnForwardFailure=yes
56
+ -o ServerAliveInterval=20
57
+ -o ServerAliveCountMax=3
58
+ -o StrictHostKeyChecking=yes
59
+ -R "127.0.0.1:${CENTRAL_ADB_PORT}:127.0.0.1:${connect_port}"
60
+ )
61
+ if [[ -n "${pair_port:-}" ]]; then
62
+ ssh_args+=( -R "127.0.0.1:${CENTRAL_PAIR_PORT}:127.0.0.1:${pair_port}" )
63
+ fi
64
+
65
+ printf 'Connecting Pixel ADB to %s:%s (central loopback port %s).\n' \
66
+ "$CENTRAL_HOST" "$CENTRAL_SSH_PORT" "$CENTRAL_ADB_PORT"
67
+ printf 'Keep this Termux session open. Press Ctrl+C or run pixel-disconnect to stop sharing.\n'
68
+ exec ssh "${ssh_args[@]}" "${CENTRAL_USER}@${CENTRAL_HOST}"
@@ -0,0 +1,143 @@
1
+ #!/data/data/com.termux/files/usr/bin/bash
2
+ set -euo pipefail
3
+
4
+ usage() {
5
+ cat >&2 <<'USAGE'
6
+ Usage: pixelremotecontrol startservice <mobiledevice> <destination> [CONNECT_PORT]
7
+
8
+ mobiledevice: pixel8, pixel, thisdevice, or thismachine
9
+ destination: asml (or the host configured for pixel-connect)
10
+
11
+ Run this in Termux on the Pixel. Wireless debugging must be enabled. If its
12
+ connection port cannot be found automatically, this command asks for the
13
+ "IP address & Port" shown in Android's Wireless debugging settings.
14
+ USAGE
15
+ exit 2
16
+ }
17
+
18
+ valid_port() {
19
+ [[ "$1" =~ ^[0-9]{1,5}$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 ))
20
+ }
21
+
22
+ port_open() {
23
+ # The reverse tunnel connects to the phone's own loopback ADB listener.
24
+ # A two-second limit avoids hanging if the listener is unavailable.
25
+ timeout 2 bash -c 'exec 3<>"/dev/tcp/127.0.0.1/$1"' _ "$1" >/dev/null 2>&1
26
+ }
27
+
28
+ [[ $# -ge 3 && $# -le 4 && "$1" == startservice ]] || usage
29
+ device="${2,,}"
30
+ destination="${3,,}"
31
+ case "$device" in
32
+ pixel8|pixel|thisdevice|thismachine) ;;
33
+ *) printf 'Unknown mobile device: %s\n' "$2" >&2; usage ;;
34
+ esac
35
+
36
+ config="${PIXEL_CONNECT_CONFIG:-$HOME/.config/pixel-connect/config}"
37
+ cache="$(dirname "$config")/last-connect-port"
38
+ if [[ ! -r "$config" ]]; then
39
+ printf 'Missing pixel-connect configuration: %s\n' "$config" >&2
40
+ exit 2
41
+ fi
42
+ # The config is owned and edited by the phone's Termux user.
43
+ # shellcheck source=/dev/null
44
+ source "$config"
45
+ CENTRAL_HOST="${CENTRAL_HOST:-}"
46
+ CENTRAL_USER="${CENTRAL_USER:-}"
47
+ CENTRAL_ADB_PORT="${CENTRAL_ADB_PORT:-15555}"
48
+ if [[ -z "$CENTRAL_HOST" || -z "$CENTRAL_USER" ]] || ! valid_port "$CENTRAL_ADB_PORT"; then
49
+ printf 'Set a valid CENTRAL_HOST, CENTRAL_USER, and CENTRAL_ADB_PORT in %s.\n' "$config" >&2
50
+ exit 2
51
+ fi
52
+ case "$destination" in
53
+ asml|"${CENTRAL_HOST,,}"|"${CENTRAL_USER,,}@${CENTRAL_HOST,,}") ;;
54
+ *) printf 'Destination %s does not match the configured central machine (%s).\n' "$3" "$CENTRAL_HOST" >&2; exit 2 ;;
55
+ esac
56
+ command -v pixel-connect >/dev/null 2>&1 || {
57
+ printf 'pixel-connect is not installed in Termux. Install the Pixel phone tools first.\n' >&2
58
+ exit 2
59
+ }
60
+ command -v timeout >/dev/null 2>&1 || {
61
+ printf 'The Termux coreutils package is required (pkg install coreutils).\n' >&2
62
+ exit 2
63
+ }
64
+
65
+ # Find only this phone's existing reverse tunnel to the configured machine.
66
+ pattern="^ssh -N -T -i .*/central_tunnel .* -R 127\\.0\\.0\\.1:${CENTRAL_ADB_PORT}:"
67
+ mapfile -t tunnel_pids < <(pgrep -f "$pattern" || true)
68
+ active_port=''
69
+ if ((${#tunnel_pids[@]})); then
70
+ for pid in "${tunnel_pids[@]}"; do
71
+ [[ -r "/proc/$pid/cmdline" ]] || continue
72
+ cmdline="$(tr '\0' ' ' < "/proc/$pid/cmdline")"
73
+ [[ "$cmdline" == *" ${CENTRAL_USER}@${CENTRAL_HOST} "* ]] || continue
74
+ if [[ "$cmdline" =~ 127\.0\.0\.1:${CENTRAL_ADB_PORT}:127\.0\.0\.1:([0-9]+) ]]; then
75
+ active_port="${BASH_REMATCH[1]}"
76
+ active_pid="$pid"
77
+ break
78
+ fi
79
+ done
80
+ fi
81
+
82
+ connect_port=''
83
+ if [[ $# -eq 4 ]]; then
84
+ connect_port="$4"
85
+ elif [[ -n "${PIXEL_CONNECT_PORT:-}" ]]; then
86
+ connect_port="$PIXEL_CONNECT_PORT"
87
+ else
88
+ # This Android system property may be hidden from Termux on some builds.
89
+ if command -v getprop >/dev/null 2>&1; then
90
+ property_port="$(getprop service.adb.tls.port 2>/dev/null || true)"
91
+ if valid_port "$property_port" && port_open "$property_port"; then
92
+ connect_port="$property_port"
93
+ fi
94
+ fi
95
+ if [[ -z "$connect_port" && -n "$active_port" ]] && port_open "$active_port"; then
96
+ connect_port="$active_port"
97
+ fi
98
+ if [[ -z "$connect_port" && -r "$cache" ]]; then
99
+ IFS= read -r cached_port < "$cache" || true
100
+ if valid_port "${cached_port:-}" && port_open "$cached_port"; then
101
+ connect_port="$cached_port"
102
+ fi
103
+ fi
104
+ fi
105
+
106
+ if [[ -z "$connect_port" ]]; then
107
+ if [[ ! -t 0 ]]; then
108
+ printf 'Wireless debugging port unknown. Run interactively or pass CONNECT_PORT from Settings > System > Developer options > Wireless debugging > IP address & Port.\n' >&2
109
+ exit 2
110
+ fi
111
+ printf 'Open Settings > System > Developer options > Wireless debugging.\n'
112
+ printf 'Enable Wireless debugging and read the port under "IP address & Port".\n'
113
+ while :; do
114
+ printf 'Connection port: '
115
+ IFS= read -r connect_port || exit 2
116
+ if valid_port "$connect_port" && port_open "$connect_port"; then
117
+ break
118
+ fi
119
+ printf 'That port is invalid or not listening on this phone. Check Wireless debugging and try again.\n' >&2
120
+ done
121
+ elif ! valid_port "$connect_port" || ! port_open "$connect_port"; then
122
+ printf 'Wireless debugging is not listening on port %s. Check its "IP address & Port" in Android Settings.\n' "$connect_port" >&2
123
+ exit 2
124
+ fi
125
+
126
+ if [[ -n "$active_port" ]]; then
127
+ if [[ "$active_port" == "$connect_port" ]]; then
128
+ printf 'Pixel service is already connected to %s using wireless debugging port %s.\n' "$CENTRAL_HOST" "$connect_port"
129
+ exit 0
130
+ fi
131
+ printf 'Replacing the stale Pixel tunnel (port %s) with port %s.\n' "$active_port" "$connect_port"
132
+ kill "$active_pid" 2>/dev/null || true
133
+ # Give sshd time to release the existing reverse-forwarded port.
134
+ for ((attempt=0; attempt<20; attempt++)); do
135
+ kill -0 "$active_pid" 2>/dev/null || break
136
+ sleep 0.1
137
+ done
138
+ fi
139
+
140
+ mkdir -p "$(dirname "$cache")"
141
+ umask 077
142
+ printf '%s\n' "$connect_port" > "$cache"
143
+ exec pixel-connect "$connect_port"
@@ -0,0 +1,207 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [Parameter(Position = 0)]
4
+ [string]$Action = 'control',
5
+ [Parameter(Position = 1)]
6
+ [string]$Display = 'local',
7
+ [Parameter(Position = 2)]
8
+ [string]$DeviceId = 'pixel8',
9
+ [string]$CentralHost = '',
10
+ [string]$CentralUser = '',
11
+ [ValidateRange(1, 65535)]
12
+ [int]$SshPort = 22,
13
+ [ValidateRange(1024, 65535)]
14
+ [int]$AdbForwardPort = 5038,
15
+ [ValidateRange(1024, 65535)]
16
+ [int]$VideoPort = 27183,
17
+ [string]$ScrcpyPath = '',
18
+ [string]$AdbPath = '',
19
+ [ValidateRange(0, 86400)]
20
+ [int]$TimeLimitSeconds = 0,
21
+ [switch]$Help
22
+ )
23
+
24
+ $ErrorActionPreference = 'Stop'
25
+
26
+ function Show-Usage {
27
+ Write-Host 'Usage: pixelremotecontrol control <local|asml> <pixel8|Pixel_8> [options]'
28
+ Write-Host ' pixelremotecontrol <wake|lock|restart|status> <local|asml> <pixel8|Pixel_8>'
29
+ Write-Host 'Options: -CentralHost HOST -CentralUser USER -SshPort PORT -ScrcpyPath PATH -AdbPath PATH'
30
+ Write-Host 'Run "control local" on a Windows viewer to see and control the Pixel here.'
31
+ Write-Host 'Run "control asml" only in an interactive desktop session on ASML.'
32
+ }
33
+
34
+ function Resolve-Program {
35
+ param([string]$Name, [string]$ExplicitPath, [string]$SiblingPath)
36
+ if ($ExplicitPath) {
37
+ if (-not (Test-Path -LiteralPath $ExplicitPath -PathType Leaf)) {
38
+ throw "$Name was not found at '$ExplicitPath'."
39
+ }
40
+ return (Resolve-Path -LiteralPath $ExplicitPath).Path
41
+ }
42
+ if ($SiblingPath -and (Test-Path -LiteralPath $SiblingPath -PathType Leaf)) {
43
+ return (Resolve-Path -LiteralPath $SiblingPath).Path
44
+ }
45
+ $besideScript = Join-Path $PSScriptRoot $Name
46
+ if (Test-Path -LiteralPath $besideScript -PathType Leaf) {
47
+ return (Resolve-Path -LiteralPath $besideScript).Path
48
+ }
49
+ $command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
50
+ if ($command) { return $command.Source }
51
+ if ($env:LOCALAPPDATA) {
52
+ $packages = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages'
53
+ if (Test-Path -LiteralPath $packages -PathType Container) {
54
+ $found = Get-ChildItem -LiteralPath $packages -Directory -Filter 'Genymobile.scrcpy*' -ErrorAction SilentlyContinue |
55
+ ForEach-Object { Get-ChildItem -LiteralPath $_.FullName -Directory -Filter 'scrcpy-win64-*' -ErrorAction SilentlyContinue } |
56
+ Sort-Object Name -Descending |
57
+ ForEach-Object { Join-Path $_.FullName $Name } |
58
+ Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } |
59
+ Select-Object -First 1
60
+ if ($found) { return $found }
61
+ }
62
+ }
63
+ throw "$Name was not found. Extract the scrcpy Windows ZIP or pass the corresponding path option."
64
+ }
65
+
66
+ function Test-PortFree {
67
+ param([int]$Port)
68
+ $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
69
+ try { $listener.Start() }
70
+ catch { throw "Local port $Port is already in use. Close the other viewer or choose another forward port." }
71
+ finally { $listener.Stop() }
72
+ }
73
+
74
+ function Test-PortOpen {
75
+ param([int]$Port)
76
+ $client = New-Object System.Net.Sockets.TcpClient
77
+ try {
78
+ $pending = $client.BeginConnect('127.0.0.1', $Port, $null, $null)
79
+ if (-not $pending.AsyncWaitHandle.WaitOne(200)) { return $false }
80
+ $client.EndConnect($pending)
81
+ return $true
82
+ } catch { return $false }
83
+ finally { $client.Dispose() }
84
+ }
85
+
86
+ function Invoke-Adb {
87
+ param([string]$Program, [string[]]$Arguments)
88
+ # Windows PowerShell 5.1 may turn a native stderr line into a terminating
89
+ # NativeCommandError when ErrorActionPreference is Stop.
90
+ $oldPreference = $ErrorActionPreference
91
+ $ErrorActionPreference = 'Continue'
92
+ try {
93
+ $output = @(& $Program @Arguments 2>&1 | ForEach-Object { $_.ToString() })
94
+ $exitCode = $LASTEXITCODE
95
+ return [pscustomobject]@{ Output = $output; ExitCode = $exitCode }
96
+ } finally { $ErrorActionPreference = $oldPreference }
97
+ }
98
+
99
+ try {
100
+ if ($Help -or $Action -in @('help', '--help', '-help', '/?')) { Show-Usage; exit 0 }
101
+
102
+ if ($Display -eq 'thismachine') { $Display = 'local' }
103
+ if ($Action -notin @('control', 'wake', 'lock', 'restart', 'status')) {
104
+ throw "Unknown action '$Action'. Run pixelremotecontrol --help."
105
+ }
106
+ if ($Display -notin @('local', 'asml')) {
107
+ throw "Unknown display '$Display'. Use local or asml."
108
+ }
109
+ if ($DeviceId -notin @('pixel8', 'pixel_8', 'pixel-8', '127.0.0.1:15555')) {
110
+ throw "Unknown device '$DeviceId'. This relay currently has one registered device: pixel8 (Pixel_8)."
111
+ }
112
+ if ($CentralUser -notmatch '^[A-Za-z0-9._-]+$') { throw 'CentralUser contains invalid SSH username characters.' }
113
+ if ($CentralHost -notmatch '^[A-Za-z0-9.:-]+$') { throw 'CentralHost contains invalid SSH host characters.' }
114
+ if ($AdbForwardPort -eq $VideoPort) { throw 'AdbForwardPort and VideoPort must differ.' }
115
+
116
+ $ssh = (Get-Command ssh.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
117
+ $destination = "$CentralUser@$CentralHost"
118
+
119
+ if ($Action -ne 'control') {
120
+ $remoteArgs = @('-T', '-p', [string]$SshPort, $destination, "pixelctl.cmd $Action")
121
+ $remote = Start-Process -FilePath $ssh -ArgumentList $remoteArgs -NoNewWindow -PassThru -Wait
122
+ exit $remote.ExitCode
123
+ }
124
+
125
+ if ($Display -eq 'asml') {
126
+ if ($env:COMPUTERNAME -ine 'ASML' -or -not [Environment]::UserInteractive) {
127
+ throw 'ASML screen mode needs an interactive desktop login on ASML. Use "control local pixel8" here, or open an ASML desktop through Remote Desktop.'
128
+ }
129
+ $pixelctl = Join-Path $env:USERPROFILE 'pixelctl.cmd'
130
+ if (-not (Test-Path -LiteralPath $pixelctl -PathType Leaf)) {
131
+ throw "pixelctl.cmd was not found in $env:USERPROFILE."
132
+ }
133
+ & $pixelctl view
134
+ exit $LASTEXITCODE
135
+ }
136
+
137
+ $scrcpy = Resolve-Program -Name 'scrcpy.exe' -ExplicitPath $ScrcpyPath
138
+ $adb = Resolve-Program -Name 'adb.exe' -ExplicitPath $AdbPath -SiblingPath (Join-Path (Split-Path -Parent $scrcpy) 'adb.exe')
139
+ Test-PortFree -Port $AdbForwardPort
140
+ Test-PortFree -Port $VideoPort
141
+
142
+ $previousAdbSocket = $env:ADB_SERVER_SOCKET
143
+ $previousAdb = $env:ADB
144
+ $sshProcess = $null
145
+ try {
146
+ $sshArgs = @(
147
+ '-T', '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15',
148
+ '-o', 'ServerAliveCountMax=3', '-p', [string]$SshPort,
149
+ '-L', "127.0.0.1:${AdbForwardPort}:127.0.0.1:5039",
150
+ '-L', "127.0.0.1:${VideoPort}:127.0.0.1:${VideoPort}",
151
+ $destination, 'pixelctl.cmd serve'
152
+ )
153
+ Write-Host "Opening SSH to $destination. Complete any host key or password prompt in this window."
154
+ $sshProcess = Start-Process -FilePath $ssh -ArgumentList $sshArgs -NoNewWindow -PassThru
155
+
156
+ $tunnelReady = $false
157
+ for ($attempt = 0; $attempt -lt 600; $attempt++) {
158
+ if ($sshProcess.HasExited) { throw "SSH exited with code $($sshProcess.ExitCode). Check the address, login, host key, and password." }
159
+ if (Test-PortOpen -Port $AdbForwardPort) { $tunnelReady = $true; break }
160
+ Start-Sleep -Milliseconds 200
161
+ }
162
+ if (-not $tunnelReady) { throw 'SSH forwarding did not open within two minutes.' }
163
+
164
+ $env:ADB_SERVER_SOCKET = "tcp:127.0.0.1:$AdbForwardPort"
165
+ $env:ADB = $adb
166
+ $serial = '127.0.0.1:15555'
167
+ $deviceReady = $false
168
+ $lastMessage = ''
169
+ for ($attempt = 0; $attempt -lt 60; $attempt++) {
170
+ if ($sshProcess.HasExited) { throw "SSH ended while waiting for the Pixel (exit $($sshProcess.ExitCode))." }
171
+ $connect = Invoke-Adb -Program $adb -Arguments @('connect', $serial)
172
+ $lastMessage = ($connect.Output -join ' ')
173
+ if ($connect.ExitCode -eq 0) {
174
+ $devices = Invoke-Adb -Program $adb -Arguments @('devices', '-l')
175
+ if ($devices.ExitCode -eq 0 -and (($devices.Output -join "`n") -match '(?m)^127\.0\.0\.1:15555\s+device\b')) {
176
+ $deviceReady = $true
177
+ break
178
+ }
179
+ $lastMessage = ($devices.Output -join ' ')
180
+ }
181
+ Start-Sleep -Seconds 1
182
+ }
183
+ if (-not $deviceReady) {
184
+ throw "The Pixel did not become available through ASML. Ask the holder to run 'pixelremotecontrol startservice pixel8 asml' in Termux. $lastMessage"
185
+ }
186
+
187
+ Write-Host 'Pixel connected. Opening screen control; close the scrcpy window to end this session.'
188
+ $scrcpyArgs = @('--force-adb-forward', "--port=$VideoPort", '--serial', $serial)
189
+ if ($TimeLimitSeconds -gt 0) { $scrcpyArgs += "--time-limit=$TimeLimitSeconds" }
190
+ $oldPreference = $ErrorActionPreference
191
+ $ErrorActionPreference = 'Continue'
192
+ try {
193
+ & $scrcpy @scrcpyArgs
194
+ $scrcpyExit = $LASTEXITCODE
195
+ } finally { $ErrorActionPreference = $oldPreference }
196
+ if ($scrcpyExit -ne 0) { throw "scrcpy exited with code $scrcpyExit." }
197
+ } finally {
198
+ $env:ADB_SERVER_SOCKET = $previousAdbSocket
199
+ $env:ADB = $previousAdb
200
+ if ($sshProcess -and -not $sshProcess.HasExited) {
201
+ Stop-Process -Id $sshProcess.Id -Force -ErrorAction SilentlyContinue
202
+ }
203
+ }
204
+ } catch {
205
+ [Console]::Error.WriteLine("pixelremotecontrol: $($_.Exception.Message)")
206
+ exit 1
207
+ }
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { spawnSync } = require('node:child_process');
8
+ const readline = require('node:readline/promises');
9
+ const { version } = require('../package.json');
10
+
11
+ const packageRoot = path.resolve(__dirname, '..');
12
+ const assets = path.join(packageRoot, 'assets');
13
+ const actions = new Set(['control', 'wake', 'lock', 'restart', 'status']);
14
+
15
+ function isTermux() {
16
+ return process.platform === 'android' ||
17
+ (process.env.PREFIX || '').startsWith('/data/data/com.termux/files/usr');
18
+ }
19
+
20
+ function usage() {
21
+ process.stdout.write(
22
+ 'Usage:\n' +
23
+ ' pixelremotecontrol setup [--host RELAY_HOST --user SSH_USER]\n' +
24
+ ' pixelremotecontrol startservice pixel8 asml [CONNECT_PORT] (Termux on Pixel)\n' +
25
+ ' pixelremotecontrol control local pixel8 [options] (Windows viewer)\n' +
26
+ ' pixelremotecontrol control asml pixel8 (ASML desktop)\n' +
27
+ ' pixelremotecontrol <wake|lock|restart|status> local pixel8\n' +
28
+ '\nRun setup once on each Windows viewer to save its relay address.\n'
29
+ );
30
+ }
31
+
32
+ function viewerConfigPath() {
33
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
34
+ return path.join(localAppData, 'PixelRemoteControl', 'config.json');
35
+ }
36
+
37
+ function validHost(value) {
38
+ return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9.:-]*$/.test(value);
39
+ }
40
+
41
+ function validUser(value) {
42
+ return typeof value === 'string' && /^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(value);
43
+ }
44
+
45
+ function readViewerConfig() {
46
+ const file = viewerConfigPath();
47
+ if (!fs.existsSync(file)) return null;
48
+ let config;
49
+ try {
50
+ config = JSON.parse(fs.readFileSync(file, 'utf8'));
51
+ } catch (error) {
52
+ throw new Error(`Cannot read ${file}: ${error.message}`);
53
+ }
54
+ if (!validHost(config.host) || !validUser(config.user)) {
55
+ throw new Error(`Invalid relay address or SSH username in ${file}. Run setup again.`);
56
+ }
57
+ return config;
58
+ }
59
+
60
+ function saveViewerConfig(config) {
61
+ if (!validHost(config.host) || !validUser(config.user)) {
62
+ throw new Error('Relay host or SSH username contains invalid characters.');
63
+ }
64
+ const file = viewerConfigPath();
65
+ fs.mkdirSync(path.dirname(file), { recursive: true });
66
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
67
+ process.stdout.write(`Saved relay settings to ${file}\n`);
68
+ }
69
+
70
+ function setupOptions(args) {
71
+ const result = {};
72
+ for (let index = 0; index < args.length; index += 2) {
73
+ const option = args[index];
74
+ const value = args[index + 1];
75
+ if (!value || !['--host', '--user'].includes(option)) {
76
+ throw new Error('Usage: pixelremotecontrol setup --host RELAY_HOST --user SSH_USER');
77
+ }
78
+ result[option === '--host' ? 'host' : 'user'] = value;
79
+ }
80
+ return result;
81
+ }
82
+
83
+ async function setupViewerConfig(args) {
84
+ const options = setupOptions(args);
85
+ const existing = readViewerConfig();
86
+ const config = { ...existing, ...options };
87
+ if ((!config.host || !config.user) && !process.stdin.isTTY) {
88
+ throw new Error('Run setup --host RELAY_HOST --user SSH_USER in an interactive terminal.');
89
+ }
90
+ if (!config.host || !config.user) {
91
+ const interface_ = readline.createInterface({ input: process.stdin, output: process.stdout });
92
+ try {
93
+ if (!config.host) config.host = (await interface_.question('Relay Tailscale host or IP: ')).trim();
94
+ if (!config.user) config.user = (await interface_.question('Relay SSH username: ')).trim();
95
+ } finally {
96
+ interface_.close();
97
+ }
98
+ }
99
+ saveViewerConfig(config);
100
+ }
101
+
102
+ function hasCoreOption(args, name) {
103
+ return args.some((value) => value.toLowerCase() === name.toLowerCase());
104
+ }
105
+
106
+ function coreOptionValue(args, name) {
107
+ let result = null;
108
+ for (let index = 0; index < args.length; index++) {
109
+ if (args[index].toLowerCase() !== name.toLowerCase()) continue;
110
+ if (result !== null) throw new Error(`Specify ${name} only once.`);
111
+ result = args[index + 1];
112
+ }
113
+ return result;
114
+ }
115
+
116
+ function viewerRelayArgs(args) {
117
+ const explicitHost = coreOptionValue(args, '-CentralHost');
118
+ const explicitUser = coreOptionValue(args, '-CentralUser');
119
+ if (explicitHost !== null && !validHost(explicitHost)) {
120
+ throw new Error('Invalid -CentralHost value.');
121
+ }
122
+ if (explicitUser !== null && !validUser(explicitUser)) {
123
+ throw new Error('Invalid -CentralUser value.');
124
+ }
125
+ if (explicitHost && explicitUser) return [];
126
+ const config = readViewerConfig();
127
+ if (!config) {
128
+ throw new Error('Viewer relay is not configured. Run pixelremotecontrol setup --host RELAY_HOST --user SSH_USER.');
129
+ }
130
+ const result = [];
131
+ if (!hasCoreOption(args, '-CentralHost')) result.push('-CentralHost', config.host);
132
+ if (!hasCoreOption(args, '-CentralUser')) result.push('-CentralUser', config.user);
133
+ return result;
134
+ }
135
+
136
+ function run(program, args, options = {}) {
137
+ const result = spawnSync(program, args, {
138
+ stdio: 'inherit',
139
+ windowsHide: false,
140
+ ...options,
141
+ });
142
+ if (result.error) throw result.error;
143
+ return result.status === null ? 1 : result.status;
144
+ }
145
+
146
+ function commandExists(name) {
147
+ const result = spawnSync(name, ['--version'], { stdio: 'ignore' });
148
+ return !result.error || result.error.code !== 'ENOENT';
149
+ }
150
+
151
+ function ensureTermuxPackages() {
152
+ const missing = [];
153
+ if (!commandExists('ssh')) missing.push('openssh');
154
+ if (!commandExists('timeout')) missing.push('coreutils');
155
+ if (!commandExists('pgrep')) missing.push('procps');
156
+ if (missing.length === 0) return;
157
+ if (!commandExists('pkg')) {
158
+ throw new Error(`Install Termux packages: pkg install ${missing.join(' ')}`);
159
+ }
160
+ process.stdout.write(`Installing Termux packages: ${missing.join(', ')}\n`);
161
+ const code = run('pkg', ['install', '-y', ...missing]);
162
+ if (code !== 0) throw new Error(`Termux package installation exited with code ${code}`);
163
+ }
164
+
165
+ function ensureTermuxRuntime() {
166
+ const bin = path.join(os.homedir(), '.config', 'pixelremotecontrol', 'bin');
167
+ const source = path.join(assets, 'phone', 'pixel-connect');
168
+ const target = path.join(bin, 'pixel-connect');
169
+ fs.mkdirSync(bin, { recursive: true, mode: 0o700 });
170
+ // Keep the packaged helper available even when npm's global package directory
171
+ // is not executable from Termux's shell PATH.
172
+ fs.copyFileSync(source, target);
173
+ fs.chmodSync(target, 0o700);
174
+ return bin;
175
+ }
176
+
177
+ function ensureTermuxSetup() {
178
+ ensureTermuxPackages();
179
+ const runtimeBin = ensureTermuxRuntime();
180
+ const config = process.env.PIXEL_CONNECT_CONFIG ||
181
+ path.join(os.homedir(), '.config', 'pixel-connect', 'config');
182
+ const key = path.join(os.homedir(), '.ssh', 'central_tunnel');
183
+ if (!fs.existsSync(config) || !fs.existsSync(key)) {
184
+ throw new Error(
185
+ `The Pixel relay needs its configuration and SSH key: ${config}, ${key}. ` +
186
+ 'Provision the central relay and pair ADB before starting the service.'
187
+ );
188
+ }
189
+ return runtimeBin;
190
+ }
191
+
192
+ async function ensureViewerTools() {
193
+ // The dependency module downloads and verifies the official Windows scrcpy
194
+ // bundle when a suitable local copy is not already present.
195
+ const { ensureWindowsScrcpy } = require('../lib/dependencies.js');
196
+ return ensureWindowsScrcpy();
197
+ }
198
+
199
+ async function main() {
200
+ const args = process.argv.slice(2);
201
+ const action = args[0];
202
+ if (!action || ['help', '--help', '-h', '/?'].includes(action)) {
203
+ usage();
204
+ return action ? 0 : 2;
205
+ }
206
+ if (action === '--version' || action === '-v') {
207
+ process.stdout.write(`${version}\n`);
208
+ return 0;
209
+ }
210
+
211
+ if (action === 'setup') {
212
+ if (isTermux()) {
213
+ ensureTermuxSetup();
214
+ process.stdout.write('Termux command dependencies are ready.\n');
215
+ return 0;
216
+ }
217
+ if (process.platform === 'win32') {
218
+ await setupViewerConfig(args.slice(1));
219
+ if (!commandExists('ssh.exe')) {
220
+ throw new Error('Windows OpenSSH Client is required. Enable it in Windows Optional Features.');
221
+ }
222
+ const tools = await ensureViewerTools();
223
+ process.stdout.write(`Viewer tools ready: ${tools.scrcpyPath}\n`);
224
+ return 0;
225
+ }
226
+ throw new Error('This package currently supports Windows viewers and Termux on Android.');
227
+ }
228
+
229
+ if (action === 'startservice') {
230
+ if (!isTermux()) throw new Error('Run startservice in Termux on the Pixel.');
231
+ const runtimeBin = ensureTermuxSetup();
232
+ const script = path.join(assets, 'phone', 'pixelremotecontrol');
233
+ const env = { ...process.env, PATH: `${runtimeBin}:${process.env.PATH || ''}` };
234
+ return run('bash', [script, ...args], { env });
235
+ }
236
+
237
+ if (actions.has(action)) {
238
+ if (process.platform !== 'win32') {
239
+ throw new Error('Run screen control on a Windows viewer or ASML desktop.');
240
+ }
241
+ const script = path.join(assets, 'viewer', 'pixelremotecontrol-core.ps1');
242
+ const powershellArgs = [
243
+ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, ...args,
244
+ ...viewerRelayArgs(args),
245
+ ];
246
+ if (action === 'control' && (args[1] || 'local') !== 'asml' &&
247
+ !args.some((arg) => /^-ScrcpyPath$/i.test(arg))) {
248
+ const tools = await ensureViewerTools();
249
+ powershellArgs.push('-ScrcpyPath', tools.scrcpyPath, '-AdbPath', tools.adbPath);
250
+ }
251
+ return run('powershell.exe', powershellArgs);
252
+ }
253
+
254
+ throw new Error(`Unknown action '${action}'. Run pixelremotecontrol --help.`);
255
+ }
256
+
257
+ main().then((code) => {
258
+ process.exitCode = code;
259
+ }).catch((error) => {
260
+ process.stderr.write(`pixelremotecontrol: ${error.message}\n`);
261
+ process.exitCode = 1;
262
+ });
@@ -0,0 +1,176 @@
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
+ const VERSION = '5.0.1';
14
+ const ARCHIVES = {
15
+ x64: {
16
+ name: `scrcpy-win64-v${VERSION}`,
17
+ sha256: 'b12a2c4ee8be317422451fc7dcf8ee20a71b5ea7ef9ad73ddd825a25316227a5',
18
+ },
19
+ ia32: {
20
+ name: `scrcpy-win32-v${VERSION}`,
21
+ sha256: '0cb65ebf1bce892fa4d1502d699af0c7a1a7f24d7febcb9ef72894e3f23fc2ad',
22
+ },
23
+ arm64: {
24
+ name: `scrcpy-winarm64-v${VERSION}`,
25
+ sha256: '16c3fc2068df64946f670e3504b4752073ebae47a416a45b46e79a5ab19c689b',
26
+ },
27
+ };
28
+ const MAX_DOWNLOAD_BYTES = 250 * 1024 * 1024;
29
+
30
+ function cacheRoot() {
31
+ if (process.env.PIXELREMOTECONTROL_CACHE_DIR) {
32
+ return path.resolve(process.env.PIXELREMOTECONTROL_CACHE_DIR);
33
+ }
34
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
35
+ return path.join(localAppData, 'PixelRemoteControl', 'tools');
36
+ }
37
+
38
+ function containedChild(root, target) {
39
+ const relative = path.relative(path.resolve(root), path.resolve(target));
40
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
41
+ throw new Error(`Refusing to modify a path outside the PixelRemoteControl cache: ${target}`);
42
+ }
43
+ return target;
44
+ }
45
+
46
+ async function existingBundle(dir) {
47
+ const scrcpyPath = path.join(dir, 'scrcpy.exe');
48
+ const adbPath = path.join(dir, 'adb.exe');
49
+ try {
50
+ const [scrcpy, adb] = await Promise.all([fsp.stat(scrcpyPath), fsp.stat(adbPath)]);
51
+ if (scrcpy.isFile() && adb.isFile()) return { scrcpyPath, adbPath };
52
+ } catch (_) {
53
+ // An incomplete cache entry is not a usable installation.
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function findOnPath(name) {
59
+ const directories = (process.env.PATH || '').split(path.delimiter);
60
+ for (const directory of directories) {
61
+ if (!directory) continue;
62
+ const file = path.join(directory, name);
63
+ try {
64
+ if (fs.statSync(file).isFile()) return file;
65
+ } catch (_) {
66
+ // Keep searching.
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function openRelease(url, redirects = 0) {
73
+ if (redirects > 6) return Promise.reject(new Error('Too many redirects downloading scrcpy.'));
74
+ const parsed = new URL(url);
75
+ if (parsed.protocol !== 'https:') return Promise.reject(new Error('scrcpy download must use HTTPS.'));
76
+ return new Promise((resolve, reject) => {
77
+ const request = https.get(parsed, {
78
+ headers: { 'User-Agent': 'pixelremotecontrol/npm', Accept: 'application/octet-stream' },
79
+ timeout: 30000,
80
+ }, response => {
81
+ if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
82
+ const location = response.headers.location;
83
+ response.resume();
84
+ if (!location) return reject(new Error('scrcpy download redirected without a location.'));
85
+ return resolve(openRelease(new URL(location, parsed).toString(), redirects + 1));
86
+ }
87
+ if (response.statusCode !== 200) {
88
+ response.resume();
89
+ return reject(new Error(`scrcpy download returned HTTP ${response.statusCode}.`));
90
+ }
91
+ resolve(response);
92
+ });
93
+ request.on('timeout', () => request.destroy(new Error('scrcpy download timed out.')));
94
+ request.on('error', reject);
95
+ });
96
+ }
97
+
98
+ async function downloadVerified(url, destination, expectedSha256) {
99
+ const response = await openRelease(url);
100
+ const digest = crypto.createHash('sha256');
101
+ let bytes = 0;
102
+ const inspector = new Transform({
103
+ transform(chunk, _encoding, callback) {
104
+ bytes += chunk.length;
105
+ if (bytes > MAX_DOWNLOAD_BYTES) {
106
+ callback(new Error('scrcpy download exceeded the size limit.'));
107
+ } else {
108
+ digest.update(chunk);
109
+ callback(null, chunk);
110
+ }
111
+ },
112
+ });
113
+ await pipeline(response, inspector, fs.createWriteStream(destination, { flags: 'wx' }));
114
+ const actual = digest.digest('hex');
115
+ if (actual !== expectedSha256) {
116
+ throw new Error(`scrcpy archive SHA256 mismatch (received ${actual}).`);
117
+ }
118
+ }
119
+
120
+ async function ensureWindowsScrcpy() {
121
+ if (process.platform !== 'win32') throw new Error('The Windows scrcpy bundle is only available on Windows.');
122
+ const archive = ARCHIVES[process.arch];
123
+ if (!archive) throw new Error(`Automatic scrcpy installation does not yet support Windows ${process.arch}.`);
124
+
125
+ if (process.env.PIXELREMOTECONTROL_FORCE_DOWNLOAD !== '1') {
126
+ const explicit = process.env.PIXELREMOTECONTROL_SCRCPY;
127
+ if (explicit) {
128
+ const bundle = await existingBundle(path.dirname(path.resolve(explicit)));
129
+ if (bundle && path.resolve(bundle.scrcpyPath).toLowerCase() === path.resolve(explicit).toLowerCase()) return bundle;
130
+ throw new Error('PIXELREMOTECONTROL_SCRCPY must point to scrcpy.exe beside adb.exe.');
131
+ }
132
+ const onPath = findOnPath('scrcpy.exe');
133
+ if (onPath) {
134
+ const bundle = await existingBundle(path.dirname(onPath));
135
+ if (bundle) return bundle;
136
+ }
137
+ }
138
+
139
+ const root = cacheRoot();
140
+ const finalDir = containedChild(root, path.join(root, archive.name));
141
+ const cached = await existingBundle(finalDir);
142
+ if (cached) return cached;
143
+
144
+ await fsp.mkdir(root, { recursive: true });
145
+ const stage = containedChild(root, await fsp.mkdtemp(path.join(root, '.scrcpy-download-')));
146
+ try {
147
+ const zipPath = containedChild(stage, path.join(stage, `${archive.name}.zip`));
148
+ const extractDir = containedChild(stage, path.join(stage, 'extracted'));
149
+ await fsp.mkdir(extractDir);
150
+ const url = `https://github.com/Genymobile/scrcpy/releases/download/v${VERSION}/${archive.name}.zip`;
151
+ process.stderr.write(`Downloading scrcpy ${VERSION} from the official release...\n`);
152
+ await downloadVerified(url, zipPath, archive.sha256);
153
+ const tar = spawnSync('tar.exe', ['-xf', zipPath, '-C', extractDir], { stdio: 'pipe', encoding: 'utf8' });
154
+ if (tar.error || tar.status !== 0) {
155
+ throw new Error(`Could not extract scrcpy: ${tar.error?.message || tar.stderr?.trim() || `tar exited ${tar.status}`}`);
156
+ }
157
+ const extractedBundle = containedChild(extractDir, path.join(extractDir, archive.name));
158
+ const bundle = await existingBundle(extractedBundle);
159
+ if (!bundle) throw new Error('The verified scrcpy archive did not contain scrcpy.exe and adb.exe.');
160
+ try {
161
+ await fsp.rename(extractedBundle, finalDir);
162
+ } catch (error) {
163
+ // A concurrent install may have populated the cache while this one downloaded.
164
+ const winner = await existingBundle(finalDir);
165
+ if (winner) return winner;
166
+ throw error;
167
+ }
168
+ process.stderr.write(`Installed scrcpy at ${finalDir}\n`);
169
+ return { scrcpyPath: path.join(finalDir, 'scrcpy.exe'), adbPath: path.join(finalDir, 'adb.exe') };
170
+ } finally {
171
+ containedChild(root, stage);
172
+ await fsp.rm(stage, { recursive: true, force: true });
173
+ }
174
+ }
175
+
176
+ module.exports = { ensureWindowsScrcpy };
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "pixelremotecontrol",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Control a paired Pixel through a private ASML SSH relay",
5
+ "license": "UNLICENSED",
6
+ "private": false,
7
+ "contentPolicy": {
8
+ "class": "dual-use"
9
+ },
10
+ "bin": {
11
+ "pixelremotecontrol": "bin/pixelremotecontrol.js"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "lib/",
16
+ "assets/",
17
+ "scripts/postinstall.js",
18
+ "DISCLOSURE",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "prepack": "node scripts/build-assets.js",
23
+ "postinstall": "node scripts/postinstall.js"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ }
28
+ }
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ // npm writes both .cmd and .ps1 launchers for a Windows bin entry. A bare
4
+ // command in PowerShell resolves the .ps1 first, which is blocked on systems
5
+ // with Restricted execution policy. Keep npm's .cmd and remove only this
6
+ // package's generated .ps1 shim after a global install.
7
+ if (process.platform !== 'win32' || process.env.npm_config_global !== 'true') {
8
+ process.exit(0);
9
+ }
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const packageRoot = path.resolve(__dirname, '..');
14
+ if (path.basename(packageRoot).toLowerCase() !== 'pixelremotecontrol' ||
15
+ path.basename(path.dirname(packageRoot)).toLowerCase() !== 'node_modules') {
16
+ process.exit(0);
17
+ }
18
+
19
+ const prefix = path.resolve(packageRoot, '..', '..');
20
+ const shim = path.join(prefix, 'pixelremotecontrol.ps1');
21
+ if (!fs.existsSync(shim)) process.exit(0);
22
+
23
+ const content = fs.readFileSync(shim, 'utf8');
24
+ if (!content.toLowerCase().includes('node_modules/pixelremotecontrol/') &&
25
+ !content.toLowerCase().includes('node_modules\\pixelremotecontrol\\')) {
26
+ process.stderr.write(`Leaving unexpected PowerShell file untouched: ${shim}\n`);
27
+ process.exit(0);
28
+ }
29
+
30
+ fs.unlinkSync(shim);
31
+ process.stdout.write('Removed npm PowerShell shim; pixelremotecontrol uses its .cmd launcher.\n');