@chrrxs/robloxstudio-mcp 3.1.4 → 3.1.6
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/index.js +1856 -620
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +1003 -139
package/dist/index.js
CHANGED
|
@@ -2710,7 +2710,7 @@ var BridgeService = class {
|
|
|
2710
2710
|
if (this.pendingRequests.size >= MAX_PENDING_REQUESTS || this.pendingRequestBytes + requestBytes > MAX_PENDING_REQUEST_BYTES) {
|
|
2711
2711
|
return Promise.reject(new RequestFailure(`Request ${requestId} rejected at admission: pending capacity exceeded (${this.pendingRequests.size}/${MAX_PENDING_REQUESTS} requests, ${this.pendingRequestBytes + requestBytes}/${MAX_PENDING_REQUEST_BYTES} bytes); queued; not_executed`, "request_capacity_exceeded", { ...details, bytes: this.pendingRequestBytes + requestBytes, limitBytes: MAX_PENDING_REQUEST_BYTES }));
|
|
2712
2712
|
}
|
|
2713
|
-
const { promise, resolve:
|
|
2713
|
+
const { promise, resolve: resolve6, reject } = Promise.withResolvers();
|
|
2714
2714
|
const abortListener = () => {
|
|
2715
2715
|
const pending = this.pendingRequests.get(requestId);
|
|
2716
2716
|
if (pending)
|
|
@@ -2728,7 +2728,7 @@ var BridgeService = class {
|
|
|
2728
2728
|
data,
|
|
2729
2729
|
targetPeerId,
|
|
2730
2730
|
timestamp: now,
|
|
2731
|
-
resolve:
|
|
2731
|
+
resolve: resolve6,
|
|
2732
2732
|
reject,
|
|
2733
2733
|
promise,
|
|
2734
2734
|
timeoutId,
|
|
@@ -2869,7 +2869,7 @@ var BridgeService = class {
|
|
|
2869
2869
|
operation.status.executionStartedAt = now;
|
|
2870
2870
|
else {
|
|
2871
2871
|
operation.status.executionOutcome = outcome ?? "unknown";
|
|
2872
|
-
if (outcome
|
|
2872
|
+
if (outcome === "success" || outcome === "error")
|
|
2873
2873
|
operation.status.executionCompletedAt = now;
|
|
2874
2874
|
}
|
|
2875
2875
|
operation.updatedAt = now;
|
|
@@ -2885,7 +2885,7 @@ var BridgeService = class {
|
|
|
2885
2885
|
requestId,
|
|
2886
2886
|
targetPeerId: operation.status.targetPeerId,
|
|
2887
2887
|
...observations(operation.status),
|
|
2888
|
-
executionCompletedAt: executionOutcome === "
|
|
2888
|
+
executionCompletedAt: executionOutcome === "success" || executionOutcome === "error" ? operation.status.executionCompletedAt ?? Date.now() : void 0,
|
|
2889
2889
|
stage: "response_delivery",
|
|
2890
2890
|
outcome: executionOutcome === "not_executed" ? "not_executed" : "unknown",
|
|
2891
2891
|
executionOutcome
|
|
@@ -2913,13 +2913,15 @@ var BridgeService = class {
|
|
|
2913
2913
|
operation.status.state = "settled";
|
|
2914
2914
|
const localRejection = error instanceof RequestFailure && error.details.transportStage === "server_send";
|
|
2915
2915
|
const responseOutcome = handlerOutcome(response);
|
|
2916
|
-
const completedOutcome = !hasError && responseOutcome === "error" ? "error" : executionOutcome ?? (localRejection ? "not_executed" : hasError ? operation.status.executionOutcome === "success" ? "success" : "error" : responseOutcome);
|
|
2916
|
+
const completedOutcome = executionOutcome === "unknown" || executionOutcome === "not_executed" ? executionOutcome : !hasError && responseOutcome === "error" ? "error" : executionOutcome ?? (localRejection ? "not_executed" : hasError ? operation.status.executionOutcome === "success" ? "success" : "error" : responseOutcome);
|
|
2917
2917
|
operation.status.executionOutcome = completedOutcome;
|
|
2918
|
-
operation.status.outcome = hasError || completedOutcome === "error" || completedOutcome === "not_executed" ? "error" : "success";
|
|
2918
|
+
operation.status.outcome = hasError || responseOutcome === "error" || completedOutcome === "error" || completedOutcome === "not_executed" ? "error" : "success";
|
|
2919
2919
|
if (!localRejection) {
|
|
2920
2920
|
operation.status.stage = "response_delivery";
|
|
2921
|
-
if (completedOutcome
|
|
2921
|
+
if (completedOutcome === "success" || completedOutcome === "error")
|
|
2922
2922
|
operation.status.executionCompletedAt ??= now;
|
|
2923
|
+
else
|
|
2924
|
+
delete operation.status.executionCompletedAt;
|
|
2923
2925
|
}
|
|
2924
2926
|
operation.status.settledAt = now;
|
|
2925
2927
|
operation.updatedAt = now;
|
|
@@ -3125,19 +3127,20 @@ async function fetchDocCatalog() {
|
|
|
3125
3127
|
}
|
|
3126
3128
|
const controller = new AbortController();
|
|
3127
3129
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
3128
|
-
let
|
|
3130
|
+
let html;
|
|
3129
3131
|
try {
|
|
3130
|
-
response = await fetch(DOCS_INDEX_URL, {
|
|
3132
|
+
const response = await fetch(DOCS_INDEX_URL, {
|
|
3131
3133
|
signal: controller.signal,
|
|
3132
3134
|
headers: { Accept: "text/html" }
|
|
3133
3135
|
});
|
|
3136
|
+
if (!response.ok) {
|
|
3137
|
+
await response.body?.cancel();
|
|
3138
|
+
throw new Error(`Failed to fetch Roblox docs index: HTTP ${response.status}`);
|
|
3139
|
+
}
|
|
3140
|
+
html = await response.text();
|
|
3134
3141
|
} finally {
|
|
3135
3142
|
clearTimeout(timer);
|
|
3136
3143
|
}
|
|
3137
|
-
if (!response.ok) {
|
|
3138
|
-
throw new Error(`Failed to fetch Roblox docs index: HTTP ${response.status}`);
|
|
3139
|
-
}
|
|
3140
|
-
const html = await response.text();
|
|
3141
3144
|
const pages = /* @__PURE__ */ new Map();
|
|
3142
3145
|
const routePattern = /reference\/engine\/(classes|enums|datatypes|libraries|globals)\/([A-Za-z0-9_]+)/g;
|
|
3143
3146
|
for (const match of html.matchAll(routePattern)) {
|
|
@@ -3190,27 +3193,30 @@ async function fetchRobloxDoc(category, name) {
|
|
|
3190
3193
|
}
|
|
3191
3194
|
const controller = new AbortController();
|
|
3192
3195
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
3193
|
-
let response;
|
|
3194
3196
|
try {
|
|
3195
|
-
response = await fetch(docUrl(category, name), {
|
|
3197
|
+
const response = await fetch(docUrl(category, name), {
|
|
3196
3198
|
signal: controller.signal,
|
|
3197
3199
|
headers: { Accept: "text/markdown, text/plain" }
|
|
3198
3200
|
});
|
|
3201
|
+
if (response.status === 404) {
|
|
3202
|
+
await response.body?.cancel();
|
|
3203
|
+
cacheSet(key, { fetchedAt: Date.now(), notFound: true });
|
|
3204
|
+
throw new DocNotFoundError(category, name);
|
|
3205
|
+
}
|
|
3206
|
+
if (!response.ok) {
|
|
3207
|
+
await response.body?.cancel();
|
|
3208
|
+
throw new Error(`HTTP ${response.status}`);
|
|
3209
|
+
}
|
|
3210
|
+
const content = await response.text();
|
|
3211
|
+
cacheSet(key, { fetchedAt: Date.now(), content });
|
|
3212
|
+
return content;
|
|
3199
3213
|
} catch (error) {
|
|
3214
|
+
if (error instanceof DocNotFoundError)
|
|
3215
|
+
throw error;
|
|
3200
3216
|
throw new Error(`Failed to fetch Roblox docs for ${key}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3201
3217
|
} finally {
|
|
3202
3218
|
clearTimeout(timer);
|
|
3203
3219
|
}
|
|
3204
|
-
if (response.status === 404) {
|
|
3205
|
-
cacheSet(key, { fetchedAt: Date.now(), notFound: true });
|
|
3206
|
-
throw new DocNotFoundError(category, name);
|
|
3207
|
-
}
|
|
3208
|
-
if (!response.ok) {
|
|
3209
|
-
throw new Error(`Failed to fetch Roblox docs for ${key}: HTTP ${response.status}`);
|
|
3210
|
-
}
|
|
3211
|
-
const content = await response.text();
|
|
3212
|
-
cacheSet(key, { fetchedAt: Date.now(), content });
|
|
3213
|
-
return content;
|
|
3214
3220
|
}
|
|
3215
3221
|
function listSections(markdown) {
|
|
3216
3222
|
const sections = [];
|
|
@@ -3322,6 +3328,10 @@ execute_luau runs through the Studio plugin. eval_server_runtime and eval_client
|
|
|
3322
3328
|
|
|
3323
3329
|
Read output with get_runtime_logs. Reuse nextCursor as cursor for one Instance, or nextCursorByInstance as cursor_by_instance for a Multiplayer Group, instead of requesting the full process streams again.
|
|
3324
3330
|
|
|
3331
|
+
- Each Peer VM retains approximately 64 KiB of message text, rather than a fixed message count. Reads do not consume or clear the buffer; incoming messages evict old entries.
|
|
3332
|
+
- totalDropped sums lifetime eviction counts from successfully read Peers. It counts messages evicted, not messages missed by this caller, and may decrease when Peers fail, disconnect, or reload.
|
|
3333
|
+
- For successfully read Peers, nextCursor advances past all captured entries, including those omitted by filter or tail. It is a continuation watermark, not pagination through omitted results; continuing from it will not return those entries.
|
|
3334
|
+
|
|
3325
3335
|
## Simulation and input
|
|
3326
3336
|
|
|
3327
3337
|
- Inspect current settings with get_simulation_state before changing them.
|
|
@@ -3408,14 +3418,100 @@ import { execFile, execFileSync as execFileSync2, spawn } from "child_process";
|
|
|
3408
3418
|
import { copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, readFileSync as readFileSync2, realpathSync, rmSync, statSync } from "fs";
|
|
3409
3419
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
3410
3420
|
import * as os2 from "os";
|
|
3411
|
-
import * as
|
|
3421
|
+
import * as path3 from "path";
|
|
3412
3422
|
import { promisify } from "util";
|
|
3413
3423
|
|
|
3424
|
+
// ../core/dist/studio-sign-in.js
|
|
3425
|
+
import { open, readdir } from "fs/promises";
|
|
3426
|
+
import * as path from "path";
|
|
3427
|
+
import { setTimeout as delay } from "timers/promises";
|
|
3428
|
+
var SESSION_LOG = /^[\d.]+_(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z_Studio_[A-Fa-f0-9]+_last\.log$/u;
|
|
3429
|
+
var LINE_TIME = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z),/u;
|
|
3430
|
+
var SIGN_IN_START = /\[FLog::StudioKeyEvents\] login \([^)]*\) \[start\]/u;
|
|
3431
|
+
var SIGN_IN_END = /\[FLog::StudioKeyEvents\] login \[end\]\[(success|failure)\]/u;
|
|
3432
|
+
var HEAD_BYTES = 1024 * 1024;
|
|
3433
|
+
var FILETIME_UNIX_EPOCH = 116444736000000000n;
|
|
3434
|
+
function fileTimeToUnixMs(fileTime) {
|
|
3435
|
+
return Number((BigInt(fileTime) - FILETIME_UNIX_EPOCH) / 10000n);
|
|
3436
|
+
}
|
|
3437
|
+
function studioSessionLogStartMs(name) {
|
|
3438
|
+
const match = SESSION_LOG.exec(name);
|
|
3439
|
+
if (!match)
|
|
3440
|
+
return void 0;
|
|
3441
|
+
const [, year, month, day, hour, minute, second] = match.map(Number);
|
|
3442
|
+
return Date.UTC(year, month - 1, day, hour, minute, second);
|
|
3443
|
+
}
|
|
3444
|
+
function parseStudioSignIn(name, text) {
|
|
3445
|
+
const session = { name, signingIn: false };
|
|
3446
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
3447
|
+
if (SIGN_IN_START.test(line)) {
|
|
3448
|
+
session.signingIn = true;
|
|
3449
|
+
continue;
|
|
3450
|
+
}
|
|
3451
|
+
const end = SIGN_IN_END.exec(line);
|
|
3452
|
+
if (!end)
|
|
3453
|
+
continue;
|
|
3454
|
+
session.signingIn = false;
|
|
3455
|
+
session.outcome = end[1];
|
|
3456
|
+
const time = LINE_TIME.exec(line)?.[1];
|
|
3457
|
+
if (time !== void 0)
|
|
3458
|
+
session.lastEndMs = Date.parse(time);
|
|
3459
|
+
}
|
|
3460
|
+
return session;
|
|
3461
|
+
}
|
|
3462
|
+
async function readStudioSignInSessions(logsRoot, fromMs, toMs) {
|
|
3463
|
+
let names;
|
|
3464
|
+
try {
|
|
3465
|
+
names = await readdir(logsRoot);
|
|
3466
|
+
} catch (error) {
|
|
3467
|
+
if (error.code === "ENOENT")
|
|
3468
|
+
return [];
|
|
3469
|
+
throw error;
|
|
3470
|
+
}
|
|
3471
|
+
const sessions = [];
|
|
3472
|
+
for (const name of names) {
|
|
3473
|
+
const startedAt = studioSessionLogStartMs(name);
|
|
3474
|
+
if (startedAt === void 0 || startedAt < fromMs || startedAt > toMs)
|
|
3475
|
+
continue;
|
|
3476
|
+
const file = await open(path.join(logsRoot, name), "r");
|
|
3477
|
+
try {
|
|
3478
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
3479
|
+
const { bytesRead } = await file.read(buffer, 0, HEAD_BYTES, 0);
|
|
3480
|
+
sessions.push(parseStudioSignIn(name, buffer.subarray(0, bytesRead).toString("utf8")));
|
|
3481
|
+
} finally {
|
|
3482
|
+
await file.close();
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
return sessions;
|
|
3486
|
+
}
|
|
3487
|
+
async function waitForStudioSignInToSettle({ logsRoot, processStartedAtMs, timeoutMs = 15e3, settleMs = 1500, youngMs = 5e3, now = Date.now, sleep: sleep2 = delay }) {
|
|
3488
|
+
const begin = now();
|
|
3489
|
+
for (; ; ) {
|
|
3490
|
+
const sessions = await readStudioSignInSessions(logsRoot, processStartedAtMs - 2e3, processStartedAtMs + 3e4);
|
|
3491
|
+
const signingIn = sessions.some((session) => session.signingIn);
|
|
3492
|
+
const concluded = sessions.some((session) => session.outcome !== void 0);
|
|
3493
|
+
const undecided = !signingIn && !concluded && now() - processStartedAtMs < youngMs;
|
|
3494
|
+
if (!signingIn && !undecided) {
|
|
3495
|
+
const ends = sessions.flatMap((session) => session.lastEndMs === void 0 ? [] : [session.lastEndMs]);
|
|
3496
|
+
if (!ends.length)
|
|
3497
|
+
return "idle";
|
|
3498
|
+
const sinceEnd = now() - Math.max(...ends);
|
|
3499
|
+
const remaining = Math.min(settleMs, Math.max(0, settleMs - sinceEnd));
|
|
3500
|
+
if (remaining > 0)
|
|
3501
|
+
await sleep2(remaining);
|
|
3502
|
+
return "settled";
|
|
3503
|
+
}
|
|
3504
|
+
if (now() - begin >= timeoutMs)
|
|
3505
|
+
return "timeout";
|
|
3506
|
+
await sleep2(250);
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3414
3510
|
// ../core/dist/managed-instance-registry.js
|
|
3415
3511
|
import * as fs from "fs/promises";
|
|
3416
3512
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
3417
3513
|
import * as os from "os";
|
|
3418
|
-
import * as
|
|
3514
|
+
import * as path2 from "path";
|
|
3419
3515
|
import { setTimeout as delayTimer } from "timers/promises";
|
|
3420
3516
|
var REGISTRY_VERSION = 1;
|
|
3421
3517
|
var LOCK_STALE_MS = 1e4;
|
|
@@ -3432,14 +3528,14 @@ function defaultManagedInstanceRegistryDir() {
|
|
|
3432
3528
|
return process.env.ROBLOXSTUDIO_MCP_MANAGED_INSTANCE_REGISTRY_DIR;
|
|
3433
3529
|
}
|
|
3434
3530
|
if (process.platform === "win32" && process.env.LOCALAPPDATA) {
|
|
3435
|
-
return
|
|
3531
|
+
return path2.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", "managed-instances", "v1");
|
|
3436
3532
|
}
|
|
3437
3533
|
if (process.platform === "darwin") {
|
|
3438
|
-
return
|
|
3534
|
+
return path2.join(os.homedir(), "Library", "Application Support", "robloxstudio-mcp", "managed-instances", "v1");
|
|
3439
3535
|
}
|
|
3440
|
-
return
|
|
3536
|
+
return path2.join(os.homedir(), ".local", "state", "robloxstudio-mcp", "managed-instances", "v1");
|
|
3441
3537
|
}
|
|
3442
|
-
function
|
|
3538
|
+
function delay2(ms) {
|
|
3443
3539
|
return delayTimer(ms, void 0, { ref: false });
|
|
3444
3540
|
}
|
|
3445
3541
|
function ymd(timestamp) {
|
|
@@ -3534,7 +3630,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3534
3630
|
}
|
|
3535
3631
|
async withLock(fn) {
|
|
3536
3632
|
await this.ensureDir();
|
|
3537
|
-
const lockDir =
|
|
3633
|
+
const lockDir = path2.join(this.dir, ".lock");
|
|
3538
3634
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
3539
3635
|
const owner = {
|
|
3540
3636
|
pid: process.pid,
|
|
@@ -3547,7 +3643,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3547
3643
|
activeLockTokens.add(owner.token);
|
|
3548
3644
|
activeLockPaths.add(lockDir);
|
|
3549
3645
|
try {
|
|
3550
|
-
await fs.writeFile(
|
|
3646
|
+
await fs.writeFile(path2.join(lockDir, "owner.json"), `${JSON.stringify(owner)}
|
|
3551
3647
|
`, {
|
|
3552
3648
|
encoding: "utf8",
|
|
3553
3649
|
flag: "wx"
|
|
@@ -3577,7 +3673,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3577
3673
|
if (Date.now() > deadline) {
|
|
3578
3674
|
throw new Error(`Timed out waiting for managed instance registry lock: ${lockDir}`);
|
|
3579
3675
|
}
|
|
3580
|
-
await
|
|
3676
|
+
await delay2(LOCK_RETRY_MS);
|
|
3581
3677
|
}
|
|
3582
3678
|
}
|
|
3583
3679
|
try {
|
|
@@ -3598,7 +3694,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3598
3694
|
}
|
|
3599
3695
|
async readLockOwner(lockPath, isDirectory) {
|
|
3600
3696
|
try {
|
|
3601
|
-
const contents = await fs.readFile(isDirectory ?
|
|
3697
|
+
const contents = await fs.readFile(isDirectory ? path2.join(lockPath, "owner.json") : lockPath, "utf8");
|
|
3602
3698
|
const parsed = JSON.parse(contents);
|
|
3603
3699
|
return isLockOwner(parsed) ? parsed : void 0;
|
|
3604
3700
|
} catch {
|
|
@@ -3609,10 +3705,10 @@ var ManagedInstanceRegistry = class {
|
|
|
3609
3705
|
await fs.mkdir(this.dir, { recursive: true });
|
|
3610
3706
|
}
|
|
3611
3707
|
recordPath(recordId) {
|
|
3612
|
-
return
|
|
3708
|
+
return path2.join(this.dir, `${recordId}.json`);
|
|
3613
3709
|
}
|
|
3614
3710
|
async recordFilesUnlocked() {
|
|
3615
|
-
return (await fs.readdir(this.dir)).filter((name) => name.endsWith(".json")).map((name) =>
|
|
3711
|
+
return (await fs.readdir(this.dir)).filter((name) => name.endsWith(".json")).map((name) => path2.join(this.dir, name));
|
|
3616
3712
|
}
|
|
3617
3713
|
async readRecordUnlocked(recordId) {
|
|
3618
3714
|
try {
|
|
@@ -3641,7 +3737,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3641
3737
|
async writeRecordUnlocked(record) {
|
|
3642
3738
|
await this.ensureDir();
|
|
3643
3739
|
const finalPath = this.recordPath(record.recordId);
|
|
3644
|
-
const tmpPath =
|
|
3740
|
+
const tmpPath = path2.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
|
|
3645
3741
|
const fd = await fs.open(tmpPath, "w");
|
|
3646
3742
|
try {
|
|
3647
3743
|
await fd.writeFile(`${JSON.stringify(record, null, 2)}
|
|
@@ -3656,7 +3752,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3656
3752
|
await fs.rm(this.recordPath(recordId), { force: true });
|
|
3657
3753
|
}
|
|
3658
3754
|
async appendEventUnlocked(event, now) {
|
|
3659
|
-
const file =
|
|
3755
|
+
const file = path2.join(this.dir, `events-${ymd(now)}.jsonl`);
|
|
3660
3756
|
await fs.appendFile(file, `${JSON.stringify({
|
|
3661
3757
|
ts: new Date(now).toISOString(),
|
|
3662
3758
|
...event
|
|
@@ -3670,7 +3766,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3670
3766
|
if (!date || date >= cutoff)
|
|
3671
3767
|
continue;
|
|
3672
3768
|
try {
|
|
3673
|
-
await fs.rm(
|
|
3769
|
+
await fs.rm(path2.join(this.dir, name), { force: true });
|
|
3674
3770
|
} catch {
|
|
3675
3771
|
}
|
|
3676
3772
|
}
|
|
@@ -3922,7 +4018,7 @@ function getStudioPlatformCapabilities() {
|
|
|
3922
4018
|
}
|
|
3923
4019
|
|
|
3924
4020
|
// ../core/dist/studio-instance-manager.js
|
|
3925
|
-
var BASEPLATE_TEMP_DIR =
|
|
4021
|
+
var BASEPLATE_TEMP_DIR = path3.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
|
|
3926
4022
|
var BASEPLATE_TEMP_NAME = /^Baseplate-\d+-\d+\.rbxl$/;
|
|
3927
4023
|
var BASEPLATE_TEMPLATE_NAME = "Baseplate.rbxl";
|
|
3928
4024
|
var retainedLaunchControls = /* @__PURE__ */ new Map();
|
|
@@ -3950,6 +4046,40 @@ var StudioLaunchPreDispatchError = class extends Error {
|
|
|
3950
4046
|
};
|
|
3951
4047
|
}
|
|
3952
4048
|
};
|
|
4049
|
+
var StudioCloseVerificationError = class extends Error {
|
|
4050
|
+
observation;
|
|
4051
|
+
constructor(message, observation) {
|
|
4052
|
+
super(message);
|
|
4053
|
+
this.observation = observation;
|
|
4054
|
+
}
|
|
4055
|
+
};
|
|
4056
|
+
async function beforeCloseDeadline(operation, deadline, processId) {
|
|
4057
|
+
const { promise, reject } = Promise.withResolvers();
|
|
4058
|
+
const timeoutError = () => new StudioCloseVerificationError(`Timed out verifying Studio process ${processId} termination; process state is unknown.`, { status: "unknown", observedAt: Date.now(), error: "Close operation deadline exceeded." });
|
|
4059
|
+
if (Date.now() >= deadline)
|
|
4060
|
+
throw timeoutError();
|
|
4061
|
+
const timer = setTimeout(() => reject(timeoutError()), deadline - Date.now());
|
|
4062
|
+
try {
|
|
4063
|
+
return await Promise.race([operation(), promise]);
|
|
4064
|
+
} finally {
|
|
4065
|
+
clearTimeout(timer);
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
4068
|
+
function observePosixProcess(processId) {
|
|
4069
|
+
try {
|
|
4070
|
+
process.kill(processId, 0);
|
|
4071
|
+
return { status: "running", observedAt: Date.now() };
|
|
4072
|
+
} catch (error) {
|
|
4073
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") {
|
|
4074
|
+
return { status: "not_running", observedAt: Date.now(), reason: "missing" };
|
|
4075
|
+
}
|
|
4076
|
+
return {
|
|
4077
|
+
status: "unknown",
|
|
4078
|
+
observedAt: Date.now(),
|
|
4079
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4080
|
+
};
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
3953
4083
|
function run(command, args, options = {}) {
|
|
3954
4084
|
return execFileSync2(command, args, {
|
|
3955
4085
|
encoding: "utf8",
|
|
@@ -3971,9 +4101,10 @@ async function runAsync(command, args, options = {}) {
|
|
|
3971
4101
|
function isWsl() {
|
|
3972
4102
|
return getStudioPlatformCapabilities().isWsl;
|
|
3973
4103
|
}
|
|
3974
|
-
async function powershellAsync(script) {
|
|
4104
|
+
async function powershellAsync(script, timeoutMs = 15e3) {
|
|
3975
4105
|
return runAsync("powershell.exe", ["-NoProfile", "-Command", script], {
|
|
3976
|
-
cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
|
|
4106
|
+
cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
|
|
4107
|
+
timeout: timeoutMs
|
|
3977
4108
|
});
|
|
3978
4109
|
}
|
|
3979
4110
|
function windowsLocalAppData() {
|
|
@@ -4013,7 +4144,7 @@ async function toWslPathAsync(windowsPath) {
|
|
|
4013
4144
|
return runAsync("wslpath", ["-u", windowsPath]);
|
|
4014
4145
|
}
|
|
4015
4146
|
async function toStudioLaunchArgAsync(arg) {
|
|
4016
|
-
if (!isWsl() || !
|
|
4147
|
+
if (!isWsl() || !path3.isAbsolute(arg) || !existsSync2(arg))
|
|
4017
4148
|
return arg;
|
|
4018
4149
|
return runAsync("wslpath", ["-w", arg]);
|
|
4019
4150
|
}
|
|
@@ -4108,8 +4239,15 @@ function quoteWindowsCommandLineArg(value) {
|
|
|
4108
4239
|
}
|
|
4109
4240
|
return `${quoted}${"\\".repeat(backslashes * 2)}"`;
|
|
4110
4241
|
}
|
|
4111
|
-
function
|
|
4242
|
+
function parseStudioTestWorkerJobName(name) {
|
|
4243
|
+
if (name !== void 0 && !/^Local\\RsmcpStudioWorker-[a-f0-9]{32}$/u.test(name)) {
|
|
4244
|
+
throw new Error("Invalid Studio test worker job name.");
|
|
4245
|
+
}
|
|
4246
|
+
return name;
|
|
4247
|
+
}
|
|
4248
|
+
function buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory, testWorkerJobName = process.env.RSMCP_STUDIO_TEST_WORKER_JOB) {
|
|
4112
4249
|
const environmentPatch = parseStudioProcessEnvironmentPatch(processEnvironment);
|
|
4250
|
+
parseStudioTestWorkerJobName(testWorkerJobName);
|
|
4113
4251
|
const commandLine = [windowsExe, ...args].map(quoteWindowsCommandLineArg).join(" ");
|
|
4114
4252
|
return [
|
|
4115
4253
|
"$ErrorActionPreference = 'Stop'",
|
|
@@ -4222,6 +4360,15 @@ public sealed class McpSuspendedStudio : IDisposable
|
|
|
4222
4360
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
4223
4361
|
private static extern IntPtr CreateJobObjectW(IntPtr jobAttributes, string name);
|
|
4224
4362
|
|
|
4363
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
4364
|
+
private static extern IntPtr OpenJobObjectW(uint access, bool inheritHandle, string name);
|
|
4365
|
+
|
|
4366
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
4367
|
+
private static extern bool GetHandleInformation(IntPtr handle, out uint flags);
|
|
4368
|
+
|
|
4369
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
4370
|
+
private static extern bool QueryInformationJobObject(IntPtr job, int informationClass, IntPtr information, uint informationLength, out uint returned);
|
|
4371
|
+
|
|
4225
4372
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
4226
4373
|
private static extern bool SetInformationJobObject(
|
|
4227
4374
|
IntPtr job,
|
|
@@ -4309,20 +4456,60 @@ public sealed class McpSuspendedStudio : IDisposable
|
|
|
4309
4456
|
throw new InvalidOperationException("Unexpected Studio process wait result: " + wait);
|
|
4310
4457
|
}
|
|
4311
4458
|
|
|
4312
|
-
|
|
4459
|
+
private static Win32Exception TestWorkerError(string operation)
|
|
4460
|
+
{
|
|
4461
|
+
int error = Marshal.GetLastWin32Error();
|
|
4462
|
+
return new Win32Exception(error, operation + " (Win32 " + error + "): " + new Win32Exception(error).Message);
|
|
4463
|
+
}
|
|
4464
|
+
|
|
4465
|
+
private static IntPtr OpenTestWorkerJob(string name)
|
|
4466
|
+
{
|
|
4467
|
+
if (name == null) return IntPtr.Zero;
|
|
4468
|
+
if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"\\ALocal\\\\RsmcpStudioWorker-[a-f0-9]{32}\\z"))
|
|
4469
|
+
throw new ArgumentException("Invalid Studio test worker job name");
|
|
4470
|
+
IntPtr handle = OpenJobObjectW(5, false, name);
|
|
4471
|
+
if (handle == IntPtr.Zero)
|
|
4472
|
+
throw TestWorkerError("Open test worker job failed");
|
|
4473
|
+
try
|
|
4474
|
+
{
|
|
4475
|
+
uint flags;
|
|
4476
|
+
if (!GetHandleInformation(handle, out flags))
|
|
4477
|
+
throw TestWorkerError("Get test worker job handle flags failed");
|
|
4478
|
+
if ((flags & 1) != 0)
|
|
4479
|
+
throw new InvalidOperationException("Test worker job handle must not be inherited");
|
|
4480
|
+
int length = Marshal.SizeOf(typeof(ExtendedLimitInformation));
|
|
4481
|
+
IntPtr buffer = Marshal.AllocHGlobal(length);
|
|
4482
|
+
try
|
|
4483
|
+
{
|
|
4484
|
+
uint returned;
|
|
4485
|
+
if (!QueryInformationJobObject(handle, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, buffer, (uint)length, out returned))
|
|
4486
|
+
throw TestWorkerError("Query test worker job limits failed");
|
|
4487
|
+
var information = (ExtendedLimitInformation)Marshal.PtrToStructure(buffer, typeof(ExtendedLimitInformation));
|
|
4488
|
+
if (returned != length || information.BasicLimitInformation.LimitFlags != JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)
|
|
4489
|
+
throw new InvalidOperationException("Test worker job must retain kill-on-close without breakaway");
|
|
4490
|
+
}
|
|
4491
|
+
finally { Marshal.FreeHGlobal(buffer); }
|
|
4492
|
+
return handle;
|
|
4493
|
+
}
|
|
4494
|
+
catch { CloseHandle(handle); throw; }
|
|
4495
|
+
}
|
|
4496
|
+
|
|
4497
|
+
public static McpSuspendedStudio Start(string application, string commandLine, string currentDirectory, string testWorkerJobName)
|
|
4313
4498
|
{
|
|
4314
4499
|
if (String.IsNullOrEmpty(currentDirectory))
|
|
4315
4500
|
currentDirectory = null;
|
|
4316
4501
|
IntPtr jobHandle = CreateJobObjectW(IntPtr.Zero, null);
|
|
4317
4502
|
if (jobHandle == IntPtr.Zero)
|
|
4318
4503
|
throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObjectW failed");
|
|
4504
|
+
IntPtr workerJob = IntPtr.Zero;
|
|
4319
4505
|
try
|
|
4320
4506
|
{
|
|
4321
4507
|
ConfigureKillOnClose(jobHandle, true);
|
|
4508
|
+
workerJob = OpenTestWorkerJob(testWorkerJobName);
|
|
4322
4509
|
var startup = new StartupInfo();
|
|
4323
4510
|
startup.cb = Marshal.SizeOf(startup);
|
|
4324
4511
|
ProcessInformation created;
|
|
4325
|
-
uint flags = CREATE_SUSPENDED | CREATE_BREAKAWAY_FROM_JOB;
|
|
4512
|
+
uint flags = CREATE_SUSPENDED | (workerJob == IntPtr.Zero ? CREATE_BREAKAWAY_FROM_JOB : 0);
|
|
4326
4513
|
bool started = CreateProcessW(application, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, false, flags, IntPtr.Zero, currentDirectory, ref startup, out created);
|
|
4327
4514
|
if (!started && Marshal.GetLastWin32Error() == 5)
|
|
4328
4515
|
started = CreateProcessW(application, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, false, CREATE_SUSPENDED, IntPtr.Zero, currentDirectory, ref startup, out created);
|
|
@@ -4330,6 +4517,10 @@ public sealed class McpSuspendedStudio : IDisposable
|
|
|
4330
4517
|
throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateProcessW failed");
|
|
4331
4518
|
try
|
|
4332
4519
|
{
|
|
4520
|
+
// Worker lifetime encloses the private launch-authorization job.
|
|
4521
|
+
// COMPLETE may release authorization, never test-worker ownership.
|
|
4522
|
+
if (workerJob != IntPtr.Zero && !AssignProcessToJobObject(workerJob, created.hProcess))
|
|
4523
|
+
throw TestWorkerError("Assign Studio to test worker job failed");
|
|
4333
4524
|
if (!AssignProcessToJobObject(jobHandle, created.hProcess))
|
|
4334
4525
|
throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
|
|
4335
4526
|
return new McpSuspendedStudio(created, jobHandle);
|
|
@@ -4353,6 +4544,10 @@ public sealed class McpSuspendedStudio : IDisposable
|
|
|
4353
4544
|
CloseHandle(jobHandle);
|
|
4354
4545
|
throw;
|
|
4355
4546
|
}
|
|
4547
|
+
finally
|
|
4548
|
+
{
|
|
4549
|
+
if (workerJob != IntPtr.Zero) CloseHandle(workerJob);
|
|
4550
|
+
}
|
|
4356
4551
|
}
|
|
4357
4552
|
|
|
4358
4553
|
public void Resume()
|
|
@@ -4402,7 +4597,7 @@ public sealed class McpSuspendedStudio : IDisposable
|
|
|
4402
4597
|
}
|
|
4403
4598
|
}
|
|
4404
4599
|
'@`,
|
|
4405
|
-
`$launch = [McpSuspendedStudio]::Start(${powershellStringLiteral(windowsExe)}, ${powershellStringLiteral(commandLine)}, ${windowsWorkingDirectory === void 0 ? "$null" : powershellStringLiteral(windowsWorkingDirectory)})`,
|
|
4600
|
+
`$launch = [McpSuspendedStudio]::Start(${powershellStringLiteral(windowsExe)}, ${powershellStringLiteral(commandLine)}, ${windowsWorkingDirectory === void 0 ? "$null" : powershellStringLiteral(windowsWorkingDirectory)}, ${testWorkerJobName === void 0 ? "$null" : powershellStringLiteral(testWorkerJobName)})`,
|
|
4406
4601
|
"try {",
|
|
4407
4602
|
"[Console]::Out.WriteLine((ConvertTo-Json @{ pid = $launch.ProcessId; started = [string]$launch.StartedAtFileTime } -Compress)); [Console]::Out.Flush()",
|
|
4408
4603
|
"$accepted = $false",
|
|
@@ -4441,19 +4636,33 @@ function buildWindowsStudioStopScript(processId, startedAt) {
|
|
|
4441
4636
|
"} finally { if ($null -ne $studio) { $studio.Dispose() } }"
|
|
4442
4637
|
].join("; ");
|
|
4443
4638
|
}
|
|
4444
|
-
|
|
4445
|
-
|
|
4639
|
+
var studioLogsRoot;
|
|
4640
|
+
async function settleWindowsStudioSignIn(startedAt) {
|
|
4641
|
+
studioLogsRoot ??= (async () => {
|
|
4642
|
+
const localAppData = await windowsLocalAppDataAsync();
|
|
4643
|
+
return localAppData ? path3.join(await toWslPathAsync(localAppData), "Roblox", "logs") : void 0;
|
|
4644
|
+
})();
|
|
4645
|
+
const logsRoot = await studioLogsRoot;
|
|
4646
|
+
if (!logsRoot)
|
|
4647
|
+
return;
|
|
4648
|
+
const result = await waitForStudioSignInToSettle({ logsRoot, processStartedAtMs: fileTimeToUnixMs(startedAt) });
|
|
4649
|
+
if (result === "timeout")
|
|
4650
|
+
console.error("[studio] Studio was still signing in after 15 s; closing it anyway.");
|
|
4651
|
+
}
|
|
4652
|
+
async function stopWindowsStudio(processId, startedAt, timeoutMs = 15e3) {
|
|
4653
|
+
await powershellAsync(buildWindowsStudioStopScript(processId, startedAt), timeoutMs);
|
|
4446
4654
|
}
|
|
4447
4655
|
async function spawnWindowsStudio(exe, args, processEnvironment, studioWorkingDirectory) {
|
|
4448
4656
|
const windowsExe = await toStudioLaunchArgAsync(exe);
|
|
4449
4657
|
const windowsWorkingDirectory = studioWorkingDirectory ? await toStudioLaunchArgAsync(studioWorkingDirectory) : void 0;
|
|
4450
|
-
const
|
|
4658
|
+
const testWorkerJobName = process.env.RSMCP_STUDIO_TEST_WORKER_JOB;
|
|
4659
|
+
const script = buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory, testWorkerJobName);
|
|
4451
4660
|
const launcher = spawn("powershell.exe", ["-NoProfile", "-Command", script], {
|
|
4452
4661
|
cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
|
|
4453
4662
|
stdio: ["pipe", "pipe", "pipe"],
|
|
4454
4663
|
windowsHide: true
|
|
4455
4664
|
});
|
|
4456
|
-
return new Promise((
|
|
4665
|
+
return new Promise((resolve6, reject) => {
|
|
4457
4666
|
let stdout = "";
|
|
4458
4667
|
let controlOutput = "";
|
|
4459
4668
|
let stderr = "";
|
|
@@ -4600,7 +4809,7 @@ async function spawnWindowsStudio(exe, args, processEnvironment, studioWorkingDi
|
|
|
4600
4809
|
throw new Error("PowerShell returned an invalid native Studio process identity.");
|
|
4601
4810
|
}
|
|
4602
4811
|
identity = { pid: nativePid, startedAt: nativeStartedAt };
|
|
4603
|
-
|
|
4812
|
+
resolve6({
|
|
4604
4813
|
pid: nativePid,
|
|
4605
4814
|
nativePid,
|
|
4606
4815
|
nativeStartedAt,
|
|
@@ -4668,21 +4877,21 @@ function resolveEntrypointDir() {
|
|
|
4668
4877
|
if (!entrypoint)
|
|
4669
4878
|
return void 0;
|
|
4670
4879
|
try {
|
|
4671
|
-
return
|
|
4880
|
+
return path3.dirname(realpathSync(entrypoint));
|
|
4672
4881
|
} catch {
|
|
4673
|
-
return
|
|
4882
|
+
return path3.dirname(path3.resolve(entrypoint));
|
|
4674
4883
|
}
|
|
4675
4884
|
}
|
|
4676
4885
|
function resolveBaseplateTemplatePath() {
|
|
4677
4886
|
const entrypointDir = resolveEntrypointDir();
|
|
4678
4887
|
const candidates = [
|
|
4679
4888
|
...entrypointDir ? [
|
|
4680
|
-
|
|
4681
|
-
|
|
4889
|
+
path3.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
|
|
4890
|
+
path3.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
|
|
4682
4891
|
] : [],
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4892
|
+
path3.join(process.cwd(), "packages", "core", "assets", BASEPLATE_TEMPLATE_NAME),
|
|
4893
|
+
path3.join(process.cwd(), "packages", "robloxstudio-mcp", "dist", "assets", BASEPLATE_TEMPLATE_NAME),
|
|
4894
|
+
path3.join(process.cwd(), "assets", BASEPLATE_TEMPLATE_NAME)
|
|
4686
4895
|
];
|
|
4687
4896
|
for (const candidate of candidates) {
|
|
4688
4897
|
if (existsSync2(candidate))
|
|
@@ -4715,7 +4924,7 @@ function sweepStaleBaseplateFiles() {
|
|
|
4715
4924
|
continue;
|
|
4716
4925
|
if (Number(match[1]) !== process.pid && isProcessAlive2(Number(match[1])))
|
|
4717
4926
|
continue;
|
|
4718
|
-
const file =
|
|
4927
|
+
const file = path3.join(BASEPLATE_TEMP_DIR, entry);
|
|
4719
4928
|
try {
|
|
4720
4929
|
if (statSync(file).mtimeMs < cutoff)
|
|
4721
4930
|
rmSync(file, { force: true });
|
|
@@ -4726,13 +4935,13 @@ function sweepStaleBaseplateFiles() {
|
|
|
4726
4935
|
function createBaseplatePlaceFile() {
|
|
4727
4936
|
mkdirSync(BASEPLATE_TEMP_DIR, { recursive: true });
|
|
4728
4937
|
sweepStaleBaseplateFiles();
|
|
4729
|
-
const file =
|
|
4938
|
+
const file = path3.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
|
|
4730
4939
|
copyFileSync(resolveBaseplateTemplatePath(), file);
|
|
4731
4940
|
return file;
|
|
4732
4941
|
}
|
|
4733
4942
|
function isGeneratedBaseplatePlaceFile(file) {
|
|
4734
|
-
const resolvedFile =
|
|
4735
|
-
return
|
|
4943
|
+
const resolvedFile = path3.resolve(file);
|
|
4944
|
+
return path3.dirname(resolvedFile) === path3.resolve(BASEPLATE_TEMP_DIR) && BASEPLATE_TEMP_NAME.test(path3.basename(resolvedFile));
|
|
4736
4945
|
}
|
|
4737
4946
|
function cleanupManagedBaseplateFiles(record) {
|
|
4738
4947
|
if (record.source !== "baseplate" || !record.localPlaceFile)
|
|
@@ -4754,6 +4963,24 @@ function prepareStudioLaunchOptions(options) {
|
|
|
4754
4963
|
localPlaceFile: createBaseplatePlaceFile()
|
|
4755
4964
|
};
|
|
4756
4965
|
}
|
|
4966
|
+
function resolveStudioExeFromVersions(root) {
|
|
4967
|
+
if (!existsSync2(root)) {
|
|
4968
|
+
throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
4969
|
+
}
|
|
4970
|
+
const candidates = readdirSync(root).filter((name) => name.startsWith("version-")).map((name) => path3.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync2(candidate)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
4971
|
+
if (candidates.length === 0) {
|
|
4972
|
+
throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
4973
|
+
}
|
|
4974
|
+
const exe = candidates[0];
|
|
4975
|
+
const installationDir = path3.dirname(exe);
|
|
4976
|
+
const hasIncompleteDownload = readdirSync(installationDir).some((name) => /\.crdownload$/i.test(name));
|
|
4977
|
+
const settings = statSync(path3.join(installationDir, "AppSettings.xml"), { throwIfNoEntry: false });
|
|
4978
|
+
if (hasIncompleteDownload || !settings?.isFile() || settings.size === 0) {
|
|
4979
|
+
const reason = hasIncompleteDownload ? "an unfinished .crdownload download is present" : "AppSettings.xml is missing, empty, or not a regular file";
|
|
4980
|
+
throw new Error(`Roblox Studio installation/update is incomplete at ${installationDir}: ${reason}. Complete the installation/update or reinstall Roblox Studio under the owning Windows account before launching.`);
|
|
4981
|
+
}
|
|
4982
|
+
return exe;
|
|
4983
|
+
}
|
|
4757
4984
|
function resolveStudioExe() {
|
|
4758
4985
|
if (process.env.ROBLOX_STUDIO_EXE)
|
|
4759
4986
|
return process.env.ROBLOX_STUDIO_EXE;
|
|
@@ -4764,15 +4991,8 @@ function resolveStudioExe() {
|
|
|
4764
4991
|
throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
|
|
4765
4992
|
}
|
|
4766
4993
|
const localAppData = windowsLocalAppData();
|
|
4767
|
-
const root = localAppData ?
|
|
4768
|
-
|
|
4769
|
-
throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
4770
|
-
}
|
|
4771
|
-
const candidates = readdirSync(root).filter((name) => name.startsWith("version-")).map((name) => path2.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync2(candidate)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
4772
|
-
if (candidates.length === 0) {
|
|
4773
|
-
throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
4774
|
-
}
|
|
4775
|
-
return candidates[0];
|
|
4994
|
+
const root = localAppData ? path3.join(toWslPath(localAppData), "Roblox", "Versions") : path3.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
|
|
4995
|
+
return resolveStudioExeFromVersions(root);
|
|
4776
4996
|
}
|
|
4777
4997
|
async function resolveStudioExeAsync() {
|
|
4778
4998
|
if (process.env.ROBLOX_STUDIO_EXE)
|
|
@@ -4784,15 +5004,8 @@ async function resolveStudioExeAsync() {
|
|
|
4784
5004
|
throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
|
|
4785
5005
|
}
|
|
4786
5006
|
const localAppData = await windowsLocalAppDataAsync();
|
|
4787
|
-
const root = localAppData ?
|
|
4788
|
-
|
|
4789
|
-
throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
4790
|
-
}
|
|
4791
|
-
const candidates = readdirSync(root).filter((name) => name.startsWith("version-")).map((name) => path2.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync2(candidate)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
4792
|
-
if (candidates.length === 0) {
|
|
4793
|
-
throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
4794
|
-
}
|
|
4795
|
-
return candidates[0];
|
|
5007
|
+
const root = localAppData ? path3.join(await toWslPathAsync(localAppData), "Roblox", "Versions") : path3.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
|
|
5008
|
+
return resolveStudioExeFromVersions(root);
|
|
4796
5009
|
}
|
|
4797
5010
|
var WINDOWS_STUDIO_PROCESS_QUERY = [
|
|
4798
5011
|
"$ErrorActionPreference = 'Stop'",
|
|
@@ -4809,13 +5022,13 @@ function parseWindowsStudioProcesses(output) {
|
|
|
4809
5022
|
}
|
|
4810
5023
|
return processes;
|
|
4811
5024
|
}
|
|
4812
|
-
async function observeStudioProcesses() {
|
|
5025
|
+
async function observeStudioProcesses(timeoutMs = 15e3) {
|
|
4813
5026
|
const observedAt = Date.now();
|
|
4814
5027
|
try {
|
|
4815
5028
|
if (process.platform === "darwin") {
|
|
4816
5029
|
let out2 = "";
|
|
4817
5030
|
try {
|
|
4818
|
-
out2 = await runAsync("pgrep", ["-fl", "RobloxStudio"]);
|
|
5031
|
+
out2 = await runAsync("pgrep", ["-fl", "RobloxStudio"], { timeout: timeoutMs });
|
|
4819
5032
|
} catch (error) {
|
|
4820
5033
|
const code = error.code;
|
|
4821
5034
|
if (Number(code) === 1)
|
|
@@ -4831,7 +5044,7 @@ async function observeStudioProcesses() {
|
|
|
4831
5044
|
if (process.platform !== "win32" && !isWsl()) {
|
|
4832
5045
|
return { status: "ok", observedAt, processes: [] };
|
|
4833
5046
|
}
|
|
4834
|
-
const out = await powershellAsync(WINDOWS_STUDIO_PROCESS_QUERY);
|
|
5047
|
+
const out = await powershellAsync(WINDOWS_STUDIO_PROCESS_QUERY, timeoutMs);
|
|
4835
5048
|
return { status: "ok", observedAt, processes: parseWindowsStudioProcesses(out) };
|
|
4836
5049
|
} catch (error) {
|
|
4837
5050
|
return {
|
|
@@ -4895,11 +5108,11 @@ function buildStudioLaunchArgs(options) {
|
|
|
4895
5108
|
];
|
|
4896
5109
|
}
|
|
4897
5110
|
}
|
|
4898
|
-
function
|
|
4899
|
-
return new Promise((
|
|
5111
|
+
function delay3(ms) {
|
|
5112
|
+
return new Promise((resolve6) => setTimeout(resolve6, ms));
|
|
4900
5113
|
}
|
|
4901
5114
|
function basenameAny(filePath) {
|
|
4902
|
-
return
|
|
5115
|
+
return path3.basename(filePath.replace(/\\/g, "/"));
|
|
4903
5116
|
}
|
|
4904
5117
|
var StudioInstanceManager = class {
|
|
4905
5118
|
managedByInstanceId = /* @__PURE__ */ new Map();
|
|
@@ -4915,6 +5128,8 @@ var StudioInstanceManager = class {
|
|
|
4915
5128
|
confirmedExitGraceMs;
|
|
4916
5129
|
snapshotCacheMs;
|
|
4917
5130
|
launchCompletionTimeoutMs;
|
|
5131
|
+
closeTimeoutMs;
|
|
5132
|
+
settleSignIn;
|
|
4918
5133
|
coordinatorTimer;
|
|
4919
5134
|
coordinatorRefresh;
|
|
4920
5135
|
cachedSnapshot;
|
|
@@ -4929,6 +5144,8 @@ var StudioInstanceManager = class {
|
|
|
4929
5144
|
this.confirmedExitGraceMs = options.confirmedExitGraceMs ?? 5e3;
|
|
4930
5145
|
this.snapshotCacheMs = options.snapshotCacheMs ?? 0;
|
|
4931
5146
|
this.launchCompletionTimeoutMs = options.launchCompletionTimeoutMs ?? LAUNCH_COMPLETION_TIMEOUT_MS;
|
|
5147
|
+
this.closeTimeoutMs = options.closeTimeoutMs ?? 1e4;
|
|
5148
|
+
this.settleSignIn = options.settleSignIn ?? (!this.processAdapter.stopProcess && (process.platform === "win32" || isWsl()) ? settleWindowsStudioSignIn : void 0);
|
|
4932
5149
|
}
|
|
4933
5150
|
getLifecycleCapabilities() {
|
|
4934
5151
|
const platform = this.platformCapabilities ?? getStudioPlatformCapabilities();
|
|
@@ -5108,8 +5325,8 @@ var StudioInstanceManager = class {
|
|
|
5108
5325
|
async launch(options) {
|
|
5109
5326
|
const previous = this.launchQueue;
|
|
5110
5327
|
let release;
|
|
5111
|
-
this.launchQueue = new Promise((
|
|
5112
|
-
release =
|
|
5328
|
+
this.launchQueue = new Promise((resolve6) => {
|
|
5329
|
+
release = resolve6;
|
|
5113
5330
|
});
|
|
5114
5331
|
await previous;
|
|
5115
5332
|
try {
|
|
@@ -5268,7 +5485,7 @@ var StudioInstanceManager = class {
|
|
|
5268
5485
|
await this.persist(record);
|
|
5269
5486
|
break;
|
|
5270
5487
|
}
|
|
5271
|
-
await
|
|
5488
|
+
await delay3(250);
|
|
5272
5489
|
}
|
|
5273
5490
|
if (record.nativeProcessId === void 0 && process.platform !== "win32" && !isWsl()) {
|
|
5274
5491
|
record.nativeProcessId = proc.pid;
|
|
@@ -5326,58 +5543,59 @@ var StudioInstanceManager = class {
|
|
|
5326
5543
|
instanceId: record.instanceId
|
|
5327
5544
|
};
|
|
5328
5545
|
}
|
|
5546
|
+
let deadline = Date.now() + this.closeTimeoutMs;
|
|
5329
5547
|
const processId = record.nativeProcessId ?? record.spawnPid;
|
|
5330
5548
|
if (!processId) {
|
|
5331
5549
|
throw new Error(`Cannot close ${record.instanceId ?? "Studio launch"} because its process id was not detected.`);
|
|
5332
5550
|
}
|
|
5333
5551
|
const control = record.recordId ? this.launchControls.get(record.recordId) : void 0;
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
this.
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
source: record.source,
|
|
5367
|
-
reason: observation.reason === "identity_mismatch" ? "identity_mismatch" : "pid_not_running",
|
|
5368
|
-
action: "marked_closed_and_cleaned_baseplate"
|
|
5369
|
-
});
|
|
5370
|
-
return {
|
|
5371
|
-
status: "already_closed",
|
|
5372
|
-
launchId: record.recordId,
|
|
5373
|
-
instanceId: record.instanceId
|
|
5374
|
-
};
|
|
5552
|
+
const abort = record.processAuthorizationState !== "released" && control ? () => control.abort() : void 0;
|
|
5553
|
+
if (!abort) {
|
|
5554
|
+
let snapshot;
|
|
5555
|
+
try {
|
|
5556
|
+
snapshot = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, processId);
|
|
5557
|
+
} catch (error) {
|
|
5558
|
+
if (error instanceof StudioCloseVerificationError) {
|
|
5559
|
+
await this.applyProcessObservation(record, error.observation);
|
|
5560
|
+
}
|
|
5561
|
+
throw error;
|
|
5562
|
+
}
|
|
5563
|
+
const observation = this.observeRecord(record, snapshot);
|
|
5564
|
+
if (observation.status === "unknown") {
|
|
5565
|
+
await this.applyProcessObservation(record, observation);
|
|
5566
|
+
throw new Error(`Cannot verify the managed Studio process because process observation failed: ${observation.error}`);
|
|
5567
|
+
}
|
|
5568
|
+
if (observation.status === "not_running") {
|
|
5569
|
+
await this.markProcessExited(record, void 0, observation.reason === "identity_mismatch" ? "Studio process identity changed; the retained PID was not reused." : record.failureReason);
|
|
5570
|
+
await this.registry.logEvent({
|
|
5571
|
+
event: "registry_close_already_stopped",
|
|
5572
|
+
recordId: record.recordId,
|
|
5573
|
+
instanceId: record.instanceId,
|
|
5574
|
+
source: record.source,
|
|
5575
|
+
reason: observation.reason === "identity_mismatch" ? "identity_mismatch" : "pid_not_running",
|
|
5576
|
+
action: "marked_closed_and_cleaned_baseplate"
|
|
5577
|
+
});
|
|
5578
|
+
return {
|
|
5579
|
+
status: "already_closed",
|
|
5580
|
+
launchId: record.recordId,
|
|
5581
|
+
instanceId: record.instanceId
|
|
5582
|
+
};
|
|
5583
|
+
}
|
|
5375
5584
|
}
|
|
5585
|
+
if (!abort)
|
|
5586
|
+
deadline = await this.beforeStudioStop(record.nativeProcessStartedAt, deadline);
|
|
5376
5587
|
try {
|
|
5377
|
-
await this.closeProcess(processId, record.nativeProcessStartedAt);
|
|
5588
|
+
await this.closeProcess(processId, record.nativeProcessStartedAt, abort, deadline);
|
|
5378
5589
|
} catch (error) {
|
|
5379
|
-
|
|
5590
|
+
if (error instanceof StudioCloseVerificationError) {
|
|
5591
|
+
await this.applyProcessObservation(record, error.observation);
|
|
5592
|
+
throw error;
|
|
5593
|
+
}
|
|
5594
|
+
if (abort)
|
|
5595
|
+
throw error;
|
|
5596
|
+
const retry = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, processId);
|
|
5380
5597
|
const retryObservation = this.observeRecord(record, retry);
|
|
5598
|
+
await this.applyProcessObservation(record, retryObservation);
|
|
5381
5599
|
if (retryObservation.status === "running" || retryObservation.status === "unknown")
|
|
5382
5600
|
throw error;
|
|
5383
5601
|
await this.registry.logEvent({
|
|
@@ -5414,7 +5632,8 @@ var StudioInstanceManager = class {
|
|
|
5414
5632
|
};
|
|
5415
5633
|
}
|
|
5416
5634
|
async closeConnectedInstance(instance) {
|
|
5417
|
-
|
|
5635
|
+
let deadline = Date.now() + this.closeTimeoutMs;
|
|
5636
|
+
const snapshot = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, 0);
|
|
5418
5637
|
if (snapshot.status === "error") {
|
|
5419
5638
|
throw new Error(`Could not enumerate Studio processes: ${snapshot.error}`);
|
|
5420
5639
|
}
|
|
@@ -5422,26 +5641,76 @@ var StudioInstanceManager = class {
|
|
|
5422
5641
|
if (!process2) {
|
|
5423
5642
|
throw new Error(`Could not find a Studio process for connected instance "${instance.instanceId}".`);
|
|
5424
5643
|
}
|
|
5425
|
-
await this.
|
|
5644
|
+
deadline = await this.beforeStudioStop(process2.StartTimeUtcFileTime, deadline);
|
|
5645
|
+
await this.closeProcess(process2.Id, process2.StartTimeUtcFileTime, void 0, deadline);
|
|
5426
5646
|
}
|
|
5427
|
-
async
|
|
5428
|
-
if (this.
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5647
|
+
async beforeStudioStop(startedAt, deadline) {
|
|
5648
|
+
if (!this.settleSignIn || startedAt === void 0 || !/^[1-9]\d*$/u.test(startedAt))
|
|
5649
|
+
return deadline;
|
|
5650
|
+
const waitStarted = Date.now();
|
|
5651
|
+
try {
|
|
5652
|
+
await this.settleSignIn(startedAt);
|
|
5653
|
+
} catch (error) {
|
|
5654
|
+
console.error(`[studio] Could not check Studio sign-in before closing: ${error instanceof Error ? error.message : String(error)}`);
|
|
5655
|
+
}
|
|
5656
|
+
return deadline + (Date.now() - waitStarted);
|
|
5657
|
+
}
|
|
5658
|
+
async closeProcess(processId, startedAt, stop, deadline = Date.now() + this.closeTimeoutMs) {
|
|
5659
|
+
this.cachedSnapshot = void 0;
|
|
5660
|
+
await beforeCloseDeadline(async () => {
|
|
5661
|
+
if (stop) {
|
|
5662
|
+
await stop();
|
|
5663
|
+
} else if (this.processAdapter.stopProcess) {
|
|
5664
|
+
await this.processAdapter.stopProcess(processId, startedAt);
|
|
5665
|
+
} else if (process.platform === "win32" || isWsl()) {
|
|
5666
|
+
if (startedAt === void 0) {
|
|
5667
|
+
throw new Error("Cannot stop a Windows Studio process without its creation-time identity.");
|
|
5668
|
+
}
|
|
5669
|
+
await stopWindowsStudio(processId, startedAt, Math.max(1, deadline - Date.now()));
|
|
5670
|
+
} else {
|
|
5671
|
+
try {
|
|
5672
|
+
process.kill(processId, "SIGTERM");
|
|
5673
|
+
} catch (error) {
|
|
5674
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ESRCH")
|
|
5675
|
+
throw error;
|
|
5676
|
+
}
|
|
5435
5677
|
}
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5678
|
+
}, deadline, processId);
|
|
5679
|
+
for (; ; ) {
|
|
5680
|
+
const observation = await beforeCloseDeadline(() => this.observeClosingProcess(processId, startedAt, deadline), deadline, processId);
|
|
5681
|
+
if (observation.status === "not_running")
|
|
5682
|
+
return;
|
|
5683
|
+
if (observation.status === "unknown") {
|
|
5684
|
+
throw new StudioCloseVerificationError(`Could not verify Studio process ${processId} termination: ${observation.error}`, observation);
|
|
5685
|
+
}
|
|
5686
|
+
const remaining = deadline - Date.now();
|
|
5687
|
+
if (remaining <= 0) {
|
|
5688
|
+
throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
|
|
5689
|
+
}
|
|
5690
|
+
await delay3(Math.min(100, remaining));
|
|
5691
|
+
if (Date.now() >= deadline) {
|
|
5692
|
+
throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
|
|
5693
|
+
}
|
|
5694
|
+
}
|
|
5695
|
+
}
|
|
5696
|
+
async observeClosingProcess(processId, startedAt, deadline) {
|
|
5697
|
+
if (process.platform === "darwin" && !this.processAdapter.observeStudioProcesses && !this.processAdapter.listStudioProcesses) {
|
|
5698
|
+
return observePosixProcess(processId);
|
|
5699
|
+
}
|
|
5700
|
+
const snapshot = await this.readProcessSnapshot(Math.max(1, deadline - Date.now()));
|
|
5701
|
+
if (snapshot.status === "error") {
|
|
5702
|
+
return { status: "unknown", observedAt: snapshot.observedAt, error: snapshot.error };
|
|
5703
|
+
}
|
|
5704
|
+
const target = snapshot.processes.find((candidate) => candidate.Id === processId);
|
|
5705
|
+
if (!target)
|
|
5706
|
+
return { status: "not_running", observedAt: snapshot.observedAt, reason: "missing" };
|
|
5707
|
+
if (startedAt !== void 0 && target.StartTimeUtcFileTime !== startedAt) {
|
|
5708
|
+
if (target.StartTimeUtcFileTime === void 0) {
|
|
5709
|
+
return { status: "unknown", observedAt: snapshot.observedAt, error: "Process creation-time identity is unavailable." };
|
|
5443
5710
|
}
|
|
5711
|
+
return { status: "not_running", observedAt: snapshot.observedAt, reason: "identity_mismatch" };
|
|
5444
5712
|
}
|
|
5713
|
+
return { status: "running", observedAt: snapshot.observedAt };
|
|
5445
5714
|
}
|
|
5446
5715
|
findProcessForConnectedInstance(instance, processes) {
|
|
5447
5716
|
if (processes.length === 0)
|
|
@@ -5471,24 +5740,7 @@ var StudioInstanceManager = class {
|
|
|
5471
5740
|
return this.snapshotInFlight;
|
|
5472
5741
|
this.snapshotInFlight = (async () => {
|
|
5473
5742
|
try {
|
|
5474
|
-
|
|
5475
|
-
if (this.processAdapter.observeStudioProcesses) {
|
|
5476
|
-
snapshot = await this.processAdapter.observeStudioProcesses();
|
|
5477
|
-
} else if (this.processAdapter.listStudioProcesses) {
|
|
5478
|
-
const observedAt = Date.now();
|
|
5479
|
-
const processes = await this.processAdapter.listStudioProcesses();
|
|
5480
|
-
snapshot = { status: "ok", observedAt, processes };
|
|
5481
|
-
} else {
|
|
5482
|
-
snapshot = await observeStudioProcesses();
|
|
5483
|
-
}
|
|
5484
|
-
this.cachedSnapshot = snapshot;
|
|
5485
|
-
return snapshot;
|
|
5486
|
-
} catch (error) {
|
|
5487
|
-
const snapshot = {
|
|
5488
|
-
status: "error",
|
|
5489
|
-
observedAt: Date.now(),
|
|
5490
|
-
error: error instanceof Error ? error.message : String(error)
|
|
5491
|
-
};
|
|
5743
|
+
const snapshot = await this.readProcessSnapshot();
|
|
5492
5744
|
this.cachedSnapshot = snapshot;
|
|
5493
5745
|
return snapshot;
|
|
5494
5746
|
} finally {
|
|
@@ -5497,6 +5749,25 @@ var StudioInstanceManager = class {
|
|
|
5497
5749
|
})();
|
|
5498
5750
|
return this.snapshotInFlight;
|
|
5499
5751
|
}
|
|
5752
|
+
async readProcessSnapshot(timeoutMs = 15e3) {
|
|
5753
|
+
try {
|
|
5754
|
+
if (this.processAdapter.observeStudioProcesses) {
|
|
5755
|
+
return await this.processAdapter.observeStudioProcesses();
|
|
5756
|
+
}
|
|
5757
|
+
if (this.processAdapter.listStudioProcesses) {
|
|
5758
|
+
const observedAt = Date.now();
|
|
5759
|
+
const processes = await this.processAdapter.listStudioProcesses();
|
|
5760
|
+
return { status: "ok", observedAt, processes };
|
|
5761
|
+
}
|
|
5762
|
+
return await observeStudioProcesses(Math.max(1, timeoutMs));
|
|
5763
|
+
} catch (error) {
|
|
5764
|
+
return {
|
|
5765
|
+
status: "error",
|
|
5766
|
+
observedAt: Date.now(),
|
|
5767
|
+
error: error instanceof Error ? error.message : String(error)
|
|
5768
|
+
};
|
|
5769
|
+
}
|
|
5770
|
+
}
|
|
5500
5771
|
async getCurrentBootId() {
|
|
5501
5772
|
return this.processAdapter.currentBootId ? await this.processAdapter.currentBootId() : currentBootIdAsync();
|
|
5502
5773
|
}
|
|
@@ -5562,8 +5833,16 @@ var StudioInstanceManager = class {
|
|
|
5562
5833
|
if (!processId)
|
|
5563
5834
|
return { status: "running", observedAt: snapshot.observedAt };
|
|
5564
5835
|
const studioProcess = snapshot.processes.find((candidate) => candidate.Id === processId);
|
|
5565
|
-
if (!studioProcess)
|
|
5836
|
+
if (!studioProcess) {
|
|
5837
|
+
if (process.platform === "darwin" && !this.processAdapter.observeStudioProcesses && !this.processAdapter.listStudioProcesses) {
|
|
5838
|
+
const observation = observePosixProcess(processId);
|
|
5839
|
+
return observation.status === "running" ? { status: "unknown", observedAt: observation.observedAt, error: `Studio PID ${processId} is still alive but missing from process enumeration.` } : observation;
|
|
5840
|
+
}
|
|
5566
5841
|
return { status: "not_running", observedAt: snapshot.observedAt, reason: "missing" };
|
|
5842
|
+
}
|
|
5843
|
+
if (record.nativeProcessStartedAt !== void 0 && studioProcess.StartTimeUtcFileTime === void 0) {
|
|
5844
|
+
return { status: "unknown", observedAt: snapshot.observedAt, error: "Process creation-time identity is unavailable." };
|
|
5845
|
+
}
|
|
5567
5846
|
return this.verifyProcessForRecord(record, studioProcess) ? { status: "running", observedAt: snapshot.observedAt } : { status: "not_running", observedAt: snapshot.observedAt, reason: "identity_mismatch" };
|
|
5568
5847
|
}
|
|
5569
5848
|
verifyProcessForRecord(record, studioProcess) {
|
|
@@ -5576,13 +5855,13 @@ var StudioInstanceManager = class {
|
|
|
5576
5855
|
const processId = record.nativeProcessId ?? record.spawnPid;
|
|
5577
5856
|
if (record.spawnPid && record.spawnPid === processId && studioProcess.Id === processId)
|
|
5578
5857
|
return true;
|
|
5579
|
-
const processPath = studioProcess.Path ?
|
|
5580
|
-
const exePath = record.exe ?
|
|
5858
|
+
const processPath = studioProcess.Path ? path3.normalize(studioProcess.Path).toLowerCase() : "";
|
|
5859
|
+
const exePath = record.exe ? path3.normalize(record.exe).toLowerCase() : "";
|
|
5581
5860
|
if (processPath && exePath && (processPath === exePath || basenameAny(processPath) === basenameAny(exePath))) {
|
|
5582
5861
|
return true;
|
|
5583
5862
|
}
|
|
5584
5863
|
const commandLine = studioProcess.CommandLine ?? "";
|
|
5585
|
-
if (record.localPlaceFile && commandLine.includes(
|
|
5864
|
+
if (record.localPlaceFile && commandLine.includes(path3.basename(record.localPlaceFile)))
|
|
5586
5865
|
return true;
|
|
5587
5866
|
if (record.placeId !== void 0 && commandLine.includes(String(record.placeId)))
|
|
5588
5867
|
return true;
|
|
@@ -6113,11 +6392,21 @@ function createToolHttpHandler(options) {
|
|
|
6113
6392
|
|
|
6114
6393
|
// ../core/dist/auth.js
|
|
6115
6394
|
import { randomBytes, createHash as createHash2, timingSafeEqual } from "crypto";
|
|
6116
|
-
import {
|
|
6117
|
-
import { join as
|
|
6395
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync, linkSync, unlinkSync } from "fs";
|
|
6396
|
+
import { join as join4, dirname as dirname2 } from "path";
|
|
6118
6397
|
import { homedir as homedir3 } from "os";
|
|
6119
6398
|
function authTokenFilePath() {
|
|
6120
|
-
return
|
|
6399
|
+
return join4(homedir3(), ".robloxstudio-mcp", "auth-token");
|
|
6400
|
+
}
|
|
6401
|
+
var EMPTY_TOKEN_RETRY_MS = 25;
|
|
6402
|
+
var EMPTY_TOKEN_RETRIES = 20;
|
|
6403
|
+
function readTokenFile(filePath) {
|
|
6404
|
+
for (let attempt = 0; ; attempt++) {
|
|
6405
|
+
const token = readFileSync3(filePath, "utf8").trim();
|
|
6406
|
+
if (token || attempt >= EMPTY_TOKEN_RETRIES)
|
|
6407
|
+
return token;
|
|
6408
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, EMPTY_TOKEN_RETRY_MS);
|
|
6409
|
+
}
|
|
6121
6410
|
}
|
|
6122
6411
|
function resolveAuthToken() {
|
|
6123
6412
|
const noAuth = (process.env.ROBLOX_STUDIO_NO_AUTH || "").toLowerCase();
|
|
@@ -6130,20 +6419,36 @@ function resolveAuthToken() {
|
|
|
6130
6419
|
}
|
|
6131
6420
|
const filePath = authTokenFilePath();
|
|
6132
6421
|
try {
|
|
6133
|
-
|
|
6134
|
-
const existing =
|
|
6135
|
-
if (existing)
|
|
6136
|
-
|
|
6137
|
-
}
|
|
6422
|
+
try {
|
|
6423
|
+
const existing = readTokenFile(filePath);
|
|
6424
|
+
if (!existing)
|
|
6425
|
+
throw new Error("Auth token file is empty; remove it while all MCP servers are stopped to reinitialize it.");
|
|
6426
|
+
return { token: existing, source: "file", filePath };
|
|
6427
|
+
} catch (error) {
|
|
6428
|
+
if (error.code !== "ENOENT")
|
|
6429
|
+
throw error;
|
|
6138
6430
|
}
|
|
6139
6431
|
const fresh = randomBytes(32).toString("hex");
|
|
6140
6432
|
mkdirSync2(dirname2(filePath), { recursive: true, mode: 448 });
|
|
6141
|
-
|
|
6433
|
+
const stagingPath = `${filePath}.${process.pid}.${randomBytes(16).toString("hex")}.tmp`;
|
|
6434
|
+
writeFileSync(stagingPath, fresh + "\n", { encoding: "utf8", mode: 384, flag: "wx" });
|
|
6142
6435
|
try {
|
|
6143
|
-
|
|
6144
|
-
|
|
6436
|
+
try {
|
|
6437
|
+
linkSync(stagingPath, filePath);
|
|
6438
|
+
} catch (error) {
|
|
6439
|
+
if (error.code !== "EEXIST")
|
|
6440
|
+
throw error;
|
|
6441
|
+
}
|
|
6442
|
+
const token = readTokenFile(filePath);
|
|
6443
|
+
if (!token)
|
|
6444
|
+
throw new Error("Auth token file is empty.");
|
|
6445
|
+
return { token, source: "file", filePath };
|
|
6446
|
+
} finally {
|
|
6447
|
+
try {
|
|
6448
|
+
unlinkSync(stagingPath);
|
|
6449
|
+
} catch {
|
|
6450
|
+
}
|
|
6145
6451
|
}
|
|
6146
|
-
return { token: fresh, source: "file", filePath };
|
|
6147
6452
|
} catch (err2) {
|
|
6148
6453
|
console.error(`[auth] Could not read/create ${filePath} (${err2 instanceof Error ? err2.message : err2}). Using an in-memory token for this process; set ROBLOX_STUDIO_AUTH_TOKEN to share one across sessions.`);
|
|
6149
6454
|
return { token: randomBytes(32).toString("hex"), source: "file" };
|
|
@@ -6488,7 +6793,7 @@ var TOOL_PROXY_ENDPOINTS = {
|
|
|
6488
6793
|
insert_asset: ["/api/insert-asset"],
|
|
6489
6794
|
generate_model: ["/api/generate-model"],
|
|
6490
6795
|
preview_asset: ["/api/preview-asset"],
|
|
6491
|
-
capture_screenshot: ["/api/capture-studio", "/api/capture-screenshot", "/api/capture-begin", "/api/capture-read"],
|
|
6796
|
+
capture_screenshot: ["/api/capture-studio", "/api/capture-screenshot", "/api/capture-begin", "/api/capture-read", "/api/capture-markers"],
|
|
6492
6797
|
simulate_mouse_input: ["/api/simulate-mouse-input"],
|
|
6493
6798
|
simulate_keyboard_input: ["/api/simulate-keyboard-input"],
|
|
6494
6799
|
get_memory_breakdown: ["/api/get-memory-breakdown"],
|
|
@@ -6661,7 +6966,7 @@ var TOOL_HANDLERS = {
|
|
|
6661
6966
|
get_memory_breakdown: (tools, body) => tools.getMemoryBreakdown(body.target, body.tags, body.instance_id),
|
|
6662
6967
|
get_scene_analysis: (tools, body) => tools.getSceneAnalysis(body.mode, body.target, body.topN, body.raw, body.instance_id),
|
|
6663
6968
|
export_rbxm: (tools, body) => tools.exportRbxm(body.instance_paths, body.output_path, body.target, body.instance_id),
|
|
6664
|
-
import_rbxm: (tools, body) => tools.importRbxm(body.source, body.parent_path, body.target, body.instance_id),
|
|
6969
|
+
import_rbxm: (tools, body, context) => tools.importRbxm(body.source, body.parent_path, body.target, body.instance_id, context?.signal),
|
|
6665
6970
|
find_and_replace_in_scripts: (tools, body) => tools.findAndReplaceInScripts(body.pattern, body.replacement, {
|
|
6666
6971
|
caseSensitive: body.caseSensitive,
|
|
6667
6972
|
usePattern: body.usePattern,
|
|
@@ -6830,8 +7135,8 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6830
7135
|
});
|
|
6831
7136
|
const authToken = security?.authToken;
|
|
6832
7137
|
const authRequired = (requestPath) => {
|
|
6833
|
-
const
|
|
6834
|
-
return
|
|
7138
|
+
const path9 = requestPath.toLowerCase().replace(/\/+$/, "");
|
|
7139
|
+
return path9 === "/mcp" || path9.startsWith("/mcp/") || path9 === "/proxy" || path9 === "/topology" || path9 === "/request-status" || path9 === "/unregister-instance-id" || path9 === "/create-multiplayer-group" || path9 === "/remove-multiplayer-group";
|
|
6835
7140
|
};
|
|
6836
7141
|
app.use((req, res, next) => {
|
|
6837
7142
|
if (!authToken || !authRequired(req.path)) {
|
|
@@ -7260,6 +7565,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
7260
7565
|
studioTransport.close();
|
|
7261
7566
|
webSocketServer.close();
|
|
7262
7567
|
await mcpHandler?.close();
|
|
7568
|
+
await tools.dispose();
|
|
7263
7569
|
};
|
|
7264
7570
|
return app;
|
|
7265
7571
|
}
|
|
@@ -7280,7 +7586,7 @@ async function listenWithRetry(app, host, startPort, maxAttempts = 5) {
|
|
|
7280
7586
|
throw new Error(`All ports ${startPort}-${startPort + maxAttempts - 1} are in use. Stop some MCP server instances and retry.`);
|
|
7281
7587
|
}
|
|
7282
7588
|
function bindPort(app, host, port) {
|
|
7283
|
-
const { promise, resolve:
|
|
7589
|
+
const { promise, resolve: resolve6, reject } = Promise.withResolvers();
|
|
7284
7590
|
const server = http.createServer(app);
|
|
7285
7591
|
const onError = (err2) => {
|
|
7286
7592
|
server.removeListener("error", onError);
|
|
@@ -7293,7 +7599,7 @@ function bindPort(app, host, port) {
|
|
|
7293
7599
|
if ("attachStudioTransport" in app && typeof app.attachStudioTransport === "function") {
|
|
7294
7600
|
app.attachStudioTransport(server);
|
|
7295
7601
|
}
|
|
7296
|
-
|
|
7602
|
+
resolve6(server);
|
|
7297
7603
|
} catch (error) {
|
|
7298
7604
|
server.close();
|
|
7299
7605
|
reject(error);
|
|
@@ -7302,6 +7608,123 @@ function bindPort(app, host, port) {
|
|
|
7302
7608
|
return promise;
|
|
7303
7609
|
}
|
|
7304
7610
|
|
|
7611
|
+
// ../core/dist/rbxm-input.js
|
|
7612
|
+
import { open as open3 } from "fs/promises";
|
|
7613
|
+
import { resolve as resolve2 } from "path";
|
|
7614
|
+
var ENVELOPE_RESERVE_BYTES = 16 * 1024;
|
|
7615
|
+
var DOWNLOAD_TIMEOUT_MS = 3e4;
|
|
7616
|
+
function rbxmInputByteLimit(parentPath, sourceLabel) {
|
|
7617
|
+
const metadataBytes = Buffer.byteLength(JSON.stringify({
|
|
7618
|
+
base64: "",
|
|
7619
|
+
parent_path: parentPath,
|
|
7620
|
+
source_label: sourceLabel
|
|
7621
|
+
}));
|
|
7622
|
+
const encodedBytes = Math.min(HTTP_BODY_LIMIT_BYTES, MAX_STUDIO_FRAME_BYTES) - ENVELOPE_RESERVE_BYTES - metadataBytes;
|
|
7623
|
+
if (encodedBytes < 4)
|
|
7624
|
+
throw new Error("RBXM import metadata exceeds the transport byte budget.");
|
|
7625
|
+
return Math.floor(encodedBytes / 4) * 3;
|
|
7626
|
+
}
|
|
7627
|
+
function tooLarge(limit) {
|
|
7628
|
+
return new Error(`RBXM input exceeds the ${limit}-byte limit after allowing for base64 and transport overhead.`);
|
|
7629
|
+
}
|
|
7630
|
+
async function readBoundedResponse(response, limit) {
|
|
7631
|
+
const declared = Number(response.headers.get("content-length"));
|
|
7632
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
7633
|
+
await response.body?.cancel();
|
|
7634
|
+
throw tooLarge(limit);
|
|
7635
|
+
}
|
|
7636
|
+
if (!response.body)
|
|
7637
|
+
return Buffer.alloc(0);
|
|
7638
|
+
const reader = response.body.getReader();
|
|
7639
|
+
const chunks = [];
|
|
7640
|
+
let size = 0;
|
|
7641
|
+
try {
|
|
7642
|
+
for (; ; ) {
|
|
7643
|
+
const { done, value } = await reader.read();
|
|
7644
|
+
if (done)
|
|
7645
|
+
break;
|
|
7646
|
+
size += value.byteLength;
|
|
7647
|
+
if (size > limit)
|
|
7648
|
+
throw tooLarge(limit);
|
|
7649
|
+
chunks.push(Buffer.from(value));
|
|
7650
|
+
}
|
|
7651
|
+
return Buffer.concat(chunks, size);
|
|
7652
|
+
} finally {
|
|
7653
|
+
await reader.cancel().catch(() => {
|
|
7654
|
+
});
|
|
7655
|
+
reader.releaseLock();
|
|
7656
|
+
}
|
|
7657
|
+
}
|
|
7658
|
+
async function readRbxmInput(source, parentPath, signal) {
|
|
7659
|
+
const modes = ["path", "url", "base64"];
|
|
7660
|
+
const provided = modes.filter((key) => source[key] !== void 0);
|
|
7661
|
+
if (provided.length !== 1 || typeof source[provided[0]] !== "string") {
|
|
7662
|
+
throw new Error("source must contain exactly one string: path, url, or base64");
|
|
7663
|
+
}
|
|
7664
|
+
signal?.throwIfAborted();
|
|
7665
|
+
if (source.path !== void 0) {
|
|
7666
|
+
const sourceLabel = resolve2(source.path);
|
|
7667
|
+
const limit2 = rbxmInputByteLimit(parentPath, sourceLabel);
|
|
7668
|
+
const file = await open3(sourceLabel, "r");
|
|
7669
|
+
try {
|
|
7670
|
+
const stats = await file.stat();
|
|
7671
|
+
if (!stats.isFile())
|
|
7672
|
+
throw new Error("RBXM source must be a regular file.");
|
|
7673
|
+
if (stats.size > limit2)
|
|
7674
|
+
throw tooLarge(limit2);
|
|
7675
|
+
const chunks = [];
|
|
7676
|
+
let size = 0;
|
|
7677
|
+
for (; ; ) {
|
|
7678
|
+
signal?.throwIfAborted();
|
|
7679
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, limit2 - size + 1));
|
|
7680
|
+
const { bytesRead } = await file.read(chunk);
|
|
7681
|
+
if (bytesRead === 0)
|
|
7682
|
+
break;
|
|
7683
|
+
size += bytesRead;
|
|
7684
|
+
if (size > limit2)
|
|
7685
|
+
throw tooLarge(limit2);
|
|
7686
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
7687
|
+
}
|
|
7688
|
+
return { bytes: Buffer.concat(chunks, size), sourceLabel };
|
|
7689
|
+
} finally {
|
|
7690
|
+
await file.close();
|
|
7691
|
+
}
|
|
7692
|
+
}
|
|
7693
|
+
if (source.url !== void 0) {
|
|
7694
|
+
const url = new URL(source.url);
|
|
7695
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
7696
|
+
throw new Error(`import_rbxm url must use http(s); got ${url.protocol}`);
|
|
7697
|
+
}
|
|
7698
|
+
const limit2 = rbxmInputByteLimit(parentPath, source.url);
|
|
7699
|
+
const deadline = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
|
|
7700
|
+
const response = await fetch(url, {
|
|
7701
|
+
signal: signal ? AbortSignal.any([signal, deadline]) : deadline
|
|
7702
|
+
});
|
|
7703
|
+
if (!response.ok) {
|
|
7704
|
+
await response.body?.cancel();
|
|
7705
|
+
throw new Error(`RBXM download returned HTTP ${response.status}`);
|
|
7706
|
+
}
|
|
7707
|
+
return { bytes: await readBoundedResponse(response, limit2), sourceLabel: source.url };
|
|
7708
|
+
}
|
|
7709
|
+
const limit = rbxmInputByteLimit(parentPath, "base64(9999999999B)");
|
|
7710
|
+
const maxEncoded = 4 * Math.ceil(limit / 3);
|
|
7711
|
+
if (source.base64.length > maxEncoded + 2 * Math.ceil(maxEncoded / 64))
|
|
7712
|
+
throw tooLarge(limit);
|
|
7713
|
+
const encoded = source.base64.replace(/[\t\n\r ]+/g, "");
|
|
7714
|
+
if (encoded.length > maxEncoded)
|
|
7715
|
+
throw tooLarge(limit);
|
|
7716
|
+
if (encoded.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.includes("=") && encoded.length % 4 !== 0) {
|
|
7717
|
+
throw new Error("RBXM base64 must be valid base64.");
|
|
7718
|
+
}
|
|
7719
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
7720
|
+
if (bytes.length > limit)
|
|
7721
|
+
throw tooLarge(limit);
|
|
7722
|
+
if (bytes.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, "")) {
|
|
7723
|
+
throw new Error("RBXM base64 is not canonical.");
|
|
7724
|
+
}
|
|
7725
|
+
return { bytes, sourceLabel: `base64(${bytes.length}B)` };
|
|
7726
|
+
}
|
|
7727
|
+
|
|
7305
7728
|
// ../core/dist/tools/studio-client.js
|
|
7306
7729
|
var StudioHttpClient = class {
|
|
7307
7730
|
bridge;
|
|
@@ -7369,7 +7792,6 @@ var OpenCloudClient = class {
|
|
|
7369
7792
|
body: body ? JSON.stringify(body) : void 0,
|
|
7370
7793
|
signal: controller.signal
|
|
7371
7794
|
});
|
|
7372
|
-
clearTimeout(timeoutId);
|
|
7373
7795
|
if (!response.ok) {
|
|
7374
7796
|
const errorBody = await response.text();
|
|
7375
7797
|
let errorMessage2;
|
|
@@ -7391,7 +7813,8 @@ var OpenCloudClient = class {
|
|
|
7391
7813
|
}
|
|
7392
7814
|
return await response.json();
|
|
7393
7815
|
} catch (error) {
|
|
7394
|
-
|
|
7816
|
+
if (controller.signal.aborted)
|
|
7817
|
+
throw new Error("Request timed out");
|
|
7395
7818
|
if (error instanceof Error) {
|
|
7396
7819
|
if (error.name === "AbortError") {
|
|
7397
7820
|
throw new Error("Request timed out");
|
|
@@ -7399,6 +7822,8 @@ var OpenCloudClient = class {
|
|
|
7399
7822
|
throw error;
|
|
7400
7823
|
}
|
|
7401
7824
|
throw new Error(`Unknown error: ${String(error)}`);
|
|
7825
|
+
} finally {
|
|
7826
|
+
clearTimeout(timeoutId);
|
|
7402
7827
|
}
|
|
7403
7828
|
}
|
|
7404
7829
|
async searchAssets(params) {
|
|
@@ -7433,7 +7858,8 @@ var OpenCloudClient = class {
|
|
|
7433
7858
|
async getAssetThumbnail(assetId, size = "420x420") {
|
|
7434
7859
|
const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${assetId}&size=${size}&format=Png`;
|
|
7435
7860
|
try {
|
|
7436
|
-
const
|
|
7861
|
+
const signal = AbortSignal.timeout(this.timeout);
|
|
7862
|
+
const response = await fetch(url, { signal });
|
|
7437
7863
|
if (!response.ok)
|
|
7438
7864
|
return null;
|
|
7439
7865
|
const data = await response.json();
|
|
@@ -7441,7 +7867,7 @@ var OpenCloudClient = class {
|
|
|
7441
7867
|
if (!thumbnail || thumbnail.state !== "Completed" || !thumbnail.imageUrl) {
|
|
7442
7868
|
return null;
|
|
7443
7869
|
}
|
|
7444
|
-
const imageResponse = await fetch(thumbnail.imageUrl);
|
|
7870
|
+
const imageResponse = await fetch(thumbnail.imageUrl, { signal });
|
|
7445
7871
|
if (!imageResponse.ok)
|
|
7446
7872
|
return null;
|
|
7447
7873
|
const arrayBuffer = await imageResponse.arrayBuffer();
|
|
@@ -7462,7 +7888,7 @@ var OpenCloudClient = class {
|
|
|
7462
7888
|
for (const batch of batches) {
|
|
7463
7889
|
const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${batch.join(",")}&size=${size}&format=Png`;
|
|
7464
7890
|
try {
|
|
7465
|
-
const response = await fetch(url);
|
|
7891
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(this.timeout) });
|
|
7466
7892
|
if (response.ok) {
|
|
7467
7893
|
const data = await response.json();
|
|
7468
7894
|
for (const thumbnail of data.data) {
|
|
@@ -7523,7 +7949,7 @@ var OpenCloudClient = class {
|
|
|
7523
7949
|
const chunks = [];
|
|
7524
7950
|
let totalBytes = 0;
|
|
7525
7951
|
const reader = contentResponse.body.getReader();
|
|
7526
|
-
|
|
7952
|
+
for (; ; ) {
|
|
7527
7953
|
const { done, value } = await reader.read();
|
|
7528
7954
|
if (done)
|
|
7529
7955
|
break;
|
|
@@ -7604,7 +8030,6 @@ var OpenCloudClient = class {
|
|
|
7604
8030
|
body: formData,
|
|
7605
8031
|
signal: controller.signal
|
|
7606
8032
|
});
|
|
7607
|
-
clearTimeout(timeoutId);
|
|
7608
8033
|
if (!response.ok) {
|
|
7609
8034
|
const errorBody = await response.text();
|
|
7610
8035
|
let errorMessage2;
|
|
@@ -7626,7 +8051,8 @@ var OpenCloudClient = class {
|
|
|
7626
8051
|
}
|
|
7627
8052
|
return await response.json();
|
|
7628
8053
|
} catch (error) {
|
|
7629
|
-
|
|
8054
|
+
if (controller.signal.aborted)
|
|
8055
|
+
throw new Error("Request timed out");
|
|
7630
8056
|
if (error instanceof Error) {
|
|
7631
8057
|
if (error.name === "AbortError") {
|
|
7632
8058
|
throw new Error("Request timed out");
|
|
@@ -7634,6 +8060,8 @@ var OpenCloudClient = class {
|
|
|
7634
8060
|
throw error;
|
|
7635
8061
|
}
|
|
7636
8062
|
throw new Error(`Unknown error: ${String(error)}`);
|
|
8063
|
+
} finally {
|
|
8064
|
+
clearTimeout(timeoutId);
|
|
7637
8065
|
}
|
|
7638
8066
|
}
|
|
7639
8067
|
async pollOperation(operationPath, maxAttempts = 30, intervalMs = 2e3) {
|
|
@@ -7645,7 +8073,7 @@ var OpenCloudClient = class {
|
|
|
7645
8073
|
if (result.error) {
|
|
7646
8074
|
throw new Error(`Asset upload failed: ${result.error.message}`);
|
|
7647
8075
|
}
|
|
7648
|
-
await new Promise((
|
|
8076
|
+
await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
|
|
7649
8077
|
}
|
|
7650
8078
|
throw new Error(`Asset upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
|
|
7651
8079
|
}
|
|
@@ -7662,6 +8090,7 @@ var RobloxCookieClient = class {
|
|
|
7662
8090
|
return !!this.cookie;
|
|
7663
8091
|
}
|
|
7664
8092
|
async fetchWithCsrf(url, options = {}) {
|
|
8093
|
+
options = { ...options, signal: options.signal ?? AbortSignal.timeout(3e4) };
|
|
7665
8094
|
const headers = {
|
|
7666
8095
|
Cookie: `.ROBLOSECURITY=${this.cookie}`,
|
|
7667
8096
|
...options.headers || {}
|
|
@@ -7675,6 +8104,7 @@ var RobloxCookieClient = class {
|
|
|
7675
8104
|
if (newToken) {
|
|
7676
8105
|
this.csrfToken = newToken;
|
|
7677
8106
|
headers["X-CSRF-TOKEN"] = newToken;
|
|
8107
|
+
await response.body?.cancel();
|
|
7678
8108
|
return fetch(url, { ...options, headers });
|
|
7679
8109
|
}
|
|
7680
8110
|
}
|
|
@@ -7794,7 +8224,7 @@ var RobloxCookieClient = class {
|
|
|
7794
8224
|
throw new Error("Image upload completed without an asset ID.");
|
|
7795
8225
|
}
|
|
7796
8226
|
if (attempt < maxAttempts - 1) {
|
|
7797
|
-
await new Promise((
|
|
8227
|
+
await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
|
|
7798
8228
|
}
|
|
7799
8229
|
}
|
|
7800
8230
|
throw new Error(`Image upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
|
|
@@ -7818,18 +8248,16 @@ var RobloxCookieClient = class {
|
|
|
7818
8248
|
|
|
7819
8249
|
// ../core/dist/image-decode.js
|
|
7820
8250
|
import * as fs2 from "fs";
|
|
7821
|
-
import * as
|
|
8251
|
+
import * as path4 from "path";
|
|
7822
8252
|
import { inflateSync } from "zlib";
|
|
7823
8253
|
var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
7824
8254
|
var MAX_EDITABLE_IMAGE_DIMENSION = 1024;
|
|
7825
8255
|
var MAX_PNG_DIMENSION = 16 * 1024;
|
|
7826
8256
|
var MAX_PNG_PIXELS = 8 * 1024 * 1024;
|
|
8257
|
+
var MAX_SCREENSHOT_PNG_PIXELS = 32 * 1024 * 1024;
|
|
7827
8258
|
var MAX_PNG_INPUT_BYTES = 32 * 1024 * 1024;
|
|
7828
8259
|
var MAX_PNG_BASE64_CHARACTERS = 4 * Math.ceil(MAX_PNG_INPUT_BYTES / 3);
|
|
7829
|
-
var MAX_PNG_CHUNK_BYTES = 32 * 1024 * 1024;
|
|
7830
|
-
var MAX_PNG_COMPRESSED_BYTES = 32 * 1024 * 1024;
|
|
7831
8260
|
var MAX_PNG_CHUNKS = 65536;
|
|
7832
|
-
var MAX_PNG_INFLATED_BYTES = MAX_PNG_PIXELS * 4 + MAX_PNG_DIMENSION;
|
|
7833
8261
|
function paethPredictor(a, b, c) {
|
|
7834
8262
|
const p = a + b - c;
|
|
7835
8263
|
const pa = Math.abs(p - a);
|
|
@@ -7852,12 +8280,12 @@ function bytesPerPixel(colorType) {
|
|
|
7852
8280
|
return 2;
|
|
7853
8281
|
throw new Error(`Unsupported PNG color type ${colorType}. Supported color types: 0, 2, 4, 6.`);
|
|
7854
8282
|
}
|
|
7855
|
-
function validatePngInputSize(byteLength) {
|
|
8283
|
+
function validatePngInputSize(byteLength, maxInputBytes = MAX_PNG_INPUT_BYTES) {
|
|
7856
8284
|
if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
|
|
7857
8285
|
throw new Error(`Invalid PNG input length ${byteLength}.`);
|
|
7858
8286
|
}
|
|
7859
|
-
if (byteLength >
|
|
7860
|
-
throw new Error(`PNG input length ${byteLength} exceeds the ${
|
|
8287
|
+
if (byteLength > maxInputBytes) {
|
|
8288
|
+
throw new Error(`PNG input length ${byteLength} exceeds the ${maxInputBytes}-byte limit.`);
|
|
7861
8289
|
}
|
|
7862
8290
|
}
|
|
7863
8291
|
function getBase64DecodedLength(encoded) {
|
|
@@ -7919,7 +8347,7 @@ function readPngFileWithinLimit(resolved, imagePath) {
|
|
|
7919
8347
|
fs2.closeSync(descriptor);
|
|
7920
8348
|
}
|
|
7921
8349
|
}
|
|
7922
|
-
function validateSourceDimensions(width, height) {
|
|
8350
|
+
function validateSourceDimensions(width, height, maxPixels) {
|
|
7923
8351
|
if (width <= 0 || height <= 0) {
|
|
7924
8352
|
throw new Error("PNG IHDR dimensions must be positive.");
|
|
7925
8353
|
}
|
|
@@ -7927,78 +8355,65 @@ function validateSourceDimensions(width, height) {
|
|
|
7927
8355
|
throw new Error(`PNG dimensions ${width}x${height} exceed the ${MAX_PNG_DIMENSION}-pixel dimension limit.`);
|
|
7928
8356
|
}
|
|
7929
8357
|
const pixelCount = width * height;
|
|
7930
|
-
if (!Number.isSafeInteger(pixelCount) || pixelCount >
|
|
7931
|
-
throw new Error(`PNG pixel count ${pixelCount} exceeds the ${
|
|
8358
|
+
if (!Number.isSafeInteger(pixelCount) || pixelCount > maxPixels) {
|
|
8359
|
+
throw new Error(`PNG pixel count ${pixelCount} exceeds the ${maxPixels}-pixel limit.`);
|
|
7932
8360
|
}
|
|
7933
8361
|
}
|
|
7934
|
-
function getExpectedScanlineLength(width, height, bpp) {
|
|
8362
|
+
function getExpectedScanlineLength(width, height, bpp, maxPixels) {
|
|
8363
|
+
const maxInflatedBytes = maxPixels * 4 + MAX_PNG_DIMENSION;
|
|
7935
8364
|
const stride = width * bpp;
|
|
7936
8365
|
const expected = height * (stride + 1);
|
|
7937
|
-
if (!Number.isSafeInteger(stride) || !Number.isSafeInteger(expected) || expected >
|
|
7938
|
-
throw new Error(`PNG scanline data length ${expected} exceeds the ${
|
|
8366
|
+
if (!Number.isSafeInteger(stride) || !Number.isSafeInteger(expected) || expected > maxInflatedBytes) {
|
|
8367
|
+
throw new Error(`PNG scanline data length ${expected} exceeds the ${maxInflatedBytes}-byte limit.`);
|
|
7939
8368
|
}
|
|
7940
8369
|
return expected;
|
|
7941
8370
|
}
|
|
7942
|
-
function
|
|
7943
|
-
const
|
|
7944
|
-
if (
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
const upLeft = y > 0 && x >= bpp ? unfiltered[prevRowOffset + x - bpp] : 0;
|
|
7961
|
-
let decoded;
|
|
7962
|
-
if (filter === 0) {
|
|
7963
|
-
decoded = value;
|
|
7964
|
-
} else if (filter === 1) {
|
|
7965
|
-
decoded = value + left;
|
|
7966
|
-
} else if (filter === 2) {
|
|
7967
|
-
decoded = value + up;
|
|
7968
|
-
} else if (filter === 3) {
|
|
7969
|
-
decoded = value + Math.floor((left + up) / 2);
|
|
7970
|
-
} else if (filter === 4) {
|
|
7971
|
-
decoded = value + paethPredictor(left, up, upLeft);
|
|
7972
|
-
} else {
|
|
7973
|
-
throw new Error(`Unsupported PNG filter type ${filter}.`);
|
|
7974
|
-
}
|
|
7975
|
-
unfiltered[rowOffset + x] = decoded & 255;
|
|
8371
|
+
function unfilterPngRow(raw, rowOffset, stride, bpp) {
|
|
8372
|
+
const filter = raw[rowOffset - 1];
|
|
8373
|
+
if (filter === 0)
|
|
8374
|
+
return;
|
|
8375
|
+
const prevRowOffset = rowOffset - stride - 1;
|
|
8376
|
+
for (let x = 0; x < stride; x++) {
|
|
8377
|
+
const left = x >= bpp ? raw[rowOffset + x - bpp] : 0;
|
|
8378
|
+
const up = prevRowOffset > 0 ? raw[prevRowOffset + x] : 0;
|
|
8379
|
+
const upLeft = prevRowOffset > 0 && x >= bpp ? raw[prevRowOffset + x - bpp] : 0;
|
|
8380
|
+
let predictor;
|
|
8381
|
+
if (filter === 1) {
|
|
8382
|
+
predictor = left;
|
|
8383
|
+
} else if (filter === 2) {
|
|
8384
|
+
predictor = up;
|
|
8385
|
+
} else if (filter === 3) {
|
|
8386
|
+
predictor = Math.floor((left + up) / 2);
|
|
8387
|
+
} else {
|
|
8388
|
+
predictor = paethPredictor(left, up, upLeft);
|
|
7976
8389
|
}
|
|
8390
|
+
raw[rowOffset + x] = raw[rowOffset + x] + predictor & 255;
|
|
7977
8391
|
}
|
|
7978
|
-
|
|
7979
|
-
|
|
8392
|
+
}
|
|
8393
|
+
function convertScanlinesToRgba({ raw, width, height, colorType, bpp }) {
|
|
8394
|
+
const stride = width * bpp;
|
|
7980
8395
|
const rgba = Buffer.alloc(width * height * 4);
|
|
7981
|
-
let si = 0;
|
|
7982
8396
|
let di = 0;
|
|
7983
|
-
for (let
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
rgba
|
|
7987
|
-
|
|
7988
|
-
|
|
7989
|
-
}
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
|
|
8001
|
-
|
|
8397
|
+
for (let rowOffset = 1; rowOffset < raw.length; rowOffset += stride + 1) {
|
|
8398
|
+
unfilterPngRow(raw, rowOffset, stride, bpp);
|
|
8399
|
+
if (colorType === 6) {
|
|
8400
|
+
raw.copy(rgba, di, rowOffset, rowOffset + stride);
|
|
8401
|
+
di += stride;
|
|
8402
|
+
continue;
|
|
8403
|
+
}
|
|
8404
|
+
for (let si = rowOffset; si < rowOffset + stride; si += bpp) {
|
|
8405
|
+
if (colorType === 2) {
|
|
8406
|
+
rgba[di++] = raw[si];
|
|
8407
|
+
rgba[di++] = raw[si + 1];
|
|
8408
|
+
rgba[di++] = raw[si + 2];
|
|
8409
|
+
rgba[di++] = 255;
|
|
8410
|
+
} else {
|
|
8411
|
+
const gray = raw[si];
|
|
8412
|
+
rgba[di++] = gray;
|
|
8413
|
+
rgba[di++] = gray;
|
|
8414
|
+
rgba[di++] = gray;
|
|
8415
|
+
rgba[di++] = colorType === 4 ? raw[si + 1] : 255;
|
|
8416
|
+
}
|
|
8002
8417
|
}
|
|
8003
8418
|
}
|
|
8004
8419
|
return rgba;
|
|
@@ -8025,8 +8440,9 @@ function resizeRgbaNearest(image) {
|
|
|
8025
8440
|
}
|
|
8026
8441
|
return { width, height, rgba };
|
|
8027
8442
|
}
|
|
8028
|
-
function
|
|
8029
|
-
|
|
8443
|
+
function inflatePng(data, maxPixels) {
|
|
8444
|
+
const maxInputBytes = maxPixels * 4;
|
|
8445
|
+
validatePngInputSize(data.length, maxInputBytes);
|
|
8030
8446
|
if (data.length < PNG_SIGNATURE.length || !data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
|
8031
8447
|
throw new Error("Unsupported image format. generate_model currently supports PNG images.");
|
|
8032
8448
|
}
|
|
@@ -8047,8 +8463,8 @@ function decodePngToRgba(data) {
|
|
|
8047
8463
|
}
|
|
8048
8464
|
const length = data.readUInt32BE(offset);
|
|
8049
8465
|
const type = data.toString("ascii", offset + 4, offset + 8);
|
|
8050
|
-
if (length >
|
|
8051
|
-
throw new Error(`PNG chunk ${type} length ${length} exceeds the ${
|
|
8466
|
+
if (length > maxInputBytes) {
|
|
8467
|
+
throw new Error(`PNG chunk ${type} length ${length} exceeds the ${maxInputBytes}-byte chunk limit.`);
|
|
8052
8468
|
}
|
|
8053
8469
|
if (++chunkCount > MAX_PNG_CHUNKS) {
|
|
8054
8470
|
throw new Error(`PNG contains more than the ${MAX_PNG_CHUNKS}-chunk limit.`);
|
|
@@ -8084,12 +8500,12 @@ function decodePngToRgba(data) {
|
|
|
8084
8500
|
if (interlace !== 0)
|
|
8085
8501
|
throw new Error("Interlaced PNG images are not supported.");
|
|
8086
8502
|
bpp = bytesPerPixel(colorType);
|
|
8087
|
-
validateSourceDimensions(width, height);
|
|
8088
|
-
expectedScanlineLength = getExpectedScanlineLength(width, height, bpp);
|
|
8503
|
+
validateSourceDimensions(width, height, maxPixels);
|
|
8504
|
+
expectedScanlineLength = getExpectedScanlineLength(width, height, bpp, maxPixels);
|
|
8089
8505
|
sawIhdr = true;
|
|
8090
8506
|
} else if (type === "IDAT") {
|
|
8091
|
-
if (length >
|
|
8092
|
-
throw new Error(`PNG image data exceeds the ${
|
|
8507
|
+
if (length > maxInputBytes - idatLength) {
|
|
8508
|
+
throw new Error(`PNG image data exceeds the ${maxInputBytes}-byte compressed-data limit.`);
|
|
8093
8509
|
}
|
|
8094
8510
|
idatLength += length;
|
|
8095
8511
|
idatChunks.push(chunk);
|
|
@@ -8117,8 +8533,43 @@ function decodePngToRgba(data) {
|
|
|
8117
8533
|
const detail = error instanceof Error ? error.message : String(error);
|
|
8118
8534
|
throw new Error(`Invalid PNG compressed data: ${detail}`);
|
|
8119
8535
|
}
|
|
8120
|
-
|
|
8121
|
-
|
|
8536
|
+
if (raw.length !== expectedScanlineLength) {
|
|
8537
|
+
throw new Error(`PNG data ended early. Expected ${expectedScanlineLength} bytes after inflate, got ${raw.length}.`);
|
|
8538
|
+
}
|
|
8539
|
+
const rowLength = width * bpp + 1;
|
|
8540
|
+
for (let rowOffset = 0; rowOffset < raw.length; rowOffset += rowLength) {
|
|
8541
|
+
if (raw[rowOffset] > 4) {
|
|
8542
|
+
throw new Error(`Unsupported PNG filter type ${raw[rowOffset]}.`);
|
|
8543
|
+
}
|
|
8544
|
+
}
|
|
8545
|
+
return { width, height, colorType, bpp, raw };
|
|
8546
|
+
}
|
|
8547
|
+
function isUniformPng(data) {
|
|
8548
|
+
const { raw, width, colorType, bpp } = inflatePng(data, MAX_SCREENSHOT_PNG_PIXELS);
|
|
8549
|
+
const stride = width * bpp;
|
|
8550
|
+
unfilterPngRow(raw, 1, stride, bpp);
|
|
8551
|
+
const red = raw[1];
|
|
8552
|
+
const grayscale = colorType === 0 || colorType === 4;
|
|
8553
|
+
const green = grayscale ? red : raw[2];
|
|
8554
|
+
const blue = grayscale ? red : raw[3];
|
|
8555
|
+
for (let rowOffset = 1; rowOffset < raw.length; rowOffset += stride + 1) {
|
|
8556
|
+
if (rowOffset !== 1)
|
|
8557
|
+
unfilterPngRow(raw, rowOffset, stride, bpp);
|
|
8558
|
+
for (let offset = rowOffset; offset < rowOffset + stride; offset += bpp) {
|
|
8559
|
+
if (raw[offset] !== red || !grayscale && (raw[offset + 1] !== green || raw[offset + 2] !== blue)) {
|
|
8560
|
+
return false;
|
|
8561
|
+
}
|
|
8562
|
+
}
|
|
8563
|
+
}
|
|
8564
|
+
return true;
|
|
8565
|
+
}
|
|
8566
|
+
function decodePngToRgba(data) {
|
|
8567
|
+
const png = inflatePng(data, MAX_PNG_PIXELS);
|
|
8568
|
+
return resizeRgbaNearest({
|
|
8569
|
+
width: png.width,
|
|
8570
|
+
height: png.height,
|
|
8571
|
+
rgba: convertScanlinesToRgba(png)
|
|
8572
|
+
});
|
|
8122
8573
|
}
|
|
8123
8574
|
function decodePngBase64ToRgba(encoded) {
|
|
8124
8575
|
const expectedLength = getBase64DecodedLength(encoded);
|
|
@@ -8129,14 +8580,14 @@ function decodePngBase64ToRgba(encoded) {
|
|
|
8129
8580
|
return decodePngToRgba(data);
|
|
8130
8581
|
}
|
|
8131
8582
|
function decodeImagePathToRgba(imagePath) {
|
|
8132
|
-
const resolved =
|
|
8583
|
+
const resolved = path4.resolve(imagePath);
|
|
8133
8584
|
return decodePngToRgba(readPngFileWithinLimit(resolved, imagePath));
|
|
8134
8585
|
}
|
|
8135
8586
|
|
|
8136
8587
|
// ../core/dist/studio-skills.js
|
|
8137
8588
|
import { createHash as createHash3 } from "crypto";
|
|
8138
|
-
import { existsSync as
|
|
8139
|
-
import * as
|
|
8589
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
8590
|
+
import * as path5 from "path";
|
|
8140
8591
|
|
|
8141
8592
|
// ../../node_modules/fzstd/esm/index.mjs
|
|
8142
8593
|
var ab = ArrayBuffer;
|
|
@@ -9024,7 +9475,7 @@ function parseBuiltInStudioSkills(buffer) {
|
|
|
9024
9475
|
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
9025
9476
|
}
|
|
9026
9477
|
function discoverNamedFile(root, fileName, depth = 0) {
|
|
9027
|
-
if (depth > MAX_DISCOVERY_DEPTH || !
|
|
9478
|
+
if (depth > MAX_DISCOVERY_DEPTH || !existsSync3(root))
|
|
9028
9479
|
return [];
|
|
9029
9480
|
const matches = [];
|
|
9030
9481
|
let entries;
|
|
@@ -9034,7 +9485,7 @@ function discoverNamedFile(root, fileName, depth = 0) {
|
|
|
9034
9485
|
return matches;
|
|
9035
9486
|
}
|
|
9036
9487
|
for (const entry of entries) {
|
|
9037
|
-
const entryPath =
|
|
9488
|
+
const entryPath = path5.join(root, entry.name);
|
|
9038
9489
|
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
|
|
9039
9490
|
matches.push(entryPath);
|
|
9040
9491
|
} else if (entry.isDirectory()) {
|
|
@@ -9046,20 +9497,20 @@ function discoverNamedFile(root, fileName, depth = 0) {
|
|
|
9046
9497
|
function resolveAssistantBundlePath(studioExe) {
|
|
9047
9498
|
const override = process.env.ROBLOX_STUDIO_ASSISTANT_BUNDLE;
|
|
9048
9499
|
if (override) {
|
|
9049
|
-
if (!
|
|
9500
|
+
if (!existsSync3(override)) {
|
|
9050
9501
|
throw new Error(`ROBLOX_STUDIO_ASSISTANT_BUNDLE does not exist: ${override}`);
|
|
9051
9502
|
}
|
|
9052
9503
|
return override;
|
|
9053
9504
|
}
|
|
9054
|
-
const exeDirectory =
|
|
9505
|
+
const exeDirectory = path5.dirname(studioExe ?? resolveStudioExe());
|
|
9055
9506
|
const roots = [
|
|
9056
|
-
|
|
9057
|
-
|
|
9058
|
-
|
|
9507
|
+
path5.join(exeDirectory, "BuiltInStandalonePlugins"),
|
|
9508
|
+
path5.resolve(exeDirectory, "..", "Resources", "BuiltInStandalonePlugins"),
|
|
9509
|
+
path5.resolve(exeDirectory, "..", "..", "OTAPlugins")
|
|
9059
9510
|
];
|
|
9060
|
-
const direct =
|
|
9511
|
+
const direct = path5.join(exeDirectory, "BuiltInStandalonePlugins", "Optimized_Embedded_Signature", "Assistant.rbxm");
|
|
9061
9512
|
const candidates = /* @__PURE__ */ new Set();
|
|
9062
|
-
if (
|
|
9513
|
+
if (existsSync3(direct))
|
|
9063
9514
|
candidates.add(direct);
|
|
9064
9515
|
for (const root of roots) {
|
|
9065
9516
|
for (const candidate of discoverNamedFile(root, "Assistant.rbxm"))
|
|
@@ -9082,7 +9533,7 @@ function loadBuiltInStudioSkills(bundlePath = resolveAssistantBundlePath()) {
|
|
|
9082
9533
|
if (skills.length === 0) {
|
|
9083
9534
|
throw new Error(`No built-in skill documents found in ${bundlePath}`);
|
|
9084
9535
|
}
|
|
9085
|
-
const studioVersion = bundlePath.split(
|
|
9536
|
+
const studioVersion = bundlePath.split(path5.sep).find((segment) => /^version-[a-z0-9]+$/i.test(segment));
|
|
9086
9537
|
const value = {
|
|
9087
9538
|
bundlePath,
|
|
9088
9539
|
bundleModifiedAt: stats.mtime.toISOString(),
|
|
@@ -10049,62 +10500,688 @@ function rgbaToJpeg(rgba, width, height, quality = 80) {
|
|
|
10049
10500
|
return encoder.encode(rgba, width, height);
|
|
10050
10501
|
}
|
|
10051
10502
|
|
|
10052
|
-
// ../core/dist/png-encoder.js
|
|
10053
|
-
import { deflateSync } from "zlib";
|
|
10054
|
-
var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
10055
|
-
var CRC_TABLE = new Uint32Array(256);
|
|
10056
|
-
for (let n = 0; n < 256; n++) {
|
|
10057
|
-
let c = n;
|
|
10058
|
-
for (let k = 0; k < 8; k++)
|
|
10059
|
-
c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
10060
|
-
CRC_TABLE[n] = c;
|
|
10503
|
+
// ../core/dist/png-encoder.js
|
|
10504
|
+
import { deflateSync } from "zlib";
|
|
10505
|
+
var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
10506
|
+
var CRC_TABLE = new Uint32Array(256);
|
|
10507
|
+
for (let n = 0; n < 256; n++) {
|
|
10508
|
+
let c = n;
|
|
10509
|
+
for (let k = 0; k < 8; k++)
|
|
10510
|
+
c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
10511
|
+
CRC_TABLE[n] = c;
|
|
10512
|
+
}
|
|
10513
|
+
function crc32(buf) {
|
|
10514
|
+
let crc = 4294967295;
|
|
10515
|
+
for (let i = 0; i < buf.length; i++)
|
|
10516
|
+
crc = CRC_TABLE[(crc ^ buf[i]) & 255] ^ crc >>> 8;
|
|
10517
|
+
return (crc ^ 4294967295) >>> 0;
|
|
10518
|
+
}
|
|
10519
|
+
function writeChunk(type, data) {
|
|
10520
|
+
const typeBytes = Buffer.from(type, "ascii");
|
|
10521
|
+
const length = Buffer.alloc(4);
|
|
10522
|
+
length.writeUInt32BE(data.length);
|
|
10523
|
+
const crcInput = Buffer.concat([typeBytes, data]);
|
|
10524
|
+
const checksum = Buffer.alloc(4);
|
|
10525
|
+
checksum.writeUInt32BE(crc32(crcInput));
|
|
10526
|
+
return Buffer.concat([length, typeBytes, data, checksum]);
|
|
10527
|
+
}
|
|
10528
|
+
function rgbaToPng(rgba, width, height) {
|
|
10529
|
+
if (width <= 0 || height <= 0)
|
|
10530
|
+
throw new Error(`Invalid PNG dimensions: ${width}x${height}`);
|
|
10531
|
+
const expected = width * height * 4;
|
|
10532
|
+
if (rgba.length < expected)
|
|
10533
|
+
throw new Error(`Buffer too small: got ${rgba.length}, need ${expected}`);
|
|
10534
|
+
const stride = width * 4;
|
|
10535
|
+
const filtered = Buffer.alloc(height * (1 + stride));
|
|
10536
|
+
for (let y = 0; y < height; y++) {
|
|
10537
|
+
filtered[y * (1 + stride)] = 0;
|
|
10538
|
+
rgba.copy(filtered, y * (1 + stride) + 1, y * stride, (y + 1) * stride);
|
|
10539
|
+
}
|
|
10540
|
+
const ihdr = Buffer.alloc(13);
|
|
10541
|
+
ihdr.writeUInt32BE(width, 0);
|
|
10542
|
+
ihdr.writeUInt32BE(height, 4);
|
|
10543
|
+
ihdr[8] = 8;
|
|
10544
|
+
ihdr[9] = 6;
|
|
10545
|
+
ihdr[10] = 0;
|
|
10546
|
+
ihdr[11] = 0;
|
|
10547
|
+
ihdr[12] = 0;
|
|
10548
|
+
return Buffer.concat([
|
|
10549
|
+
PNG_SIGNATURE2,
|
|
10550
|
+
writeChunk("IHDR", ihdr),
|
|
10551
|
+
writeChunk("IDAT", deflateSync(filtered)),
|
|
10552
|
+
writeChunk("IEND", Buffer.alloc(0))
|
|
10553
|
+
]);
|
|
10554
|
+
}
|
|
10555
|
+
|
|
10556
|
+
// ../core/dist/host-capture.js
|
|
10557
|
+
import { spawn as spawn3 } from "child_process";
|
|
10558
|
+
import * as fs4 from "fs";
|
|
10559
|
+
import * as os4 from "os";
|
|
10560
|
+
import * as path7 from "path";
|
|
10561
|
+
|
|
10562
|
+
// ../core/dist/host-capture-macos.js
|
|
10563
|
+
import { spawn as spawn2 } from "child_process";
|
|
10564
|
+
import * as fs3 from "fs";
|
|
10565
|
+
import * as os3 from "os";
|
|
10566
|
+
import * as path6 from "path";
|
|
10567
|
+
var MAX_IMAGE_BYTES = 128 * 1024 * 1024;
|
|
10568
|
+
var MAX_OUTPUT_BYTES = 64 * 1024;
|
|
10569
|
+
var STUDIO_BUNDLE_ID = "com.Roblox.RobloxStudio";
|
|
10570
|
+
var MACOS_CAPTURE_SOURCE = String.raw`
|
|
10571
|
+
import Foundation
|
|
10572
|
+
import AppKit
|
|
10573
|
+
import CoreGraphics
|
|
10574
|
+
import ScreenCaptureKit
|
|
10575
|
+
|
|
10576
|
+
struct CaptureFailure: Error { let message: String }
|
|
10577
|
+
func fail(_ message: String) throws -> Never { throw CaptureFailure(message: message) }
|
|
10578
|
+
func emit(_ value: [String: Any]) {
|
|
10579
|
+
if let data = try? JSONSerialization.data(withJSONObject: value), let text = String(data: data, encoding: .utf8) {
|
|
10580
|
+
print(text)
|
|
10581
|
+
}
|
|
10582
|
+
}
|
|
10583
|
+
|
|
10584
|
+
// Local files can use their absolute path in the macOS window title while
|
|
10585
|
+
// Studio reports only the basename as DataModel.Name. Match that basename
|
|
10586
|
+
// after removing Studio's exact title suffix; retain ambiguity checks below.
|
|
10587
|
+
func matchesStudioTitle(_ title: String, hint: String) -> Bool {
|
|
10588
|
+
if hint.isEmpty || title == hint || title.hasPrefix(hint) { return true }
|
|
10589
|
+
let suffix = " - Roblox Studio"
|
|
10590
|
+
guard title.hasSuffix(suffix) else { return false }
|
|
10591
|
+
let place = String(title.dropLast(suffix.count))
|
|
10592
|
+
return place.hasPrefix("/") && (place as NSString).lastPathComponent == hint
|
|
10593
|
+
}
|
|
10594
|
+
|
|
10595
|
+
@available(macOS 14.0, *)
|
|
10596
|
+
@MainActor
|
|
10597
|
+
func capture() async throws {
|
|
10598
|
+
// Never request or bypass Screen Recording permission from an MCP call.
|
|
10599
|
+
guard CGPreflightScreenCaptureAccess() else {
|
|
10600
|
+
try fail("macOS Screen Recording permission is not granted to the MCP host. Enable it in System Settings > Privacy & Security > Screen Recording, then restart the MCP host.")
|
|
10601
|
+
}
|
|
10602
|
+
// A command-line helper must establish its WindowServer connection before
|
|
10603
|
+
// ScreenCaptureKit uses Core Graphics. Do not activate or create any UI.
|
|
10604
|
+
NSApplication.shared.setActivationPolicy(.prohibited)
|
|
10605
|
+
let env = ProcessInfo.processInfo.environment
|
|
10606
|
+
let hint = env["MCP_CAPTURE_TITLE_HINT"] ?? ""
|
|
10607
|
+
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
|
|
10608
|
+
let candidates = content.windows.filter { window in
|
|
10609
|
+
window.owningApplication?.bundleIdentifier == "com.Roblox.RobloxStudio" &&
|
|
10610
|
+
window.windowLayer == 0 && window.isOnScreen &&
|
|
10611
|
+
window.frame.width > 0 && window.frame.height > 0 &&
|
|
10612
|
+
matchesStudioTitle(window.title ?? "", hint: hint)
|
|
10613
|
+
}
|
|
10614
|
+
guard candidates.count == 1, let window = candidates.first, let app = window.owningApplication else {
|
|
10615
|
+
try fail(candidates.isEmpty ? "no visible Roblox Studio window matches the requested place title" : "multiple Roblox Studio windows match the requested place title; the capture is ambiguous")
|
|
10616
|
+
}
|
|
10617
|
+
if let expected = env["MCP_CAPTURE_EXPECTED_IDENTITY"], !expected.isEmpty {
|
|
10618
|
+
guard let data = expected.data(using: .utf8),
|
|
10619
|
+
let identity = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
10620
|
+
(identity["windowId"] as? NSNumber)?.uint32Value == window.windowID,
|
|
10621
|
+
(identity["processId"] as? NSNumber)?.int32Value == app.processID,
|
|
10622
|
+
identity["bundleIdentifier"] as? String == app.bundleIdentifier else {
|
|
10623
|
+
try fail("the selected Roblox Studio window identity changed between captures")
|
|
10624
|
+
}
|
|
10625
|
+
}
|
|
10626
|
+
let filter = SCContentFilter(desktopIndependentWindow: window)
|
|
10627
|
+
let scale = CGFloat(filter.pointPixelScale)
|
|
10628
|
+
let pixelWidth = filter.contentRect.width * scale
|
|
10629
|
+
let pixelHeight = filter.contentRect.height * scale
|
|
10630
|
+
guard pixelWidth.isFinite, pixelHeight.isFinite, pixelWidth > 0, pixelHeight > 0,
|
|
10631
|
+
pixelWidth <= 16384, pixelHeight <= 16384 else { try fail("invalid Studio window capture dimensions") }
|
|
10632
|
+
let width = Int(pixelWidth.rounded(.up))
|
|
10633
|
+
let height = Int(pixelHeight.rounded(.up))
|
|
10634
|
+
guard width * height * 4 <= 134217728 else { try fail("Studio window capture exceeds the image byte limit") }
|
|
10635
|
+
let config = SCStreamConfiguration()
|
|
10636
|
+
config.width = width
|
|
10637
|
+
config.height = height
|
|
10638
|
+
config.showsCursor = false
|
|
10639
|
+
config.ignoreShadowsSingleWindow = true
|
|
10640
|
+
config.ignoreGlobalClipSingleWindow = true
|
|
10641
|
+
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
|
|
10642
|
+
guard image.width == width, image.height == height else { try fail("Studio window capture returned unexpected dimensions") }
|
|
10643
|
+
var bytes = [UInt8](repeating: 0, count: width * height * 4)
|
|
10644
|
+
try bytes.withUnsafeMutableBytes { raw in
|
|
10645
|
+
guard let context = CGContext(data: raw.baseAddress, width: width, height: height,
|
|
10646
|
+
bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
|
10647
|
+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) else {
|
|
10648
|
+
try fail("could not allocate the RGBA capture context")
|
|
10649
|
+
}
|
|
10650
|
+
// Quartz draws the image upright into row-major top-to-bottom bitmap storage.
|
|
10651
|
+
context.setBlendMode(.copy)
|
|
10652
|
+
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
10653
|
+
}
|
|
10654
|
+
guard let output = env["MCP_CAPTURE_OUT"] else { try fail("missing capture output path") }
|
|
10655
|
+
try Data(bytes).write(to: URL(fileURLWithPath: output), options: .atomic)
|
|
10656
|
+
emit(["ok": true, "width": width, "height": height, "title": String((window.title ?? "").prefix(2048)),
|
|
10657
|
+
"identity": ["windowId": window.windowID, "processId": app.processID, "bundleIdentifier": app.bundleIdentifier]])
|
|
10658
|
+
}
|
|
10659
|
+
|
|
10660
|
+
@main struct Main {
|
|
10661
|
+
@MainActor
|
|
10662
|
+
static func main() async {
|
|
10663
|
+
do {
|
|
10664
|
+
if #available(macOS 14.0, *) { try await capture() }
|
|
10665
|
+
else { try fail("macOS host window capture requires macOS 14 or newer") }
|
|
10666
|
+
} catch {
|
|
10667
|
+
emit(["ok": false, "error": String(((error as? CaptureFailure)?.message ?? error.localizedDescription).prefix(2048))])
|
|
10668
|
+
}
|
|
10669
|
+
}
|
|
10670
|
+
}
|
|
10671
|
+
`;
|
|
10672
|
+
function runBoundedCaptureProcess(executable, args, timeoutMs, env = process.env) {
|
|
10673
|
+
return new Promise((resolve6, reject) => {
|
|
10674
|
+
const processGroup = process.platform !== "win32";
|
|
10675
|
+
const child = spawn2(executable, args, { env, stdio: ["ignore", "pipe", "pipe"], detached: processGroup });
|
|
10676
|
+
let stdout = "";
|
|
10677
|
+
let stderr = "";
|
|
10678
|
+
let outputBytes = 0;
|
|
10679
|
+
let failure;
|
|
10680
|
+
const stop = (message) => {
|
|
10681
|
+
failure ??= new Error(message);
|
|
10682
|
+
try {
|
|
10683
|
+
if (processGroup && child.pid)
|
|
10684
|
+
process.kill(-child.pid, "SIGKILL");
|
|
10685
|
+
else
|
|
10686
|
+
child.kill("SIGKILL");
|
|
10687
|
+
} catch {
|
|
10688
|
+
child.kill("SIGKILL");
|
|
10689
|
+
}
|
|
10690
|
+
};
|
|
10691
|
+
const timer = setTimeout(() => stop(`macOS host capture helper timed out after ${timeoutMs}ms`), timeoutMs);
|
|
10692
|
+
child.stdout.setEncoding("utf8");
|
|
10693
|
+
child.stderr.setEncoding("utf8");
|
|
10694
|
+
const collect = (chunk, isError) => {
|
|
10695
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
10696
|
+
if (outputBytes > MAX_OUTPUT_BYTES) {
|
|
10697
|
+
stop("macOS host capture helper exceeded its output byte limit");
|
|
10698
|
+
return;
|
|
10699
|
+
}
|
|
10700
|
+
if (isError)
|
|
10701
|
+
stderr += chunk;
|
|
10702
|
+
else
|
|
10703
|
+
stdout += chunk;
|
|
10704
|
+
};
|
|
10705
|
+
child.stdout.on("data", (chunk) => collect(chunk, false));
|
|
10706
|
+
child.stderr.on("data", (chunk) => collect(chunk, true));
|
|
10707
|
+
child.on("error", (error) => {
|
|
10708
|
+
failure ??= error;
|
|
10709
|
+
});
|
|
10710
|
+
child.on("close", (code) => {
|
|
10711
|
+
clearTimeout(timer);
|
|
10712
|
+
if (failure)
|
|
10713
|
+
reject(failure);
|
|
10714
|
+
else if (code !== 0)
|
|
10715
|
+
reject(new Error(`macOS host capture helper exited with code ${code}: ${stderr.trim().slice(0, 600)}`));
|
|
10716
|
+
else
|
|
10717
|
+
resolve6(stdout);
|
|
10718
|
+
});
|
|
10719
|
+
});
|
|
10720
|
+
}
|
|
10721
|
+
var MISSING_SWIFT_TOOLCHAIN = "macOS host capture requires Xcode or the Xcode Command Line Tools (xcrun swiftc). Install them with `xcode-select --install`, then retry the capture.";
|
|
10722
|
+
async function assertSwiftToolchain(env = process.env, selectedDeveloperDir = () => runBoundedCaptureProcess("/usr/bin/xcode-select", ["-p"], 5e3, env), exists = fs3.existsSync) {
|
|
10723
|
+
let developerDir = env.DEVELOPER_DIR?.trim();
|
|
10724
|
+
if (!developerDir) {
|
|
10725
|
+
try {
|
|
10726
|
+
developerDir = (await selectedDeveloperDir()).trim();
|
|
10727
|
+
} catch {
|
|
10728
|
+
throw new Error(MISSING_SWIFT_TOOLCHAIN);
|
|
10729
|
+
}
|
|
10730
|
+
}
|
|
10731
|
+
const compilers = [
|
|
10732
|
+
path6.join(developerDir, "usr", "bin", "swiftc"),
|
|
10733
|
+
path6.join(developerDir, "Toolchains", "XcodeDefault.xctoolchain", "usr", "bin", "swiftc")
|
|
10734
|
+
];
|
|
10735
|
+
if (!path6.isAbsolute(developerDir) || !compilers.some((compiler) => exists(compiler))) {
|
|
10736
|
+
throw new Error(MISSING_SWIFT_TOOLCHAIN);
|
|
10737
|
+
}
|
|
10738
|
+
}
|
|
10739
|
+
var compiledHelper;
|
|
10740
|
+
async function getHelper() {
|
|
10741
|
+
if (!compiledHelper) {
|
|
10742
|
+
compiledHelper = (async () => {
|
|
10743
|
+
await assertSwiftToolchain();
|
|
10744
|
+
const directory = fs3.mkdtempSync(path6.join(os3.tmpdir(), "robloxstudio-mcp-macos-helper-"));
|
|
10745
|
+
const cleanup = () => fs3.rmSync(directory, { recursive: true, force: true });
|
|
10746
|
+
try {
|
|
10747
|
+
const source = path6.join(directory, "capture.swift");
|
|
10748
|
+
const executable = path6.join(directory, "capture");
|
|
10749
|
+
fs3.writeFileSync(source, MACOS_CAPTURE_SOURCE, { mode: 384 });
|
|
10750
|
+
await runBoundedCaptureProcess("/usr/bin/xcrun", [
|
|
10751
|
+
"swiftc",
|
|
10752
|
+
"-parse-as-library",
|
|
10753
|
+
source,
|
|
10754
|
+
"-o",
|
|
10755
|
+
executable,
|
|
10756
|
+
"-module-cache-path",
|
|
10757
|
+
path6.join(directory, "modules")
|
|
10758
|
+
], 6e4);
|
|
10759
|
+
process.once("exit", cleanup);
|
|
10760
|
+
return executable;
|
|
10761
|
+
} catch (error) {
|
|
10762
|
+
cleanup();
|
|
10763
|
+
throw error;
|
|
10764
|
+
}
|
|
10765
|
+
})();
|
|
10766
|
+
compiledHelper.catch(() => {
|
|
10767
|
+
compiledHelper = void 0;
|
|
10768
|
+
});
|
|
10769
|
+
}
|
|
10770
|
+
return compiledHelper;
|
|
10771
|
+
}
|
|
10772
|
+
async function prepareMacHostCapture() {
|
|
10773
|
+
await getHelper();
|
|
10774
|
+
}
|
|
10775
|
+
function validIdentity(value) {
|
|
10776
|
+
if (!value || typeof value !== "object")
|
|
10777
|
+
return false;
|
|
10778
|
+
const id = value;
|
|
10779
|
+
return Number.isSafeInteger(id.windowId) && id.windowId > 0 && id.windowId <= 4294967295 && Number.isSafeInteger(id.processId) && id.processId > 0 && id.processId <= 2147483647 && id.bundleIdentifier === STUDIO_BUNDLE_ID;
|
|
10780
|
+
}
|
|
10781
|
+
function parseMacCaptureReport(stdout, expectedIdentity) {
|
|
10782
|
+
if (Buffer.byteLength(stdout) > MAX_OUTPUT_BYTES)
|
|
10783
|
+
throw new Error("macOS capture report exceeds its byte limit");
|
|
10784
|
+
const value = JSON.parse(stdout.trim());
|
|
10785
|
+
if (!value || typeof value !== "object")
|
|
10786
|
+
throw new Error("invalid macOS capture report");
|
|
10787
|
+
if (value.ok === false && typeof value.error === "string")
|
|
10788
|
+
return { ok: false, error: value.error.slice(0, 2048) };
|
|
10789
|
+
const { width, height, title, identity } = value;
|
|
10790
|
+
if (value.ok !== true || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 || width > 16384 || height > 16384 || width * height * 4 > MAX_IMAGE_BYTES || typeof title !== "string" || title.length > 2048 || !validIdentity(identity)) {
|
|
10791
|
+
throw new Error("invalid macOS capture dimensions or window identity");
|
|
10792
|
+
}
|
|
10793
|
+
if (expectedIdentity && (identity.windowId !== expectedIdentity.windowId || identity.processId !== expectedIdentity.processId || identity.bundleIdentifier !== expectedIdentity.bundleIdentifier)) {
|
|
10794
|
+
throw new Error("the selected Roblox Studio window identity changed between captures");
|
|
10795
|
+
}
|
|
10796
|
+
return { ok: true, width, height, title, identity };
|
|
10797
|
+
}
|
|
10798
|
+
async function runMacCapture(output, titleHint, expectedIdentity) {
|
|
10799
|
+
const executable = await getHelper();
|
|
10800
|
+
return runBoundedCaptureProcess(executable, [], 2e4, {
|
|
10801
|
+
...process.env,
|
|
10802
|
+
MCP_CAPTURE_TITLE_HINT: titleHint ?? "",
|
|
10803
|
+
MCP_CAPTURE_OUT: output,
|
|
10804
|
+
MCP_CAPTURE_EXPECTED_IDENTITY: expectedIdentity ? JSON.stringify(expectedIdentity) : ""
|
|
10805
|
+
});
|
|
10806
|
+
}
|
|
10807
|
+
async function captureMacStudioWindow(titleHint, expectedIdentity, runCapture = runMacCapture) {
|
|
10808
|
+
const directory = fs3.mkdtempSync(path6.join(os3.tmpdir(), "robloxstudio-mcp-macos-capture-"));
|
|
10809
|
+
try {
|
|
10810
|
+
const output = path6.join(directory, "capture.rgba");
|
|
10811
|
+
const stdout = await runCapture(output, titleHint, expectedIdentity);
|
|
10812
|
+
const report = parseMacCaptureReport(stdout, expectedIdentity);
|
|
10813
|
+
if (!report.ok)
|
|
10814
|
+
return report;
|
|
10815
|
+
const expectedBytes = report.width * report.height * 4;
|
|
10816
|
+
const stat2 = fs3.lstatSync(output);
|
|
10817
|
+
if (!stat2.isFile() || stat2.size !== expectedBytes)
|
|
10818
|
+
throw new Error("macOS capture returned an invalid RGBA byte count");
|
|
10819
|
+
const rgba = fs3.readFileSync(output);
|
|
10820
|
+
if (rgba.length !== expectedBytes)
|
|
10821
|
+
throw new Error("macOS capture RGBA byte count changed during read");
|
|
10822
|
+
return { ok: true, capture: { width: report.width, height: report.height, title: report.title, identity: report.identity, rgba } };
|
|
10823
|
+
} catch (error) {
|
|
10824
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
10825
|
+
} finally {
|
|
10826
|
+
fs3.rmSync(directory, { recursive: true, force: true });
|
|
10827
|
+
}
|
|
10828
|
+
}
|
|
10829
|
+
|
|
10830
|
+
// ../core/dist/host-capture.js
|
|
10831
|
+
var HOST_CAPTURE_TIMEOUT_MS = 2e4;
|
|
10832
|
+
var MARKER_MIN_RB = 200;
|
|
10833
|
+
var MARKER_MAX_G = 60;
|
|
10834
|
+
var MARKER_SCALE_TOLERANCE = 0.03;
|
|
10835
|
+
function isHostCaptureDisabled(env = process.env) {
|
|
10836
|
+
const value = (env.ROBLOX_STUDIO_HOST_CAPTURE ?? "").trim().toLowerCase();
|
|
10837
|
+
return value === "0" || value === "false" || value === "off";
|
|
10061
10838
|
}
|
|
10062
|
-
function
|
|
10063
|
-
|
|
10064
|
-
for (let i = 0; i < buf.length; i++)
|
|
10065
|
-
crc = CRC_TABLE[(crc ^ buf[i]) & 255] ^ crc >>> 8;
|
|
10066
|
-
return (crc ^ 4294967295) >>> 0;
|
|
10839
|
+
function isHostCaptureSupported(platform = process.platform) {
|
|
10840
|
+
return platform === "win32" || platform === "darwin";
|
|
10067
10841
|
}
|
|
10068
|
-
function
|
|
10069
|
-
|
|
10070
|
-
const length = Buffer.alloc(4);
|
|
10071
|
-
length.writeUInt32BE(data.length);
|
|
10072
|
-
const crcInput = Buffer.concat([typeBytes, data]);
|
|
10073
|
-
const checksum = Buffer.alloc(4);
|
|
10074
|
-
checksum.writeUInt32BE(crc32(crcInput));
|
|
10075
|
-
return Buffer.concat([length, typeBytes, data, checksum]);
|
|
10842
|
+
function hostCaptureUnsupportedReason(platform = process.platform) {
|
|
10843
|
+
return `host window capture is only implemented on Windows and macOS (this is ${platform})`;
|
|
10076
10844
|
}
|
|
10077
|
-
function
|
|
10078
|
-
if (
|
|
10079
|
-
|
|
10080
|
-
|
|
10081
|
-
|
|
10082
|
-
|
|
10083
|
-
|
|
10084
|
-
|
|
10845
|
+
async function prepareHostWindowCapture() {
|
|
10846
|
+
if (process.platform === "darwin" && !isHostCaptureDisabled())
|
|
10847
|
+
await prepareMacHostCapture();
|
|
10848
|
+
}
|
|
10849
|
+
function isUniformFrame(rgba, width, height) {
|
|
10850
|
+
const pixels = width * height;
|
|
10851
|
+
if (pixels <= 0 || rgba.length < pixels * 4)
|
|
10852
|
+
return false;
|
|
10853
|
+
const r = rgba[0];
|
|
10854
|
+
const g = rgba[1];
|
|
10855
|
+
const b = rgba[2];
|
|
10856
|
+
for (let i = 0; i < pixels; i++) {
|
|
10857
|
+
const o = i * 4;
|
|
10858
|
+
if (rgba[o] !== r || rgba[o + 1] !== g || rgba[o + 2] !== b)
|
|
10859
|
+
return false;
|
|
10860
|
+
}
|
|
10861
|
+
return true;
|
|
10862
|
+
}
|
|
10863
|
+
function isMarkerPixel(rgba, offset) {
|
|
10864
|
+
return rgba[offset] >= MARKER_MIN_RB && rgba[offset + 1] <= MARKER_MAX_G && rgba[offset + 2] >= MARKER_MIN_RB;
|
|
10865
|
+
}
|
|
10866
|
+
function findViewportRect(rgba, width, height, hint) {
|
|
10867
|
+
let minX = width;
|
|
10868
|
+
let minY = height;
|
|
10869
|
+
let maxX = -1;
|
|
10870
|
+
let maxY = -1;
|
|
10085
10871
|
for (let y = 0; y < height; y++) {
|
|
10086
|
-
|
|
10087
|
-
|
|
10872
|
+
const row = y * width * 4;
|
|
10873
|
+
for (let x = 0; x < width; x++) {
|
|
10874
|
+
if (!isMarkerPixel(rgba, row + x * 4))
|
|
10875
|
+
continue;
|
|
10876
|
+
if (x < minX)
|
|
10877
|
+
minX = x;
|
|
10878
|
+
if (x > maxX)
|
|
10879
|
+
maxX = x;
|
|
10880
|
+
if (y < minY)
|
|
10881
|
+
minY = y;
|
|
10882
|
+
if (y > maxY)
|
|
10883
|
+
maxY = y;
|
|
10884
|
+
}
|
|
10885
|
+
}
|
|
10886
|
+
if (maxX < 0) {
|
|
10887
|
+
return { error: "no viewport markers were visible in the Studio window capture" };
|
|
10888
|
+
}
|
|
10889
|
+
const rect = { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
|
10890
|
+
const inset = Math.max(1, Math.floor(hint.markerSize / 4));
|
|
10891
|
+
const corners = [
|
|
10892
|
+
[minX + inset, minY + inset],
|
|
10893
|
+
[maxX - inset, minY + inset],
|
|
10894
|
+
[minX + inset, maxY - inset],
|
|
10895
|
+
[maxX - inset, maxY - inset]
|
|
10896
|
+
];
|
|
10897
|
+
for (const [x, y] of corners) {
|
|
10898
|
+
if (!isMarkerPixel(rgba, (y * width + x) * 4)) {
|
|
10899
|
+
return { error: `viewport markers were found but do not form a rectangle (box ${rect.width}x${rect.height} at ${rect.x},${rect.y})` };
|
|
10900
|
+
}
|
|
10901
|
+
}
|
|
10902
|
+
const scaleX = rect.width / hint.viewportWidth;
|
|
10903
|
+
const scaleY = rect.height / hint.viewportHeight;
|
|
10904
|
+
if (Math.abs(scaleX - scaleY) > MARKER_SCALE_TOLERANCE * Math.max(scaleX, scaleY)) {
|
|
10905
|
+
return {
|
|
10906
|
+
error: `viewport marker box ${rect.width}x${rect.height} does not match the reported ${hint.viewportWidth}x${hint.viewportHeight} viewport aspect`
|
|
10907
|
+
};
|
|
10908
|
+
}
|
|
10909
|
+
return { rect };
|
|
10910
|
+
}
|
|
10911
|
+
function cropToViewport(rgba, width, height, rect, targetWidth, targetHeight) {
|
|
10912
|
+
const x0 = Math.max(0, Math.min(width - 1, rect.x));
|
|
10913
|
+
const y0 = Math.max(0, Math.min(height - 1, rect.y));
|
|
10914
|
+
const srcW = Math.max(1, Math.min(rect.width, width - x0));
|
|
10915
|
+
const srcH = Math.max(1, Math.min(rect.height, height - y0));
|
|
10916
|
+
const exact = srcW === targetWidth && srcH === targetHeight;
|
|
10917
|
+
const outW = targetWidth;
|
|
10918
|
+
const outH = targetHeight;
|
|
10919
|
+
const out = Buffer.alloc(outW * outH * 4);
|
|
10920
|
+
if (exact) {
|
|
10921
|
+
for (let y = 0; y < outH; y++) {
|
|
10922
|
+
rgba.copy(out, y * outW * 4, ((y0 + y) * width + x0) * 4, ((y0 + y) * width + x0 + outW) * 4);
|
|
10923
|
+
}
|
|
10924
|
+
return { width: outW, height: outH, rgba: out };
|
|
10925
|
+
}
|
|
10926
|
+
const sx = srcW / outW;
|
|
10927
|
+
const sy = srcH / outH;
|
|
10928
|
+
for (let y = 0; y < outH; y++) {
|
|
10929
|
+
const fy = Math.min(srcH - 1, (y + 0.5) * sy - 0.5);
|
|
10930
|
+
const iy = Math.max(0, Math.floor(fy));
|
|
10931
|
+
const ny = Math.min(srcH - 1, iy + 1);
|
|
10932
|
+
const wy = Math.max(0, fy - iy);
|
|
10933
|
+
for (let x = 0; x < outW; x++) {
|
|
10934
|
+
const fx = Math.min(srcW - 1, (x + 0.5) * sx - 0.5);
|
|
10935
|
+
const ix = Math.max(0, Math.floor(fx));
|
|
10936
|
+
const nx = Math.min(srcW - 1, ix + 1);
|
|
10937
|
+
const wx = Math.max(0, fx - ix);
|
|
10938
|
+
const o00 = ((y0 + iy) * width + x0 + ix) * 4;
|
|
10939
|
+
const o10 = ((y0 + iy) * width + x0 + nx) * 4;
|
|
10940
|
+
const o01 = ((y0 + ny) * width + x0 + ix) * 4;
|
|
10941
|
+
const o11 = ((y0 + ny) * width + x0 + nx) * 4;
|
|
10942
|
+
const dst = (y * outW + x) * 4;
|
|
10943
|
+
for (let c = 0; c < 4; c++) {
|
|
10944
|
+
const top = rgba[o00 + c] * (1 - wx) + rgba[o10 + c] * wx;
|
|
10945
|
+
const bottom = rgba[o01 + c] * (1 - wx) + rgba[o11 + c] * wx;
|
|
10946
|
+
out[dst + c] = Math.round(top * (1 - wy) + bottom * wy);
|
|
10947
|
+
}
|
|
10948
|
+
}
|
|
10949
|
+
}
|
|
10950
|
+
return { width: outW, height: outH, rgba: out };
|
|
10951
|
+
}
|
|
10952
|
+
var WINDOWS_CAPTURE_SCRIPT = String.raw`
|
|
10953
|
+
$ErrorActionPreference = 'Stop'
|
|
10954
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
10955
|
+
Add-Type -AssemblyName System.Drawing
|
|
10956
|
+
Add-Type -TypeDefinition @"
|
|
10957
|
+
using System;
|
|
10958
|
+
using System.Collections.Generic;
|
|
10959
|
+
using System.Runtime.InteropServices;
|
|
10960
|
+
using System.Text;
|
|
10961
|
+
public static class McpStudioCapture {
|
|
10962
|
+
[DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);
|
|
10963
|
+
[DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr hWnd);
|
|
10964
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
|
|
10965
|
+
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
|
10966
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowTextW(IntPtr hWnd, StringBuilder text, int count);
|
|
10967
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowTextLengthW(IntPtr hWnd);
|
|
10968
|
+
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr hWnd, out RECT rect);
|
|
10969
|
+
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdc, uint flags);
|
|
10970
|
+
delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
|
10971
|
+
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
|
10972
|
+
public class Candidate { public IntPtr Handle; public uint Pid; public string Title; }
|
|
10973
|
+
public static List<Candidate> Find(string processName) {
|
|
10974
|
+
var found = new List<Candidate>();
|
|
10975
|
+
EnumWindows((hWnd, lParam) => {
|
|
10976
|
+
if (!IsWindowVisible(hWnd)) return true;
|
|
10977
|
+
int length = GetWindowTextLengthW(hWnd);
|
|
10978
|
+
if (length <= 0) return true;
|
|
10979
|
+
var sb = new StringBuilder(length + 1);
|
|
10980
|
+
GetWindowTextW(hWnd, sb, sb.Capacity);
|
|
10981
|
+
string title = sb.ToString();
|
|
10982
|
+
if (!title.EndsWith("Roblox Studio", StringComparison.Ordinal)) return true;
|
|
10983
|
+
uint pid;
|
|
10984
|
+
GetWindowThreadProcessId(hWnd, out pid);
|
|
10985
|
+
try {
|
|
10986
|
+
var proc = System.Diagnostics.Process.GetProcessById((int)pid);
|
|
10987
|
+
if (!string.Equals(proc.ProcessName, processName, StringComparison.OrdinalIgnoreCase)) return true;
|
|
10988
|
+
} catch { return true; }
|
|
10989
|
+
found.Add(new Candidate { Handle = hWnd, Pid = pid, Title = title });
|
|
10990
|
+
return true;
|
|
10991
|
+
}, IntPtr.Zero);
|
|
10992
|
+
return found;
|
|
10993
|
+
}
|
|
10994
|
+
}
|
|
10995
|
+
"@
|
|
10996
|
+
function Emit($obj) { Write-Output ($obj | ConvertTo-Json -Compress) }
|
|
10997
|
+
# Local files show their absolute path in the window title while Studio reports
|
|
10998
|
+
# only the basename as DataModel.Name. Match that basename after removing
|
|
10999
|
+
# Studio's exact title suffix; the ambiguity checks below still apply.
|
|
11000
|
+
function Test-StudioTitle([string]$title, [string]$hint) {
|
|
11001
|
+
if (-not $hint -or $title.StartsWith($hint, [StringComparison]::Ordinal)) { return $true }
|
|
11002
|
+
$suffix = ' - Roblox Studio'
|
|
11003
|
+
if (-not $title.EndsWith($suffix, [StringComparison]::Ordinal)) { return $false }
|
|
11004
|
+
$place = $title.Substring(0, $title.Length - $suffix.Length)
|
|
11005
|
+
if ($place -cnotmatch '^(?:[A-Za-z]:[\\/]|\\\\|/)') { return $false }
|
|
11006
|
+
return $place.Substring($place.LastIndexOfAny([char[]]@([char]'\', [char]'/')) + 1) -ceq $hint
|
|
11007
|
+
}
|
|
11008
|
+
$hint = $env:MCP_CAPTURE_TITLE_HINT
|
|
11009
|
+
$outFile = $env:MCP_CAPTURE_OUT
|
|
11010
|
+
$candidates = [McpStudioCapture]::Find('RobloxStudioBeta')
|
|
11011
|
+
if ($candidates.Count -eq 0) { Emit @{ ok = $false; error = 'no visible Roblox Studio window was found' }; exit 0 }
|
|
11012
|
+
$matching = @($candidates | Where-Object { Test-StudioTitle $_.Title $hint })
|
|
11013
|
+
$expectedJson = $env:MCP_CAPTURE_EXPECTED_IDENTITY
|
|
11014
|
+
if ($expectedJson) {
|
|
11015
|
+
try { $expected = $expectedJson | ConvertFrom-Json -ErrorAction Stop }
|
|
11016
|
+
catch { Emit @{ ok = $false; error = 'invalid expected Studio window identity' }; exit 0 }
|
|
11017
|
+
$matching = @($matching | Where-Object {
|
|
11018
|
+
$_.Handle.ToInt64() -eq $expected.windowId -and $_.Pid -eq $expected.processId -and
|
|
11019
|
+
$expected.bundleIdentifier -ceq 'RobloxStudioBeta'
|
|
11020
|
+
})
|
|
11021
|
+
if ($matching.Count -ne 1) {
|
|
11022
|
+
Emit @{ ok = $false; error = 'the selected Roblox Studio window identity changed between captures' }; exit 0
|
|
11023
|
+
}
|
|
11024
|
+
} elseif ($matching.Count -ne 1) {
|
|
11025
|
+
$reason = if ($matching.Count -eq 0) { 'no visible Roblox Studio window matches the requested place title' }
|
|
11026
|
+
else { 'multiple Roblox Studio windows match the requested place title; the capture is ambiguous' }
|
|
11027
|
+
Emit @{ ok = $false; error = $reason }; exit 0
|
|
11028
|
+
}
|
|
11029
|
+
$pick = $matching[0]
|
|
11030
|
+
$identity = @{ windowId = $pick.Handle.ToInt64(); processId = [long]$pick.Pid; bundleIdentifier = 'RobloxStudioBeta' }
|
|
11031
|
+
if ([McpStudioCapture]::IsIconic($pick.Handle)) {
|
|
11032
|
+
Emit @{ ok = $false; error = "the Studio window '$($pick.Title)' is minimized; restore it (it may stay behind other windows)" }; exit 0
|
|
11033
|
+
}
|
|
11034
|
+
$rect = New-Object McpStudioCapture+RECT
|
|
11035
|
+
[void][McpStudioCapture]::GetClientRect($pick.Handle, [ref]$rect)
|
|
11036
|
+
$w = $rect.Right - $rect.Left; $h = $rect.Bottom - $rect.Top
|
|
11037
|
+
if ($w -le 0 -or $h -le 0) { Emit @{ ok = $false; error = "the Studio window client area is empty ($w x $h)" }; exit 0 }
|
|
11038
|
+
$bmp = New-Object System.Drawing.Bitmap $w, $h, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
|
11039
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
11040
|
+
$hdc = $g.GetHdc()
|
|
11041
|
+
# PW_CLIENTONLY (1) | PW_RENDERFULLCONTENT (2): DWM-composited client area, occluded windows included.
|
|
11042
|
+
$printed = [McpStudioCapture]::PrintWindow($pick.Handle, $hdc, 3)
|
|
11043
|
+
$g.ReleaseHdc($hdc); $g.Dispose()
|
|
11044
|
+
if (-not $printed) { $bmp.Dispose(); Emit @{ ok = $false; error = 'PrintWindow failed for the Studio window' }; exit 0 }
|
|
11045
|
+
$bounds = New-Object System.Drawing.Rectangle 0, 0, $w, $h
|
|
11046
|
+
$data = $bmp.LockBits($bounds, [System.Drawing.Imaging.ImageLockMode]::ReadOnly, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
|
11047
|
+
$bytes = New-Object byte[] ($data.Stride * $h)
|
|
11048
|
+
[System.Runtime.InteropServices.Marshal]::Copy($data.Scan0, $bytes, 0, $bytes.Length)
|
|
11049
|
+
$stride = $data.Stride
|
|
11050
|
+
$bmp.UnlockBits($data); $bmp.Dispose()
|
|
11051
|
+
[System.IO.File]::WriteAllBytes($outFile, $bytes)
|
|
11052
|
+
Emit @{ ok = $true; width = $w; height = $h; stride = $stride; title = $pick.Title; identity = $identity }
|
|
11053
|
+
`;
|
|
11054
|
+
function validWindowsIdentity(value) {
|
|
11055
|
+
return value !== null && typeof value === "object" && "windowId" in value && typeof value.windowId === "number" && Number.isSafeInteger(value.windowId) && value.windowId > 0 && "processId" in value && typeof value.processId === "number" && Number.isSafeInteger(value.processId) && value.processId > 0 && value.processId <= 4294967295 && "bundleIdentifier" in value && value.bundleIdentifier === "RobloxStudioBeta";
|
|
11056
|
+
}
|
|
11057
|
+
function parseWindowsCaptureReport(value, expectedIdentity) {
|
|
11058
|
+
if (value === null || typeof value !== "object" || !("ok" in value))
|
|
11059
|
+
throw new Error("invalid Windows capture report");
|
|
11060
|
+
if (value.ok === false && "error" in value && typeof value.error === "string")
|
|
11061
|
+
return { ok: false, error: value.error };
|
|
11062
|
+
if (!("identity" in value) || !validWindowsIdentity(value.identity))
|
|
11063
|
+
throw new Error("invalid Windows capture window identity");
|
|
11064
|
+
const identity = value.identity;
|
|
11065
|
+
if (expectedIdentity && (identity.windowId !== expectedIdentity.windowId || identity.processId !== expectedIdentity.processId || identity.bundleIdentifier !== expectedIdentity.bundleIdentifier)) {
|
|
11066
|
+
throw new Error("the selected Roblox Studio window identity changed between captures");
|
|
11067
|
+
}
|
|
11068
|
+
if (value.ok !== true || !("width" in value) || typeof value.width !== "number" || !Number.isSafeInteger(value.width) || value.width <= 0 || !("height" in value) || typeof value.height !== "number" || !Number.isSafeInteger(value.height) || value.height <= 0 || !("stride" in value) || typeof value.stride !== "number" || !Number.isSafeInteger(value.stride) || value.stride < value.width * 4 || value.width > 16384 || value.height > 16384 || value.stride * value.height > 128 * 1024 * 1024 || !("title" in value) || typeof value.title !== "string") {
|
|
11069
|
+
throw new Error("invalid Windows capture dimensions or title");
|
|
11070
|
+
}
|
|
11071
|
+
return { ok: true, width: value.width, height: value.height, stride: value.stride, title: value.title, identity };
|
|
11072
|
+
}
|
|
11073
|
+
function powershellPath() {
|
|
11074
|
+
const systemRoot = process.env.SystemRoot ?? process.env.windir;
|
|
11075
|
+
if (systemRoot) {
|
|
11076
|
+
const candidate = path7.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
11077
|
+
if (fs4.existsSync(candidate))
|
|
11078
|
+
return candidate;
|
|
11079
|
+
}
|
|
11080
|
+
return "powershell.exe";
|
|
11081
|
+
}
|
|
11082
|
+
function runWindowsCapture(titleHint, outFile, expectedIdentity) {
|
|
11083
|
+
const { promise, resolve: resolve6, reject } = Promise.withResolvers();
|
|
11084
|
+
const encoded = Buffer.from(WINDOWS_CAPTURE_SCRIPT, "utf16le").toString("base64");
|
|
11085
|
+
const child = spawn3(powershellPath(), ["-NoProfile", "-NonInteractive", "-NoLogo", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], {
|
|
11086
|
+
env: {
|
|
11087
|
+
...process.env,
|
|
11088
|
+
MCP_CAPTURE_TITLE_HINT: titleHint ?? "",
|
|
11089
|
+
MCP_CAPTURE_OUT: outFile,
|
|
11090
|
+
MCP_CAPTURE_EXPECTED_IDENTITY: expectedIdentity ? JSON.stringify(expectedIdentity) : ""
|
|
11091
|
+
},
|
|
11092
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
11093
|
+
windowsHide: true
|
|
11094
|
+
});
|
|
11095
|
+
let stdout = "";
|
|
11096
|
+
let stderr = "";
|
|
11097
|
+
let settled = false;
|
|
11098
|
+
const timer = setTimeout(() => {
|
|
11099
|
+
if (settled)
|
|
11100
|
+
return;
|
|
11101
|
+
settled = true;
|
|
11102
|
+
child.kill();
|
|
11103
|
+
reject(new Error(`host window capture timed out after ${HOST_CAPTURE_TIMEOUT_MS}ms`));
|
|
11104
|
+
}, HOST_CAPTURE_TIMEOUT_MS);
|
|
11105
|
+
child.stdout.setEncoding("utf8");
|
|
11106
|
+
child.stderr.setEncoding("utf8");
|
|
11107
|
+
child.stdout.on("data", (chunk) => {
|
|
11108
|
+
stdout += chunk;
|
|
11109
|
+
});
|
|
11110
|
+
child.stderr.on("data", (chunk) => {
|
|
11111
|
+
stderr += chunk;
|
|
11112
|
+
});
|
|
11113
|
+
child.on("error", (error) => {
|
|
11114
|
+
if (settled)
|
|
11115
|
+
return;
|
|
11116
|
+
settled = true;
|
|
11117
|
+
clearTimeout(timer);
|
|
11118
|
+
reject(new Error(`could not start PowerShell for host window capture: ${error.message}`));
|
|
11119
|
+
});
|
|
11120
|
+
child.on("close", (code) => {
|
|
11121
|
+
if (settled)
|
|
11122
|
+
return;
|
|
11123
|
+
settled = true;
|
|
11124
|
+
clearTimeout(timer);
|
|
11125
|
+
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
11126
|
+
const last = lines[lines.length - 1];
|
|
11127
|
+
if (last && last.startsWith("{")) {
|
|
11128
|
+
try {
|
|
11129
|
+
resolve6(JSON.parse(last));
|
|
11130
|
+
return;
|
|
11131
|
+
} catch {
|
|
11132
|
+
}
|
|
11133
|
+
}
|
|
11134
|
+
const detail = (stderr.trim() || stdout.trim()).slice(0, 600);
|
|
11135
|
+
reject(new Error(`host window capture helper exited with code ${code}${detail ? `: ${detail}` : ""}`));
|
|
11136
|
+
});
|
|
11137
|
+
return promise;
|
|
11138
|
+
}
|
|
11139
|
+
async function captureStudioWindow(titleHint, expectedIdentity) {
|
|
11140
|
+
if (isHostCaptureDisabled()) {
|
|
11141
|
+
return { ok: false, error: "host window capture is disabled by ROBLOX_STUDIO_HOST_CAPTURE" };
|
|
11142
|
+
}
|
|
11143
|
+
if (!isHostCaptureSupported()) {
|
|
11144
|
+
return { ok: false, error: hostCaptureUnsupportedReason() };
|
|
11145
|
+
}
|
|
11146
|
+
if (process.platform === "darwin")
|
|
11147
|
+
return captureMacStudioWindow(titleHint, expectedIdentity);
|
|
11148
|
+
const outFile = path7.join(os4.tmpdir(), `robloxstudio-mcp-capture-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.bgra`);
|
|
11149
|
+
try {
|
|
11150
|
+
const report = parseWindowsCaptureReport(await runWindowsCapture(titleHint, outFile, expectedIdentity), expectedIdentity);
|
|
11151
|
+
if (!report.ok)
|
|
11152
|
+
return report;
|
|
11153
|
+
const raw = fs4.readFileSync(outFile);
|
|
11154
|
+
if (raw.length !== report.stride * report.height)
|
|
11155
|
+
throw new Error("Windows capture returned an invalid BGRA byte count");
|
|
11156
|
+
const { width, height, stride } = report;
|
|
11157
|
+
const rgba = Buffer.alloc(width * height * 4);
|
|
11158
|
+
for (let y = 0; y < height; y++) {
|
|
11159
|
+
const src = y * stride;
|
|
11160
|
+
const dst = y * width * 4;
|
|
11161
|
+
for (let x = 0; x < width; x++) {
|
|
11162
|
+
const s = src + x * 4;
|
|
11163
|
+
const d = dst + x * 4;
|
|
11164
|
+
rgba[d] = raw[s + 2];
|
|
11165
|
+
rgba[d + 1] = raw[s + 1];
|
|
11166
|
+
rgba[d + 2] = raw[s];
|
|
11167
|
+
rgba[d + 3] = 255;
|
|
11168
|
+
}
|
|
11169
|
+
}
|
|
11170
|
+
return { ok: true, capture: { width, height, rgba, title: report.title, identity: report.identity } };
|
|
11171
|
+
} catch (error) {
|
|
11172
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
11173
|
+
} finally {
|
|
11174
|
+
fs4.rmSync(outFile, { force: true });
|
|
10088
11175
|
}
|
|
10089
|
-
const ihdr = Buffer.alloc(13);
|
|
10090
|
-
ihdr.writeUInt32BE(width, 0);
|
|
10091
|
-
ihdr.writeUInt32BE(height, 4);
|
|
10092
|
-
ihdr[8] = 8;
|
|
10093
|
-
ihdr[9] = 6;
|
|
10094
|
-
ihdr[10] = 0;
|
|
10095
|
-
ihdr[11] = 0;
|
|
10096
|
-
ihdr[12] = 0;
|
|
10097
|
-
return Buffer.concat([
|
|
10098
|
-
PNG_SIGNATURE2,
|
|
10099
|
-
writeChunk("IHDR", ihdr),
|
|
10100
|
-
writeChunk("IDAT", deflateSync(filtered)),
|
|
10101
|
-
writeChunk("IEND", Buffer.alloc(0))
|
|
10102
|
-
]);
|
|
10103
11176
|
}
|
|
10104
11177
|
|
|
10105
11178
|
// ../core/dist/tools/index.js
|
|
10106
|
-
import * as
|
|
10107
|
-
import * as
|
|
11179
|
+
import * as fs5 from "fs";
|
|
11180
|
+
import * as path8 from "path";
|
|
11181
|
+
function sameHostWindowIdentity(actual, expected) {
|
|
11182
|
+
return actual?.windowId === expected.windowId && actual.processId === expected.processId && actual.bundleIdentifier === expected.bundleIdentifier;
|
|
11183
|
+
}
|
|
11184
|
+
var HOST_VIEWPORT_RECT_TTL_MS = 6e4;
|
|
10108
11185
|
var MAX_INLINE_IMAGE_BYTES = 6e6;
|
|
10109
11186
|
var MAX_MATRIX_IMAGE_BYTES = 8e6;
|
|
10110
11187
|
var DEFAULT_ASSET_AUDIO_PREVIEWS = 3;
|
|
@@ -10189,7 +11266,7 @@ function encodeImageFromRgbaResponse(response, format, quality) {
|
|
|
10189
11266
|
};
|
|
10190
11267
|
}
|
|
10191
11268
|
function sleep(ms) {
|
|
10192
|
-
return new Promise((
|
|
11269
|
+
return new Promise((resolve6) => setTimeout(resolve6, ms));
|
|
10193
11270
|
}
|
|
10194
11271
|
function errorMessage(error) {
|
|
10195
11272
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -10283,9 +11360,9 @@ function compactSoundReference(sound) {
|
|
|
10283
11360
|
name: stringField(sound, "name"),
|
|
10284
11361
|
className: stringField(sound, "className")
|
|
10285
11362
|
};
|
|
10286
|
-
const
|
|
10287
|
-
if (
|
|
10288
|
-
compact.path =
|
|
11363
|
+
const path9 = stringField(sound, "path");
|
|
11364
|
+
if (path9)
|
|
11365
|
+
compact.path = path9;
|
|
10289
11366
|
const assetId = robloxAssetIdFromContentId(sound.assetId ?? sound.soundId ?? sound.asset);
|
|
10290
11367
|
if (assetId !== void 0)
|
|
10291
11368
|
compact.assetId = assetId;
|
|
@@ -10348,8 +11425,8 @@ function loadMicroProfilerBaseline(source, sourcePath) {
|
|
|
10348
11425
|
if (typeof sourcePath !== "string" || sourcePath === "") {
|
|
10349
11426
|
throw new Error("baseline_path must be a non-empty string when provided");
|
|
10350
11427
|
}
|
|
10351
|
-
const resolved =
|
|
10352
|
-
const parsed = JSON.parse(
|
|
11428
|
+
const resolved = path8.resolve(sourcePath);
|
|
11429
|
+
const parsed = JSON.parse(fs5.readFileSync(resolved, "utf8"));
|
|
10353
11430
|
const record = asRecord(parsed);
|
|
10354
11431
|
if (!record)
|
|
10355
11432
|
throw new Error(`baseline_path did not contain a JSON object: ${resolved}`);
|
|
@@ -10851,13 +11928,17 @@ var RobloxStudioTools = class {
|
|
|
10851
11928
|
cookieClient;
|
|
10852
11929
|
instanceManager;
|
|
10853
11930
|
managedConnectionAssociations = Promise.resolve();
|
|
11931
|
+
unsubscribePeerRegistered;
|
|
11932
|
+
hostWindowCapture = captureStudioWindow;
|
|
11933
|
+
hostViewportRects = /* @__PURE__ */ new Map();
|
|
11934
|
+
viewportCaptureQueues = /* @__PURE__ */ new Map();
|
|
10854
11935
|
constructor(bridge) {
|
|
10855
11936
|
this.client = new StudioHttpClient(bridge);
|
|
10856
11937
|
this.bridge = bridge;
|
|
10857
11938
|
this.openCloudClient = new OpenCloudClient();
|
|
10858
11939
|
this.cookieClient = new RobloxCookieClient();
|
|
10859
11940
|
this.instanceManager = new StudioInstanceManager();
|
|
10860
|
-
this.bridge.onPeerRegistered((peer) => {
|
|
11941
|
+
this.unsubscribePeerRegistered = this.bridge.onPeerRegistered((peer) => {
|
|
10861
11942
|
const instanceManager = this.instanceManager;
|
|
10862
11943
|
const association = this.managedConnectionAssociations.then(() => this._associateManagedEditConnection(peer, instanceManager));
|
|
10863
11944
|
this.managedConnectionAssociations = association.catch((error) => {
|
|
@@ -10868,6 +11949,10 @@ var RobloxStudioTools = class {
|
|
|
10868
11949
|
getStudioLifecycleCapabilities() {
|
|
10869
11950
|
return this.instanceManager.getLifecycleCapabilities();
|
|
10870
11951
|
}
|
|
11952
|
+
async dispose() {
|
|
11953
|
+
this.unsubscribePeerRegistered();
|
|
11954
|
+
await this.managedConnectionAssociations;
|
|
11955
|
+
}
|
|
10871
11956
|
_textResult(body) {
|
|
10872
11957
|
return { content: [{ type: "text", text: JSON.stringify(body) }] };
|
|
10873
11958
|
}
|
|
@@ -10991,8 +12076,8 @@ var RobloxStudioTools = class {
|
|
|
10991
12076
|
}
|
|
10992
12077
|
return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal, operationId);
|
|
10993
12078
|
}
|
|
10994
|
-
//
|
|
10995
|
-
//
|
|
12079
|
+
// Honor an explicitly selected client for viewport/input operations; otherwise
|
|
12080
|
+
// prefer the first client in the scope, then retain the default Peer's Instance.
|
|
10996
12081
|
_resolveRuntime(instance_id) {
|
|
10997
12082
|
const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
|
|
10998
12083
|
if (!resolved.ok)
|
|
@@ -11004,7 +12089,9 @@ var RobloxStudioTools = class {
|
|
|
11004
12089
|
data: this._routingErrorData()
|
|
11005
12090
|
});
|
|
11006
12091
|
}
|
|
11007
|
-
const
|
|
12092
|
+
const clients = this.bridge.getPeersInScope(resolved.targetInstanceId).filter((peer) => /^client-\d+$/.test(peer.role));
|
|
12093
|
+
const selectedClients = clients.filter((peer) => instance_id === peer.instanceId || instance_id === `${peer.instanceId}-${peer.role}`);
|
|
12094
|
+
const client = selectedClients.length === 1 ? selectedClients[0] : clients.sort((a, b) => a.role.localeCompare(b.role) || a.peerId.localeCompare(b.peerId))[0];
|
|
11008
12095
|
return {
|
|
11009
12096
|
instanceId: client?.instanceId ?? resolved.targetInstanceId,
|
|
11010
12097
|
clientRole: client?.role
|
|
@@ -11290,8 +12377,8 @@ var RobloxStudioTools = class {
|
|
|
11290
12377
|
timedOut: true
|
|
11291
12378
|
};
|
|
11292
12379
|
}
|
|
11293
|
-
async getFileTree(
|
|
11294
|
-
const response = await this._callSingle("/api/file-tree", { path:
|
|
12380
|
+
async getFileTree(path9 = "", instance_id) {
|
|
12381
|
+
const response = await this._callSingle("/api/file-tree", { path: path9 }, void 0, instance_id);
|
|
11295
12382
|
return {
|
|
11296
12383
|
content: [
|
|
11297
12384
|
{
|
|
@@ -11383,9 +12470,9 @@ var RobloxStudioTools = class {
|
|
|
11383
12470
|
]
|
|
11384
12471
|
};
|
|
11385
12472
|
}
|
|
11386
|
-
async getProjectStructure(
|
|
12473
|
+
async getProjectStructure(path9, maxDepth, scriptsOnly, instance_id) {
|
|
11387
12474
|
const response = await this._callSingle("/api/project-structure", {
|
|
11388
|
-
path:
|
|
12475
|
+
path: path9,
|
|
11389
12476
|
maxDepth,
|
|
11390
12477
|
scriptsOnly
|
|
11391
12478
|
}, void 0, instance_id);
|
|
@@ -11546,7 +12633,7 @@ var RobloxStudioTools = class {
|
|
|
11546
12633
|
};
|
|
11547
12634
|
}
|
|
11548
12635
|
async setSelection(paths, mode, instance_id) {
|
|
11549
|
-
if (!Array.isArray(paths) || paths.some((
|
|
12636
|
+
if (!Array.isArray(paths) || paths.some((path9) => typeof path9 !== "string" || path9.length === 0)) {
|
|
11550
12637
|
throw new Error("selection paths must contain only non-empty instance paths");
|
|
11551
12638
|
}
|
|
11552
12639
|
const selectionMode = mode ?? "set";
|
|
@@ -12283,9 +13370,9 @@ var RobloxStudioTools = class {
|
|
|
12283
13370
|
const rawJson = mutable.raw_json;
|
|
12284
13371
|
if (typeof rawJson === "string") {
|
|
12285
13372
|
if (typeof outputPath === "string" && outputPath !== "") {
|
|
12286
|
-
const resolvedOutputPath =
|
|
12287
|
-
|
|
12288
|
-
|
|
13373
|
+
const resolvedOutputPath = path8.resolve(outputPath);
|
|
13374
|
+
fs5.mkdirSync(path8.dirname(resolvedOutputPath), { recursive: true });
|
|
13375
|
+
fs5.writeFileSync(resolvedOutputPath, rawJson, "utf8");
|
|
12289
13376
|
mutable.output_path = resolvedOutputPath;
|
|
12290
13377
|
}
|
|
12291
13378
|
delete mutable.raw_json;
|
|
@@ -12346,9 +13433,9 @@ var RobloxStudioTools = class {
|
|
|
12346
13433
|
const rawSnapshotBase64 = mutable.raw_snapshot_base64;
|
|
12347
13434
|
if (typeof rawSnapshotBase64 === "string") {
|
|
12348
13435
|
if (typeof outputPath === "string" && outputPath !== "") {
|
|
12349
|
-
const resolvedOutputPath =
|
|
12350
|
-
|
|
12351
|
-
|
|
13436
|
+
const resolvedOutputPath = path8.resolve(outputPath);
|
|
13437
|
+
fs5.mkdirSync(path8.dirname(resolvedOutputPath), { recursive: true });
|
|
13438
|
+
fs5.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
|
|
12352
13439
|
mutable.output_path = resolvedOutputPath;
|
|
12353
13440
|
}
|
|
12354
13441
|
delete mutable.raw_snapshot_base64;
|
|
@@ -12362,9 +13449,9 @@ var RobloxStudioTools = class {
|
|
|
12362
13449
|
});
|
|
12363
13450
|
}
|
|
12364
13451
|
if (typeof summaryOutputPath === "string" && summaryOutputPath !== "") {
|
|
12365
|
-
const resolvedSummaryPath =
|
|
12366
|
-
|
|
12367
|
-
|
|
13452
|
+
const resolvedSummaryPath = path8.resolve(summaryOutputPath);
|
|
13453
|
+
fs5.mkdirSync(path8.dirname(resolvedSummaryPath), { recursive: true });
|
|
13454
|
+
fs5.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
|
|
12368
13455
|
mutable.summary_output_path = resolvedSummaryPath;
|
|
12369
13456
|
}
|
|
12370
13457
|
if (!includeComparisonIndex) {
|
|
@@ -12426,7 +13513,7 @@ var RobloxStudioTools = class {
|
|
|
12426
13513
|
return record.placeId !== void 0 && instance.placeId === record.placeId;
|
|
12427
13514
|
}
|
|
12428
13515
|
if ((record.source === "baseplate" || record.source === "local_file") && record.localPlaceFile) {
|
|
12429
|
-
const expectedName =
|
|
13516
|
+
const expectedName = path8.basename(record.localPlaceFile);
|
|
12430
13517
|
return instance.placeName === expectedName || instance.dataModelName === expectedName;
|
|
12431
13518
|
}
|
|
12432
13519
|
return true;
|
|
@@ -12439,7 +13526,7 @@ var RobloxStudioTools = class {
|
|
|
12439
13526
|
await instanceManager.attachInstanceId(candidate, instance.instanceId);
|
|
12440
13527
|
}
|
|
12441
13528
|
async _deriveUniverseId(placeId) {
|
|
12442
|
-
const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe
|
|
13529
|
+
const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe`, { signal: AbortSignal.timeout(3e4) });
|
|
12443
13530
|
if (!response.ok) {
|
|
12444
13531
|
const body = await response.text().catch(() => "");
|
|
12445
13532
|
throw new Error(`Could not resolve the universe for place_id ${placeId} (${response.status}): ${body}`);
|
|
@@ -12573,6 +13660,7 @@ var RobloxStudioTools = class {
|
|
|
12573
13660
|
});
|
|
12574
13661
|
}
|
|
12575
13662
|
return this._textResult({
|
|
13663
|
+
test_worker_job_name: parseStudioTestWorkerJobName(process.env.RSMCP_STUDIO_TEST_WORKER_JOB),
|
|
12576
13664
|
managed: (await this.instanceManager.list()).filter((record2) => record2.closedAt === void 0).map((record2) => this._managedStatus(record2)),
|
|
12577
13665
|
connected: this.bridge.getPublicInstances().map((instance) => ({
|
|
12578
13666
|
instance_id: instance.id,
|
|
@@ -12651,10 +13739,9 @@ var RobloxStudioTools = class {
|
|
|
12651
13739
|
}
|
|
12652
13740
|
record2 = active[0];
|
|
12653
13741
|
}
|
|
12654
|
-
if (record2.instanceId)
|
|
12655
|
-
await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
|
|
12656
13742
|
const closeResult = await this.instanceManager.close(record2);
|
|
12657
13743
|
if (record2.instanceId) {
|
|
13744
|
+
await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
|
|
12658
13745
|
await sleep(500);
|
|
12659
13746
|
await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
|
|
12660
13747
|
}
|
|
@@ -13757,11 +14844,11 @@ var RobloxStudioTools = class {
|
|
|
13757
14844
|
return this.resolveUploadedReferenceImageId(decalId, instance_id);
|
|
13758
14845
|
}
|
|
13759
14846
|
async uploadAsset(filePath, assetType, displayName, description, userId, groupId) {
|
|
13760
|
-
if (!
|
|
14847
|
+
if (!fs5.existsSync(filePath)) {
|
|
13761
14848
|
throw new Error(`File not found: ${filePath}`);
|
|
13762
14849
|
}
|
|
13763
|
-
const fileContent =
|
|
13764
|
-
const fileName =
|
|
14850
|
+
const fileContent = fs5.readFileSync(filePath);
|
|
14851
|
+
const fileName = path8.basename(filePath);
|
|
13765
14852
|
const resolvedGroupId = groupId || process.env.ROBLOX_CREATOR_GROUP_ID;
|
|
13766
14853
|
const resolvedUserId = userId || process.env.ROBLOX_CREATOR_USER_ID;
|
|
13767
14854
|
if (assetType === "Decal" && this.cookieClient.hasCookie()) {
|
|
@@ -13977,10 +15064,10 @@ var RobloxStudioTools = class {
|
|
|
13977
15064
|
return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
|
|
13978
15065
|
}
|
|
13979
15066
|
const bytes = Buffer.from(response.base64, "base64");
|
|
13980
|
-
const resolved =
|
|
15067
|
+
const resolved = path8.resolve(outputPath);
|
|
13981
15068
|
try {
|
|
13982
|
-
|
|
13983
|
-
|
|
15069
|
+
fs5.mkdirSync(path8.dirname(resolved), { recursive: true });
|
|
15070
|
+
fs5.writeFileSync(resolved, bytes);
|
|
13984
15071
|
} catch (err2) {
|
|
13985
15072
|
return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err2.message}` }) }] };
|
|
13986
15073
|
}
|
|
@@ -13995,7 +15082,7 @@ var RobloxStudioTools = class {
|
|
|
13995
15082
|
}]
|
|
13996
15083
|
};
|
|
13997
15084
|
}
|
|
13998
|
-
async importRbxm(source, parentPath, target, instance_id) {
|
|
15085
|
+
async importRbxm(source, parentPath, target, instance_id, signal) {
|
|
13999
15086
|
if (!source || typeof source !== "object") {
|
|
14000
15087
|
throw new Error("source is required for import_rbxm");
|
|
14001
15088
|
}
|
|
@@ -14010,86 +15097,80 @@ var RobloxStudioTools = class {
|
|
|
14010
15097
|
if (modes.length !== 1) {
|
|
14011
15098
|
throw new Error(`source must contain exactly one of { path, url, base64 } (got: ${modes.join(", ") || "none"})`);
|
|
14012
15099
|
}
|
|
14013
|
-
let
|
|
14014
|
-
|
|
14015
|
-
|
|
14016
|
-
|
|
14017
|
-
|
|
14018
|
-
bytes = fs3.readFileSync(resolved);
|
|
14019
|
-
} catch (err2) {
|
|
14020
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `failed to read ${resolved}: ${err2.message}` }) }] };
|
|
14021
|
-
}
|
|
14022
|
-
sourceLabel = resolved;
|
|
14023
|
-
} else if (source.url !== void 0) {
|
|
14024
|
-
let parsedUrl;
|
|
14025
|
-
try {
|
|
14026
|
-
parsedUrl = new URL(source.url);
|
|
14027
|
-
} catch {
|
|
14028
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `import_rbxm url is not a valid URL: ${source.url}` }) }] };
|
|
14029
|
-
}
|
|
14030
|
-
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
14031
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `import_rbxm url must use http(s); got ${parsedUrl.protocol}` }) }] };
|
|
14032
|
-
}
|
|
14033
|
-
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
|
14034
|
-
try {
|
|
14035
|
-
const res = await fetch(source.url);
|
|
14036
|
-
if (!res.ok) {
|
|
14037
|
-
const snippet = (await res.text()).slice(0, 500);
|
|
14038
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url} returned ${res.status}: ${snippet}` }) }] };
|
|
14039
|
-
}
|
|
14040
|
-
const claimed = Number(res.headers.get("content-length") ?? "0");
|
|
14041
|
-
if (claimed > MAX_IMPORT_BYTES) {
|
|
14042
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url}: content-length ${claimed} exceeds ${MAX_IMPORT_BYTES} byte cap` }) }] };
|
|
14043
|
-
}
|
|
14044
|
-
const arr = await res.arrayBuffer();
|
|
14045
|
-
if (arr.byteLength > MAX_IMPORT_BYTES) {
|
|
14046
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url}: downloaded ${arr.byteLength} bytes exceeds ${MAX_IMPORT_BYTES} byte cap` }) }] };
|
|
14047
|
-
}
|
|
14048
|
-
bytes = Buffer.from(arr);
|
|
14049
|
-
} catch (err2) {
|
|
14050
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url} failed: ${err2.message}` }) }] };
|
|
14051
|
-
}
|
|
14052
|
-
sourceLabel = source.url;
|
|
14053
|
-
} else {
|
|
14054
|
-
try {
|
|
14055
|
-
bytes = Buffer.from(source.base64, "base64");
|
|
14056
|
-
} catch (err2) {
|
|
14057
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `base64 decode failed: ${err2.message}` }) }] };
|
|
14058
|
-
}
|
|
14059
|
-
sourceLabel = `base64(${bytes.length}B)`;
|
|
15100
|
+
let input;
|
|
15101
|
+
try {
|
|
15102
|
+
input = await readRbxmInput(source, parentPath, signal);
|
|
15103
|
+
} catch (error) {
|
|
15104
|
+
return this._textResult({ error: error instanceof Error ? error.message : String(error) });
|
|
14060
15105
|
}
|
|
15106
|
+
const { bytes, sourceLabel } = input;
|
|
14061
15107
|
const response = await this._callSingle("/api/import-rbxm", {
|
|
14062
15108
|
base64: bytes.toString("base64"),
|
|
14063
15109
|
parent_path: parentPath,
|
|
14064
15110
|
source_label: sourceLabel
|
|
14065
|
-
}, tgt, instance_id);
|
|
15111
|
+
}, tgt, instance_id, void 0, signal);
|
|
14066
15112
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
14067
15113
|
}
|
|
15114
|
+
// Even native capture must wait while another call has fitted the simulator
|
|
15115
|
+
// or drawn markers. Otherwise it could observe temporary UI or dimensions.
|
|
14068
15116
|
async _captureViewportImage(instanceId, targetRole, format, quality, maxBytes = MAX_INLINE_IMAGE_BYTES) {
|
|
15117
|
+
const previous = this.viewportCaptureQueues.get(instanceId) ?? Promise.resolve();
|
|
15118
|
+
const capture = previous.then(() => this._captureViewportImageNow(instanceId, targetRole, format, quality, maxBytes));
|
|
15119
|
+
const settled = capture.then(() => void 0, () => void 0);
|
|
15120
|
+
this.viewportCaptureQueues.set(instanceId, settled);
|
|
15121
|
+
try {
|
|
15122
|
+
return await capture;
|
|
15123
|
+
} finally {
|
|
15124
|
+
if (this.viewportCaptureQueues.get(instanceId) === settled)
|
|
15125
|
+
this.viewportCaptureQueues.delete(instanceId);
|
|
15126
|
+
}
|
|
15127
|
+
}
|
|
15128
|
+
async _captureViewportImageNow(instanceId, targetRole, format, quality, maxBytes = MAX_INLINE_IMAGE_BYTES) {
|
|
14069
15129
|
const fmt = format === "png" ? "png" : "jpeg";
|
|
14070
15130
|
const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
|
|
14071
15131
|
let response = await this._callSingle("/api/capture-studio", { encoding: fmt === "png" ? "png" : "rgba8" }, targetRole, instanceId);
|
|
14072
15132
|
const studioFastPathMissing = response.unavailable !== void 0 || (response.error?.includes("/api/capture-studio") ?? false);
|
|
15133
|
+
const studioFastPathUnavailable = studioFastPathMissing ? response.unavailable ?? response.error : void 0;
|
|
14073
15134
|
if (studioFastPathMissing) {
|
|
14074
15135
|
if (targetRole.startsWith("client-")) {
|
|
14075
15136
|
const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
|
|
14076
15137
|
if (begin.error) {
|
|
14077
|
-
|
|
14078
|
-
}
|
|
14079
|
-
|
|
14080
|
-
|
|
15138
|
+
response = { error: begin.error };
|
|
15139
|
+
} else if (!begin.contentId) {
|
|
15140
|
+
response = { error: "Screenshot capture failed: no content id returned from client." };
|
|
15141
|
+
} else {
|
|
15142
|
+
response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
|
|
14081
15143
|
}
|
|
14082
|
-
response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
|
|
14083
15144
|
} else {
|
|
14084
15145
|
response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
|
|
14085
15146
|
}
|
|
15147
|
+
response.source ??= "CaptureService";
|
|
15148
|
+
}
|
|
15149
|
+
let hostNote = "";
|
|
15150
|
+
let hostReason;
|
|
15151
|
+
try {
|
|
15152
|
+
hostReason = response.error ? `Studio's capture failed (${response.error})` : this._isUniformCaptureResponse(response) ? `Studio's ${response.source ?? "capture"} returned a blank (single-colour) frame` : void 0;
|
|
15153
|
+
} catch (error) {
|
|
15154
|
+
response = { ...response, error: `Could not decode Studio's screenshot: ${error instanceof Error ? error.message : String(error)}` };
|
|
15155
|
+
hostReason = `Studio's capture failed (${response.error})`;
|
|
15156
|
+
}
|
|
15157
|
+
if (hostReason !== void 0) {
|
|
15158
|
+
const host = await this._captureViewportFromHostWindow(instanceId, targetRole);
|
|
15159
|
+
if (host.success) {
|
|
15160
|
+
response = host.response;
|
|
15161
|
+
hostNote = ` Captured from the Studio window through the host OS because ${hostReason}.`;
|
|
15162
|
+
} else if (response.error) {
|
|
15163
|
+
response = { ...response, error: `${response.error} Host window capture also failed: ${host.error}.` };
|
|
15164
|
+
} else {
|
|
15165
|
+
hostNote = ` Warning: ${hostReason} and host window capture also failed (${host.error}), so this image may be blank.`;
|
|
15166
|
+
}
|
|
14086
15167
|
}
|
|
14087
15168
|
if (response.error) {
|
|
14088
15169
|
let text = response.error;
|
|
14089
15170
|
if (targetRole.startsWith("client-") && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
|
|
14090
15171
|
text = `Screenshot capture reached the multiplayer client, but Roblox returned a temporary screenshot texture that the edit peer cannot read in StudioTestService multiplayer sessions. Regular solo_playtest capture works because the temporary rbxtemp:// handle is readable from the edit process; multiplayer client handles appear to be scoped to the client process. Raw error: ${response.error}`;
|
|
14091
15172
|
}
|
|
14092
|
-
return { success: false, error: text };
|
|
15173
|
+
return { success: false, error: text, studioFastPathUnavailable };
|
|
14093
15174
|
}
|
|
14094
15175
|
const w = response.width;
|
|
14095
15176
|
const h = response.height;
|
|
@@ -14133,7 +15214,7 @@ var RobloxStudioTools = class {
|
|
|
14133
15214
|
}
|
|
14134
15215
|
const nativeW = response.nativeWidth ?? w;
|
|
14135
15216
|
const nativeH = response.nativeHeight ?? h;
|
|
14136
|
-
const message = nativeW !== w || nativeH !== h ? `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}, downscaled from the ${nativeW}x${nativeH} viewport to fit transport limits. For simulate_mouse_input, multiply x read off this image by ${(nativeW / w).toFixed(4)} and y by ${(nativeH / h).toFixed(4)} to get viewport pixel coordinates ((0,0) at the top-left).` : `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}. For simulate_mouse_input, x/y are pixel coordinates in this exact image with (0,0) at the top-left; it is not downscaled, so use coordinates as you read them off the image
|
|
15217
|
+
const message = (nativeW !== w || nativeH !== h ? `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}, downscaled from the ${nativeW}x${nativeH} viewport to fit transport limits. For simulate_mouse_input, multiply x read off this image by ${(nativeW / w).toFixed(4)} and y by ${(nativeH / h).toFixed(4)} to get viewport pixel coordinates ((0,0) at the top-left).` : `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}. For simulate_mouse_input, x/y are pixel coordinates in this exact image with (0,0) at the top-left; it is not downscaled, so use coordinates as you read them off the image.`) + hostNote;
|
|
14137
15218
|
return {
|
|
14138
15219
|
success: true,
|
|
14139
15220
|
width: w,
|
|
@@ -14143,7 +15224,172 @@ var RobloxStudioTools = class {
|
|
|
14143
15224
|
note,
|
|
14144
15225
|
data: buffer.toString("base64"),
|
|
14145
15226
|
mimeType,
|
|
14146
|
-
message
|
|
15227
|
+
message,
|
|
15228
|
+
source: response.source,
|
|
15229
|
+
studioFastPathUnavailable
|
|
15230
|
+
};
|
|
15231
|
+
}
|
|
15232
|
+
_isUniformCaptureResponse(response) {
|
|
15233
|
+
if (!response.data || !response.width || !response.height)
|
|
15234
|
+
return false;
|
|
15235
|
+
const data = Buffer.from(response.data, "base64");
|
|
15236
|
+
if (response.encoding === "png")
|
|
15237
|
+
return isUniformPng(data);
|
|
15238
|
+
return isUniformFrame(data, response.width, response.height);
|
|
15239
|
+
}
|
|
15240
|
+
_hostCaptureTitleHint(instanceId) {
|
|
15241
|
+
const peers = this.bridge.getPeers().filter((peer) => peer.instanceId === instanceId);
|
|
15242
|
+
const edit = peers.find((peer) => peer.role === "edit") ?? peers[0];
|
|
15243
|
+
const name = edit?.dataModelName || edit?.placeName;
|
|
15244
|
+
return name ? name : void 0;
|
|
15245
|
+
}
|
|
15246
|
+
async _callViewportMarkers(action, targetRole, instanceId, captureId) {
|
|
15247
|
+
return await this._callSingle("/api/capture-markers", { action, ...captureId ? { captureId } : {} }, targetRole, instanceId);
|
|
15248
|
+
}
|
|
15249
|
+
// Always restore the simulator, including failures and cached window grabs.
|
|
15250
|
+
async _captureViewportFromHostWindow(instanceId, targetRole) {
|
|
15251
|
+
if (isHostCaptureDisabled()) {
|
|
15252
|
+
return { success: false, error: "host window capture is disabled by ROBLOX_STUDIO_HOST_CAPTURE" };
|
|
15253
|
+
}
|
|
15254
|
+
if (!isHostCaptureSupported()) {
|
|
15255
|
+
return { success: false, error: hostCaptureUnsupportedReason() };
|
|
15256
|
+
}
|
|
15257
|
+
if (this.hostWindowCapture === captureStudioWindow) {
|
|
15258
|
+
try {
|
|
15259
|
+
await prepareHostWindowCapture();
|
|
15260
|
+
} catch (error) {
|
|
15261
|
+
return { success: false, error: `could not prepare host capture: ${error instanceof Error ? error.message : String(error)}` };
|
|
15262
|
+
}
|
|
15263
|
+
}
|
|
15264
|
+
let prepared;
|
|
15265
|
+
let result;
|
|
15266
|
+
try {
|
|
15267
|
+
prepared = await this._callViewportMarkers("prepare", targetRole, instanceId);
|
|
15268
|
+
if (prepared.error || !prepared.captureId || !prepared.viewportWidth || !prepared.viewportHeight) {
|
|
15269
|
+
result = { success: false, error: prepared.error ?? "the Studio plugin could not prepare host capture (update the plugin)" };
|
|
15270
|
+
} else {
|
|
15271
|
+
if (prepared.viewportChanged)
|
|
15272
|
+
this.hostViewportRects.delete(`${instanceId}|${targetRole}`);
|
|
15273
|
+
result = await this._capturePreparedViewportFromHostWindow(instanceId, targetRole, prepared);
|
|
15274
|
+
}
|
|
15275
|
+
} catch (error) {
|
|
15276
|
+
result = { success: false, error: `host capture failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
15277
|
+
} finally {
|
|
15278
|
+
if (prepared?.captureId) {
|
|
15279
|
+
try {
|
|
15280
|
+
const restored = await this._callViewportMarkers("finish", targetRole, instanceId, prepared.captureId);
|
|
15281
|
+
if (restored.error)
|
|
15282
|
+
result = { success: false, error: `could not restore the Studio viewport: ${restored.error}` };
|
|
15283
|
+
else if (restored.stale || !restored.success)
|
|
15284
|
+
result = { success: false, error: "the host capture transaction expired or lost ownership; retry the capture" };
|
|
15285
|
+
} catch (error) {
|
|
15286
|
+
result = { success: false, error: `could not restore the Studio viewport: ${error instanceof Error ? error.message : String(error)}` };
|
|
15287
|
+
}
|
|
15288
|
+
}
|
|
15289
|
+
}
|
|
15290
|
+
if (!result.success)
|
|
15291
|
+
this.hostViewportRects.delete(`${instanceId}|${targetRole}`);
|
|
15292
|
+
return result;
|
|
15293
|
+
}
|
|
15294
|
+
// Grabs the Studio window through the host OS and crops it to the viewport
|
|
15295
|
+
// of `targetRole`. The plugin pins magenta markers to the viewport corners
|
|
15296
|
+
// so the crop is exact regardless of Studio's dock layout or DPI scale; the
|
|
15297
|
+
// located rect is cached briefly so repeated captures cost one window grab.
|
|
15298
|
+
async _capturePreparedViewportFromHostWindow(instanceId, targetRole, prepared) {
|
|
15299
|
+
const titleHint = this._hostCaptureTitleHint(instanceId);
|
|
15300
|
+
const cacheKey = `${instanceId}|${targetRole}`;
|
|
15301
|
+
const cached = this.hostViewportRects.get(cacheKey);
|
|
15302
|
+
let sizeInfo;
|
|
15303
|
+
try {
|
|
15304
|
+
sizeInfo = await this._callViewportMarkers("query", targetRole, instanceId, prepared.captureId);
|
|
15305
|
+
} catch (error) {
|
|
15306
|
+
return { success: false, error: `viewport markers unavailable: ${error instanceof Error ? error.message : String(error)}` };
|
|
15307
|
+
}
|
|
15308
|
+
if (sizeInfo.error || !sizeInfo.viewportWidth || !sizeInfo.viewportHeight) {
|
|
15309
|
+
return {
|
|
15310
|
+
success: false,
|
|
15311
|
+
error: sizeInfo.error ?? "the Studio plugin did not report a viewport size (update the plugin to a build with /api/capture-markers)"
|
|
15312
|
+
};
|
|
15313
|
+
}
|
|
15314
|
+
const viewportWidth = prepared.viewportWidth ?? sizeInfo.viewportWidth;
|
|
15315
|
+
const viewportHeight = prepared.viewportHeight ?? sizeInfo.viewportHeight;
|
|
15316
|
+
if (cached && Date.now() - cached.cachedAt < HOST_VIEWPORT_RECT_TTL_MS && cached.viewportWidth === viewportWidth && cached.viewportHeight === viewportHeight) {
|
|
15317
|
+
const grab = await this.hostWindowCapture(titleHint, cached.identity);
|
|
15318
|
+
if (!grab.ok)
|
|
15319
|
+
return { success: false, error: grab.error };
|
|
15320
|
+
if (cached.identity !== void 0 && !sameHostWindowIdentity(grab.capture.identity, cached.identity)) {
|
|
15321
|
+
return { success: false, error: "the Studio window identity changed since locating the viewport; retry the capture" };
|
|
15322
|
+
}
|
|
15323
|
+
if (grab.capture.width === cached.windowWidth && grab.capture.height === cached.windowHeight) {
|
|
15324
|
+
return {
|
|
15325
|
+
success: true,
|
|
15326
|
+
response: this._hostResponseFromCrop(grab.capture.rgba, grab.capture.width, grab.capture.height, cached.rect, viewportWidth, viewportHeight)
|
|
15327
|
+
};
|
|
15328
|
+
}
|
|
15329
|
+
this.hostViewportRects.delete(cacheKey);
|
|
15330
|
+
}
|
|
15331
|
+
let shown;
|
|
15332
|
+
try {
|
|
15333
|
+
shown = await this._callViewportMarkers("show", targetRole, instanceId, prepared.captureId);
|
|
15334
|
+
} catch (error) {
|
|
15335
|
+
return { success: false, error: `could not draw viewport markers: ${error instanceof Error ? error.message : String(error)}` };
|
|
15336
|
+
}
|
|
15337
|
+
if (shown.error)
|
|
15338
|
+
return { success: false, error: `could not draw viewport markers: ${shown.error}` };
|
|
15339
|
+
const markerSize = shown.markerSize ?? 12;
|
|
15340
|
+
let markerGrab;
|
|
15341
|
+
let hidden;
|
|
15342
|
+
try {
|
|
15343
|
+
markerGrab = await this.hostWindowCapture(titleHint);
|
|
15344
|
+
} finally {
|
|
15345
|
+
hidden = await this._callViewportMarkers("hide", targetRole, instanceId, prepared.captureId);
|
|
15346
|
+
}
|
|
15347
|
+
if (hidden.error)
|
|
15348
|
+
return { success: false, error: `could not hide viewport markers: ${hidden.error}` };
|
|
15349
|
+
if (!markerGrab.ok)
|
|
15350
|
+
return { success: false, error: markerGrab.error };
|
|
15351
|
+
const located = findViewportRect(markerGrab.capture.rgba, markerGrab.capture.width, markerGrab.capture.height, {
|
|
15352
|
+
viewportWidth: shown.viewportWidth ?? viewportWidth,
|
|
15353
|
+
viewportHeight: shown.viewportHeight ?? viewportHeight,
|
|
15354
|
+
markerSize
|
|
15355
|
+
});
|
|
15356
|
+
if ("error" in located)
|
|
15357
|
+
return { success: false, error: located.error };
|
|
15358
|
+
const cleanGrab = await this.hostWindowCapture(titleHint, markerGrab.capture.identity);
|
|
15359
|
+
if (!cleanGrab.ok)
|
|
15360
|
+
return { success: false, error: cleanGrab.error };
|
|
15361
|
+
if (markerGrab.capture.identity !== void 0 && !sameHostWindowIdentity(cleanGrab.capture.identity, markerGrab.capture.identity)) {
|
|
15362
|
+
return { success: false, error: "the Studio window identity changed while locating the viewport; retry the capture" };
|
|
15363
|
+
}
|
|
15364
|
+
if (cleanGrab.capture.width !== markerGrab.capture.width || cleanGrab.capture.height !== markerGrab.capture.height) {
|
|
15365
|
+
this.hostViewportRects.delete(cacheKey);
|
|
15366
|
+
return { success: false, error: "the Studio window was resized while locating the viewport; retry the capture" };
|
|
15367
|
+
}
|
|
15368
|
+
this.hostViewportRects.set(cacheKey, {
|
|
15369
|
+
rect: located.rect,
|
|
15370
|
+
identity: markerGrab.capture.identity,
|
|
15371
|
+
windowWidth: markerGrab.capture.width,
|
|
15372
|
+
windowHeight: markerGrab.capture.height,
|
|
15373
|
+
viewportWidth,
|
|
15374
|
+
viewportHeight,
|
|
15375
|
+
cachedAt: Date.now()
|
|
15376
|
+
});
|
|
15377
|
+
return {
|
|
15378
|
+
success: true,
|
|
15379
|
+
response: this._hostResponseFromCrop(cleanGrab.capture.rgba, cleanGrab.capture.width, cleanGrab.capture.height, located.rect, viewportWidth, viewportHeight)
|
|
15380
|
+
};
|
|
15381
|
+
}
|
|
15382
|
+
_hostResponseFromCrop(rgba, width, height, rect, viewportWidth, viewportHeight) {
|
|
15383
|
+
const cropped = cropToViewport(rgba, width, height, rect, viewportWidth, viewportHeight);
|
|
15384
|
+
return {
|
|
15385
|
+
success: true,
|
|
15386
|
+
encoding: "rgba8",
|
|
15387
|
+
source: "host-window",
|
|
15388
|
+
width: cropped.width,
|
|
15389
|
+
height: cropped.height,
|
|
15390
|
+
nativeWidth: viewportWidth,
|
|
15391
|
+
nativeHeight: viewportHeight,
|
|
15392
|
+
data: cropped.rgba.toString("base64")
|
|
14147
15393
|
};
|
|
14148
15394
|
}
|
|
14149
15395
|
async captureScreenshot(instance_id, format, quality) {
|
|
@@ -14153,7 +15399,7 @@ var RobloxStudioTools = class {
|
|
|
14153
15399
|
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
14154
15400
|
const capture = await this._captureViewportImage(instanceId, clientRole ?? "edit", format, quality);
|
|
14155
15401
|
if (!capture.success) {
|
|
14156
|
-
return this._textResult({ error: capture.error });
|
|
15402
|
+
return this._textResult({ error: capture.error, studioFastPathUnavailable: capture.studioFastPathUnavailable });
|
|
14157
15403
|
}
|
|
14158
15404
|
return {
|
|
14159
15405
|
content: [
|
|
@@ -14165,7 +15411,9 @@ var RobloxStudioTools = class {
|
|
|
14165
15411
|
format: capture.format,
|
|
14166
15412
|
mimeType: capture.mimeType,
|
|
14167
15413
|
...capture.quality === void 0 ? {} : { quality: capture.quality },
|
|
14168
|
-
message: capture.message
|
|
15414
|
+
message: capture.message,
|
|
15415
|
+
source: capture.source,
|
|
15416
|
+
studioFastPathUnavailable: capture.studioFastPathUnavailable
|
|
14169
15417
|
})
|
|
14170
15418
|
},
|
|
14171
15419
|
{
|
|
@@ -14315,6 +15563,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
14315
15563
|
const response = await fetch(`${this.primaryBaseUrl}/create-multiplayer-group`, {
|
|
14316
15564
|
method: "POST",
|
|
14317
15565
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
15566
|
+
signal: AbortSignal.timeout(5e3),
|
|
14318
15567
|
body: JSON.stringify({ groupId, controllerInstanceId })
|
|
14319
15568
|
});
|
|
14320
15569
|
if (!response.ok) {
|
|
@@ -14345,6 +15594,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
14345
15594
|
const response = await fetch(`${this.primaryBaseUrl}/remove-multiplayer-group`, {
|
|
14346
15595
|
method: "POST",
|
|
14347
15596
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
15597
|
+
signal: AbortSignal.timeout(5e3),
|
|
14348
15598
|
body: JSON.stringify({ groupId })
|
|
14349
15599
|
});
|
|
14350
15600
|
if (response.status === 409) {
|
|
@@ -14376,6 +15626,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
14376
15626
|
const response = await fetch(`${this.primaryBaseUrl}/unregister-instance-id`, {
|
|
14377
15627
|
method: "POST",
|
|
14378
15628
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
15629
|
+
signal: AbortSignal.timeout(5e3),
|
|
14379
15630
|
body: JSON.stringify({ instanceId })
|
|
14380
15631
|
});
|
|
14381
15632
|
if (!response.ok) {
|
|
@@ -14405,7 +15656,8 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
14405
15656
|
}
|
|
14406
15657
|
async getRequestStatusEverywhere(requestId) {
|
|
14407
15658
|
const response = await fetch(`${this.primaryBaseUrl}/request-status?requestId=${encodeURIComponent(requestId)}`, {
|
|
14408
|
-
headers: this.authHeaders()
|
|
15659
|
+
headers: this.authHeaders(),
|
|
15660
|
+
signal: AbortSignal.timeout(5e3)
|
|
14409
15661
|
});
|
|
14410
15662
|
if (!response.ok)
|
|
14411
15663
|
throw new Error(`Proxy request status failed (${response.status})`);
|
|
@@ -14710,8 +15962,8 @@ var BoundedStdioTransport = class {
|
|
|
14710
15962
|
this.onStreamError(error);
|
|
14711
15963
|
return Promise.reject(error);
|
|
14712
15964
|
}
|
|
14713
|
-
const { promise, resolve:
|
|
14714
|
-
const pending = { resolve:
|
|
15965
|
+
const { promise, resolve: resolve6, reject } = Promise.withResolvers();
|
|
15966
|
+
const pending = { resolve: resolve6, reject };
|
|
14715
15967
|
this.pendingSends.add(pending);
|
|
14716
15968
|
this.pendingWrites++;
|
|
14717
15969
|
try {
|
|
@@ -14723,7 +15975,7 @@ var BoundedStdioTransport = class {
|
|
|
14723
15975
|
});
|
|
14724
15976
|
if (writable) {
|
|
14725
15977
|
this.pendingSends.delete(pending);
|
|
14726
|
-
|
|
15978
|
+
resolve6();
|
|
14727
15979
|
} else if (!this.closed) {
|
|
14728
15980
|
this.outputBlocked = true;
|
|
14729
15981
|
this.input.pause();
|
|
@@ -14822,6 +16074,7 @@ var RobloxStudioMCPServer = class {
|
|
|
14822
16074
|
try {
|
|
14823
16075
|
const result = await listenWithRetry(candidateApp, host, basePort, 1);
|
|
14824
16076
|
const oldBridge = this.bridge;
|
|
16077
|
+
const oldTools = this.tools;
|
|
14825
16078
|
this.bridge = candidateBridge;
|
|
14826
16079
|
this.tools = candidateTools;
|
|
14827
16080
|
if (oldBridge instanceof ProxyBridgeService) {
|
|
@@ -14835,6 +16088,7 @@ var RobloxStudioMCPServer = class {
|
|
|
14835
16088
|
console.error(`Promoted from proxy to primary on port ${boundPort}`);
|
|
14836
16089
|
if (promotionInterval)
|
|
14837
16090
|
clearInterval(promotionInterval);
|
|
16091
|
+
await oldTools.dispose();
|
|
14838
16092
|
} catch {
|
|
14839
16093
|
await candidateApp.cleanup().catch(() => {
|
|
14840
16094
|
});
|
|
@@ -14899,6 +16153,7 @@ var RobloxStudioMCPServer = class {
|
|
|
14899
16153
|
});
|
|
14900
16154
|
if (httpHandle)
|
|
14901
16155
|
httpHandle.close();
|
|
16156
|
+
await this.tools.dispose();
|
|
14902
16157
|
process.exit(0);
|
|
14903
16158
|
};
|
|
14904
16159
|
process.on("SIGTERM", shutdown);
|
|
@@ -16560,7 +17815,7 @@ var TOOL_DEFINITIONS = [
|
|
|
16560
17815
|
properties: {
|
|
16561
17816
|
source: {
|
|
16562
17817
|
type: "object",
|
|
16563
|
-
description: "Exactly one path, URL, or base64 source;
|
|
17818
|
+
description: "Exactly one path, URL, or base64 source. All inputs are capped below 37.5 MiB to fit base64 plus routing metadata within transport limits; URL downloads have a 30-second deadline.",
|
|
16564
17819
|
properties: {
|
|
16565
17820
|
path: { type: "string", description: "Absolute local .rbxm path." },
|
|
16566
17821
|
url: { type: "string", description: "HTTP or HTTPS .rbxm URL." },
|
|
@@ -16689,10 +17944,10 @@ var getAllTools = () => [...TOOL_DEFINITIONS];
|
|
|
16689
17944
|
|
|
16690
17945
|
// ../core/dist/install-plugin-helpers.js
|
|
16691
17946
|
var import_saxes = __toESM(require_saxes(), 1);
|
|
16692
|
-
import { existsSync as
|
|
17947
|
+
import { existsSync as existsSync7, lstatSync as lstatSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync8, renameSync, rmSync as rmSync4, rmdirSync, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
16693
17948
|
import { execSync } from "child_process";
|
|
16694
17949
|
import { randomUUID as randomUUID5 } from "crypto";
|
|
16695
|
-
import { basename as basename3, join as
|
|
17950
|
+
import { basename as basename3, join as join8 } from "path";
|
|
16696
17951
|
import { homedir as homedir4 } from "os";
|
|
16697
17952
|
import { isUtf8 } from "buffer";
|
|
16698
17953
|
var DEFAULT_PLUGIN_PORT = 58741;
|
|
@@ -16744,15 +17999,15 @@ function getPluginsFolder() {
|
|
|
16744
17999
|
if (process.env.MCP_PLUGINS_DIR)
|
|
16745
18000
|
return process.env.MCP_PLUGINS_DIR;
|
|
16746
18001
|
if (process.platform === "win32") {
|
|
16747
|
-
return
|
|
18002
|
+
return join8(process.env.LOCALAPPDATA || join8(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
|
|
16748
18003
|
}
|
|
16749
18004
|
if (isWSL()) {
|
|
16750
18005
|
const localAppData = getWindowsUserLocalAppData();
|
|
16751
18006
|
if (localAppData)
|
|
16752
|
-
return
|
|
18007
|
+
return join8(localAppData.linuxPath, "Roblox", "Plugins");
|
|
16753
18008
|
console.warn("[install-plugin] WSL detected but could not resolve Windows %LOCALAPPDATA%. Falling back to ~/Documents/Roblox/Plugins/ - you will likely need to copy the rbxmx to /mnt/c/Users/<you>/AppData/Local/Roblox/Plugins/ manually. Set MCP_PLUGINS_DIR to skip detection.");
|
|
16754
18009
|
}
|
|
16755
|
-
return
|
|
18010
|
+
return join8(homedir4(), "Documents", "Roblox", "Plugins");
|
|
16756
18011
|
}
|
|
16757
18012
|
var STALE_TEST_PLUGINS_DIRECTORY = "RsmcpIsolatedPlugins";
|
|
16758
18013
|
function pluginDirectorySettingPattern() {
|
|
@@ -16762,12 +18017,12 @@ function escapeXmlText(value) {
|
|
|
16762
18017
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
16763
18018
|
}
|
|
16764
18019
|
function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDirectory }) {
|
|
16765
|
-
if (!
|
|
18020
|
+
if (!existsSync7(settingsPath))
|
|
16766
18021
|
return false;
|
|
16767
18022
|
if (studioPluginsDirectory === "") {
|
|
16768
18023
|
throw new Error("studioPluginsDirectory must be a non-empty path.");
|
|
16769
18024
|
}
|
|
16770
|
-
const contents =
|
|
18025
|
+
const contents = readFileSync8(settingsPath, "utf8");
|
|
16771
18026
|
const matches = [...contents.matchAll(pluginDirectorySettingPattern())];
|
|
16772
18027
|
if (matches.length !== 1) {
|
|
16773
18028
|
throw new Error(`Expected exactly one Studio.PluginsDir QDir in ${settingsPath}; found ${matches.length}.`);
|
|
@@ -16775,16 +18030,16 @@ function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDi
|
|
|
16775
18030
|
if (matches[0][2] !== STALE_TEST_PLUGINS_DIRECTORY)
|
|
16776
18031
|
return false;
|
|
16777
18032
|
const studioPath = escapeXmlText(studioPluginsDirectory);
|
|
16778
|
-
const updated = contents.replace(pluginDirectorySettingPattern(), (_match,
|
|
18033
|
+
const updated = contents.replace(pluginDirectorySettingPattern(), (_match, open4, _value, close) => `${open4}${studioPath}${close}`);
|
|
16779
18034
|
const temporaryPath = `${settingsPath}.rsmcp-install-${process.pid}-${randomUUID5()}.tmp`;
|
|
16780
18035
|
try {
|
|
16781
|
-
|
|
18036
|
+
writeFileSync4(temporaryPath, updated, {
|
|
16782
18037
|
encoding: "utf8",
|
|
16783
18038
|
mode: statSync3(settingsPath).mode
|
|
16784
18039
|
});
|
|
16785
18040
|
renameSync(temporaryPath, settingsPath);
|
|
16786
18041
|
} finally {
|
|
16787
|
-
|
|
18042
|
+
rmSync4(temporaryPath, { force: true });
|
|
16788
18043
|
}
|
|
16789
18044
|
return true;
|
|
16790
18045
|
}
|
|
@@ -16795,16 +18050,16 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
|
|
|
16795
18050
|
let defaultPluginsFolder;
|
|
16796
18051
|
let studioPluginsDirectory;
|
|
16797
18052
|
if (process.platform === "win32") {
|
|
16798
|
-
const localAppData = process.env.LOCALAPPDATA ||
|
|
16799
|
-
defaultPluginsFolder =
|
|
16800
|
-
settingsPath =
|
|
18053
|
+
const localAppData = process.env.LOCALAPPDATA || join8(homedir4(), "AppData", "Local");
|
|
18054
|
+
defaultPluginsFolder = join8(localAppData, "Roblox", "Plugins");
|
|
18055
|
+
settingsPath = join8(localAppData, "Roblox", "GlobalSettings_13.xml");
|
|
16801
18056
|
studioPluginsDirectory = defaultPluginsFolder.replaceAll("\\", "/");
|
|
16802
18057
|
} else if (isWSL()) {
|
|
16803
18058
|
const localAppData = getWindowsUserLocalAppData();
|
|
16804
18059
|
if (!localAppData)
|
|
16805
18060
|
return;
|
|
16806
|
-
defaultPluginsFolder =
|
|
16807
|
-
settingsPath =
|
|
18061
|
+
defaultPluginsFolder = join8(localAppData.linuxPath, "Roblox", "Plugins");
|
|
18062
|
+
settingsPath = join8(localAppData.linuxPath, "Roblox", "GlobalSettings_13.xml");
|
|
16808
18063
|
studioPluginsDirectory = [
|
|
16809
18064
|
localAppData.windowsPath.replace(/[\\/]+$/u, ""),
|
|
16810
18065
|
"Roblox",
|
|
@@ -16820,12 +18075,12 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
|
|
|
16820
18075
|
}
|
|
16821
18076
|
}
|
|
16822
18077
|
function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
|
|
16823
|
-
const otherDest =
|
|
16824
|
-
if (!
|
|
18078
|
+
const otherDest = join8(pluginsFolder, otherAssetName);
|
|
18079
|
+
if (!existsSync7(otherDest))
|
|
16825
18080
|
return;
|
|
16826
18081
|
if (replace) {
|
|
16827
18082
|
try {
|
|
16828
|
-
|
|
18083
|
+
unlinkSync2(otherDest);
|
|
16829
18084
|
log(`Removed conflicting ${otherAssetName}.`);
|
|
16830
18085
|
} catch (err2) {
|
|
16831
18086
|
warn(`[install-plugin] Could not remove ${otherDest}: ${err2}. Continuing.`);
|
|
@@ -16846,11 +18101,11 @@ function pluginInstallLockError(lockPath, reason) {
|
|
|
16846
18101
|
return new Error(`${reason}: ${lockPath}. If no installer or lock recovery is running, remove that lock directory and retry.`);
|
|
16847
18102
|
}
|
|
16848
18103
|
function readPluginInstallLockOwner(lockPath) {
|
|
16849
|
-
const ownerPath =
|
|
16850
|
-
if (!
|
|
18104
|
+
const ownerPath = join8(lockPath, "owner.json");
|
|
18105
|
+
if (!lstatSync2(lockPath).isDirectory() || !lstatSync2(ownerPath).isFile()) {
|
|
16851
18106
|
throw new Error("Lock and owner metadata must be a directory and regular file");
|
|
16852
18107
|
}
|
|
16853
|
-
const owner = JSON.parse(
|
|
18108
|
+
const owner = JSON.parse(readFileSync8(ownerPath, "utf8"));
|
|
16854
18109
|
if (owner === null || typeof owner !== "object" || !("pid" in owner) || typeof owner.pid !== "number" || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.pid > 2147483647 || !("token" in owner) || typeof owner.token !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(owner.token) || !("createdAt" in owner) || typeof owner.createdAt !== "number" || !Number.isFinite(owner.createdAt) || owner.createdAt < 0) {
|
|
16855
18110
|
throw new Error("Invalid lock owner metadata");
|
|
16856
18111
|
}
|
|
@@ -16876,7 +18131,7 @@ function recoverPluginInstallLock(lockPath, warn) {
|
|
|
16876
18131
|
throw pluginInstallLockError(lockPath, `Cannot safely read plugin installation lock owner (${error})`);
|
|
16877
18132
|
}
|
|
16878
18133
|
assertPluginInstallOwnerExited(lockPath, owner);
|
|
16879
|
-
const claimPath =
|
|
18134
|
+
const claimPath = join8(lockPath, `recovery-${owner.token}`);
|
|
16880
18135
|
try {
|
|
16881
18136
|
mkdirSync4(claimPath);
|
|
16882
18137
|
} catch (error) {
|
|
@@ -16909,14 +18164,14 @@ function recoverPluginInstallLock(lockPath, warn) {
|
|
|
16909
18164
|
}
|
|
16910
18165
|
}
|
|
16911
18166
|
try {
|
|
16912
|
-
|
|
18167
|
+
rmSync4(abandonedPath, { recursive: true, force: true });
|
|
16913
18168
|
} catch (error) {
|
|
16914
18169
|
warn(`[install-plugin] Could not clean abandoned install lock ${abandonedPath}: ${error}`);
|
|
16915
18170
|
}
|
|
16916
18171
|
}
|
|
16917
18172
|
function acquirePluginInstallLock(pluginsFolder, warn) {
|
|
16918
|
-
const lockPath =
|
|
16919
|
-
const ownerPath =
|
|
18173
|
+
const lockPath = join8(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
|
|
18174
|
+
const ownerPath = join8(lockPath, "owner.json");
|
|
16920
18175
|
const owner = {
|
|
16921
18176
|
pid: process.pid,
|
|
16922
18177
|
token: randomUUID5(),
|
|
@@ -16937,14 +18192,14 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
|
|
|
16937
18192
|
}
|
|
16938
18193
|
}
|
|
16939
18194
|
try {
|
|
16940
|
-
|
|
18195
|
+
writeFileSync4(ownerPath, `${JSON.stringify(owner)}
|
|
16941
18196
|
`, {
|
|
16942
18197
|
encoding: "utf8",
|
|
16943
18198
|
flag: "wx"
|
|
16944
18199
|
});
|
|
16945
18200
|
} catch (error) {
|
|
16946
18201
|
try {
|
|
16947
|
-
|
|
18202
|
+
rmSync4(lockPath, { recursive: true, force: true });
|
|
16948
18203
|
} catch (cleanupError) {
|
|
16949
18204
|
warn(`[install-plugin] Could not clean failed install lock ${lockPath}: ${cleanupError}`);
|
|
16950
18205
|
}
|
|
@@ -16954,7 +18209,7 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
|
|
|
16954
18209
|
try {
|
|
16955
18210
|
const currentOwner = readPluginInstallLockOwner(lockPath);
|
|
16956
18211
|
if (currentOwner.pid === owner.pid && currentOwner.token === owner.token) {
|
|
16957
|
-
|
|
18212
|
+
rmSync4(lockPath, { recursive: true, force: true });
|
|
16958
18213
|
}
|
|
16959
18214
|
} catch (error) {
|
|
16960
18215
|
if (errorCode(error) !== "ENOENT") {
|
|
@@ -17046,11 +18301,11 @@ function assertPluginAssetIdentity(source, assetName, expectedVersion, expectedV
|
|
|
17046
18301
|
}
|
|
17047
18302
|
}
|
|
17048
18303
|
function installedFileMatches(source, destination) {
|
|
17049
|
-
if (!
|
|
18304
|
+
if (!existsSync7(destination))
|
|
17050
18305
|
return false;
|
|
17051
|
-
if (!
|
|
18306
|
+
if (!lstatSync2(destination).isFile())
|
|
17052
18307
|
return false;
|
|
17053
|
-
const installed =
|
|
18308
|
+
const installed = readFileSync8(destination);
|
|
17054
18309
|
return installed.length === source.length && installed.equals(source);
|
|
17055
18310
|
}
|
|
17056
18311
|
function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source, expectedVersion, expectedVariant, rawPort, replaceVariant = true, log = console.log, warn = console.warn }) {
|
|
@@ -17062,19 +18317,19 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
|
|
|
17062
18317
|
const configured = configurePluginAssetForPort(source, rawPort);
|
|
17063
18318
|
assertPluginAssetIdentity(configured, assetName, expectedVersion, expectedVariant);
|
|
17064
18319
|
mkdirSync4(pluginsFolder, { recursive: true });
|
|
17065
|
-
const destination =
|
|
18320
|
+
const destination = join8(pluginsFolder, assetName);
|
|
17066
18321
|
const releaseLock = acquirePluginInstallLock(pluginsFolder, warn);
|
|
17067
18322
|
try {
|
|
17068
18323
|
let installed = false;
|
|
17069
18324
|
if (!installedFileMatches(configured, destination)) {
|
|
17070
|
-
const staged =
|
|
18325
|
+
const staged = join8(pluginsFolder, `.${assetName}.${process.pid}.${randomUUID5()}.tmp`);
|
|
17071
18326
|
try {
|
|
17072
|
-
|
|
18327
|
+
writeFileSync4(staged, configured, { flag: "wx" });
|
|
17073
18328
|
renameSync(staged, destination);
|
|
17074
18329
|
installed = true;
|
|
17075
18330
|
} finally {
|
|
17076
18331
|
try {
|
|
17077
|
-
|
|
18332
|
+
unlinkSync2(staged);
|
|
17078
18333
|
} catch (error) {
|
|
17079
18334
|
if (errorCode(error) !== "ENOENT") {
|
|
17080
18335
|
warn(`[install-plugin] Could not remove staging file ${staged}: ${error}`);
|
|
@@ -17096,175 +18351,156 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
|
|
|
17096
18351
|
}
|
|
17097
18352
|
}
|
|
17098
18353
|
|
|
17099
|
-
//
|
|
17100
|
-
import {
|
|
17101
|
-
|
|
17102
|
-
// src/install-plugin.ts
|
|
17103
|
-
import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
|
|
17104
|
-
import { dirname as dirname5, join as join6 } from "path";
|
|
18354
|
+
// ../core/dist/plugin-installer.js
|
|
18355
|
+
import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
|
|
18356
|
+
import { dirname as dirname5, join as join9 } from "path";
|
|
17105
18357
|
import { fileURLToPath } from "url";
|
|
17106
18358
|
import { get } from "https";
|
|
17107
18359
|
var REPO = "chrrxs/robloxstudio-mcp";
|
|
17108
|
-
var ASSET_NAME = "MCPPlugin.rbxmx";
|
|
17109
|
-
var OTHER_VARIANT = "MCPInspectorPlugin.rbxmx";
|
|
17110
18360
|
var TIMEOUT_MS = 3e4;
|
|
17111
18361
|
var MAX_REDIRECTS = 5;
|
|
17112
18362
|
var MAX_ASSET_BYTES = 64 * 1024 * 1024;
|
|
17113
|
-
function
|
|
17114
|
-
|
|
17115
|
-
|
|
17116
|
-
|
|
17117
|
-
|
|
17118
|
-
|
|
18363
|
+
function createPluginInstaller(moduleUrl, variant) {
|
|
18364
|
+
const currentDir = dirname5(fileURLToPath(moduleUrl));
|
|
18365
|
+
const ASSET_NAME = variant === "main" ? "MCPPlugin.rbxmx" : "MCPInspectorPlugin.rbxmx";
|
|
18366
|
+
const OTHER_VARIANT = variant === "main" ? "MCPInspectorPlugin.rbxmx" : "MCPPlugin.rbxmx";
|
|
18367
|
+
const userAgent = variant === "main" ? "robloxstudio-mcp" : "robloxstudio-mcp-inspector";
|
|
18368
|
+
const buildCommand = variant === "main" ? "build:plugin" : "build:plugin:inspector";
|
|
18369
|
+
function httpsGet(url) {
|
|
18370
|
+
return new Promise((resolve6, reject) => {
|
|
18371
|
+
const req = get(url, { headers: { "User-Agent": userAgent } }, resolve6);
|
|
18372
|
+
req.on("error", reject);
|
|
18373
|
+
req.setTimeout(TIMEOUT_MS, () => {
|
|
18374
|
+
req.destroy(new Error(`Request timed out after ${TIMEOUT_MS}ms`));
|
|
18375
|
+
});
|
|
17119
18376
|
});
|
|
17120
|
-
});
|
|
17121
|
-
}
|
|
17122
|
-
async function download(url, redirects = 0) {
|
|
17123
|
-
const res = await httpsGet(url);
|
|
17124
|
-
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
17125
|
-
if (redirects >= MAX_REDIRECTS) throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
|
|
17126
|
-
const location = res.headers.location;
|
|
17127
|
-
if (!location) throw new Error("Redirect with no location header");
|
|
17128
|
-
for await (const _chunk of res) {
|
|
17129
|
-
}
|
|
17130
|
-
return download(location, redirects + 1);
|
|
17131
|
-
}
|
|
17132
|
-
if (res.statusCode !== 200) {
|
|
17133
|
-
throw new Error(`Download failed: HTTP ${res.statusCode}`);
|
|
17134
18377
|
}
|
|
17135
|
-
|
|
17136
|
-
|
|
17137
|
-
|
|
17138
|
-
|
|
17139
|
-
|
|
17140
|
-
|
|
17141
|
-
|
|
17142
|
-
|
|
17143
|
-
|
|
17144
|
-
|
|
18378
|
+
async function download(url, redirects = 0) {
|
|
18379
|
+
const res = await httpsGet(url);
|
|
18380
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
18381
|
+
if (redirects >= MAX_REDIRECTS)
|
|
18382
|
+
throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
|
|
18383
|
+
const location = res.headers.location;
|
|
18384
|
+
if (!location)
|
|
18385
|
+
throw new Error("Redirect with no location header");
|
|
18386
|
+
for await (const chunk of res) {
|
|
18387
|
+
void chunk;
|
|
18388
|
+
}
|
|
18389
|
+
return download(location, redirects + 1);
|
|
18390
|
+
}
|
|
18391
|
+
if (res.statusCode !== 200) {
|
|
18392
|
+
throw new Error(`Download failed: HTTP ${res.statusCode}`);
|
|
17145
18393
|
}
|
|
17146
|
-
chunks
|
|
18394
|
+
const chunks = [];
|
|
18395
|
+
let totalBytes = 0;
|
|
18396
|
+
for await (const chunk of res) {
|
|
18397
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
18398
|
+
totalBytes += bytes.length;
|
|
18399
|
+
if (totalBytes > MAX_ASSET_BYTES) {
|
|
18400
|
+
res.destroy();
|
|
18401
|
+
throw new Error(`${ASSET_NAME} download exceeds the ${MAX_ASSET_BYTES}-byte limit.`);
|
|
18402
|
+
}
|
|
18403
|
+
chunks.push(bytes);
|
|
18404
|
+
}
|
|
18405
|
+
return Buffer.concat(chunks, totalBytes);
|
|
17147
18406
|
}
|
|
17148
|
-
|
|
17149
|
-
|
|
17150
|
-
|
|
17151
|
-
|
|
17152
|
-
|
|
17153
|
-
|
|
18407
|
+
async function fetchJson(url) {
|
|
18408
|
+
const res = await httpsGet(url);
|
|
18409
|
+
if (res.statusCode !== 200) {
|
|
18410
|
+
throw new Error(`GitHub API returned HTTP ${res.statusCode}`);
|
|
18411
|
+
}
|
|
18412
|
+
const chunks = [];
|
|
18413
|
+
for await (const chunk of res) {
|
|
18414
|
+
chunks.push(chunk);
|
|
18415
|
+
}
|
|
18416
|
+
return JSON.parse(Buffer.concat(chunks).toString());
|
|
17154
18417
|
}
|
|
17155
|
-
|
|
17156
|
-
|
|
17157
|
-
|
|
18418
|
+
function bundledAssetPath() {
|
|
18419
|
+
const candidates = [
|
|
18420
|
+
join9(currentDir, "..", "studio-plugin", ASSET_NAME),
|
|
18421
|
+
join9(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
|
|
18422
|
+
];
|
|
18423
|
+
return candidates.find((candidate) => existsSync8(candidate)) ?? null;
|
|
17158
18424
|
}
|
|
17159
|
-
|
|
17160
|
-
|
|
17161
|
-
|
|
17162
|
-
|
|
17163
|
-
const prerelease = releases.find(
|
|
17164
|
-
(r) => r.prerelease && r.assets.some((a) => a.name === ASSET_NAME)
|
|
17165
|
-
);
|
|
17166
|
-
if (!prerelease) {
|
|
17167
|
-
throw new Error(`No prerelease found with ${ASSET_NAME}`);
|
|
17168
|
-
}
|
|
17169
|
-
return prerelease;
|
|
17170
|
-
}
|
|
17171
|
-
function bundledAssetPath() {
|
|
17172
|
-
const currentDir = dirname5(fileURLToPath(import.meta.url));
|
|
17173
|
-
const candidates = [
|
|
17174
|
-
join6(currentDir, "..", "studio-plugin", ASSET_NAME),
|
|
17175
|
-
join6(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
|
|
17176
|
-
];
|
|
17177
|
-
return candidates.find((candidate) => existsSync7(candidate)) ?? null;
|
|
17178
|
-
}
|
|
17179
|
-
function resolvePluginAssetPath(sourcePath) {
|
|
17180
|
-
if (sourcePath === void 0) return bundledAssetPath();
|
|
17181
|
-
return existsSync7(sourcePath) ? sourcePath : null;
|
|
17182
|
-
}
|
|
17183
|
-
function packageVersion() {
|
|
17184
|
-
const currentDir = dirname5(fileURLToPath(import.meta.url));
|
|
17185
|
-
const pkg = JSON.parse(readFileSync7(join6(currentDir, "..", "package.json"), "utf8"));
|
|
17186
|
-
if (!pkg.version) {
|
|
17187
|
-
throw new Error("Package version not found");
|
|
18425
|
+
function resolvePluginAssetPath(sourcePath) {
|
|
18426
|
+
if (sourcePath === void 0)
|
|
18427
|
+
return bundledAssetPath();
|
|
18428
|
+
return existsSync8(sourcePath) ? sourcePath : null;
|
|
17188
18429
|
}
|
|
17189
|
-
|
|
17190
|
-
|
|
17191
|
-
|
|
17192
|
-
|
|
17193
|
-
|
|
17194
|
-
|
|
17195
|
-
const sourcePath = resolvePluginAssetPath(options.sourcePath);
|
|
17196
|
-
if (!sourcePath) {
|
|
17197
|
-
throw new Error(
|
|
17198
|
-
`Bundled ${ASSET_NAME} not found. Run npm run build:plugin in this worktree first.`
|
|
17199
|
-
);
|
|
17200
|
-
}
|
|
17201
|
-
const result = await installPluginAsset({
|
|
17202
|
-
pluginsFolder: getPluginsFolder(),
|
|
17203
|
-
assetName: ASSET_NAME,
|
|
17204
|
-
otherAssetName: OTHER_VARIANT,
|
|
17205
|
-
source: readFileSync7(sourcePath),
|
|
17206
|
-
expectedVersion: packageVersion(),
|
|
17207
|
-
expectedVariant: "main",
|
|
17208
|
-
replaceVariant,
|
|
17209
|
-
log,
|
|
17210
|
-
warn
|
|
17211
|
-
});
|
|
17212
|
-
if (result.installed) {
|
|
17213
|
-
log(`Installed ${ASSET_NAME} to ${result.destination}`);
|
|
18430
|
+
function packageVersion() {
|
|
18431
|
+
const pkg = JSON.parse(readFileSync9(join9(currentDir, "..", "package.json"), "utf8"));
|
|
18432
|
+
if (!pkg.version) {
|
|
18433
|
+
throw new Error("Package version not found");
|
|
18434
|
+
}
|
|
18435
|
+
return pkg.version;
|
|
17214
18436
|
}
|
|
17215
|
-
|
|
17216
|
-
|
|
17217
|
-
const dev = options.dev ?? process.argv.includes("--dev");
|
|
17218
|
-
const replaceVariant = options.replaceVariant ?? true;
|
|
17219
|
-
const log = options.log ?? console.log;
|
|
17220
|
-
const warn = options.warn ?? console.warn;
|
|
17221
|
-
const bundled = resolvePluginAssetPath(options.sourcePath);
|
|
17222
|
-
if (options.sourcePath !== void 0 && !bundled) {
|
|
17223
|
-
throw new Error(`Plugin asset not found at explicit path ${options.sourcePath}.`);
|
|
17224
|
-
}
|
|
17225
|
-
if (bundled) {
|
|
17226
|
-
const result2 = await installPluginAsset({
|
|
18437
|
+
function installSource(source, options) {
|
|
18438
|
+
return installPluginAsset({
|
|
17227
18439
|
pluginsFolder: getPluginsFolder(),
|
|
17228
18440
|
assetName: ASSET_NAME,
|
|
17229
18441
|
otherAssetName: OTHER_VARIANT,
|
|
17230
|
-
source
|
|
18442
|
+
source,
|
|
17231
18443
|
expectedVersion: packageVersion(),
|
|
17232
|
-
expectedVariant:
|
|
17233
|
-
replaceVariant,
|
|
17234
|
-
log,
|
|
17235
|
-
warn
|
|
18444
|
+
expectedVariant: variant,
|
|
18445
|
+
replaceVariant: options.replaceVariant ?? true,
|
|
18446
|
+
log: options.log ?? console.log,
|
|
18447
|
+
warn: options.warn ?? console.warn
|
|
17236
18448
|
});
|
|
17237
|
-
|
|
17238
|
-
|
|
18449
|
+
}
|
|
18450
|
+
async function installBundledPlugin2(options = {}) {
|
|
18451
|
+
const log = options.log ?? console.log;
|
|
18452
|
+
const sourcePath = resolvePluginAssetPath(options.sourcePath);
|
|
18453
|
+
if (!sourcePath) {
|
|
18454
|
+
throw new Error(`Bundled ${ASSET_NAME} not found. Run npm run ${buildCommand} in this worktree first.`);
|
|
18455
|
+
}
|
|
18456
|
+
const result = installSource(readFileSync9(sourcePath), options);
|
|
18457
|
+
if (result.installed) {
|
|
18458
|
+
log(`Installed ${ASSET_NAME} to ${result.destination}`);
|
|
18459
|
+
}
|
|
18460
|
+
}
|
|
18461
|
+
async function installPlugin2(options = {}, dev = false) {
|
|
18462
|
+
const log = options.log ?? console.log;
|
|
18463
|
+
const bundled = resolvePluginAssetPath(options.sourcePath);
|
|
18464
|
+
if (options.sourcePath !== void 0 && !bundled) {
|
|
18465
|
+
throw new Error(`Plugin asset not found at explicit path ${options.sourcePath}.`);
|
|
18466
|
+
}
|
|
18467
|
+
if (bundled) {
|
|
18468
|
+
const result2 = installSource(readFileSync9(bundled), options);
|
|
18469
|
+
if (result2.installed) {
|
|
18470
|
+
log(`Installed bundled ${ASSET_NAME} to ${result2.destination}`);
|
|
18471
|
+
} else {
|
|
18472
|
+
log(`${ASSET_NAME} already installed.`);
|
|
18473
|
+
}
|
|
18474
|
+
return;
|
|
18475
|
+
}
|
|
18476
|
+
log(dev ? "Fetching matching dev release..." : "Fetching matching release...");
|
|
18477
|
+
const release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/tags/v${encodeURIComponent(packageVersion())}`);
|
|
18478
|
+
const asset = release.assets?.find((a) => a.name === ASSET_NAME);
|
|
18479
|
+
if (!asset) {
|
|
18480
|
+
throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
|
|
18481
|
+
}
|
|
18482
|
+
log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
|
|
18483
|
+
const downloaded = await download(asset.browser_download_url);
|
|
18484
|
+
const result = installSource(downloaded, options);
|
|
18485
|
+
if (result.installed) {
|
|
18486
|
+
log(`Installed to ${result.destination}`);
|
|
17239
18487
|
} else {
|
|
17240
18488
|
log(`${ASSET_NAME} already installed.`);
|
|
17241
18489
|
}
|
|
17242
|
-
return;
|
|
17243
|
-
}
|
|
17244
|
-
log(dev ? "Fetching latest dev prerelease..." : "Fetching latest release...");
|
|
17245
|
-
const release = dev ? await findDevRelease() : await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
|
|
17246
|
-
const asset = release.assets?.find((a) => a.name === ASSET_NAME);
|
|
17247
|
-
if (!asset) {
|
|
17248
|
-
throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
|
|
17249
|
-
}
|
|
17250
|
-
log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
|
|
17251
|
-
const downloaded = await download(asset.browser_download_url);
|
|
17252
|
-
const result = await installPluginAsset({
|
|
17253
|
-
pluginsFolder: getPluginsFolder(),
|
|
17254
|
-
assetName: ASSET_NAME,
|
|
17255
|
-
otherAssetName: OTHER_VARIANT,
|
|
17256
|
-
source: downloaded,
|
|
17257
|
-
expectedVersion: release.tag_name.replace(/^v/, ""),
|
|
17258
|
-
expectedVariant: "main",
|
|
17259
|
-
replaceVariant,
|
|
17260
|
-
log,
|
|
17261
|
-
warn
|
|
17262
|
-
});
|
|
17263
|
-
if (result.installed) {
|
|
17264
|
-
log(`Installed to ${result.destination}`);
|
|
17265
|
-
} else {
|
|
17266
|
-
log(`${ASSET_NAME} already installed.`);
|
|
17267
18490
|
}
|
|
18491
|
+
return { installBundledPlugin: installBundledPlugin2, installPlugin: installPlugin2 };
|
|
18492
|
+
}
|
|
18493
|
+
|
|
18494
|
+
// src/index.ts
|
|
18495
|
+
import { createRequire } from "module";
|
|
18496
|
+
|
|
18497
|
+
// src/install-plugin.ts
|
|
18498
|
+
var installer = createPluginInstaller(import.meta.url, "main");
|
|
18499
|
+
async function installBundledPlugin(options = {}) {
|
|
18500
|
+
await installer.installBundledPlugin(options);
|
|
18501
|
+
}
|
|
18502
|
+
async function installPlugin(options = {}) {
|
|
18503
|
+
await installer.installPlugin(options, options.dev ?? process.argv.includes("--dev"));
|
|
17268
18504
|
}
|
|
17269
18505
|
|
|
17270
18506
|
// src/index.ts
|