macvscr 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 +39 -66
- package/bin/macvscr.js +379 -0
- package/binaries/macvscr-darwin-arm64 +0 -0
- package/package.json +5 -5
- package/bin/vscr.js +0 -318
- package/binaries/vscr-darwin-arm64 +0 -0
package/README.md
CHANGED
|
@@ -1,90 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# macvscr
|
|
2
2
|
|
|
3
|
-
A macOS menu-bar tool that creates controllable
|
|
4
|
-
|
|
5
|
-
## Why
|
|
6
|
-
|
|
7
|
-
When you screen-share into a headless Mac, macOS locks the session to a 1920×1080 non-HiDPI surface. `vscr` creates a dummy display at any resolution + Retina, which Screen Sharing then renders.
|
|
8
|
-
|
|
9
|
-
## Resolution model
|
|
10
|
-
|
|
11
|
-
Everything you type and see is in **logical pixels**; HiDPI (`@2x`) means the physical surface is doubled for crispness.
|
|
12
|
-
|
|
13
|
-
- `2560×1440 @2x` → logical workspace 2560×1440, physical 5120×2880 (the Retina way macOS itself describes 5K iMacs)
|
|
14
|
-
- `1920×1080` (HiDPI off) → logical = physical = 1920×1080
|
|
15
|
-
|
|
16
|
-
The tray header shows both, so logical and physical are never confused.
|
|
3
|
+
A macOS menu-bar tool that creates **controllable virtual displays** — arbitrary resolution, HiDPI (Retina `@2x`), aspect ratio, and iMac presets. It unblocks HiDPI screen-sharing on headless Macs (e.g. a Mac mini with no monitor attached), where the session is otherwise locked to 1920×1080 non-HiDPI.
|
|
17
4
|
|
|
18
5
|
## Install
|
|
19
6
|
|
|
20
|
-
Zero-install, one line (recommended):
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
npx -y vscr@latest setup
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
`setup` copies a stable binary to `~/.vscr/vscr`, adds `alias vscr='npx -y vscr@latest'` to your shell rc (if you don't already have a `vscr` command), and registers a login LaunchAgent so the tray app runs at login and restarts on crash. It prints a `source` reminder and how to stop it.
|
|
27
|
-
|
|
28
|
-
Or install globally:
|
|
29
|
-
|
|
30
7
|
```bash
|
|
31
|
-
|
|
32
|
-
|
|
8
|
+
npx -y macvscr@latest setup # zero-install: stable binary + shell alias + login LaunchAgent
|
|
9
|
+
# or
|
|
10
|
+
npm install -g macvscr && macvscr setup
|
|
33
11
|
```
|
|
34
12
|
|
|
35
13
|
Requires macOS 13+ on Apple Silicon (arm64).
|
|
36
14
|
|
|
37
|
-
##
|
|
15
|
+
## Three ways to run (progressive)
|
|
16
|
+
|
|
17
|
+
| Mode | Command | Behavior | Stop |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| ① Foreground | `macvscr` *(default)* or `macvscr run` | occupies the terminal, prints guidance; **Ctrl+C** to quit | Ctrl+C |
|
|
20
|
+
| ② Background | `macvscr run -d` | detached; the terminal returns immediately | `macvscr stop` |
|
|
21
|
+
| ③ Login service | `macvscr setup` | LaunchAgent — runs at login, restarts on crash | `macvscr stop` / `macvscr uninstall` |
|
|
38
22
|
|
|
39
23
|
```bash
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
vscr uninstall # full teardown: LaunchAgent + ~/.vscr + shell alias
|
|
45
|
-
vscr --help # everything
|
|
24
|
+
macvscr # foreground, default 3440×1440 @2x
|
|
25
|
+
macvscr --width 2560 --ratio 16:9 --hidpi
|
|
26
|
+
macvscr run -d --width 2560 --ratio 16:9
|
|
27
|
+
macvscr setup --width 2560 --ratio 16:9
|
|
46
28
|
```
|
|
47
29
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
## Run directly (one-off)
|
|
30
|
+
### Manage the service
|
|
51
31
|
|
|
52
32
|
```bash
|
|
53
|
-
|
|
54
|
-
|
|
33
|
+
macvscr status # loaded? running? pid?
|
|
34
|
+
macvscr stop # universal stop (service + any background instance)
|
|
35
|
+
macvscr start # start the installed service
|
|
36
|
+
macvscr restart # restart
|
|
37
|
+
macvscr uninstall # full teardown: LaunchAgent + ~/.macvscr + shell alias
|
|
38
|
+
macvscr --help # everything
|
|
55
39
|
```
|
|
56
40
|
|
|
57
|
-
|
|
41
|
+
## Resolution model
|
|
58
42
|
|
|
59
|
-
|
|
60
|
-
- **宽度 ▸** / **高度 ▸** / **比例 ▸** — each has quick picks **and a `自定义…`** entry that opens an input dialog.
|
|
61
|
-
- Width, height, and ratio are linked: width is the master axis; set height or ratio and the other adjusts.
|
|
62
|
-
- **HiDPI / Retina @2x** — toggle Retina backing.
|
|
63
|
-
- **退出** — quit the app (clean exit; the service won't relaunch it until next login or `vscr start`).
|
|
43
|
+
Everything you type and see is in **logical pixels**; HiDPI (`@2x`) doubles the physical surface.
|
|
64
44
|
|
|
65
|
-
|
|
45
|
+
- `2560×1440 @2x` → logical workspace 2560×1440, physical 5120×2880 (the Retina way macOS describes a 5K iMac)
|
|
46
|
+
- `1920×1080` (HiDPI off) → logical = physical = 1920×1080
|
|
47
|
+
|
|
48
|
+
The tray header shows both, so logical and physical are never confused.
|
|
66
49
|
|
|
67
50
|
### CLI flags (all pixel values are logical)
|
|
68
51
|
|
|
69
52
|
```
|
|
70
53
|
--width <px> logical width
|
|
71
54
|
--height <px> logical height (mutually exclusive with --ratio)
|
|
72
|
-
--ratio <16:9
|
|
55
|
+
--ratio <W:H> compute height from width + aspect (16:9, 21:9, …)
|
|
73
56
|
--hidpi / --no-hidpi Retina @2x on/off (default on)
|
|
74
57
|
--refresh <Hz> refresh rate (default 60)
|
|
75
58
|
--name <name> display name prefix
|
|
76
59
|
```
|
|
77
60
|
|
|
78
|
-
##
|
|
61
|
+
## Menu bar
|
|
79
62
|
|
|
80
|
-
|
|
81
|
-
- The LaunchAgent runs the stable `~/.vscr/vscr` directly — no `npx`/network at login. The `vscr` command (alias) still uses npx so you always get subcommands + the latest version.
|
|
82
|
-
- Logs: `/tmp/vscr.out.log`, `/tmp/vscr.err.log`.
|
|
63
|
+
Click the display icon: **Presets ▸** (iMac sizes folded in with tails), **Width ▸ / Height ▸ / Aspect ▸** (each with a `Custom…` input dialog), **HiDPI / Retina @2x**, **Quit**. Width, height, and ratio are linked (width is the master axis). Switching rebuilds the display live — no restart.
|
|
83
64
|
|
|
84
65
|
## Headless / Screen Sharing
|
|
85
66
|
|
|
86
|
-
1. On the headless Mac, run `
|
|
87
|
-
2. From another Mac, connect via Screen Sharing
|
|
67
|
+
1. On the headless Mac, run `macvscr setup` and pick a preset from the menu bar.
|
|
68
|
+
2. From another Mac, connect via Screen Sharing → **View** menu → select the dummy.
|
|
88
69
|
3. The remote session renders at the chosen geometry with Retina.
|
|
89
70
|
|
|
90
71
|
## Verify
|
|
@@ -96,17 +77,9 @@ system_profiler SPDisplaysDataType | grep -i virtual
|
|
|
96
77
|
|
|
97
78
|
## Notes
|
|
98
79
|
|
|
99
|
-
-
|
|
80
|
+
- A menu-bar app needs your GUI session, so the service starts at **login** (LaunchAgent), not boot (a LaunchDaemon has no GUI).
|
|
81
|
+
- The LaunchAgent runs the stable `~/.macvscr/macvscr` directly — no `npx`/network at login. The `macvscr` command (alias) still uses npx so you always get subcommands + the latest version.
|
|
82
|
+
- The virtual display is owned by the process and is destroyed when `macvscr` quits.
|
|
100
83
|
- Uses the macOS private `CGVirtualDisplay*` SPI (in the public CoreGraphics binary). SIP stays on; the app is non-sandboxed.
|
|
101
|
-
-
|
|
102
|
-
|
|
103
|
-
## Build from source
|
|
104
|
-
|
|
105
|
-
This package bundles a prebuilt universal macOS binary. To rebuild from the Swift source:
|
|
106
|
-
|
|
107
|
-
```bash
|
|
108
|
-
git clone https://github.com/kzf/macos_virtual_screen
|
|
109
|
-
cd macos_virtual_screen
|
|
110
|
-
swift build -c release --arch arm64
|
|
111
|
-
cp .build/release/VirtualScreen npm/binaries/vscr-darwin-arm64
|
|
112
|
-
```
|
|
84
|
+
- Logs: `/tmp/macvscr.out.log`, `/tmp/macvscr.err.log`.
|
|
85
|
+
- MIT licensed. Source: <https://github.com/gaubee/macvscr>
|
package/bin/macvscr.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// macvscr CLI — launches the native macOS menu-bar binary and manages a user
|
|
5
|
+
// LaunchAgent (login start + crash restart).
|
|
6
|
+
//
|
|
7
|
+
// Three ways to run (progressive):
|
|
8
|
+
// macvscr run in the FOREGROUND (Ctrl+C to quit) [default]
|
|
9
|
+
// macvscr run -d run in the BACKGROUND (terminal returns)
|
|
10
|
+
// macvscr setup install a login LaunchAgent (runs at login, restarts on crash)
|
|
11
|
+
//
|
|
12
|
+
// `setup` also copies a stable binary to ~/.macvscr/macvscr (so login launch
|
|
13
|
+
// needs no network / no npx) and adds `alias macvscr='npx -y macvscr@latest'`
|
|
14
|
+
// to the shell rc if no macvscr command exists.
|
|
15
|
+
//
|
|
16
|
+
// Why a LaunchAgent (login) not a LaunchDaemon (boot): a menu-bar tray app
|
|
17
|
+
// needs the user's GUI session (WindowServer), which exists only after login.
|
|
18
|
+
|
|
19
|
+
const { spawn, spawnSync } = require('child_process');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
const PKG = 'macvscr'; // npm package name (== command name)
|
|
25
|
+
const CMD = 'macvscr';
|
|
26
|
+
const LABEL = 'npm.macvscr';
|
|
27
|
+
const BIN = path.join(__dirname, '..', 'binaries', 'macvscr-darwin-arm64');
|
|
28
|
+
const AGENT_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
29
|
+
const PLIST = path.join(AGENT_DIR, `${LABEL}.plist`);
|
|
30
|
+
const STABLE_DIR = path.join(os.homedir(), '.macvscr');
|
|
31
|
+
const STABLE_BIN = path.join(STABLE_DIR, 'macvscr');
|
|
32
|
+
|
|
33
|
+
// --- color ---
|
|
34
|
+
const TTY = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
35
|
+
const C = TTY
|
|
36
|
+
? {
|
|
37
|
+
bold: s => `\x1b[1m${s}\x1b[22m`,
|
|
38
|
+
dim: s => `\x1b[2m${s}\x1b[22m`,
|
|
39
|
+
cyan: s => `\x1b[36m${s}\x1b[39m`,
|
|
40
|
+
green: s => `\x1b[32m${s}\x1b[39m`,
|
|
41
|
+
yellow: s => `\x1b[33m${s}\x1b[39m`,
|
|
42
|
+
red: s => `\x1b[31m${s}\x1b[39m`,
|
|
43
|
+
}
|
|
44
|
+
: { bold: s => s, dim: s => s, cyan: s => s, green: s => s, yellow: s => s, red: s => s };
|
|
45
|
+
const tag = () => `${C.bold(C.cyan(CMD))}:`;
|
|
46
|
+
const hint = s => C.dim(s);
|
|
47
|
+
|
|
48
|
+
if (process.platform !== 'darwin') {
|
|
49
|
+
console.error(`${tag()} ${C.red('macOS only.')}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
if (!fs.existsSync(BIN)) {
|
|
53
|
+
console.error(`${tag()} native binary not found at ${BIN}`);
|
|
54
|
+
console.error(` The package looks corrupt — try reinstalling it.`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const uid = process.getuid();
|
|
59
|
+
const domainTarget = `gui/${uid}`;
|
|
60
|
+
const serviceTarget = `gui/${uid}/${LABEL}`;
|
|
61
|
+
|
|
62
|
+
// --- helpers ---
|
|
63
|
+
|
|
64
|
+
function sh(file, args) {
|
|
65
|
+
return spawnSync(file, args, { stdio: 'inherit' });
|
|
66
|
+
}
|
|
67
|
+
function shOut(file, args) {
|
|
68
|
+
return spawnSync(file, args, { encoding: 'utf8' });
|
|
69
|
+
}
|
|
70
|
+
function escapeXML(s) {
|
|
71
|
+
return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
72
|
+
}
|
|
73
|
+
function isLoaded() {
|
|
74
|
+
return shOut('launchctl', ['print', serviceTarget]).status === 0;
|
|
75
|
+
}
|
|
76
|
+
function servicePid() {
|
|
77
|
+
if (!isLoaded()) return null;
|
|
78
|
+
const r = shOut('launchctl', ['print', serviceTarget]);
|
|
79
|
+
const m = r.stdout && r.stdout.match(/\bpid\s*=\s*(\d+)/);
|
|
80
|
+
return m ? m[1] : null;
|
|
81
|
+
}
|
|
82
|
+
function hasCommand() {
|
|
83
|
+
return shOut('sh', ['-c', `command -v ${CMD}`]).status === 0;
|
|
84
|
+
}
|
|
85
|
+
function rcFile() {
|
|
86
|
+
const shell = process.env.SHELL || '';
|
|
87
|
+
if (shell.endsWith('bash')) return path.join(os.homedir(), '.bashrc');
|
|
88
|
+
return path.join(os.homedir(), '.zshrc'); // macOS default + fallback
|
|
89
|
+
}
|
|
90
|
+
function isDetach(a) {
|
|
91
|
+
return a.some(x => x === '-d' || x === '--detach' || x === '--background');
|
|
92
|
+
}
|
|
93
|
+
function stripDetach(a) {
|
|
94
|
+
return a.filter(x => x !== '-d' && x !== '--detach' && x !== '--background');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The alias block is wrapped in stable sentinel markers so future changes to
|
|
98
|
+
// the alias command itself never break detection/removal.
|
|
99
|
+
const ALIAS_HEAD = '# +++npm:macvscr';
|
|
100
|
+
const ALIAS_TAIL = '# ---npm:macvscr';
|
|
101
|
+
const ALIAS_LINE = `alias ${CMD}='npx -y ${PKG}@latest'`;
|
|
102
|
+
const ALIAS_BLOCK = `\n${ALIAS_HEAD}\n# managed by macvscr setup — remove with \`macvscr uninstall\`\n${ALIAS_LINE}\n${ALIAS_TAIL}\n`;
|
|
103
|
+
const ALIAS_BLOCK_RE = /\n# \+\+\+npm:macvscr[\s\S]*?# ---npm:macvscr\n?/;
|
|
104
|
+
|
|
105
|
+
function aliasInRc() {
|
|
106
|
+
const rc = rcFile();
|
|
107
|
+
try {
|
|
108
|
+
return fs.existsSync(rc) && fs.readFileSync(rc, 'utf8').includes(ALIAS_HEAD);
|
|
109
|
+
} catch (_) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function appendAliasBlock() {
|
|
114
|
+
const rc = rcFile();
|
|
115
|
+
try {
|
|
116
|
+
if (fs.existsSync(rc)) fs.appendFileSync(rc, ALIAS_BLOCK);
|
|
117
|
+
else fs.writeFileSync(rc, ALIAS_BLOCK);
|
|
118
|
+
return true;
|
|
119
|
+
} catch (_) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function removeAlias() {
|
|
124
|
+
const rc = rcFile();
|
|
125
|
+
if (!fs.existsSync(rc)) return false;
|
|
126
|
+
try {
|
|
127
|
+
const content = fs.readFileSync(rc, 'utf8');
|
|
128
|
+
if (!ALIAS_BLOCK_RE.test(content)) return false;
|
|
129
|
+
fs.writeFileSync(rc, content.replace(ALIAS_BLOCK_RE, ''));
|
|
130
|
+
return true;
|
|
131
|
+
} catch (_) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Reconcile the shell alias with reality so it never shadows a real command
|
|
137
|
+
* and never goes stale:
|
|
138
|
+
* - global `macvscr` on PATH -> remove any alias (it would shadow the command)
|
|
139
|
+
* - no global `macvscr` -> ensure the alias exists
|
|
140
|
+
* Returns { note, newlyAdded }.
|
|
141
|
+
*/
|
|
142
|
+
function syncAlias() {
|
|
143
|
+
const rc = rcFile();
|
|
144
|
+
const present = aliasInRc();
|
|
145
|
+
if (hasCommand()) {
|
|
146
|
+
if (present) {
|
|
147
|
+
removeAlias();
|
|
148
|
+
return { note: `${rc} (alias removed — global macvscr on PATH; alias would shadow it)`, newlyAdded: false };
|
|
149
|
+
}
|
|
150
|
+
return { note: 'skipped (global macvscr on PATH)', newlyAdded: false };
|
|
151
|
+
}
|
|
152
|
+
if (present) return { note: `${rc} (already present)`, newlyAdded: false };
|
|
153
|
+
const ok = appendAliasBlock();
|
|
154
|
+
return { note: ok ? `${rc} (added)` : `(could not write ${rc})`, newlyAdded: ok };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function plistXML(daemonBin, args) {
|
|
158
|
+
const progArgs = [daemonBin, ...args].map(a => ` <string>${escapeXML(a)}</string>`).join('\n');
|
|
159
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
160
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
161
|
+
<plist version="1.0">
|
|
162
|
+
<dict>
|
|
163
|
+
<key>Label</key>
|
|
164
|
+
<string>${LABEL}</string>
|
|
165
|
+
<key>ProgramArguments</key>
|
|
166
|
+
<array>
|
|
167
|
+
${progArgs}
|
|
168
|
+
</array>
|
|
169
|
+
<key>RunAtLoad</key>
|
|
170
|
+
<true/>
|
|
171
|
+
<key>KeepAlive</key>
|
|
172
|
+
<dict>
|
|
173
|
+
<key>SuccessfulExit</key>
|
|
174
|
+
<false/>
|
|
175
|
+
</dict>
|
|
176
|
+
<key>ProcessType</key>
|
|
177
|
+
<string>Interactive</string>
|
|
178
|
+
<key>StandardOutPath</key>
|
|
179
|
+
<string>/tmp/macvscr.out.log</string>
|
|
180
|
+
<key>StandardErrorPath</key>
|
|
181
|
+
<string>/tmp/macvscr.err.log</string>
|
|
182
|
+
</dict>
|
|
183
|
+
</plist>
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Write the plist (pointing at daemonBin) and bootstrap it. */
|
|
188
|
+
function bootstrapService(daemonBin, args) {
|
|
189
|
+
fs.mkdirSync(AGENT_DIR, { recursive: true });
|
|
190
|
+
if (isLoaded()) sh('launchctl', ['bootout', serviceTarget]);
|
|
191
|
+
fs.writeFileSync(PLIST, plistXML(daemonBin, args));
|
|
192
|
+
const r = sh('launchctl', ['bootstrap', domainTarget, PLIST]);
|
|
193
|
+
if (r.status !== 0) {
|
|
194
|
+
console.error(`${tag()} ${C.red('failed to bootstrap the LaunchAgent.')}`);
|
|
195
|
+
process.exit(r.status || 1);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- service commands ---
|
|
200
|
+
|
|
201
|
+
function setup(args) {
|
|
202
|
+
console.log(`${tag()} setting up…`);
|
|
203
|
+
|
|
204
|
+
fs.mkdirSync(STABLE_DIR, { recursive: true });
|
|
205
|
+
fs.copyFileSync(BIN, STABLE_BIN);
|
|
206
|
+
fs.chmodSync(STABLE_BIN, 0o755);
|
|
207
|
+
|
|
208
|
+
const { note: aliasNote, newlyAdded: addedAlias } = syncAlias();
|
|
209
|
+
|
|
210
|
+
bootstrapService(STABLE_BIN, args);
|
|
211
|
+
|
|
212
|
+
console.log('');
|
|
213
|
+
console.log(`${tag()} ${C.green('setup complete.')}`);
|
|
214
|
+
console.log(` ${hint('daemon binary:')} ${STABLE_BIN}`);
|
|
215
|
+
console.log(` ${hint('LaunchAgent:')} ${PLIST} ${hint('(runs at login; restarts on crash)')}`);
|
|
216
|
+
console.log(` ${hint('shell alias:')} ${aliasNote}`);
|
|
217
|
+
if (addedAlias) {
|
|
218
|
+
console.log('');
|
|
219
|
+
console.log(` ▸ The \`${CMD}\` command won't exist in THIS terminal until you reload the shell:`);
|
|
220
|
+
console.log(` ${C.cyan(`source ${rcFile()}`)}`);
|
|
221
|
+
}
|
|
222
|
+
console.log('');
|
|
223
|
+
console.log(` ${hint('How to control macvscr:')}`);
|
|
224
|
+
console.log(` • ${hint('Quit the app now:')} click the display icon in the menu bar → Quit`);
|
|
225
|
+
console.log(` • ${hint('Stop:')} ${C.cyan(`${CMD} stop`)}`);
|
|
226
|
+
console.log(` • ${hint('Full teardown:')} ${C.cyan(`${CMD} uninstall`)}`);
|
|
227
|
+
console.log(` • ${hint('All options:')} ${C.cyan(`${CMD} --help`)}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function uninstall() {
|
|
231
|
+
if (isLoaded()) sh('launchctl', ['bootout', serviceTarget]);
|
|
232
|
+
if (fs.existsSync(PLIST)) fs.unlinkSync(PLIST);
|
|
233
|
+
if (fs.existsSync(STABLE_DIR)) fs.rmSync(STABLE_DIR, { recursive: true, force: true });
|
|
234
|
+
removeAlias();
|
|
235
|
+
sh('pkill', ['-f', STABLE_BIN]);
|
|
236
|
+
sh('pkill', ['-f', BIN]);
|
|
237
|
+
console.log(`${tag()} ${C.green('uninstalled')} ${hint('(LaunchAgent, ~/.macvscr, and shell alias removed).')}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function start() {
|
|
241
|
+
if (!fs.existsSync(PLIST)) {
|
|
242
|
+
console.error(`${tag()} service not installed. Run \`${C.cyan(`${CMD} setup`)}\` first.`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
if (isLoaded()) {
|
|
246
|
+
sh('launchctl', ['kickstart', '-k', serviceTarget]);
|
|
247
|
+
} else {
|
|
248
|
+
sh('launchctl', ['bootstrap', domainTarget, PLIST]);
|
|
249
|
+
}
|
|
250
|
+
console.log(`${tag()} ${C.green('service started.')}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function stop() {
|
|
254
|
+
// Universal: stop the service AND any background instance.
|
|
255
|
+
if (isLoaded()) sh('launchctl', ['bootout', serviceTarget]);
|
|
256
|
+
sh('pkill', ['-f', STABLE_BIN]);
|
|
257
|
+
sh('pkill', ['-f', BIN]);
|
|
258
|
+
console.log(`${tag()} ${C.green('stopped')} ${hint('(service and any background instance).')}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function status() {
|
|
262
|
+
if (!fs.existsSync(PLIST)) {
|
|
263
|
+
console.log(`${tag()} ${hint('service not installed.')}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!isLoaded()) {
|
|
267
|
+
console.log(`${tag()} ${hint('installed (plist present) but not loaded in this session.')}`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const r = shOut('launchctl', ['print', serviceTarget]);
|
|
271
|
+
const pid = servicePid();
|
|
272
|
+
const lastM = r.stdout && r.stdout.match(/last exit code = (\d+)/);
|
|
273
|
+
console.log(`${tag()} ${C.green(`loaded`)} ${hint(`(${LABEL})`)}`);
|
|
274
|
+
console.log(` ${pid ? `${C.green(`running`)}${hint(', pid')} ${pid}` : hint('not currently running')}`);
|
|
275
|
+
if (lastM) console.log(` ${hint('last exit code:')} ${lastM[1]}`);
|
|
276
|
+
console.log(` ${hint('plist:')} ${PLIST}`);
|
|
277
|
+
console.log(` ${hint('daemon binary:')} ${fs.existsSync(STABLE_BIN) ? STABLE_BIN : '(~/.macvscr/macvscr missing — run macvscr setup)'}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- run (foreground / background) ---
|
|
281
|
+
|
|
282
|
+
function guardDuplicate() {
|
|
283
|
+
const pid = servicePid();
|
|
284
|
+
if (pid) {
|
|
285
|
+
console.log(`${tag()} already running as a login service ${hint(`(pid ${pid})`)}. Use the menu-bar icon, or \`${C.cyan(`${CMD} stop`)}\` first.`);
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function printRunBanner() {
|
|
292
|
+
console.log(`${tag()} ${C.green('running in the foreground.') } ${hint('Press Ctrl+C to quit.')}`);
|
|
293
|
+
console.log(` → ${hint('Change resolution live from the display icon in the menu bar.')}`);
|
|
294
|
+
console.log(` → ${hint('Background instead:')} ${C.cyan(`${CMD} run -d`)}`);
|
|
295
|
+
console.log(` → ${hint('Always-on at login:')} ${C.cyan(`${CMD} setup`)}`);
|
|
296
|
+
console.log(` → ${hint('More:')} ${C.cyan(`${CMD} --help`)}`);
|
|
297
|
+
console.log('');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function runForeground(args) {
|
|
301
|
+
if (guardDuplicate()) return;
|
|
302
|
+
printRunBanner();
|
|
303
|
+
const child = spawn(BIN, args, { stdio: 'inherit' });
|
|
304
|
+
child.on('error', err => {
|
|
305
|
+
console.error(`${tag()} ${C.red('failed to launch:')}`, err.message);
|
|
306
|
+
process.exit(1);
|
|
307
|
+
});
|
|
308
|
+
child.on('exit', (code, signal) => {
|
|
309
|
+
if (signal === 'SIGINT' || signal === 'SIGTERM') process.exit(130);
|
|
310
|
+
process.exit(code == null ? 1 : code);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function runBackground(args) {
|
|
315
|
+
if (guardDuplicate()) return;
|
|
316
|
+
const child = spawn(BIN, args, { detached: true, stdio: 'ignore' });
|
|
317
|
+
child.on('error', err => {
|
|
318
|
+
console.error(`${tag()} ${C.red('failed to launch:')}`, err.message);
|
|
319
|
+
process.exit(1);
|
|
320
|
+
});
|
|
321
|
+
child.unref();
|
|
322
|
+
console.log(`${tag()} ${C.green('running in background')} ${hint(`(pid ${child.pid}).`)} Stop with: ${C.cyan(`${CMD} stop`)}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- help ---
|
|
326
|
+
|
|
327
|
+
function printHelp() {
|
|
328
|
+
console.log(`${C.bold(C.cyan('macvscr'))} ${hint('— macOS virtual display tray tool')}`);
|
|
329
|
+
console.log('');
|
|
330
|
+
console.log(C.bold('Three ways to run (progressive):'));
|
|
331
|
+
console.log(` ${C.cyan('macvscr')} run in the ${C.bold('FOREGROUND')} (Ctrl+C to quit) ${hint('[default]')}`);
|
|
332
|
+
console.log(` ${C.cyan('macvscr run -d')} run in the ${C.bold('BACKGROUND')} (terminal returns)`);
|
|
333
|
+
console.log(` ${C.cyan('macvscr setup')} install a login LaunchAgent ${hint('(runs at login, restarts on crash)')}`);
|
|
334
|
+
console.log('');
|
|
335
|
+
console.log(C.bold('Resolution flags (logical pixels; @2x = physical × 2):'));
|
|
336
|
+
// Delegate the detailed flag list to the native binary's --help.
|
|
337
|
+
const r = spawnSync(BIN, ['--help'], { stdio: 'inherit' });
|
|
338
|
+
if (r.status) process.exit(r.status);
|
|
339
|
+
console.log('');
|
|
340
|
+
console.log(C.bold('Manage the service:'));
|
|
341
|
+
console.log(` ${C.cyan('macvscr status')} ${hint('service state')}`);
|
|
342
|
+
console.log(` ${C.cyan('macvscr start')} ${hint('start the installed service')}`);
|
|
343
|
+
console.log(` ${C.cyan('macvscr stop')} ${hint('stop (service + any background instance)')}`);
|
|
344
|
+
console.log(` ${C.cyan('macvscr restart')} ${hint('restart the service')}`);
|
|
345
|
+
console.log(` ${C.cyan('macvscr uninstall')} ${hint('full teardown (service + ~/.macvscr + shell alias)')}`);
|
|
346
|
+
console.log('');
|
|
347
|
+
console.log(hint('A menu-bar app needs your GUI session, so it starts at LOGIN, not boot.'));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- dispatch ---
|
|
351
|
+
|
|
352
|
+
const args = process.argv.slice(2);
|
|
353
|
+
const sub = args[0];
|
|
354
|
+
const rest = args.slice(1);
|
|
355
|
+
|
|
356
|
+
if (sub === 'setup' || sub === 'install') {
|
|
357
|
+
setup(rest);
|
|
358
|
+
} else if (sub === 'uninstall') {
|
|
359
|
+
uninstall();
|
|
360
|
+
} else if (sub === 'start') {
|
|
361
|
+
start();
|
|
362
|
+
} else if (sub === 'stop') {
|
|
363
|
+
stop();
|
|
364
|
+
} else if (sub === 'restart') {
|
|
365
|
+
stop();
|
|
366
|
+
setTimeout(start, 700);
|
|
367
|
+
} else if (sub === 'status') {
|
|
368
|
+
status();
|
|
369
|
+
} else if (sub === 'run') {
|
|
370
|
+
if (isDetach(rest)) runBackground(stripDetach(rest));
|
|
371
|
+
else runForeground(rest);
|
|
372
|
+
} else if (sub === 'help' || sub === '-h' || sub === '--help') {
|
|
373
|
+
printHelp();
|
|
374
|
+
} else if (sub === undefined) {
|
|
375
|
+
runForeground([]);
|
|
376
|
+
} else {
|
|
377
|
+
if (isDetach(args)) runBackground(stripDetach(args));
|
|
378
|
+
else runForeground(args);
|
|
379
|
+
}
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "macvscr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "macOS menu-bar tool that creates controllable virtual displays — arbitrary resolution, HiDPI, aspect ratio, and iMac presets. Unblocks HiDPI screen-sharing on headless Macs.",
|
|
5
5
|
"bin": {
|
|
6
|
-
"
|
|
6
|
+
"macvscr": "bin/macvscr.js"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"bin/",
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
"cli"
|
|
38
38
|
],
|
|
39
39
|
"license": "MIT",
|
|
40
|
-
"author": "
|
|
41
|
-
"homepage": "https://github.com/
|
|
40
|
+
"author": "gaubee",
|
|
41
|
+
"homepage": "https://github.com/gaubee/macvscr",
|
|
42
42
|
"repository": {
|
|
43
43
|
"type": "git",
|
|
44
|
-
"url": "https://github.com/
|
|
44
|
+
"url": "https://github.com/gaubee/macvscr.git",
|
|
45
45
|
"directory": "npm"
|
|
46
46
|
}
|
|
47
47
|
}
|
package/bin/vscr.js
DELETED
|
@@ -1,318 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
// vscr CLI — launches the native macOS menu-bar binary and manages a user
|
|
5
|
-
// LaunchAgent (login start + crash restart = the "daemon" mode).
|
|
6
|
-
//
|
|
7
|
-
// `vscr setup` is the standard one-liner (`npx -y vscr@latest setup`):
|
|
8
|
-
// 1. copies the bundled binary to a stable path (~/.vscr/vscr) for the daemon
|
|
9
|
-
// 2. adds `alias vscr='npx -y vscr@latest'` to the shell rc if no vscr command exists
|
|
10
|
-
// 3. registers + starts the LaunchAgent (runs at login, restarts on crash)
|
|
11
|
-
//
|
|
12
|
-
// Why a LaunchAgent (login) not a LaunchDaemon (boot): a menu-bar tray app
|
|
13
|
-
// needs the user's GUI session (WindowServer), which exists only after login.
|
|
14
|
-
|
|
15
|
-
const { spawn, spawnSync } = require('child_process');
|
|
16
|
-
const fs = require('fs');
|
|
17
|
-
const os = require('os');
|
|
18
|
-
const path = require('path');
|
|
19
|
-
|
|
20
|
-
const LABEL = 'npm.vscr';
|
|
21
|
-
const BIN = path.join(__dirname, '..', 'binaries', 'vscr-darwin-arm64');
|
|
22
|
-
const AGENT_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
23
|
-
const PLIST = path.join(AGENT_DIR, `${LABEL}.plist`);
|
|
24
|
-
const STABLE_DIR = path.join(os.homedir(), '.vscr');
|
|
25
|
-
const STABLE_BIN = path.join(STABLE_DIR, 'vscr');
|
|
26
|
-
|
|
27
|
-
if (process.platform !== 'darwin') {
|
|
28
|
-
console.error('vscr: macOS only.');
|
|
29
|
-
process.exit(1);
|
|
30
|
-
}
|
|
31
|
-
if (!fs.existsSync(BIN)) {
|
|
32
|
-
console.error('vscr: native binary not found at ' + BIN);
|
|
33
|
-
console.error(' The package looks corrupt — try reinstalling it.');
|
|
34
|
-
process.exit(1);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const uid = process.getuid();
|
|
38
|
-
const domainTarget = `gui/${uid}`;
|
|
39
|
-
const serviceTarget = `gui/${uid}/${LABEL}`;
|
|
40
|
-
|
|
41
|
-
// --- helpers ---
|
|
42
|
-
|
|
43
|
-
function sh(file, args) {
|
|
44
|
-
return spawnSync(file, args, { stdio: 'inherit' });
|
|
45
|
-
}
|
|
46
|
-
function shOut(file, args) {
|
|
47
|
-
return spawnSync(file, args, { encoding: 'utf8' });
|
|
48
|
-
}
|
|
49
|
-
function escapeXML(s) {
|
|
50
|
-
return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
51
|
-
}
|
|
52
|
-
function isLoaded() {
|
|
53
|
-
return shOut('launchctl', ['print', serviceTarget]).status === 0;
|
|
54
|
-
}
|
|
55
|
-
function servicePid() {
|
|
56
|
-
if (!isLoaded()) return null;
|
|
57
|
-
const r = shOut('launchctl', ['print', serviceTarget]);
|
|
58
|
-
const m = r.stdout && r.stdout.match(/\bpid\s*=\s*(\d+)/);
|
|
59
|
-
return m ? m[1] : null;
|
|
60
|
-
}
|
|
61
|
-
function hasVscrCommand() {
|
|
62
|
-
return shOut('sh', ['-c', 'command -v vscr']).status === 0;
|
|
63
|
-
}
|
|
64
|
-
function rcFile() {
|
|
65
|
-
const shell = process.env.SHELL || '';
|
|
66
|
-
if (shell.endsWith('bash')) return path.join(os.homedir(), '.bashrc');
|
|
67
|
-
return path.join(os.homedir(), '.zshrc'); // macOS default + fallback
|
|
68
|
-
}
|
|
69
|
-
// The alias block is wrapped in stable sentinel markers so future changes to
|
|
70
|
-
// the alias command itself never break detection/removal — we match the
|
|
71
|
-
// markers, not the inner command.
|
|
72
|
-
const ALIAS_HEAD = '# +++npm:vscr';
|
|
73
|
-
const ALIAS_TAIL = '# ---npm:vscr';
|
|
74
|
-
const ALIAS_LINE = `alias vscr='npx -y vscr@latest'`;
|
|
75
|
-
const ALIAS_BLOCK = `\n${ALIAS_HEAD}\n# managed by vscr setup — remove with \`vscr uninstall\`\n${ALIAS_LINE}\n${ALIAS_TAIL}\n`;
|
|
76
|
-
const ALIAS_BLOCK_RE = /\n# \+\+\+npm:vscr[\s\S]*?# ---npm:vscr\n?/;
|
|
77
|
-
|
|
78
|
-
function aliasInRc() {
|
|
79
|
-
const rc = rcFile();
|
|
80
|
-
try {
|
|
81
|
-
return fs.existsSync(rc) && fs.readFileSync(rc, 'utf8').includes(ALIAS_HEAD);
|
|
82
|
-
} catch (_) {
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
function appendAliasBlock() {
|
|
87
|
-
const rc = rcFile();
|
|
88
|
-
try {
|
|
89
|
-
if (fs.existsSync(rc)) fs.appendFileSync(rc, ALIAS_BLOCK);
|
|
90
|
-
else fs.writeFileSync(rc, ALIAS_BLOCK);
|
|
91
|
-
return true;
|
|
92
|
-
} catch (_) {
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
function removeAlias() {
|
|
97
|
-
const rc = rcFile();
|
|
98
|
-
if (!fs.existsSync(rc)) return false;
|
|
99
|
-
try {
|
|
100
|
-
const content = fs.readFileSync(rc, 'utf8');
|
|
101
|
-
if (!ALIAS_BLOCK_RE.test(content)) return false;
|
|
102
|
-
fs.writeFileSync(rc, content.replace(ALIAS_BLOCK_RE, ''));
|
|
103
|
-
return true;
|
|
104
|
-
} catch (_) {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Reconcile the shell alias with reality so it never shadows a real command
|
|
110
|
-
* and never goes stale:
|
|
111
|
-
* - global `vscr` on PATH -> remove any alias (it would shadow the command)
|
|
112
|
-
* - no global `vscr` -> ensure the alias exists
|
|
113
|
-
* Returns { note, newlyAdded } for setup messaging.
|
|
114
|
-
*/
|
|
115
|
-
function syncAlias() {
|
|
116
|
-
const rc = rcFile();
|
|
117
|
-
const present = aliasInRc();
|
|
118
|
-
if (hasVscrCommand()) {
|
|
119
|
-
if (present) {
|
|
120
|
-
removeAlias();
|
|
121
|
-
return { note: `${rc} (alias removed — global vscr on PATH; alias would shadow it)`, newlyAdded: false };
|
|
122
|
-
}
|
|
123
|
-
return { note: 'skipped (global vscr on PATH)', newlyAdded: false };
|
|
124
|
-
}
|
|
125
|
-
if (present) return { note: `${rc} (already present)`, newlyAdded: false };
|
|
126
|
-
const ok = appendAliasBlock();
|
|
127
|
-
return { note: ok ? `${rc} (added)` : `(could not write ${rc})`, newlyAdded: ok };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function plistXML(daemonBin, args) {
|
|
131
|
-
const progArgs = [daemonBin, ...args].map(a => ` <string>${escapeXML(a)}</string>`).join('\n');
|
|
132
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
133
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
134
|
-
<plist version="1.0">
|
|
135
|
-
<dict>
|
|
136
|
-
<key>Label</key>
|
|
137
|
-
<string>${LABEL}</string>
|
|
138
|
-
<key>ProgramArguments</key>
|
|
139
|
-
<array>
|
|
140
|
-
${progArgs}
|
|
141
|
-
</array>
|
|
142
|
-
<key>RunAtLoad</key>
|
|
143
|
-
<true/>
|
|
144
|
-
<key>KeepAlive</key>
|
|
145
|
-
<dict>
|
|
146
|
-
<key>SuccessfulExit</key>
|
|
147
|
-
<false/>
|
|
148
|
-
</dict>
|
|
149
|
-
<key>ProcessType</key>
|
|
150
|
-
<string>Interactive</string>
|
|
151
|
-
<key>StandardOutPath</key>
|
|
152
|
-
<string>/tmp/vscr.out.log</string>
|
|
153
|
-
<key>StandardErrorPath</key>
|
|
154
|
-
<string>/tmp/vscr.err.log</string>
|
|
155
|
-
</dict>
|
|
156
|
-
</plist>
|
|
157
|
-
`;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Write the plist (pointing at daemonBin) and bootstrap it. */
|
|
161
|
-
function bootstrapService(daemonBin, args) {
|
|
162
|
-
fs.mkdirSync(AGENT_DIR, { recursive: true });
|
|
163
|
-
if (isLoaded()) sh('launchctl', ['bootout', serviceTarget]);
|
|
164
|
-
fs.writeFileSync(PLIST, plistXML(daemonBin, args));
|
|
165
|
-
const r = sh('launchctl', ['bootstrap', domainTarget, PLIST]);
|
|
166
|
-
if (r.status !== 0) {
|
|
167
|
-
console.error('vscr: failed to bootstrap the LaunchAgent.');
|
|
168
|
-
process.exit(r.status || 1);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// --- commands ---
|
|
173
|
-
|
|
174
|
-
function setup(args) {
|
|
175
|
-
console.log('vscr: setting up…');
|
|
176
|
-
|
|
177
|
-
// 1. stable daemon binary (so login launch needs no network / no npx)
|
|
178
|
-
fs.mkdirSync(STABLE_DIR, { recursive: true });
|
|
179
|
-
fs.copyFileSync(BIN, STABLE_BIN);
|
|
180
|
-
fs.chmodSync(STABLE_BIN, 0o755);
|
|
181
|
-
|
|
182
|
-
// 2. shell convenience: smart alias (add if no vscr; remove if global vscr would be shadowed)
|
|
183
|
-
const { note: aliasNote, newlyAdded: addedAlias } = syncAlias();
|
|
184
|
-
|
|
185
|
-
// 3. register + start the LaunchAgent, pointing at the stable binary
|
|
186
|
-
bootstrapService(STABLE_BIN, args);
|
|
187
|
-
|
|
188
|
-
console.log('');
|
|
189
|
-
console.log('vscr: setup complete.');
|
|
190
|
-
console.log(` daemon binary: ${STABLE_BIN}`);
|
|
191
|
-
console.log(` LaunchAgent: ${PLIST} (runs at login; restarts on crash)`);
|
|
192
|
-
console.log(` shell alias: ${aliasNote}`);
|
|
193
|
-
if (addedAlias) {
|
|
194
|
-
console.log('');
|
|
195
|
-
console.log(' ▸ The `vscr` command won\'t exist in THIS terminal until you reload the shell:');
|
|
196
|
-
console.log(` source ${rcFile()}`);
|
|
197
|
-
}
|
|
198
|
-
console.log('');
|
|
199
|
-
console.log(' How to control vscr:');
|
|
200
|
-
console.log(' • Quit the app now: click the display icon in the menu bar → 退出');
|
|
201
|
-
console.log(' • Stop the service: vscr stop');
|
|
202
|
-
console.log(' • Full teardown: vscr uninstall');
|
|
203
|
-
console.log(' • All options: vscr --help');
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function uninstall() {
|
|
207
|
-
if (isLoaded()) sh('launchctl', ['bootout', serviceTarget]);
|
|
208
|
-
if (fs.existsSync(PLIST)) fs.unlinkSync(PLIST);
|
|
209
|
-
if (fs.existsSync(STABLE_DIR)) fs.rmSync(STABLE_DIR, { recursive: true, force: true });
|
|
210
|
-
removeAlias();
|
|
211
|
-
sh('pkill', ['-f', STABLE_BIN]);
|
|
212
|
-
sh('pkill', ['-f', BIN]);
|
|
213
|
-
console.log('vscr: uninstalled (LaunchAgent, ~/.vscr, and shell alias removed).');
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function start() {
|
|
217
|
-
if (!fs.existsSync(PLIST)) {
|
|
218
|
-
console.error('vscr: not installed. Run `vscr setup` first.');
|
|
219
|
-
process.exit(1);
|
|
220
|
-
}
|
|
221
|
-
if (isLoaded()) {
|
|
222
|
-
sh('launchctl', ['kickstart', '-k', serviceTarget]);
|
|
223
|
-
} else {
|
|
224
|
-
sh('launchctl', ['bootstrap', domainTarget, PLIST]);
|
|
225
|
-
}
|
|
226
|
-
console.log('vscr: started.');
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function stop() {
|
|
230
|
-
if (isLoaded()) sh('launchctl', ['bootout', serviceTarget]);
|
|
231
|
-
sh('pkill', ['-f', STABLE_BIN]);
|
|
232
|
-
sh('pkill', ['-f', BIN]);
|
|
233
|
-
console.log('vscr: stopped. (Returns at next login, or run `vscr start`.)');
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function status() {
|
|
237
|
-
if (!fs.existsSync(PLIST)) {
|
|
238
|
-
console.log('vscr: not installed.');
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
if (!isLoaded()) {
|
|
242
|
-
console.log('vscr: installed (plist present) but not loaded in this session.');
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
const r = shOut('launchctl', ['print', serviceTarget]);
|
|
246
|
-
const pid = servicePid();
|
|
247
|
-
const lastM = r.stdout && r.stdout.match(/last exit code = (\d+)/);
|
|
248
|
-
console.log(`vscr: loaded (${LABEL}).`);
|
|
249
|
-
console.log(` ${pid ? `running, pid ${pid}` : 'not currently running'}`);
|
|
250
|
-
if (lastM) console.log(` last exit code: ${lastM[1]}`);
|
|
251
|
-
console.log(` plist: ${PLIST}`);
|
|
252
|
-
console.log(` daemon binary: ${fs.existsSync(STABLE_BIN) ? STABLE_BIN : '(~/.vscr/vscr missing — run vscr setup)'}`);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function runApp(args) {
|
|
256
|
-
const pid = servicePid();
|
|
257
|
-
if (pid) {
|
|
258
|
-
console.log(`vscr: already running as a login service (pid ${pid}). Use the menu-bar icon.`);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
const wantsHelp = args.some(a => a === '-h' || a === '--help' || a === '--version' || a === '-v');
|
|
262
|
-
if (wantsHelp) {
|
|
263
|
-
const r = spawnSync(BIN, args, { stdio: 'inherit' });
|
|
264
|
-
process.exit(r.status == null ? 1 : r.status);
|
|
265
|
-
}
|
|
266
|
-
const child = spawn(BIN, args, { detached: true, stdio: 'ignore' });
|
|
267
|
-
child.on('error', err => {
|
|
268
|
-
console.error('vscr: failed to launch:', err.message);
|
|
269
|
-
process.exit(1);
|
|
270
|
-
});
|
|
271
|
-
child.unref();
|
|
272
|
-
console.log(`vscr: running (pid ${child.pid}). Look for the display icon in the menu bar.`);
|
|
273
|
-
console.log(' For always-on: `vscr setup`. Manage: `vscr status | start | stop | uninstall`.');
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function printHelp() {
|
|
277
|
-
console.log(`vscr — macOS virtual display tray tool
|
|
278
|
-
|
|
279
|
-
Standard setup (recommended):
|
|
280
|
-
npx -y vscr@latest setup [--width N --ratio W:H ...]
|
|
281
|
-
copies a stable binary, adds a shell alias if needed, and registers +
|
|
282
|
-
starts a login LaunchAgent (runs at login, restarts on crash).
|
|
283
|
-
|
|
284
|
-
Manage the service:
|
|
285
|
-
vscr status loaded? running? pid?
|
|
286
|
-
vscr start start the service
|
|
287
|
-
vscr stop stop (returns at next login, or 'vscr start')
|
|
288
|
-
vscr restart restart
|
|
289
|
-
vscr uninstall remove the LaunchAgent, ~/.vscr, and the shell alias
|
|
290
|
-
|
|
291
|
-
Run directly (one-off):
|
|
292
|
-
vscr [resolution flags] launch the menu-bar app now
|
|
293
|
-
|
|
294
|
-
A menu-bar app needs your GUI session, so it starts at LOGIN, not boot.
|
|
295
|
-
Resolution flags (all pixels are LOGICAL; @2x = physical × 2):`);
|
|
296
|
-
const r = spawnSync(BIN, ['--help'], { stdio: 'inherit' });
|
|
297
|
-
if (r.status) process.exit(r.status);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// --- dispatch ---
|
|
301
|
-
|
|
302
|
-
const sub = process.argv[2];
|
|
303
|
-
const rest = process.argv.slice(3).map(String);
|
|
304
|
-
|
|
305
|
-
switch (sub) {
|
|
306
|
-
case 'setup':
|
|
307
|
-
case 'install': setup(rest); break;
|
|
308
|
-
case 'uninstall': uninstall(); break;
|
|
309
|
-
case 'start': start(); break;
|
|
310
|
-
case 'stop': stop(); break;
|
|
311
|
-
case 'restart': stop(); setTimeout(start, 700); break;
|
|
312
|
-
case 'status': status(); break;
|
|
313
|
-
case 'help':
|
|
314
|
-
case '-h':
|
|
315
|
-
case '--help': printHelp(); break;
|
|
316
|
-
case undefined: runApp([]); break;
|
|
317
|
-
default: runApp(process.argv.slice(2)); break;
|
|
318
|
-
}
|
|
Binary file
|