launch-unity 0.2.1 → 0.4.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/dist/launch.js CHANGED
@@ -3,33 +3,89 @@
3
3
  launch-unity: Open a Unity project with the matching Editor version.
4
4
  Platforms: macOS, Windows
5
5
  */
6
- import { spawn } from "node:child_process";
7
- import { existsSync, readFileSync, rmSync, createReadStream, createWriteStream } from "node:fs";
6
+ import { execFile, spawn } from "node:child_process";
7
+ import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from "node:fs";
8
+ import { rm } from "node:fs/promises";
8
9
  import { join, resolve } from "node:path";
9
- import readline from "node:readline";
10
+ import { promisify } from "node:util";
11
+ import { updateLastModifiedIfExists } from "./unityHub.js";
12
+ const execFileAsync = promisify(execFile);
13
+ const UNITY_EXECUTABLE_PATTERN_MAC = /Unity\.app\/Contents\/MacOS\/Unity/i;
14
+ const UNITY_EXECUTABLE_PATTERN_WINDOWS = /Unity\.exe/i;
15
+ const PROJECT_PATH_PATTERN = /-(?:projectPath|projectpath)(?:=|\s+)("[^"]+"|'[^']+'|[^\s"']+)/i;
16
+ const PROCESS_LIST_COMMAND_MAC = "ps";
17
+ const PROCESS_LIST_ARGS_MAC = ["-axo", "pid=,command=", "-ww"];
18
+ const WINDOWS_POWERSHELL = "powershell";
19
+ const UNITY_LOCKFILE_NAME = "UnityLockfile";
20
+ const TEMP_DIRECTORY_NAME = "Temp";
10
21
  function parseArgs(argv) {
11
- const defaultProjectPath = process.cwd();
12
22
  const args = argv.slice(2);
13
23
  const doubleDashIndex = args.indexOf("--");
14
24
  const cliArgs = doubleDashIndex >= 0 ? args.slice(0, doubleDashIndex) : args;
15
25
  const unityArgs = doubleDashIndex >= 0 ? args.slice(doubleDashIndex + 1) : [];
16
26
  const positionals = [];
17
- for (const arg of cliArgs) {
27
+ let maxDepth = 3; // default 3; -1 means unlimited
28
+ for (let i = 0; i < cliArgs.length; i++) {
29
+ const arg = cliArgs[i] ?? "";
18
30
  if (arg === "--help" || arg === "-h") {
19
31
  printHelp();
20
32
  process.exit(0);
21
33
  }
22
- else if (arg.startsWith("-")) {
23
- // Unknown flags are ignored to keep CLI permissive
34
+ if (arg.startsWith("--max-depth")) {
35
+ const parts = arg.split("=");
36
+ if (parts.length === 2) {
37
+ const value = Number.parseInt(parts[1] ?? "", 10);
38
+ if (Number.isFinite(value)) {
39
+ maxDepth = value;
40
+ }
41
+ continue;
42
+ }
43
+ const next = cliArgs[i + 1];
44
+ if (typeof next === "string" && !next.startsWith("-")) {
45
+ const value = Number.parseInt(next, 10);
46
+ if (Number.isFinite(value)) {
47
+ maxDepth = value;
48
+ }
49
+ i += 1;
50
+ continue;
51
+ }
24
52
  continue;
25
53
  }
54
+ if (arg.startsWith("-")) {
55
+ continue;
56
+ }
57
+ positionals.push(arg);
58
+ }
59
+ let projectPath;
60
+ let platform;
61
+ if (positionals.length === 0) {
62
+ projectPath = undefined; // trigger search
63
+ platform = undefined;
64
+ }
65
+ else if (positionals.length === 1) {
66
+ const first = positionals[0] ?? "";
67
+ const resolvedFirst = resolve(first);
68
+ if (existsSync(resolvedFirst)) {
69
+ projectPath = resolvedFirst;
70
+ platform = undefined;
71
+ }
26
72
  else {
27
- positionals.push(arg);
73
+ // Treat as platform when path does not exist
74
+ projectPath = undefined; // trigger search
75
+ platform = String(first);
28
76
  }
29
77
  }
30
- const projectPath = positionals[0] ? resolve(positionals[0]) : defaultProjectPath;
31
- const platform = positionals[1] ? String(positionals[1]) : undefined;
32
- const options = { projectPath, platform, unityArgs };
78
+ else {
79
+ projectPath = resolve(positionals[0] ?? "");
80
+ platform = String(positionals[1] ?? "");
81
+ }
82
+ const options = { unityArgs, searchMaxDepth: maxDepth };
83
+ if (projectPath !== undefined) {
84
+ options.projectPath = projectPath;
85
+ }
86
+ if (platform !== undefined) {
87
+ options.platform = platform;
88
+ }
33
89
  return options;
34
90
  }
35
91
  function printHelp() {
@@ -47,7 +103,8 @@ Forwarding:
47
103
  If UNITY_ARGS includes -buildTarget, the PLATFORM argument is ignored.
48
104
 
49
105
  Flags:
50
- -h, --help Show this help message
106
+ -h, --help Show this help message
107
+ --max-depth <N> Search depth when PROJECT_PATH is omitted (default 3, -1 unlimited)
51
108
  `;
52
109
  process.stdout.write(help);
53
110
  }
@@ -72,8 +129,9 @@ function getUnityPathWindows(version) {
72
129
  const programFilesX86 = process.env["PROGRAMFILES(X86)"];
73
130
  const localAppData = process.env["LOCALAPPDATA"];
74
131
  const addCandidate = (base) => {
75
- if (!base)
132
+ if (!base) {
76
133
  return;
134
+ }
77
135
  candidates.push(join(base, "Unity", "Hub", "Editor", version, "Editor", "Unity.exe"));
78
136
  };
79
137
  addCandidate(programFiles);
@@ -81,8 +139,9 @@ function getUnityPathWindows(version) {
81
139
  addCandidate(localAppData);
82
140
  candidates.push(join("C:\\", "Program Files", "Unity", "Hub", "Editor", version, "Editor", "Unity.exe"));
83
141
  for (const candidate of candidates) {
84
- if (existsSync(candidate))
142
+ if (existsSync(candidate)) {
85
143
  return candidate;
144
+ }
86
145
  }
87
146
  return candidates[0] ?? join("C:\\", "Program Files", "Unity", "Hub", "Editor", version, "Editor", "Unity.exe");
88
147
  }
@@ -101,140 +160,319 @@ function ensureProjectPath(projectPath) {
101
160
  process.exit(1);
102
161
  }
103
162
  }
104
- function createPromptInterface() {
105
- if (process.stdin.isTTY && process.stdout.isTTY) {
106
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
107
- const close = () => rl.close();
108
- return { rl, close };
163
+ const removeTrailingSeparators = (target) => {
164
+ let trimmed = target;
165
+ while (trimmed.length > 1 && (trimmed.endsWith("/") || trimmed.endsWith("\\"))) {
166
+ trimmed = trimmed.slice(0, -1);
167
+ }
168
+ return trimmed;
169
+ };
170
+ const normalizePath = (target) => {
171
+ const resolvedPath = resolve(target);
172
+ const trimmed = removeTrailingSeparators(resolvedPath);
173
+ return trimmed;
174
+ };
175
+ const toComparablePath = (value) => {
176
+ return value.replace(/\\/g, "/").toLocaleLowerCase();
177
+ };
178
+ const pathsEqual = (left, right) => {
179
+ return toComparablePath(normalizePath(left)) === toComparablePath(normalizePath(right));
180
+ };
181
+ function extractProjectPath(command) {
182
+ const match = command.match(PROJECT_PATH_PATTERN);
183
+ if (!match) {
184
+ return undefined;
109
185
  }
186
+ const raw = match[1];
187
+ if (!raw) {
188
+ return undefined;
189
+ }
190
+ const trimmed = raw.trim();
191
+ if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
192
+ return trimmed.slice(1, -1);
193
+ }
194
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
195
+ return trimmed.slice(1, -1);
196
+ }
197
+ return trimmed;
198
+ }
199
+ async function listUnityProcessesMac() {
200
+ let stdout = "";
110
201
  try {
111
- if (process.platform === "win32") {
112
- const inCandidates = ["\\\\.\\CONIN$", "CONIN$"];
113
- const outCandidates = ["\\\\.\\CONOUT$", "CONOUT$"];
114
- for (const inPath of inCandidates) {
115
- for (const outPath of outCandidates) {
116
- try {
117
- const input = createReadStream(inPath);
118
- const output = createWriteStream(outPath);
119
- const rl = readline.createInterface({ input, output });
120
- const close = () => {
121
- rl.close();
122
- input.destroy();
123
- output.end();
124
- };
125
- return { rl, close };
126
- }
127
- catch {
128
- continue;
129
- }
130
- }
131
- }
202
+ const result = await execFileAsync(PROCESS_LIST_COMMAND_MAC, PROCESS_LIST_ARGS_MAC);
203
+ stdout = result.stdout;
204
+ }
205
+ catch (error) {
206
+ const message = error instanceof Error ? error.message : String(error);
207
+ console.error(`Failed to retrieve Unity process list: ${message}`);
208
+ return [];
209
+ }
210
+ const lines = stdout
211
+ .split("\n")
212
+ .map((line) => line.trim())
213
+ .filter((line) => line.length > 0);
214
+ const processes = [];
215
+ for (const line of lines) {
216
+ const match = line.match(/^(\d+)\s+(.*)$/);
217
+ if (!match) {
218
+ continue;
132
219
  }
133
- else {
134
- const input = createReadStream("/dev/tty");
135
- const output = createWriteStream("/dev/tty");
136
- const rl = readline.createInterface({ input, output });
137
- const close = () => {
138
- rl.close();
139
- input.destroy();
140
- output.end();
141
- };
142
- return { rl, close };
220
+ const pidValue = Number.parseInt(match[1] ?? "", 10);
221
+ if (!Number.isFinite(pidValue)) {
222
+ continue;
143
223
  }
224
+ const command = match[2] ?? "";
225
+ if (!UNITY_EXECUTABLE_PATTERN_MAC.test(command)) {
226
+ continue;
227
+ }
228
+ const projectArgument = extractProjectPath(command);
229
+ if (!projectArgument) {
230
+ continue;
231
+ }
232
+ processes.push({
233
+ pid: pidValue,
234
+ projectPath: normalizePath(projectArgument),
235
+ });
144
236
  }
145
- catch {
146
- // fallthrough
147
- }
148
- return null;
237
+ return processes;
149
238
  }
150
- async function handleUnityLockfilePrompt(lockfilePath) {
151
- // Prefer single-key confirmation when a real TTY is available
152
- if (process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === "function") {
153
- const confirmedByKey = await promptYesNoSingleKey("Delete UnityLockfile and continue? Type 'y' to continue; anything else aborts: ");
154
- if (!confirmedByKey) {
155
- console.log("Aborted by user.");
156
- return false;
157
- }
158
- rmSync(lockfilePath, { force: true });
159
- console.log("Deleted UnityLockfile. Continuing launch.");
160
- return true;
161
- }
162
- // Fallback to line-based prompt through OS console handles
163
- const prompt = createPromptInterface();
164
- if (!prompt) {
165
- console.error("UnityLockfile exists. No interactive console available for confirmation.");
166
- return false;
239
+ async function listUnityProcessesWindows() {
240
+ const scriptLines = [
241
+ "$ErrorActionPreference = 'Stop'",
242
+ "$processes = Get-CimInstance Win32_Process -Filter \"Name = 'Unity.exe'\" | Where-Object { $_.CommandLine }",
243
+ "foreach ($process in $processes) {",
244
+ " $commandLine = $process.CommandLine -replace \"`r\", ' ' -replace \"`n\", ' '",
245
+ " Write-Output (\"{0}|{1}\" -f $process.ProcessId, $commandLine)",
246
+ "}",
247
+ ];
248
+ let stdout = "";
249
+ try {
250
+ const result = await execFileAsync(WINDOWS_POWERSHELL, ["-NoProfile", "-Command", scriptLines.join("\n")]);
251
+ stdout = result.stdout ?? "";
252
+ }
253
+ catch (error) {
254
+ const message = error instanceof Error ? error.message : String(error);
255
+ console.error(`Failed to retrieve Unity process list on Windows: ${message}`);
256
+ return [];
167
257
  }
168
- const confirmed = await new Promise((resolve) => {
169
- prompt.rl.question("Delete UnityLockfile and continue? Type 'y' to continue; anything else aborts: ", (answer) => {
170
- resolve(answer.trim() === "y");
258
+ const lines = stdout
259
+ .split("\n")
260
+ .map((line) => line.trim())
261
+ .filter((line) => line.length > 0);
262
+ const processes = [];
263
+ for (const line of lines) {
264
+ const delimiterIndex = line.indexOf("|");
265
+ if (delimiterIndex < 0) {
266
+ continue;
267
+ }
268
+ const pidText = line.slice(0, delimiterIndex).trim();
269
+ const command = line.slice(delimiterIndex + 1).trim();
270
+ const pidValue = Number.parseInt(pidText, 10);
271
+ if (!Number.isFinite(pidValue)) {
272
+ continue;
273
+ }
274
+ if (!UNITY_EXECUTABLE_PATTERN_WINDOWS.test(command)) {
275
+ continue;
276
+ }
277
+ const projectArgument = extractProjectPath(command);
278
+ if (!projectArgument) {
279
+ continue;
280
+ }
281
+ processes.push({
282
+ pid: pidValue,
283
+ projectPath: normalizePath(projectArgument),
171
284
  });
172
- });
173
- prompt.close();
174
- if (!confirmed) {
175
- console.log("Aborted by user.");
176
- return false;
177
285
  }
178
- rmSync(lockfilePath, { force: true });
179
- console.log("Deleted UnityLockfile. Continuing launch.");
180
- return true;
286
+ return processes;
181
287
  }
182
- function stdinSupportsRawMode() {
183
- const stdin = process.stdin;
184
- return Boolean(stdin && stdin.isTTY && typeof stdin.setRawMode === "function" && process.stdout.isTTY);
288
+ async function listUnityProcesses() {
289
+ if (process.platform === "darwin") {
290
+ return await listUnityProcessesMac();
291
+ }
292
+ if (process.platform === "win32") {
293
+ return await listUnityProcessesWindows();
294
+ }
295
+ return [];
185
296
  }
186
- async function promptYesNoSingleKey(message) {
187
- if (!stdinSupportsRawMode())
188
- return false;
189
- const stdin = process.stdin;
190
- return await new Promise((resolve) => {
191
- const handleData = (data) => {
192
- const firstByte = data[0] ?? 0;
193
- const char = data.toString();
194
- let result = false;
195
- if (char === "y") {
196
- result = true;
197
- }
198
- else if (firstByte === 3 /* Ctrl+C */ || firstByte === 27 /* ESC */ || char === "n" || char === "N" || firstByte === 13 /* Enter */) {
199
- result = false;
200
- }
201
- else {
202
- result = false;
203
- }
204
- process.stdout.write("\n");
205
- cleanup();
206
- resolve(result);
207
- };
208
- const cleanup = () => {
209
- if (typeof stdin.setRawMode === "function")
210
- stdin.setRawMode(false);
211
- stdin.pause();
212
- stdin.removeListener("data", handleData);
213
- };
214
- process.stdout.write(message);
215
- if (typeof stdin.setRawMode === "function")
216
- stdin.setRawMode(true);
217
- stdin.resume();
218
- stdin.once("data", handleData);
219
- });
297
+ async function findRunningUnityProcess(projectPath) {
298
+ const normalizedTarget = normalizePath(projectPath);
299
+ const processes = await listUnityProcesses();
300
+ return processes.find((candidate) => pathsEqual(candidate.projectPath, normalizedTarget));
220
301
  }
221
- async function checkUnityRunning(projectPath) {
222
- const lockfile = join(projectPath, "Temp", "UnityLockfile");
223
- if (!existsSync(lockfile))
224
- return true;
225
- console.log(`UnityLockfile found: ${lockfile}`);
226
- console.log("Another Unity process may be using this project.");
227
- return await handleUnityLockfilePrompt(lockfile);
302
+ async function focusUnityProcess(pid) {
303
+ if (process.platform === "darwin") {
304
+ await focusUnityProcessMac(pid);
305
+ return;
306
+ }
307
+ if (process.platform === "win32") {
308
+ await focusUnityProcessWindows(pid);
309
+ }
310
+ }
311
+ async function focusUnityProcessMac(pid) {
312
+ const script = `tell application "System Events" to set frontmost of (first process whose unix id is ${pid}) to true`;
313
+ try {
314
+ await execFileAsync("osascript", ["-e", script]);
315
+ console.log("Brought existing Unity to the front.");
316
+ }
317
+ catch (error) {
318
+ const message = error instanceof Error ? error.message : String(error);
319
+ console.warn(`Failed to bring Unity to front: ${message}`);
320
+ }
321
+ }
322
+ async function focusUnityProcessWindows(pid) {
323
+ const addTypeLines = [
324
+ "Add-Type -TypeDefinition @\"",
325
+ "using System;",
326
+ "using System.Runtime.InteropServices;",
327
+ "public static class Win32Interop {",
328
+ " [DllImport(\"user32.dll\")] public static extern bool SetForegroundWindow(IntPtr hWnd);",
329
+ " [DllImport(\"user32.dll\")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);",
330
+ "}",
331
+ "\"@",
332
+ ];
333
+ const scriptLines = [
334
+ "$ErrorActionPreference = 'Stop'",
335
+ ...addTypeLines,
336
+ `try { $process = Get-Process -Id ${pid} -ErrorAction Stop } catch { return }`,
337
+ "$handle = $process.MainWindowHandle",
338
+ "if ($handle -eq 0) { return }",
339
+ "[Win32Interop]::ShowWindowAsync($handle, 9) | Out-Null",
340
+ "[Win32Interop]::SetForegroundWindow($handle) | Out-Null",
341
+ ];
342
+ try {
343
+ await execFileAsync(WINDOWS_POWERSHELL, ["-NoProfile", "-Command", scriptLines.join("\n")]);
344
+ console.log("Brought existing Unity to the front.");
345
+ }
346
+ catch (error) {
347
+ const message = error instanceof Error ? error.message : String(error);
348
+ console.warn(`Failed to bring Unity to front on Windows: ${message}`);
349
+ }
350
+ }
351
+ async function handleStaleLockfile(projectPath) {
352
+ const tempDirectoryPath = join(projectPath, TEMP_DIRECTORY_NAME);
353
+ const lockfilePath = join(tempDirectoryPath, UNITY_LOCKFILE_NAME);
354
+ if (!existsSync(lockfilePath)) {
355
+ return;
356
+ }
357
+ console.log(`UnityLockfile found without active Unity process: ${lockfilePath}`);
358
+ console.log("Assuming previous crash. Cleaning Temp directory and continuing launch.");
359
+ try {
360
+ await rm(tempDirectoryPath, { recursive: true, force: true });
361
+ console.log("Deleted Temp directory.");
362
+ }
363
+ catch (error) {
364
+ const message = error instanceof Error ? error.message : String(error);
365
+ console.warn(`Failed to delete Temp directory: ${message}`);
366
+ }
367
+ try {
368
+ await rm(lockfilePath, { force: true });
369
+ console.log("Deleted UnityLockfile.");
370
+ }
371
+ catch (error) {
372
+ const message = error instanceof Error ? error.message : String(error);
373
+ console.warn(`Failed to delete UnityLockfile: ${message}`);
374
+ }
228
375
  }
229
376
  function hasBuildTargetArg(unityArgs) {
230
377
  for (const arg of unityArgs) {
231
- if (arg === "-buildTarget")
378
+ if (arg === "-buildTarget") {
232
379
  return true;
233
- if (arg.startsWith("-buildTarget="))
380
+ }
381
+ if (arg.startsWith("-buildTarget=")) {
234
382
  return true;
383
+ }
235
384
  }
236
385
  return false;
237
386
  }
387
+ const EXCLUDED_DIR_NAMES = new Set([
388
+ "library",
389
+ "temp",
390
+ "logs",
391
+ "obj",
392
+ ".git",
393
+ "node_modules",
394
+ ".idea",
395
+ ".vscode",
396
+ ".vs",
397
+ ]);
398
+ function isUnityProjectRoot(candidateDir) {
399
+ const versionFile = join(candidateDir, "ProjectSettings", "ProjectVersion.txt");
400
+ const hasVersion = existsSync(versionFile);
401
+ if (!hasVersion) {
402
+ return false;
403
+ }
404
+ const libraryDir = join(candidateDir, "Library");
405
+ return existsSync(libraryDir);
406
+ }
407
+ function listSubdirectoriesSorted(dir) {
408
+ let entries = [];
409
+ try {
410
+ const dirents = readdirSync(dir, { withFileTypes: true });
411
+ const subdirs = dirents
412
+ .filter((d) => d.isDirectory())
413
+ .map((d) => d.name)
414
+ .filter((name) => !EXCLUDED_DIR_NAMES.has(name.toLocaleLowerCase()));
415
+ subdirs.sort((a, b) => a.localeCompare(b));
416
+ entries = subdirs.map((name) => join(dir, name));
417
+ }
418
+ catch (_err) {
419
+ // Ignore directories we cannot read
420
+ entries = [];
421
+ }
422
+ return entries;
423
+ }
424
+ function findUnityProjectBfs(rootDir, maxDepth) {
425
+ const queue = [];
426
+ let rootCanonical;
427
+ try {
428
+ rootCanonical = realpathSync(rootDir);
429
+ }
430
+ catch (_err) {
431
+ rootCanonical = rootDir;
432
+ }
433
+ queue.push({ dir: rootCanonical, depth: 0 });
434
+ const visited = new Set([toComparablePath(normalizePath(rootCanonical))]);
435
+ while (queue.length > 0) {
436
+ const current = queue.shift();
437
+ if (!current) {
438
+ continue;
439
+ }
440
+ const { dir, depth } = current;
441
+ if (isUnityProjectRoot(dir)) {
442
+ return normalizePath(dir);
443
+ }
444
+ const canDescend = maxDepth === -1 || depth < maxDepth;
445
+ if (!canDescend) {
446
+ continue;
447
+ }
448
+ const children = listSubdirectoriesSorted(dir);
449
+ for (const child of children) {
450
+ let childCanonical = child;
451
+ try {
452
+ const stat = lstatSync(child);
453
+ if (stat.isSymbolicLink()) {
454
+ try {
455
+ childCanonical = realpathSync(child);
456
+ }
457
+ catch (_e) {
458
+ // Broken symlink: skip
459
+ continue;
460
+ }
461
+ }
462
+ }
463
+ catch (_err) {
464
+ continue;
465
+ }
466
+ const key = toComparablePath(normalizePath(childCanonical));
467
+ if (visited.has(key)) {
468
+ continue;
469
+ }
470
+ visited.add(key);
471
+ queue.push({ dir: childCanonical, depth: depth + 1 });
472
+ }
473
+ }
474
+ return undefined;
475
+ }
238
476
  function launch(opts) {
239
477
  const { projectPath, platform, unityArgs } = opts;
240
478
  const unityVersion = getUnityVersion(projectPath);
@@ -260,13 +498,43 @@ function launch(opts) {
260
498
  }
261
499
  async function main() {
262
500
  const options = parseArgs(process.argv);
263
- ensureProjectPath(options.projectPath);
264
- const ok = await checkUnityRunning(options.projectPath);
265
- if (!ok) {
501
+ let resolvedProjectPath = options.projectPath;
502
+ if (!resolvedProjectPath) {
503
+ const searchRoot = process.cwd();
504
+ const depthInfo = options.searchMaxDepth === -1 ? "unlimited" : String(options.searchMaxDepth);
505
+ console.log(`No PROJECT_PATH provided. Searching under ${searchRoot} (max-depth: ${depthInfo})...`);
506
+ const found = findUnityProjectBfs(searchRoot, options.searchMaxDepth);
507
+ if (!found) {
508
+ console.error(`Error: Unity project not found under ${searchRoot}.`);
509
+ process.exit(1);
510
+ return;
511
+ }
512
+ console.log(`Selected project: ${found}`);
513
+ resolvedProjectPath = found;
514
+ }
515
+ ensureProjectPath(resolvedProjectPath);
516
+ const runningProcess = await findRunningUnityProcess(resolvedProjectPath);
517
+ if (runningProcess) {
518
+ console.log(`Unity process already running for project: ${resolvedProjectPath} (PID: ${runningProcess.pid})`);
519
+ await focusUnityProcess(runningProcess.pid);
266
520
  process.exit(0);
267
521
  return;
268
522
  }
269
- launch(options);
523
+ await handleStaleLockfile(resolvedProjectPath);
524
+ const resolved = {
525
+ projectPath: resolvedProjectPath,
526
+ platform: options.platform,
527
+ unityArgs: options.unityArgs,
528
+ };
529
+ launch(resolved);
530
+ // Best-effort update of Unity Hub's lastModified timestamp.
531
+ try {
532
+ await updateLastModifiedIfExists(resolvedProjectPath, new Date());
533
+ }
534
+ catch (error) {
535
+ const message = error instanceof Error ? error.message : String(error);
536
+ console.warn(`Failed to update Unity Hub lastModified: ${message}`);
537
+ }
270
538
  }
271
539
  main().catch((error) => {
272
540
  console.error(error);
package/dist/quit.js CHANGED
@@ -4,13 +4,17 @@
4
4
  Platforms: macOS, Windows
5
5
  */
6
6
  import { existsSync, readFileSync } from "node:fs";
7
+ import { rm } from "node:fs/promises";
7
8
  import { join, resolve } from "node:path";
9
+ const TEMP_DIRECTORY_NAME = "Temp";
8
10
  function parseArgs(argv) {
9
11
  const defaultProjectPath = process.cwd();
12
+ const defaultTimeoutMs = 15000;
13
+ const defaultForce = false;
10
14
  const args = argv.slice(2);
11
15
  let projectPath = defaultProjectPath;
12
- let timeoutMs = 15000;
13
- let force = false;
16
+ let timeoutMs = defaultTimeoutMs;
17
+ let force = defaultForce;
14
18
  for (let i = 0; i < args.length; i++) {
15
19
  const arg = args[i] ?? "";
16
20
  if (arg === "--help" || arg === "-h") {
@@ -27,7 +31,8 @@ function parseArgs(argv) {
27
31
  console.error("Error: --timeout requires a millisecond value");
28
32
  process.exit(1);
29
33
  }
30
- const parsed = Number(value);
34
+ const parsedValue = Number(value);
35
+ const parsed = parsedValue;
31
36
  if (!Number.isFinite(parsed) || parsed < 0) {
32
37
  console.error("Error: --timeout must be a non-negative number (milliseconds)");
33
38
  process.exit(1);
@@ -106,7 +111,8 @@ function isProcessAlive(pid) {
106
111
  }
107
112
  async function waitForExit(pid, timeoutMs) {
108
113
  const start = Date.now();
109
- const stepMs = 200;
114
+ const stepIntervalMs = 200;
115
+ const stepMs = stepIntervalMs;
110
116
  while (Date.now() - start < timeoutMs) {
111
117
  if (!isProcessAlive(pid))
112
118
  return true;
@@ -119,7 +125,7 @@ async function quitByPid(pid, force, timeoutMs) {
119
125
  try {
120
126
  process.kill(pid, "SIGTERM");
121
127
  }
122
- catch (error) {
128
+ catch {
123
129
  // If process already exited, consider it success
124
130
  if (!isProcessAlive(pid))
125
131
  return true;
@@ -142,6 +148,19 @@ async function quitByPid(pid, force, timeoutMs) {
142
148
  // Give a short moment after force
143
149
  return await waitForExit(pid, 2000);
144
150
  }
151
+ async function removeTempDirectory(projectPath) {
152
+ const tempDirectoryPath = join(projectPath, TEMP_DIRECTORY_NAME);
153
+ if (!existsSync(tempDirectoryPath))
154
+ return;
155
+ try {
156
+ await rm(tempDirectoryPath, { recursive: true, force: true });
157
+ console.log(`Deleted Temp directory: ${tempDirectoryPath}`);
158
+ }
159
+ catch (error) {
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ console.warn(`Failed to delete Temp directory: ${message}`);
162
+ }
163
+ }
145
164
  async function main() {
146
165
  const options = parseArgs(process.argv);
147
166
  ensureProjectPath(options.projectPath);
@@ -159,6 +178,7 @@ async function main() {
159
178
  return;
160
179
  }
161
180
  console.log("Unity has exited.");
181
+ await removeTempDirectory(options.projectPath);
162
182
  }
163
183
  main().catch((error) => {
164
184
  console.error(error);
@@ -0,0 +1,90 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ const resolveUnityHubProjectFiles = () => {
4
+ if (process.platform === "darwin") {
5
+ const home = process.env.HOME;
6
+ if (!home) {
7
+ return [];
8
+ }
9
+ const base = join(home, "Library", "Application Support", "UnityHub");
10
+ return [join(base, "projects-v1.json"), join(base, "projects.json")];
11
+ }
12
+ if (process.platform === "win32") {
13
+ const appData = process.env.APPDATA;
14
+ if (!appData) {
15
+ return [];
16
+ }
17
+ const base = join(appData, "UnityHub");
18
+ return [join(base, "projects-v1.json"), join(base, "projects.json")];
19
+ }
20
+ return [];
21
+ };
22
+ const removeTrailingSeparators = (target) => {
23
+ let trimmed = target;
24
+ while (trimmed.length > 1 && (trimmed.endsWith("/") || trimmed.endsWith("\\"))) {
25
+ trimmed = trimmed.slice(0, -1);
26
+ }
27
+ return trimmed;
28
+ };
29
+ const normalizePath = (target) => {
30
+ const resolvedPath = resolve(target);
31
+ return removeTrailingSeparators(resolvedPath);
32
+ };
33
+ const toComparablePath = (value) => {
34
+ return value.replace(/\\/g, "/").toLocaleLowerCase();
35
+ };
36
+ const pathsEqual = (left, right) => {
37
+ return toComparablePath(normalizePath(left)) === toComparablePath(normalizePath(right));
38
+ };
39
+ export const updateLastModifiedIfExists = async (projectPath, when) => {
40
+ const candidates = resolveUnityHubProjectFiles();
41
+ if (candidates.length === 0) {
42
+ return;
43
+ }
44
+ // Try primary then fallback only if read/parse fails
45
+ for (const path of candidates) {
46
+ let content;
47
+ let json;
48
+ try {
49
+ content = await readFile(path, "utf8");
50
+ }
51
+ catch {
52
+ // Try next candidate on read error
53
+ continue;
54
+ }
55
+ try {
56
+ json = JSON.parse(content);
57
+ }
58
+ catch {
59
+ // Try next candidate on parse error
60
+ continue;
61
+ }
62
+ if (!json.data) {
63
+ // If file is readable but has no data, do not attempt fallback
64
+ return;
65
+ }
66
+ const projectKey = Object.keys(json.data).find((key) => {
67
+ const entryPath = json.data?.[key]?.path;
68
+ return entryPath ? pathsEqual(entryPath, projectPath) : false;
69
+ });
70
+ if (!projectKey) {
71
+ // Project not registered in Hub; do nothing
72
+ return;
73
+ }
74
+ const original = json.data[projectKey];
75
+ if (!original) {
76
+ return;
77
+ }
78
+ json.data[projectKey] = {
79
+ ...original,
80
+ lastModified: when.getTime(),
81
+ };
82
+ try {
83
+ await writeFile(path, JSON.stringify(json, undefined, 2), "utf8");
84
+ }
85
+ catch {
86
+ // Swallow write errors per requirement to not crash CLI
87
+ }
88
+ return;
89
+ }
90
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launch-unity",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Open a Unity project with the matching Editor version (macOS/Windows)",
5
5
  "type": "module",
6
6
  "main": "dist/launch.js",