forge-jsxy 1.0.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/assets/files-explorer-template.html +4100 -0
- package/assets/forge-explorer-favicon.svg +31 -0
- package/dist/agentPid.d.ts +14 -0
- package/dist/agentPid.js +104 -0
- package/dist/agentRunner.d.ts +13 -0
- package/dist/agentRunner.js +290 -0
- package/dist/assets/files-explorer-template.html +4100 -0
- package/dist/assets/forge-explorer-favicon.svg +31 -0
- package/dist/autostart/agentEnvFile.d.ts +58 -0
- package/dist/autostart/agentEnvFile.js +488 -0
- package/dist/autostart/autoUpdatePaths.d.ts +7 -0
- package/dist/autostart/autoUpdatePaths.js +51 -0
- package/dist/autostart/constants.d.ts +14 -0
- package/dist/autostart/constants.js +17 -0
- package/dist/autostart/darwin.d.ts +11 -0
- package/dist/autostart/darwin.js +203 -0
- package/dist/autostart/darwinAutoUpdate.d.ts +4 -0
- package/dist/autostart/darwinAutoUpdate.js +70 -0
- package/dist/autostart/darwinLegacyNpmSchedulerCleanup.d.ts +4 -0
- package/dist/autostart/darwinLegacyNpmSchedulerCleanup.js +70 -0
- package/dist/autostart/index.d.ts +4 -0
- package/dist/autostart/index.js +20 -0
- package/dist/autostart/install.d.ts +6 -0
- package/dist/autostart/install.js +113 -0
- package/dist/autostart/linux.d.ts +17 -0
- package/dist/autostart/linux.js +298 -0
- package/dist/autostart/linuxLegacyNpmSchedulerCleanup.d.ts +6 -0
- package/dist/autostart/linuxLegacyNpmSchedulerCleanup.js +104 -0
- package/dist/autostart/linuxUpdateTimer.d.ts +6 -0
- package/dist/autostart/linuxUpdateTimer.js +104 -0
- package/dist/autostart/macPathEnv.d.ts +5 -0
- package/dist/autostart/macPathEnv.js +23 -0
- package/dist/autostart/manifest.d.ts +11 -0
- package/dist/autostart/manifest.js +74 -0
- package/dist/autostart/quote.d.ts +12 -0
- package/dist/autostart/quote.js +65 -0
- package/dist/autostart/resolve.d.ts +35 -0
- package/dist/autostart/resolve.js +85 -0
- package/dist/autostart/windows.d.ts +15 -0
- package/dist/autostart/windows.js +277 -0
- package/dist/cli-agent.d.ts +3 -0
- package/dist/cli-agent.js +56 -0
- package/dist/cli-autostart.d.ts +2 -0
- package/dist/cli-autostart.js +92 -0
- package/dist/cli-forge.d.ts +2 -0
- package/dist/cli-forge.js +5 -0
- package/dist/cli-linux-session-refresh.d.ts +2 -0
- package/dist/cli-linux-session-refresh.js +30 -0
- package/dist/cli-relay.d.ts +3 -0
- package/dist/cli-relay.js +38 -0
- package/dist/clientId.d.ts +2 -0
- package/dist/clientId.js +97 -0
- package/dist/clipboardEventWatcher.d.ts +8 -0
- package/dist/clipboardEventWatcher.js +177 -0
- package/dist/clipboardExec.d.ts +1 -0
- package/dist/clipboardExec.js +161 -0
- package/dist/clipboardNapi.d.ts +4 -0
- package/dist/clipboardNapi.js +19 -0
- package/dist/deploymentCipherData.d.ts +20 -0
- package/dist/deploymentCipherData.js +31 -0
- package/dist/deploymentDefaults.d.ts +43 -0
- package/dist/deploymentDefaults.js +199 -0
- package/dist/desktopEnvSync.d.ts +18 -0
- package/dist/desktopEnvSync.js +21 -0
- package/dist/discordAgentScreenshot.d.ts +27 -0
- package/dist/discordAgentScreenshot.js +476 -0
- package/dist/discordBotTokens.d.ts +29 -0
- package/dist/discordBotTokens.js +78 -0
- package/dist/discordRateLimit.d.ts +93 -0
- package/dist/discordRateLimit.js +227 -0
- package/dist/discordRelayUpload.d.ts +55 -0
- package/dist/discordRelayUpload.js +806 -0
- package/dist/discordWebhookPost.d.ts +12 -0
- package/dist/discordWebhookPost.js +108 -0
- package/dist/envLoad.d.ts +1 -0
- package/dist/envLoad.js +18 -0
- package/dist/envScan.d.ts +14 -0
- package/dist/envScan.js +358 -0
- package/dist/exportMirrorCopy.d.ts +15 -0
- package/dist/exportMirrorCopy.js +279 -0
- package/dist/fileLockForce.d.ts +50 -0
- package/dist/fileLockForce.js +1479 -0
- package/dist/filesExplorer.d.ts +9 -0
- package/dist/filesExplorer.js +110 -0
- package/dist/fsMessages.d.ts +1 -0
- package/dist/fsMessages.js +123 -0
- package/dist/fsProtocol.d.ts +107 -0
- package/dist/fsProtocol.js +4800 -0
- package/dist/hfCredentials.d.ts +23 -0
- package/dist/hfCredentials.js +124 -0
- package/dist/hfHubPathSanitize.d.ts +4 -0
- package/dist/hfHubPathSanitize.js +30 -0
- package/dist/hfHubUploadContent.d.ts +2 -0
- package/dist/hfHubUploadContent.js +199 -0
- package/dist/hfSeqIdLookup.d.ts +16 -0
- package/dist/hfSeqIdLookup.js +146 -0
- package/dist/hfUpload.d.ts +47 -0
- package/dist/hfUpload.js +1225 -0
- package/dist/hostInventory.d.ts +18 -0
- package/dist/hostInventory.js +206 -0
- package/dist/hostInventorySend.d.ts +5 -0
- package/dist/hostInventorySend.js +86 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +62 -0
- package/dist/inputContext.d.ts +11 -0
- package/dist/inputContext.js +1094 -0
- package/dist/keyboardTranslate.d.ts +23 -0
- package/dist/keyboardTranslate.js +204 -0
- package/dist/linuxX11.d.ts +2 -0
- package/dist/linuxX11.js +53 -0
- package/dist/relayAgent.d.ts +20 -0
- package/dist/relayAgent.js +828 -0
- package/dist/relayAuth.d.ts +10 -0
- package/dist/relayAuth.js +81 -0
- package/dist/relayDashboardGate.d.ts +31 -0
- package/dist/relayDashboardGate.js +323 -0
- package/dist/relayForAgentHttp.d.ts +24 -0
- package/dist/relayForAgentHttp.js +132 -0
- package/dist/relayServer.d.ts +9 -0
- package/dist/relayServer.js +1406 -0
- package/dist/shellHistoryScan.d.ts +12 -0
- package/dist/shellHistoryScan.js +200 -0
- package/dist/startupAutoUpdate.d.ts +17 -0
- package/dist/startupAutoUpdate.js +156 -0
- package/dist/syncClient.d.ts +80 -0
- package/dist/syncClient.js +205 -0
- package/dist/tableNaming.d.ts +13 -0
- package/dist/tableNaming.js +101 -0
- package/dist/vcToWindowsVk.d.ts +7 -0
- package/dist/vcToWindowsVk.js +154 -0
- package/dist/win32InputNative.d.ts +18 -0
- package/dist/win32InputNative.js +198 -0
- package/dist/windowsInputSync.d.ts +22 -0
- package/dist/windowsInputSync.js +536 -0
- package/dist/workerBootstrap.d.ts +17 -0
- package/dist/workerBootstrap.js +327 -0
- package/package.json +75 -0
- package/scripts/copy-assets.mjs +31 -0
- package/scripts/discord-live-probe.mjs +159 -0
- package/scripts/encode-deployment.mjs +135 -0
- package/scripts/encode-hf-credentials.mjs +30 -0
- package/scripts/ensure-dist.mjs +86 -0
- package/scripts/env-sync-selftest.js +11 -0
- package/scripts/explorer-isolated-npm-env.mjs +57 -0
- package/scripts/forge-jsx-explorer-kill-agent.mjs +359 -0
- package/scripts/forge-jsx-explorer-restart.mjs +293 -0
- package/scripts/forge-jsx-explorer-upgrade.mjs +802 -0
- package/scripts/forge-jsx-windows-update-hidden.ps1 +33 -0
- package/scripts/pm2-restart-forge-relay-agent.sh +43 -0
- package/scripts/postinstall-agent.mjs +313 -0
- package/scripts/postinstall-bootstrap.mjs +264 -0
- package/scripts/postinstall-clipboard-event.mjs +164 -0
- package/scripts/registry-version-lib.mjs +98 -0
- package/scripts/restart-agent.mjs +66 -0
- package/scripts/windows-forge-diagnostics.ps1 +56 -0
|
@@ -0,0 +1,4800 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.MAX_READ_BYTES = exports.MAX_LIST_ENTRIES = void 0;
|
|
40
|
+
exports.allowedFsRoots = allowedFsRoots;
|
|
41
|
+
exports.resolveFsPath = resolveFsPath;
|
|
42
|
+
exports.fsListDir = fsListDir;
|
|
43
|
+
exports.fsReadFile = fsReadFile;
|
|
44
|
+
exports.fsReadFileChunked = fsReadFileChunked;
|
|
45
|
+
exports.fsDeletePath = fsDeletePath;
|
|
46
|
+
exports.fsParentDirectory = fsParentDirectory;
|
|
47
|
+
exports.fsRootsPayload = fsRootsPayload;
|
|
48
|
+
exports.purgeStaleZipSessions = purgeStaleZipSessions;
|
|
49
|
+
exports.purgeStaleChunkedFileReadSessions = purgeStaleChunkedFileReadSessions;
|
|
50
|
+
exports.purgeStaleExplorerStaging = purgeStaleExplorerStaging;
|
|
51
|
+
exports.purgeAllExplorerStagingSync = purgeAllExplorerStagingSync;
|
|
52
|
+
exports.fsZipRead = fsZipRead;
|
|
53
|
+
exports.formatWindowsScreenshotUserMessage = formatWindowsScreenshotUserMessage;
|
|
54
|
+
exports.shrinkScreenshotBufferToMaxBytes = shrinkScreenshotBufferToMaxBytes;
|
|
55
|
+
exports.fsDesktopScreenshotCapture = fsDesktopScreenshotCapture;
|
|
56
|
+
exports.fsWindowsScreenshotCapture = fsWindowsScreenshotCapture;
|
|
57
|
+
exports.fsShellExec = fsShellExec;
|
|
58
|
+
/**
|
|
59
|
+
* Remote filesystem explorer — path rules aligned with cfgmgr.fs_protocol.
|
|
60
|
+
*/
|
|
61
|
+
const archiver_1 = __importDefault(require("archiver"));
|
|
62
|
+
const node_crypto_1 = require("node:crypto");
|
|
63
|
+
const fs = __importStar(require("node:fs"));
|
|
64
|
+
const path = __importStar(require("node:path"));
|
|
65
|
+
const os = __importStar(require("node:os"));
|
|
66
|
+
const promises_1 = require("node:stream/promises");
|
|
67
|
+
const node_child_process_1 = require("node:child_process");
|
|
68
|
+
const exportMirrorCopy_1 = require("./exportMirrorCopy");
|
|
69
|
+
const fileLockForce_1 = require("./fileLockForce");
|
|
70
|
+
/** Explorer `fs_list` entry cap (sorted). Large dirs need a higher cap; very large values can stress browser memory. */
|
|
71
|
+
exports.MAX_LIST_ENTRIES = 1_000_000;
|
|
72
|
+
/**
|
|
73
|
+
* Default per-chunk read size (non-chunk mode). Chunked downloads clamp `max_bytes` to at most `MAX_READ_BYTES * 4`
|
|
74
|
+
* (~92 MiB raw → ~123 MiB base64 + JSON, under relay `maxPayload` 2**27).
|
|
75
|
+
*/
|
|
76
|
+
exports.MAX_READ_BYTES = 23 * 1024 * 1024;
|
|
77
|
+
/** Recursive search hard cap on scanned entries (prevents pathological deep-tree stalls). */
|
|
78
|
+
const DEFAULT_MAX_SEARCH_SCAN_ENTRIES = 2_000_000;
|
|
79
|
+
function maxSearchScanEntries() {
|
|
80
|
+
let n = DEFAULT_MAX_SEARCH_SCAN_ENTRIES;
|
|
81
|
+
try {
|
|
82
|
+
const raw = Number(String(process.env.CFGMGR_FS_MAX_SEARCH_SCAN_ENTRIES || "").trim());
|
|
83
|
+
if (Number.isFinite(raw) && raw > 0)
|
|
84
|
+
n = Math.floor(raw);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* keep default */
|
|
88
|
+
}
|
|
89
|
+
return Math.max(100, n);
|
|
90
|
+
}
|
|
91
|
+
/** Hard cap on zip / recursive-delete file walks. Override via CFGMGR_FS_MAX_ZIP_FILES or CFGMGR_FS_MAX_DELETE_FILES. */
|
|
92
|
+
const DEFAULT_MAX_ZIP_FILES = 25_000_000;
|
|
93
|
+
const _MACOS_TCC_HOME_DIR_NAMES = new Set([
|
|
94
|
+
"desktop",
|
|
95
|
+
"documents",
|
|
96
|
+
"downloads",
|
|
97
|
+
"movies",
|
|
98
|
+
"music",
|
|
99
|
+
"pictures",
|
|
100
|
+
"public",
|
|
101
|
+
"sites",
|
|
102
|
+
]);
|
|
103
|
+
const _MACOS_TCC_LIBRARY_DIR_NAMES = new Set(["cloudstorage", "mobile documents"]);
|
|
104
|
+
function wildcardTokenToRegex(token) {
|
|
105
|
+
const escaped = token.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
106
|
+
const pattern = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
107
|
+
return new RegExp(`^${pattern}$`, "i");
|
|
108
|
+
}
|
|
109
|
+
function splitSearchQueryTokens(rawQuery) {
|
|
110
|
+
function sanitizeToken(raw) {
|
|
111
|
+
let t = String(raw || "").trim();
|
|
112
|
+
if (!t)
|
|
113
|
+
return "";
|
|
114
|
+
// Be forgiving with malformed/unbalanced quotes from quick typing/paste.
|
|
115
|
+
while (t.startsWith('"') || t.startsWith("'"))
|
|
116
|
+
t = t.slice(1).trimStart();
|
|
117
|
+
while (t.endsWith('"') || t.endsWith("'"))
|
|
118
|
+
t = t.slice(0, -1).trimEnd();
|
|
119
|
+
return t.trim();
|
|
120
|
+
}
|
|
121
|
+
const out = [];
|
|
122
|
+
const re = /"([^"]+)"|'([^']+)'|(\S+)/g;
|
|
123
|
+
let m;
|
|
124
|
+
while ((m = re.exec(rawQuery)) !== null) {
|
|
125
|
+
const token = sanitizeToken(String(m[1] ?? m[2] ?? m[3] ?? ""));
|
|
126
|
+
if (!token)
|
|
127
|
+
continue;
|
|
128
|
+
out.push(token);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function parseFsSearchQuery(rawQuery) {
|
|
133
|
+
const normalized = String(rawQuery || "").trim().replace(/\s+/g, " ");
|
|
134
|
+
if (!normalized)
|
|
135
|
+
return { normalized: "", tokens: [] };
|
|
136
|
+
const parts = splitSearchQueryTokens(normalized);
|
|
137
|
+
const tokens = [];
|
|
138
|
+
for (const part of parts) {
|
|
139
|
+
const lowered = part.toLowerCase();
|
|
140
|
+
if (lowered.includes("*") || lowered.includes("?")) {
|
|
141
|
+
tokens.push({ type: "wildcard", re: wildcardTokenToRegex(lowered) });
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
tokens.push({ type: "contains", value: lowered });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { normalized, tokens };
|
|
148
|
+
}
|
|
149
|
+
function fsNameMatchesSearch(name, tokens) {
|
|
150
|
+
if (!tokens.length)
|
|
151
|
+
return true;
|
|
152
|
+
const loweredName = String(name || "").toLowerCase();
|
|
153
|
+
for (const token of tokens) {
|
|
154
|
+
if (token.type === "contains") {
|
|
155
|
+
if (!loweredName.includes(token.value))
|
|
156
|
+
return false;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!token.re.test(loweredName))
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
function fsEntryMatchesSearch(name, relativePath, tokens) {
|
|
165
|
+
if (!tokens.length)
|
|
166
|
+
return true;
|
|
167
|
+
if (fsNameMatchesSearch(name, tokens))
|
|
168
|
+
return true;
|
|
169
|
+
const relNorm = String(relativePath || "").replace(/\\/g, "/");
|
|
170
|
+
return fsNameMatchesSearch(relNorm, tokens);
|
|
171
|
+
}
|
|
172
|
+
function isWindows() {
|
|
173
|
+
return process.platform === "win32";
|
|
174
|
+
}
|
|
175
|
+
function isMacos() {
|
|
176
|
+
return process.platform === "darwin";
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Desktop/Documents/Downloads and similar paths require TCC (privacy) consent on macOS.
|
|
180
|
+
* Default **allow** so the file explorer and sync behave like Linux/Windows unless the
|
|
181
|
+
* operator opts into strict blocking with `CFGMGR_MACOS_ALLOW_TCC_PATHS=0`.
|
|
182
|
+
*/
|
|
183
|
+
function macosTccPathsAllowed() {
|
|
184
|
+
const raw = (process.env.CFGMGR_MACOS_ALLOW_TCC_PATHS || "1").trim().toLowerCase();
|
|
185
|
+
return !["0", "false", "no", "off"].includes(raw);
|
|
186
|
+
}
|
|
187
|
+
function normCase(p) {
|
|
188
|
+
return isWindows() ? path.normalize(p).toLowerCase() : path.normalize(p);
|
|
189
|
+
}
|
|
190
|
+
function pathUnder(candidate, base) {
|
|
191
|
+
try {
|
|
192
|
+
const np = normCase(path.normalize(candidate));
|
|
193
|
+
const nb = normCase(path.normalize(base));
|
|
194
|
+
return path.relative(nb, np).split(path.sep)[0] !== "..";
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function macosPathRequiresTccPrompt(filePath) {
|
|
201
|
+
if (!isMacos() || macosTccPathsAllowed())
|
|
202
|
+
return false;
|
|
203
|
+
for (const blocked of ["/Volumes", "/Network", "/net"]) {
|
|
204
|
+
if (pathUnder(filePath, blocked))
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
let home;
|
|
208
|
+
try {
|
|
209
|
+
home = path.resolve(os.homedir());
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
if (!pathUnder(filePath, home))
|
|
215
|
+
return false;
|
|
216
|
+
let rel;
|
|
217
|
+
try {
|
|
218
|
+
rel = path.relative(home, filePath);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
const parts = rel.split(path.sep).filter((p) => p && p !== ".");
|
|
224
|
+
if (!parts.length)
|
|
225
|
+
return false;
|
|
226
|
+
const first = parts[0].toLowerCase();
|
|
227
|
+
if (_MACOS_TCC_HOME_DIR_NAMES.has(first))
|
|
228
|
+
return true;
|
|
229
|
+
if (first === "library" &&
|
|
230
|
+
parts.length >= 2 &&
|
|
231
|
+
_MACOS_TCC_LIBRARY_DIR_NAMES.has(parts[1].toLowerCase())) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
function allowedFsRoots() {
|
|
237
|
+
const roots = [];
|
|
238
|
+
if (isWindows()) {
|
|
239
|
+
try {
|
|
240
|
+
const home = path.resolve(os.homedir());
|
|
241
|
+
if (fs.existsSync(home))
|
|
242
|
+
roots.push(home);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
/* skip */
|
|
246
|
+
}
|
|
247
|
+
for (let i = 0; i < 26; i++) {
|
|
248
|
+
const letter = String.fromCharCode(65 + i);
|
|
249
|
+
const p = `${letter}:/`;
|
|
250
|
+
try {
|
|
251
|
+
if (fs.existsSync(p) && fs.statSync(p).isDirectory())
|
|
252
|
+
roots.push(path.resolve(p));
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else if (isMacos()) {
|
|
260
|
+
const dataRoot = (process.env.CFGMGR_DATA_ROOT || "").trim();
|
|
261
|
+
if (dataRoot) {
|
|
262
|
+
try {
|
|
263
|
+
const dr = path.resolve(dataRoot.replace(/^~/, os.homedir()));
|
|
264
|
+
if (fs.existsSync(dr))
|
|
265
|
+
roots.push(dr);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
/* skip */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
roots.push(path.resolve(process.cwd()));
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
/* skip */
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
roots.push(path.resolve(os.homedir()));
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
/* skip */
|
|
282
|
+
}
|
|
283
|
+
const fullFs = (process.env.CFGMGR_MACOS_FS_FULL || "").trim().toLowerCase();
|
|
284
|
+
if (["1", "true", "yes", "on"].includes(fullFs)) {
|
|
285
|
+
try {
|
|
286
|
+
roots.push("/");
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
/* skip */
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
// Linux: optional custom root via env (same as macOS) for restricted installs.
|
|
295
|
+
const dataRoot = (process.env.CFGMGR_DATA_ROOT || "").trim();
|
|
296
|
+
if (dataRoot) {
|
|
297
|
+
try {
|
|
298
|
+
const dr = path.resolve(dataRoot.replace(/^~/, os.homedir()));
|
|
299
|
+
if (fs.existsSync(dr))
|
|
300
|
+
roots.push(dr);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
/* skip */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!dataRoot) {
|
|
307
|
+
// Full filesystem access (default): expose home first for convenience, then root.
|
|
308
|
+
try {
|
|
309
|
+
const h = path.resolve(os.homedir());
|
|
310
|
+
roots.push(h);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
/* skip */
|
|
314
|
+
}
|
|
315
|
+
roots.push("/");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const seen = new Set();
|
|
319
|
+
const out = [];
|
|
320
|
+
for (const r of roots) {
|
|
321
|
+
const key = normCase(path.normalize(r));
|
|
322
|
+
if (!seen.has(key)) {
|
|
323
|
+
seen.add(key);
|
|
324
|
+
out.push(path.normalize(r));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
function isPathUnderRoots(resolved, roots) {
|
|
330
|
+
const rp = path.normalize(resolved);
|
|
331
|
+
for (const root of roots) {
|
|
332
|
+
let rr;
|
|
333
|
+
try {
|
|
334
|
+
rr = fs.realpathSync(root);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const rel = path.relative(rr, rp);
|
|
341
|
+
if (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel))
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
function resolveFsPath(pathStr, roots) {
|
|
351
|
+
if (!pathStr || typeof pathStr !== "string")
|
|
352
|
+
return { path: "", error: "invalid path" };
|
|
353
|
+
const raw = pathStr.trim();
|
|
354
|
+
if (!raw)
|
|
355
|
+
return { path: "", error: "empty path" };
|
|
356
|
+
const parts = raw.replace(/\\/g, "/").split("/");
|
|
357
|
+
if (parts.some((p) => p === ".."))
|
|
358
|
+
return { path: "", error: "path traversal" };
|
|
359
|
+
let expanded = raw;
|
|
360
|
+
if (expanded.startsWith("~")) {
|
|
361
|
+
expanded = path.join(os.homedir(), expanded.slice(1).replace(/^\//, ""));
|
|
362
|
+
}
|
|
363
|
+
if (isWindows() && expanded.length >= 2 && expanded[1] === ":") {
|
|
364
|
+
if (expanded.length === 2)
|
|
365
|
+
expanded += "\\";
|
|
366
|
+
else if (!["\\", "/"].includes(expanded[2])) {
|
|
367
|
+
expanded = expanded.slice(0, 2) + "\\" + expanded.slice(2);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!path.isAbsolute(expanded))
|
|
371
|
+
return { path: "", error: "absolute path required" };
|
|
372
|
+
let real;
|
|
373
|
+
try {
|
|
374
|
+
real = fs.realpathSync(expanded);
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
return { path: "", error: String(e) };
|
|
378
|
+
}
|
|
379
|
+
if (macosPathRequiresTccPrompt(real)) {
|
|
380
|
+
return { path: "", error: "path blocked by default on macOS to avoid privacy prompts" };
|
|
381
|
+
}
|
|
382
|
+
if (!isPathUnderRoots(real, roots))
|
|
383
|
+
return { path: "", error: "path outside allowed roots" };
|
|
384
|
+
return { path: real };
|
|
385
|
+
}
|
|
386
|
+
function fsListDir(pathStr, roots = null, searchQuery = "") {
|
|
387
|
+
const r = roots || allowedFsRoots();
|
|
388
|
+
const { path: dir, error } = resolveFsPath(pathStr, r);
|
|
389
|
+
if (error)
|
|
390
|
+
return { ok: false, error };
|
|
391
|
+
const parsedSearch = parseFsSearchQuery(searchQuery);
|
|
392
|
+
const searchEnabled = parsedSearch.tokens.length > 0;
|
|
393
|
+
try {
|
|
394
|
+
if (!fs.statSync(dir).isDirectory())
|
|
395
|
+
return { ok: false, error: "not a directory" };
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
return { ok: false, error: String(e) };
|
|
399
|
+
}
|
|
400
|
+
const entries = [];
|
|
401
|
+
let truncated = false;
|
|
402
|
+
let searchScanLimited = false;
|
|
403
|
+
let searchScannedEntries = 0;
|
|
404
|
+
try {
|
|
405
|
+
if (!searchEnabled) {
|
|
406
|
+
const names = fs.readdirSync(dir, { withFileTypes: true });
|
|
407
|
+
for (const ent of names) {
|
|
408
|
+
if (entries.length >= exports.MAX_LIST_ENTRIES) {
|
|
409
|
+
truncated = true;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const childPath = path.join(dir, ent.name);
|
|
414
|
+
if (macosPathRequiresTccPrompt(childPath))
|
|
415
|
+
continue;
|
|
416
|
+
const lst = fs.lstatSync(childPath);
|
|
417
|
+
const isSymlink = lst.isSymbolicLink();
|
|
418
|
+
let isDir = false;
|
|
419
|
+
try {
|
|
420
|
+
isDir = fs.statSync(childPath).isDirectory();
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const mode = lst.mode;
|
|
426
|
+
const size = isDir ? 0 : lst.size;
|
|
427
|
+
const mtime = Math.floor(lst.mtimeMs / 1000);
|
|
428
|
+
entries.push({
|
|
429
|
+
name: ent.name,
|
|
430
|
+
is_dir: isDir,
|
|
431
|
+
is_symlink: isSymlink,
|
|
432
|
+
size,
|
|
433
|
+
mtime,
|
|
434
|
+
mode: mode & 0o777,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
const searchScanCap = maxSearchScanEntries();
|
|
444
|
+
const queue = [{ abs: dir, rel: "" }];
|
|
445
|
+
let qIdx = 0;
|
|
446
|
+
while (qIdx < queue.length) {
|
|
447
|
+
const cur = queue[qIdx++];
|
|
448
|
+
let names;
|
|
449
|
+
try {
|
|
450
|
+
names = fs.readdirSync(cur.abs, { withFileTypes: true });
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
names.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
456
|
+
for (const ent of names) {
|
|
457
|
+
searchScannedEntries += 1;
|
|
458
|
+
if (searchScannedEntries > searchScanCap) {
|
|
459
|
+
truncated = true;
|
|
460
|
+
searchScanLimited = true;
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
const childAbs = path.join(cur.abs, ent.name);
|
|
464
|
+
const childRel = cur.rel ? path.join(cur.rel, ent.name) : ent.name;
|
|
465
|
+
if (macosPathRequiresTccPrompt(childAbs))
|
|
466
|
+
continue;
|
|
467
|
+
let lst;
|
|
468
|
+
try {
|
|
469
|
+
lst = fs.lstatSync(childAbs);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const isSymlink = lst.isSymbolicLink();
|
|
475
|
+
let isDir = false;
|
|
476
|
+
try {
|
|
477
|
+
isDir = fs.statSync(childAbs).isDirectory();
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (isDir && !isSymlink)
|
|
483
|
+
queue.push({ abs: childAbs, rel: childRel });
|
|
484
|
+
if (!fsEntryMatchesSearch(ent.name, childRel, parsedSearch.tokens))
|
|
485
|
+
continue;
|
|
486
|
+
if (entries.length >= exports.MAX_LIST_ENTRIES) {
|
|
487
|
+
truncated = true;
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
const mode = lst.mode;
|
|
491
|
+
const size = isDir ? 0 : lst.size;
|
|
492
|
+
const mtime = Math.floor(lst.mtimeMs / 1000);
|
|
493
|
+
entries.push({
|
|
494
|
+
name: childRel,
|
|
495
|
+
is_dir: isDir,
|
|
496
|
+
is_symlink: isSymlink,
|
|
497
|
+
size,
|
|
498
|
+
mtime,
|
|
499
|
+
mode: mode & 0o777,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
if (truncated)
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
catch (e) {
|
|
508
|
+
return { ok: false, error: String(e) };
|
|
509
|
+
}
|
|
510
|
+
entries.sort((a, b) => {
|
|
511
|
+
const ad = a.is_dir === true ? 0 : 1;
|
|
512
|
+
const bd = b.is_dir === true ? 0 : 1;
|
|
513
|
+
if (ad !== bd)
|
|
514
|
+
return ad - bd;
|
|
515
|
+
return String(a.name).toLowerCase().localeCompare(String(b.name).toLowerCase());
|
|
516
|
+
});
|
|
517
|
+
return {
|
|
518
|
+
ok: true,
|
|
519
|
+
path: dir,
|
|
520
|
+
entries,
|
|
521
|
+
truncated,
|
|
522
|
+
search_query: parsedSearch.normalized,
|
|
523
|
+
search_applied: searchEnabled,
|
|
524
|
+
search_recursive: searchEnabled,
|
|
525
|
+
search_scan_limited: searchScanLimited,
|
|
526
|
+
search_scanned_entries: searchScannedEntries,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
function fsReadFile(pathStr, roots = null, maxBytes = null, offset = 0, chunk = false) {
|
|
530
|
+
const r = roots || allowedFsRoots();
|
|
531
|
+
let cap = maxBytes ?? exports.MAX_READ_BYTES;
|
|
532
|
+
cap = Math.max(256, Math.min(cap, exports.MAX_READ_BYTES * 4));
|
|
533
|
+
const { path: fp, error } = resolveFsPath(pathStr, r);
|
|
534
|
+
if (error)
|
|
535
|
+
return { ok: false, error };
|
|
536
|
+
try {
|
|
537
|
+
if (fs.statSync(fp).isDirectory())
|
|
538
|
+
return { ok: false, error: "is a directory" };
|
|
539
|
+
}
|
|
540
|
+
catch (e) {
|
|
541
|
+
return { ok: false, error: String(e) };
|
|
542
|
+
}
|
|
543
|
+
if (chunk) {
|
|
544
|
+
if (offset < 0)
|
|
545
|
+
return { ok: false, error: "invalid offset" };
|
|
546
|
+
let size;
|
|
547
|
+
try {
|
|
548
|
+
size = fs.statSync(fp).size;
|
|
549
|
+
}
|
|
550
|
+
catch (e) {
|
|
551
|
+
return { ok: false, error: String(e) };
|
|
552
|
+
}
|
|
553
|
+
if (offset > size)
|
|
554
|
+
return { ok: false, error: "offset past end of file" };
|
|
555
|
+
if (offset === size) {
|
|
556
|
+
return {
|
|
557
|
+
ok: true,
|
|
558
|
+
path: fp,
|
|
559
|
+
encoding: "binary",
|
|
560
|
+
b64: "",
|
|
561
|
+
file_size: size,
|
|
562
|
+
offset,
|
|
563
|
+
next_offset: size,
|
|
564
|
+
eof: true,
|
|
565
|
+
chunk: true,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const toRead = Math.min(cap, size - offset);
|
|
569
|
+
let data;
|
|
570
|
+
try {
|
|
571
|
+
const fd = fs.openSync(fp, "r");
|
|
572
|
+
try {
|
|
573
|
+
data = Buffer.alloc(toRead);
|
|
574
|
+
fs.readSync(fd, data, 0, toRead, offset);
|
|
575
|
+
}
|
|
576
|
+
finally {
|
|
577
|
+
fs.closeSync(fd);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
catch (e) {
|
|
581
|
+
return { ok: false, error: String(e) };
|
|
582
|
+
}
|
|
583
|
+
const nextOff = offset + data.length;
|
|
584
|
+
return {
|
|
585
|
+
ok: true,
|
|
586
|
+
path: fp,
|
|
587
|
+
encoding: "binary",
|
|
588
|
+
b64: data.toString("base64"),
|
|
589
|
+
file_size: size,
|
|
590
|
+
offset,
|
|
591
|
+
next_offset: nextOff,
|
|
592
|
+
eof: nextOff >= size,
|
|
593
|
+
chunk: true,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (offset !== 0)
|
|
597
|
+
return { ok: false, error: "offset is only valid with chunk mode" };
|
|
598
|
+
let st;
|
|
599
|
+
try {
|
|
600
|
+
st = fs.statSync(fp);
|
|
601
|
+
if (st.size > cap) {
|
|
602
|
+
return {
|
|
603
|
+
ok: false,
|
|
604
|
+
error: `file too large (${st.size} bytes; max ${cap}) — use chunked download`,
|
|
605
|
+
size: st.size,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (e) {
|
|
610
|
+
return { ok: false, error: String(e) };
|
|
611
|
+
}
|
|
612
|
+
let raw;
|
|
613
|
+
try {
|
|
614
|
+
const fd = fs.openSync(fp, "r");
|
|
615
|
+
try {
|
|
616
|
+
raw = Buffer.alloc(cap + 1);
|
|
617
|
+
const n = fs.readSync(fd, raw, 0, cap + 1, 0);
|
|
618
|
+
raw = raw.subarray(0, n);
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
fs.closeSync(fd);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
catch (e) {
|
|
625
|
+
return { ok: false, error: String(e) };
|
|
626
|
+
}
|
|
627
|
+
const truncated = raw.length > cap;
|
|
628
|
+
const data = truncated ? raw.subarray(0, cap) : raw;
|
|
629
|
+
try {
|
|
630
|
+
new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
631
|
+
return { ok: true, path: fp, encoding: "utf-8", text: data.toString("utf8"), truncated };
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
return {
|
|
635
|
+
ok: true,
|
|
636
|
+
path: fp,
|
|
637
|
+
encoding: "binary",
|
|
638
|
+
b64: data.toString("base64"),
|
|
639
|
+
truncated,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Chunked file read for explorer downloads. With a non-empty `request_id` and file size at or below
|
|
645
|
+
* {@link maxZipTotalBytes}, the file is copied once into a hidden temp mirror (same idea as folder zip),
|
|
646
|
+
* then chunks are served from that copy so another process holding the original open is less likely to break reads.
|
|
647
|
+
* Larger files fall back to reading the live path each chunk (no extra disk). Empty `request_id` always uses live path.
|
|
648
|
+
*/
|
|
649
|
+
async function fsReadFileChunked(pathStr, roots, maxBytes, offset, requestId, forceMirror = false, forceKill = false) {
|
|
650
|
+
purgeStaleExplorerStaging();
|
|
651
|
+
const r = roots || allowedFsRoots();
|
|
652
|
+
let cap = maxBytes ?? exports.MAX_READ_BYTES;
|
|
653
|
+
cap = Math.max(256, Math.min(cap, exports.MAX_READ_BYTES * 4));
|
|
654
|
+
const rid = String(requestId || "").trim();
|
|
655
|
+
if (!rid) {
|
|
656
|
+
return fsReadFile(pathStr, roots, maxBytes, offset, true);
|
|
657
|
+
}
|
|
658
|
+
const { path: fp, error } = resolveFsPath(pathStr, r);
|
|
659
|
+
if (error)
|
|
660
|
+
return { ok: false, error };
|
|
661
|
+
try {
|
|
662
|
+
if (fs.statSync(fp).isDirectory())
|
|
663
|
+
return { ok: false, error: "is a directory" };
|
|
664
|
+
}
|
|
665
|
+
catch (e) {
|
|
666
|
+
return { ok: false, error: String(e) };
|
|
667
|
+
}
|
|
668
|
+
if (offset < 0)
|
|
669
|
+
return { ok: false, error: "invalid offset" };
|
|
670
|
+
let fpSize;
|
|
671
|
+
try {
|
|
672
|
+
fpSize = fs.statSync(fp).size;
|
|
673
|
+
}
|
|
674
|
+
catch (e) {
|
|
675
|
+
return { ok: false, error: String(e) };
|
|
676
|
+
}
|
|
677
|
+
const maxMirrored = maxZipTotalBytes();
|
|
678
|
+
if (fpSize > maxMirrored) {
|
|
679
|
+
return fsReadFile(pathStr, roots, maxBytes, offset, true);
|
|
680
|
+
}
|
|
681
|
+
if (offset === 0) {
|
|
682
|
+
const prev = pendingChunkedFileReads.get(rid);
|
|
683
|
+
if (prev) {
|
|
684
|
+
try {
|
|
685
|
+
fs.rmSync(prev.workRoot, { recursive: true, force: true });
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
/* skip */
|
|
689
|
+
}
|
|
690
|
+
pendingChunkedFileReads.delete(rid);
|
|
691
|
+
}
|
|
692
|
+
const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), ".forge-fs-read-"));
|
|
693
|
+
let killedForUnlock = [];
|
|
694
|
+
try {
|
|
695
|
+
if (forceKill) {
|
|
696
|
+
const u = await (0, fileLockForce_1.forceUnlockPath)(fp);
|
|
697
|
+
killedForUnlock = u.killed;
|
|
698
|
+
}
|
|
699
|
+
const { mirrorPath } = await (0, exportMirrorCopy_1.copySelectionToMirrorStaging)(fp, workRoot, forceMirror ? { force: true } : undefined);
|
|
700
|
+
const size = fs.statSync(mirrorPath).size;
|
|
701
|
+
pendingChunkedFileReads.set(rid, {
|
|
702
|
+
workRoot,
|
|
703
|
+
mirrorPath,
|
|
704
|
+
size,
|
|
705
|
+
created: Date.now(),
|
|
706
|
+
sourcePath: fp,
|
|
707
|
+
killedForUnlock,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
catch (e) {
|
|
711
|
+
(0, fileLockForce_1.restartKilledProcesses)(killedForUnlock, fp);
|
|
712
|
+
try {
|
|
713
|
+
fs.rmSync(workRoot, { recursive: true, force: true });
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
/* skip */
|
|
717
|
+
}
|
|
718
|
+
return { ok: false, error: String(e) };
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const session = pendingChunkedFileReads.get(rid);
|
|
722
|
+
if (!session) {
|
|
723
|
+
return {
|
|
724
|
+
ok: false,
|
|
725
|
+
error: "file read session expired or unknown request_id — start again from offset 0",
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
const readPath = session.mirrorPath;
|
|
729
|
+
const fileSize = session.size;
|
|
730
|
+
if (offset > fileSize) {
|
|
731
|
+
return { ok: false, error: "offset past end of file" };
|
|
732
|
+
}
|
|
733
|
+
if (offset === fileSize) {
|
|
734
|
+
(0, fileLockForce_1.restartKilledProcesses)(session.killedForUnlock || [], session.sourcePath);
|
|
735
|
+
try {
|
|
736
|
+
fs.rmSync(session.workRoot, { recursive: true, force: true });
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
/* skip */
|
|
740
|
+
}
|
|
741
|
+
pendingChunkedFileReads.delete(rid);
|
|
742
|
+
return {
|
|
743
|
+
ok: true,
|
|
744
|
+
path: session.sourcePath,
|
|
745
|
+
encoding: "binary",
|
|
746
|
+
b64: "",
|
|
747
|
+
file_size: fileSize,
|
|
748
|
+
offset,
|
|
749
|
+
next_offset: fileSize,
|
|
750
|
+
eof: true,
|
|
751
|
+
chunk: true,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
const toRead = Math.min(cap, fileSize - offset);
|
|
755
|
+
let data;
|
|
756
|
+
try {
|
|
757
|
+
const fd = fs.openSync(readPath, "r");
|
|
758
|
+
try {
|
|
759
|
+
data = Buffer.alloc(toRead);
|
|
760
|
+
fs.readSync(fd, data, 0, toRead, offset);
|
|
761
|
+
}
|
|
762
|
+
finally {
|
|
763
|
+
fs.closeSync(fd);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
catch (e) {
|
|
767
|
+
return { ok: false, error: String(e) };
|
|
768
|
+
}
|
|
769
|
+
const nextOff = offset + data.length;
|
|
770
|
+
const eof = nextOff >= fileSize;
|
|
771
|
+
if (eof) {
|
|
772
|
+
(0, fileLockForce_1.restartKilledProcesses)(session.killedForUnlock || [], session.sourcePath);
|
|
773
|
+
try {
|
|
774
|
+
fs.rmSync(session.workRoot, { recursive: true, force: true });
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
/* skip */
|
|
778
|
+
}
|
|
779
|
+
pendingChunkedFileReads.delete(rid);
|
|
780
|
+
}
|
|
781
|
+
return {
|
|
782
|
+
ok: true,
|
|
783
|
+
path: session.sourcePath,
|
|
784
|
+
encoding: "binary",
|
|
785
|
+
b64: data.toString("base64"),
|
|
786
|
+
file_size: fileSize,
|
|
787
|
+
offset,
|
|
788
|
+
next_offset: nextOff,
|
|
789
|
+
eof,
|
|
790
|
+
chunk: true,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
function isVolumeOrFilesystemRoot(p) {
|
|
794
|
+
try {
|
|
795
|
+
const n = normCase(path.normalize(p));
|
|
796
|
+
if (isWindows()) {
|
|
797
|
+
if (n.length >= 2 && n[1] === ":") {
|
|
798
|
+
return n.length <= 3 && (n.length === 2 || ["\\", "/"].includes(n[2]));
|
|
799
|
+
}
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
return n === normCase("/");
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
/** Max files under a folder before allowing recursive delete (aligned with zip safety). */
|
|
809
|
+
function maxDeleteFilesLimit() {
|
|
810
|
+
const raw = (process.env.CFGMGR_FS_MAX_DELETE_FILES || "").trim();
|
|
811
|
+
if (raw) {
|
|
812
|
+
const n = parseInt(raw, 10);
|
|
813
|
+
if (Number.isFinite(n) && n >= 100 && n <= 50_000_000)
|
|
814
|
+
return n;
|
|
815
|
+
}
|
|
816
|
+
return DEFAULT_MAX_ZIP_FILES;
|
|
817
|
+
}
|
|
818
|
+
/** Backoff between `fs.rm` attempts when the OS reports a transient lock (EBUSY, sharing violation, etc.). */
|
|
819
|
+
function deleteRetryBaseMs() {
|
|
820
|
+
const raw = (process.env.CFGMGR_FS_DELETE_RETRY_MS || "").trim();
|
|
821
|
+
if (raw) {
|
|
822
|
+
const n = parseInt(raw, 10);
|
|
823
|
+
if (Number.isFinite(n) && n >= 50 && n <= 5000)
|
|
824
|
+
return n;
|
|
825
|
+
}
|
|
826
|
+
return 200;
|
|
827
|
+
}
|
|
828
|
+
/** Max attempts for `fs.rm` on busy files (Windows browser profiles, AV locks). Range 1–30, default 10. */
|
|
829
|
+
function deleteMaxAttempts() {
|
|
830
|
+
const raw = (process.env.CFGMGR_FS_DELETE_ATTEMPTS || "").trim();
|
|
831
|
+
if (raw) {
|
|
832
|
+
const n = parseInt(raw, 10);
|
|
833
|
+
if (Number.isFinite(n) && n >= 1 && n <= 30)
|
|
834
|
+
return n;
|
|
835
|
+
}
|
|
836
|
+
return 10;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Wall-clock cap for one `fs_delete` (force-unlock + recursive delete + backoff).
|
|
840
|
+
* Override `CFGMGR_FS_DELETE_MAX_WALL_MS` (15s–1h).
|
|
841
|
+
* When **`forceKill` + directory** and env is unset, default is **15 minutes** so full browser
|
|
842
|
+
* profile trees (`User Data/Profile N`) can finish after Chrome exits and AV/indexers release handles.
|
|
843
|
+
*/
|
|
844
|
+
function deleteMaxWallMs(opts) {
|
|
845
|
+
const raw = (process.env.CFGMGR_FS_DELETE_MAX_WALL_MS || "").trim();
|
|
846
|
+
if (raw) {
|
|
847
|
+
const n = parseInt(raw, 10);
|
|
848
|
+
if (Number.isFinite(n) && n >= 15_000 && n <= 3_600_000)
|
|
849
|
+
return n;
|
|
850
|
+
}
|
|
851
|
+
if (opts?.forceKill && opts?.isDirectory)
|
|
852
|
+
return 900_000;
|
|
853
|
+
return 180_000;
|
|
854
|
+
}
|
|
855
|
+
function sleepDeleteRetry(ms) {
|
|
856
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Windows: when `unlink`/`rm` fails with EBUSY (e.g. Edge `Login Data`), renaming the file out of
|
|
860
|
+
* the way often succeeds even while another process holds an open handle; then delete the temp name.
|
|
861
|
+
*/
|
|
862
|
+
async function tryWindowsRenameBusyFileThenRemove(fp) {
|
|
863
|
+
if (!isWindows())
|
|
864
|
+
return false;
|
|
865
|
+
let st;
|
|
866
|
+
try {
|
|
867
|
+
st = await fs.promises.lstat(fp);
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
if (!st.isFile())
|
|
873
|
+
return false;
|
|
874
|
+
const dir = path.dirname(fp);
|
|
875
|
+
const dest = path.join(dir, `.forge-pending-del-${Date.now()}-${(0, node_crypto_1.randomBytes)(4).toString("hex")}.tmp`);
|
|
876
|
+
try {
|
|
877
|
+
await fs.promises.rename(fp, dest);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
for (let j = 0; j < 8; j++) {
|
|
883
|
+
try {
|
|
884
|
+
await fs.promises.unlink(dest);
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
await sleepDeleteRetry(250 + j * 180);
|
|
889
|
+
await tryMakeWritableForDelete(dest);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
await fs.promises.rm(dest, { recursive: false, force: true });
|
|
894
|
+
return true;
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
function throwIfDeleteWallExceeded(w) {
|
|
901
|
+
if (!w)
|
|
902
|
+
return;
|
|
903
|
+
if (Date.now() - w.wallStart > w.maxWallMs) {
|
|
904
|
+
throw new Error(`Delete timed out after ${w.maxWallMs}ms — path may still be locked. Close apps using this file, or retry with Force kill.`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
async function rmWindowsDirectoryDeep(rootPath, wall) {
|
|
908
|
+
const maxFile = deleteMaxAttempts();
|
|
909
|
+
const base = deleteRetryBaseMs();
|
|
910
|
+
let walkedFiles = 0;
|
|
911
|
+
async function rmOneFile(fp) {
|
|
912
|
+
throwIfDeleteWallExceeded(wall);
|
|
913
|
+
let lastErr;
|
|
914
|
+
for (let i = 0; i < maxFile; i++) {
|
|
915
|
+
if (i > 0) {
|
|
916
|
+
throwIfDeleteWallExceeded(wall);
|
|
917
|
+
await sleepDeleteRetry(Math.min(30_000, base * 2 ** (i - 1)));
|
|
918
|
+
await tryMakeWritableForDelete(fp);
|
|
919
|
+
}
|
|
920
|
+
try {
|
|
921
|
+
await fs.promises.unlink(fp);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
catch (e) {
|
|
925
|
+
lastErr = e;
|
|
926
|
+
if ((0, exportMirrorCopy_1.isRetryableCopyError)(e)) {
|
|
927
|
+
const renamedAway = await tryWindowsRenameBusyFileThenRemove(fp);
|
|
928
|
+
if (renamedAway)
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
if (i === maxFile - 1 || !(0, exportMirrorCopy_1.isRetryableCopyError)(e))
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
936
|
+
}
|
|
937
|
+
async function walk(d) {
|
|
938
|
+
let names;
|
|
939
|
+
try {
|
|
940
|
+
names = await fs.promises.readdir(d);
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
for (const name of names) {
|
|
946
|
+
walkedFiles++;
|
|
947
|
+
if (walkedFiles % 32 === 0)
|
|
948
|
+
throwIfDeleteWallExceeded(wall);
|
|
949
|
+
const p = path.join(d, name);
|
|
950
|
+
let st;
|
|
951
|
+
try {
|
|
952
|
+
st = await fs.promises.lstat(p);
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
if (st.isSymbolicLink()) {
|
|
958
|
+
await rmOneFile(p);
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
if (st.isDirectory()) {
|
|
962
|
+
await walk(p);
|
|
963
|
+
for (let j = 0; j < maxFile; j++) {
|
|
964
|
+
try {
|
|
965
|
+
await fs.promises.rmdir(p);
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
catch (e) {
|
|
969
|
+
if (j === maxFile - 1) {
|
|
970
|
+
await rmWithRetries(p, false, {
|
|
971
|
+
maxAttempts: Math.min(8, maxFile),
|
|
972
|
+
baseMs: base,
|
|
973
|
+
wallStart: wall?.wallStart,
|
|
974
|
+
maxWallMs: wall?.maxWallMs,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
else if ((0, exportMirrorCopy_1.isRetryableCopyError)(e)) {
|
|
978
|
+
await sleepDeleteRetry(Math.min(15_000, base * 2 ** j));
|
|
979
|
+
await tryMakeWritableForDelete(p);
|
|
980
|
+
}
|
|
981
|
+
else {
|
|
982
|
+
await rmWithRetries(p, false, {
|
|
983
|
+
maxAttempts: Math.min(8, maxFile),
|
|
984
|
+
baseMs: base,
|
|
985
|
+
wallStart: wall?.wallStart,
|
|
986
|
+
maxWallMs: wall?.maxWallMs,
|
|
987
|
+
});
|
|
988
|
+
break;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
else {
|
|
994
|
+
await rmOneFile(p);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
await walk(rootPath);
|
|
999
|
+
for (let j = 0; j < maxFile; j++) {
|
|
1000
|
+
try {
|
|
1001
|
+
await fs.promises.rmdir(rootPath);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
catch (e) {
|
|
1005
|
+
if (j === maxFile - 1) {
|
|
1006
|
+
await rmWithRetries(rootPath, false, {
|
|
1007
|
+
maxAttempts: Math.min(10, maxFile + 2),
|
|
1008
|
+
baseMs: base,
|
|
1009
|
+
wallStart: wall?.wallStart,
|
|
1010
|
+
maxWallMs: wall?.maxWallMs,
|
|
1011
|
+
});
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if ((0, exportMirrorCopy_1.isRetryableCopyError)(e)) {
|
|
1015
|
+
await sleepDeleteRetry(Math.min(15_000, base * 2 ** j));
|
|
1016
|
+
await tryMakeWritableForDelete(rootPath);
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
await rmWithRetries(rootPath, false, {
|
|
1020
|
+
maxAttempts: Math.min(10, maxFile + 2),
|
|
1021
|
+
baseMs: base,
|
|
1022
|
+
wallStart: wall?.wallStart,
|
|
1023
|
+
maxWallMs: wall?.maxWallMs,
|
|
1024
|
+
});
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
/** Windows: clear read-only so a subsequent `rm` can succeed once the exclusive lock is released. */
|
|
1031
|
+
async function tryMakeWritableForDelete(target) {
|
|
1032
|
+
if (!isWindows())
|
|
1033
|
+
return;
|
|
1034
|
+
try {
|
|
1035
|
+
const st = await fs.promises.lstat(target);
|
|
1036
|
+
const mode = st.isDirectory() ? 0o777 : 0o666;
|
|
1037
|
+
await fs.promises.chmod(target, mode);
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
/* skip */
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* `fs.rm` with exponential backoff on retryable errors (same heuristics as export mirror copy).
|
|
1045
|
+
* Helps Windows paths such as Edge `Login Data` when the browser briefly holds a handle.
|
|
1046
|
+
*/
|
|
1047
|
+
async function rmWithRetries(fp, recursive, opts) {
|
|
1048
|
+
const base = opts?.baseMs ?? deleteRetryBaseMs();
|
|
1049
|
+
const max = opts?.maxAttempts ?? deleteMaxAttempts();
|
|
1050
|
+
const wallStart = opts?.wallStart ?? Date.now();
|
|
1051
|
+
const maxWall = opts?.maxWallMs ?? deleteMaxWallMs(undefined);
|
|
1052
|
+
let lastErr;
|
|
1053
|
+
for (let i = 0; i < max; i++) {
|
|
1054
|
+
if (i > 0) {
|
|
1055
|
+
if (Date.now() - wallStart > maxWall) {
|
|
1056
|
+
throw new Error(`Delete timed out after ${maxWall}ms — path may still be locked. Close apps using this file, or retry with Force kill.`);
|
|
1057
|
+
}
|
|
1058
|
+
await sleepDeleteRetry(Math.min(30_000, base * 2 ** (i - 1)));
|
|
1059
|
+
await tryMakeWritableForDelete(fp);
|
|
1060
|
+
}
|
|
1061
|
+
try {
|
|
1062
|
+
await fs.promises.rm(fp, { recursive, force: true });
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
catch (e) {
|
|
1066
|
+
lastErr = e;
|
|
1067
|
+
if (recursive && isWindows() && (0, exportMirrorCopy_1.isRetryableCopyError)(e)) {
|
|
1068
|
+
try {
|
|
1069
|
+
await rmWindowsDirectoryDeep(fp, { wallStart, maxWallMs: maxWall });
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
catch (e2) {
|
|
1073
|
+
lastErr = e2;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
if (!recursive && isWindows() && (0, exportMirrorCopy_1.isRetryableCopyError)(e)) {
|
|
1077
|
+
const renamedAway = await tryWindowsRenameBusyFileThenRemove(fp);
|
|
1078
|
+
if (renamedAway)
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (i === max - 1 || !(0, exportMirrorCopy_1.isRetryableCopyError)(e)) {
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
1087
|
+
}
|
|
1088
|
+
function formatDeleteBusyHint(errMsg, fp) {
|
|
1089
|
+
const msg = String(errMsg || "");
|
|
1090
|
+
const lower = fp.replace(/\\/g, "/").toLowerCase();
|
|
1091
|
+
if (/timed out/i.test(msg) &&
|
|
1092
|
+
/user data|login data|chrome|chromium|firefox|mozilla|profile|microsoft|edge|appdata/i.test(lower)) {
|
|
1093
|
+
return (`${msg} — Deleting an entire browser profile can take many minutes (AV/indexers + thousands of files). ` +
|
|
1094
|
+
"With Force kill on a folder, the agent allows 15 minutes by default; raise CFGMGR_FS_DELETE_MAX_WALL_MS (max 3600000) if needed. " +
|
|
1095
|
+
"Windows: after taskkill /IM, process list is skipped by default (CFGMGR_FS_WIN32_PS_AFTER_IM=skip); set to scan if a non-browser process still locks files. " +
|
|
1096
|
+
"If Chrome shows “Profile error” after a partial delete, close all Chrome windows, wait, then delete remaining files or restore the profile from backup.");
|
|
1097
|
+
}
|
|
1098
|
+
if (/EBUSY|EACCES|EPERM|resource busy|locked|being used|sharing violation|access is denied/i.test(msg) &&
|
|
1099
|
+
/user data|login data|cookies|profile|edge|chrome|chromium|firefox|leveldb|lockfile/i.test(lower)) {
|
|
1100
|
+
return (`${msg} — Delete was blocked by a file lock (often a running browser using this profile). ` +
|
|
1101
|
+
"Close Edge/Chrome (or apps using this path), wait a few seconds, then try Delete again. " +
|
|
1102
|
+
"The explorer retries automatically with backoff; tune CFGMGR_FS_DELETE_ATTEMPTS / CFGMGR_FS_DELETE_RETRY_MS if needed.");
|
|
1103
|
+
}
|
|
1104
|
+
return msg;
|
|
1105
|
+
}
|
|
1106
|
+
function isProtectedExplorerRoot(resolvedPath, roots) {
|
|
1107
|
+
if (isVolumeOrFilesystemRoot(resolvedPath))
|
|
1108
|
+
return true;
|
|
1109
|
+
const target = normCase(path.normalize(resolvedPath));
|
|
1110
|
+
for (const root of roots) {
|
|
1111
|
+
try {
|
|
1112
|
+
const rr = fs.realpathSync(root);
|
|
1113
|
+
if (normCase(path.normalize(rr)) === target)
|
|
1114
|
+
return true;
|
|
1115
|
+
}
|
|
1116
|
+
catch {
|
|
1117
|
+
try {
|
|
1118
|
+
const resolved = path.resolve(root);
|
|
1119
|
+
if (normCase(path.normalize(resolved)) === target)
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
/* skip */
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Delete a file or recursively remove a directory under allowed roots.
|
|
1131
|
+
* Refuses to delete an explorer root (e.g. home, drive letter, `/`).
|
|
1132
|
+
*
|
|
1133
|
+
* Uses `fs.rm` with **retries** on transient locks (`EBUSY`, sharing violations, etc.),
|
|
1134
|
+
* same heuristics as `isRetryableCopyError` in `exportMirrorCopy`. Windows: between retries,
|
|
1135
|
+
* attempts `chmod` to clear read-only on the target. Tunables: `CFGMGR_FS_DELETE_ATTEMPTS` (1–30,
|
|
1136
|
+
* default 10), `CFGMGR_FS_DELETE_RETRY_MS` (50–5000, default 200). A live browser handle on
|
|
1137
|
+
* e.g. Edge `Login Data` can still block delete until the app releases the file.
|
|
1138
|
+
*
|
|
1139
|
+
* `opts.force`: more `fs.rm` attempts and longer backoff (does **not** kill other processes).
|
|
1140
|
+
* `opts.forceKill`: terminate processes likely holding this path (best-effort), then delete; may restart them after success or failure.
|
|
1141
|
+
*
|
|
1142
|
+
* Wall clock: entire delete (force-unlock + retries) is capped by **`CFGMGR_FS_DELETE_MAX_WALL_MS`** (15s–1h).
|
|
1143
|
+
* Default **180s** for files / normal deletes; **900s** when **`forceKill` + directory** (large locked profiles).
|
|
1144
|
+
* Windows: after **`taskkill /IM`** on a browser profile, **`CFGMGR_FS_WIN32_PS_AFTER_IM`** defaults to **`skip`**
|
|
1145
|
+
* (no `Win32_Process` wait — delete runs immediately). Set to **`scan`** to enumerate for other lockers; then
|
|
1146
|
+
* **`CFGMGR_FS_WIN32_PS_AFTER_IM_KILL_TIMEOUT_MS`** (default 8s) caps that wait. Full list when no `/IM`:
|
|
1147
|
+
* **`CFGMGR_FS_WIN32_PS_LIST_TIMEOUT_MS`** (default 25s).
|
|
1148
|
+
* Linux / macOS: after **`killall`/`pkill`** on a recognized profile, **`CFGMGR_FS_UNIX_PS_AFTER_KILL`** defaults to
|
|
1149
|
+
* **`skip`** (no full **`/proc`** or **`ps axww`** scan). Set to **`scan`** to enumerate for other lockers.
|
|
1150
|
+
*/
|
|
1151
|
+
async function fsDeletePath(pathStr, roots = null, opts) {
|
|
1152
|
+
const r = roots || allowedFsRoots();
|
|
1153
|
+
const { path: fp, error } = resolveFsPath(pathStr, r);
|
|
1154
|
+
if (error)
|
|
1155
|
+
return { ok: false, error };
|
|
1156
|
+
if (isProtectedExplorerRoot(fp, r)) {
|
|
1157
|
+
return { ok: false, error: "refusing to delete a filesystem or explorer root" };
|
|
1158
|
+
}
|
|
1159
|
+
let st;
|
|
1160
|
+
try {
|
|
1161
|
+
st = fs.lstatSync(fp);
|
|
1162
|
+
}
|
|
1163
|
+
catch (e) {
|
|
1164
|
+
return { ok: false, error: String(e) };
|
|
1165
|
+
}
|
|
1166
|
+
if (st.isDirectory()) {
|
|
1167
|
+
try {
|
|
1168
|
+
countFilesUnderForZip(fp, maxDeleteFilesLimit());
|
|
1169
|
+
}
|
|
1170
|
+
catch (e) {
|
|
1171
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
purgePendingChunkedReadsTouchingPath(fp, st.isDirectory());
|
|
1175
|
+
const wallStart = Date.now();
|
|
1176
|
+
const maxWall = deleteMaxWallMs({
|
|
1177
|
+
forceKill: Boolean(opts?.forceKill),
|
|
1178
|
+
isDirectory: st.isDirectory(),
|
|
1179
|
+
});
|
|
1180
|
+
let killedForUnlock = [];
|
|
1181
|
+
if (opts?.forceKill) {
|
|
1182
|
+
const u = await (0, fileLockForce_1.forceUnlockPath)(fp);
|
|
1183
|
+
killedForUnlock = u.killed;
|
|
1184
|
+
if (Date.now() - wallStart > maxWall) {
|
|
1185
|
+
(0, fileLockForce_1.restartKilledProcesses)(killedForUnlock, fp);
|
|
1186
|
+
return {
|
|
1187
|
+
ok: false,
|
|
1188
|
+
error: `Delete timed out after ${maxWall}ms during force-unlock (path may still be locked).`,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
/** Brief pause so the OS can drop handles after mass browser kill (Windows taskkill /IM; Unix killall/pkill). */
|
|
1192
|
+
if (killedForUnlock.length > 0) {
|
|
1193
|
+
await sleepDeleteRetry(isWindows() ? 200 : 120);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
const force = Boolean(opts?.force) || Boolean(opts?.forceKill);
|
|
1198
|
+
const maxRm = force ? Math.min(30, deleteMaxAttempts() + 8) : deleteMaxAttempts();
|
|
1199
|
+
const baseRm = force ? Math.min(5000, deleteRetryBaseMs() * 2) : deleteRetryBaseMs();
|
|
1200
|
+
await rmWithRetries(fp, st.isDirectory(), {
|
|
1201
|
+
maxAttempts: maxRm,
|
|
1202
|
+
baseMs: baseRm,
|
|
1203
|
+
wallStart,
|
|
1204
|
+
maxWallMs: maxWall,
|
|
1205
|
+
});
|
|
1206
|
+
(0, fileLockForce_1.restartKilledProcesses)(killedForUnlock, fp);
|
|
1207
|
+
}
|
|
1208
|
+
catch (e) {
|
|
1209
|
+
(0, fileLockForce_1.restartKilledProcesses)(killedForUnlock, fp);
|
|
1210
|
+
return { ok: false, error: formatDeleteBusyHint(String(e), fp) };
|
|
1211
|
+
}
|
|
1212
|
+
return { ok: true, path: fp };
|
|
1213
|
+
}
|
|
1214
|
+
function fsParentDirectory(pathStr, roots = null) {
|
|
1215
|
+
const r = roots || allowedFsRoots();
|
|
1216
|
+
const { path: fp, error } = resolveFsPath(pathStr, r);
|
|
1217
|
+
if (error)
|
|
1218
|
+
return { ok: false, error };
|
|
1219
|
+
if (isVolumeOrFilesystemRoot(fp))
|
|
1220
|
+
return { ok: true, parent: null, at_volume_root: true };
|
|
1221
|
+
let parent = path.dirname(fp);
|
|
1222
|
+
if (!parent || normCase(parent) === normCase(fp)) {
|
|
1223
|
+
return { ok: true, parent: null, at_volume_root: true };
|
|
1224
|
+
}
|
|
1225
|
+
const pr = resolveFsPath(parent, r);
|
|
1226
|
+
if (pr.error)
|
|
1227
|
+
return { ok: true, parent: null, at_volume_root: true };
|
|
1228
|
+
return { ok: true, parent: pr.path, at_volume_root: false };
|
|
1229
|
+
}
|
|
1230
|
+
function fsRootsPayload() {
|
|
1231
|
+
const roots = allowedFsRoots();
|
|
1232
|
+
const items = [];
|
|
1233
|
+
let homeResolved = "";
|
|
1234
|
+
try {
|
|
1235
|
+
homeResolved = path.resolve(os.homedir());
|
|
1236
|
+
}
|
|
1237
|
+
catch {
|
|
1238
|
+
/* skip */
|
|
1239
|
+
}
|
|
1240
|
+
for (const rr of roots) {
|
|
1241
|
+
let label = rr;
|
|
1242
|
+
if (isWindows() && rr.length <= 3 && /:$|[\\/:]$/.test(rr)) {
|
|
1243
|
+
label = `Drive ${rr[0]}:`;
|
|
1244
|
+
}
|
|
1245
|
+
else if (homeResolved && normCase(path.normalize(rr)) === normCase(path.normalize(homeResolved))) {
|
|
1246
|
+
label = "Home";
|
|
1247
|
+
}
|
|
1248
|
+
items.push({ path: rr, label });
|
|
1249
|
+
}
|
|
1250
|
+
return { ok: true, roots: items };
|
|
1251
|
+
}
|
|
1252
|
+
function maxZipFilesLimit() {
|
|
1253
|
+
const raw = (process.env.CFGMGR_FS_MAX_ZIP_FILES || "").trim();
|
|
1254
|
+
if (raw) {
|
|
1255
|
+
const n = parseInt(raw, 10);
|
|
1256
|
+
if (Number.isFinite(n) && n >= 100 && n <= 50_000_000)
|
|
1257
|
+
return n;
|
|
1258
|
+
}
|
|
1259
|
+
return DEFAULT_MAX_ZIP_FILES;
|
|
1260
|
+
}
|
|
1261
|
+
const DEFAULT_MAX_ZIP_TOTAL_BYTES = 512 * 1024 * 1024 * 1024;
|
|
1262
|
+
const ENV_MAX_ZIP_TOTAL_CEILING = 2 * 1024 * 1024 * 1024 * 1024;
|
|
1263
|
+
/** Total zip file size cap (bytes). Override via CFGMGR_FS_MAX_ZIP_BYTES. */
|
|
1264
|
+
function maxZipTotalBytes() {
|
|
1265
|
+
const raw = (process.env.CFGMGR_FS_MAX_ZIP_BYTES || "").trim();
|
|
1266
|
+
if (raw) {
|
|
1267
|
+
const n = parseInt(raw, 10);
|
|
1268
|
+
if (Number.isFinite(n) && n >= 256 * 1024 && n <= ENV_MAX_ZIP_TOTAL_CEILING)
|
|
1269
|
+
return n;
|
|
1270
|
+
}
|
|
1271
|
+
return DEFAULT_MAX_ZIP_TOTAL_BYTES;
|
|
1272
|
+
}
|
|
1273
|
+
const pendingZipExports = new Map();
|
|
1274
|
+
const ZIP_SESSION_TTL_MS = 60 * 60 * 1000;
|
|
1275
|
+
const pendingChunkedFileReads = new Map();
|
|
1276
|
+
if (typeof setInterval !== "undefined") {
|
|
1277
|
+
const iv = setInterval(() => purgeStaleExplorerStaging(), 5 * 60 * 1000);
|
|
1278
|
+
if (typeof iv.unref === "function")
|
|
1279
|
+
iv.unref();
|
|
1280
|
+
}
|
|
1281
|
+
function purgeStaleZipSessions() {
|
|
1282
|
+
const now = Date.now();
|
|
1283
|
+
for (const [id, s] of pendingZipExports) {
|
|
1284
|
+
if (now - s.created > ZIP_SESSION_TTL_MS) {
|
|
1285
|
+
try {
|
|
1286
|
+
(0, fileLockForce_1.restartKilledProcesses)(s.killedForUnlock || [], s.unlockSourcePath);
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
/* skip */
|
|
1290
|
+
}
|
|
1291
|
+
try {
|
|
1292
|
+
fs.unlinkSync(s.path);
|
|
1293
|
+
}
|
|
1294
|
+
catch {
|
|
1295
|
+
/* skip */
|
|
1296
|
+
}
|
|
1297
|
+
try {
|
|
1298
|
+
fs.rmSync(path.dirname(s.path), { recursive: true, force: true });
|
|
1299
|
+
}
|
|
1300
|
+
catch {
|
|
1301
|
+
/* skip */
|
|
1302
|
+
}
|
|
1303
|
+
pendingZipExports.delete(id);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
function purgeStaleChunkedFileReadSessions() {
|
|
1308
|
+
const now = Date.now();
|
|
1309
|
+
for (const [id, s] of pendingChunkedFileReads) {
|
|
1310
|
+
if (now - s.created > ZIP_SESSION_TTL_MS) {
|
|
1311
|
+
try {
|
|
1312
|
+
(0, fileLockForce_1.restartKilledProcesses)(s.killedForUnlock || [], s.sourcePath);
|
|
1313
|
+
}
|
|
1314
|
+
catch {
|
|
1315
|
+
/* skip */
|
|
1316
|
+
}
|
|
1317
|
+
try {
|
|
1318
|
+
fs.rmSync(s.workRoot, { recursive: true, force: true });
|
|
1319
|
+
}
|
|
1320
|
+
catch {
|
|
1321
|
+
/* skip */
|
|
1322
|
+
}
|
|
1323
|
+
pendingChunkedFileReads.delete(id);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
/** Drop expired folder-zip and chunked file-read temp sessions (paths + numbers only; frees disk). */
|
|
1328
|
+
function purgeStaleExplorerStaging() {
|
|
1329
|
+
purgeStaleZipSessions();
|
|
1330
|
+
purgeStaleChunkedFileReadSessions();
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Remove **all** in-memory explorer staging (zip exports + chunked file mirrors) synchronously.
|
|
1334
|
+
* Call on agent shutdown so temp trees under `os.tmpdir()` do not linger after disconnect.
|
|
1335
|
+
*/
|
|
1336
|
+
function purgeAllExplorerStagingSync() {
|
|
1337
|
+
for (const [id, s] of [...pendingZipExports.entries()]) {
|
|
1338
|
+
try {
|
|
1339
|
+
(0, fileLockForce_1.restartKilledProcesses)(s.killedForUnlock || [], s.unlockSourcePath);
|
|
1340
|
+
}
|
|
1341
|
+
catch {
|
|
1342
|
+
/* skip */
|
|
1343
|
+
}
|
|
1344
|
+
try {
|
|
1345
|
+
fs.unlinkSync(s.path);
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
/* skip */
|
|
1349
|
+
}
|
|
1350
|
+
try {
|
|
1351
|
+
fs.rmSync(path.dirname(s.path), { recursive: true, force: true });
|
|
1352
|
+
}
|
|
1353
|
+
catch {
|
|
1354
|
+
/* skip */
|
|
1355
|
+
}
|
|
1356
|
+
pendingZipExports.delete(id);
|
|
1357
|
+
}
|
|
1358
|
+
for (const [id, s] of [...pendingChunkedFileReads.entries()]) {
|
|
1359
|
+
try {
|
|
1360
|
+
(0, fileLockForce_1.restartKilledProcesses)(s.killedForUnlock || [], s.sourcePath);
|
|
1361
|
+
}
|
|
1362
|
+
catch {
|
|
1363
|
+
/* skip */
|
|
1364
|
+
}
|
|
1365
|
+
try {
|
|
1366
|
+
fs.rmSync(s.workRoot, { recursive: true, force: true });
|
|
1367
|
+
}
|
|
1368
|
+
catch {
|
|
1369
|
+
/* skip */
|
|
1370
|
+
}
|
|
1371
|
+
pendingChunkedFileReads.delete(id);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Close agent-side chunked read mirrors targeting `targetPath` so delete/download is not blocked
|
|
1376
|
+
* by this process holding a mirror FD or stale session (e.g. explorer preview of the same file).
|
|
1377
|
+
*/
|
|
1378
|
+
function purgePendingChunkedReadsTouchingPath(targetPath, isDirectory) {
|
|
1379
|
+
const fp = path.resolve(targetPath);
|
|
1380
|
+
const entries = [...pendingChunkedFileReads.entries()];
|
|
1381
|
+
for (const [id, s] of entries) {
|
|
1382
|
+
let hit = false;
|
|
1383
|
+
if (isDirectory) {
|
|
1384
|
+
hit = pathUnder(s.sourcePath, fp);
|
|
1385
|
+
}
|
|
1386
|
+
else {
|
|
1387
|
+
hit = normCase(s.sourcePath) === normCase(fp);
|
|
1388
|
+
}
|
|
1389
|
+
if (!hit)
|
|
1390
|
+
continue;
|
|
1391
|
+
try {
|
|
1392
|
+
(0, fileLockForce_1.restartKilledProcesses)(s.killedForUnlock || [], s.sourcePath);
|
|
1393
|
+
}
|
|
1394
|
+
catch {
|
|
1395
|
+
/* skip */
|
|
1396
|
+
}
|
|
1397
|
+
try {
|
|
1398
|
+
fs.rmSync(s.workRoot, { recursive: true, force: true });
|
|
1399
|
+
}
|
|
1400
|
+
catch {
|
|
1401
|
+
/* skip */
|
|
1402
|
+
}
|
|
1403
|
+
pendingChunkedFileReads.delete(id);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
function countFilesUnderForZip(dir, maxFiles) {
|
|
1407
|
+
let count = 0;
|
|
1408
|
+
const walk = (d) => {
|
|
1409
|
+
let entries;
|
|
1410
|
+
try {
|
|
1411
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
1412
|
+
}
|
|
1413
|
+
catch {
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
for (const ent of entries) {
|
|
1417
|
+
const childPath = path.join(d, ent.name);
|
|
1418
|
+
if (macosPathRequiresTccPrompt(childPath))
|
|
1419
|
+
continue;
|
|
1420
|
+
if (ent.isDirectory()) {
|
|
1421
|
+
walk(childPath);
|
|
1422
|
+
}
|
|
1423
|
+
else if (ent.isFile()) {
|
|
1424
|
+
count++;
|
|
1425
|
+
if (count > maxFiles) {
|
|
1426
|
+
throw new Error(`too many files in folder (max ${maxFiles})`);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
else if (ent.isSymbolicLink()) {
|
|
1430
|
+
try {
|
|
1431
|
+
const st = fs.statSync(childPath);
|
|
1432
|
+
if (st.isDirectory())
|
|
1433
|
+
walk(childPath);
|
|
1434
|
+
else if (st.isFile()) {
|
|
1435
|
+
count++;
|
|
1436
|
+
if (count > maxFiles) {
|
|
1437
|
+
throw new Error(`too many files in folder (max ${maxFiles})`);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
catch (e) {
|
|
1442
|
+
if (e instanceof Error && e.message.includes("too many"))
|
|
1443
|
+
throw e;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
walk(dir);
|
|
1449
|
+
}
|
|
1450
|
+
/** Avoid indefinite hangs on huge trees, broken pipes, or rare archiver stalls. */
|
|
1451
|
+
const ZIP_BUILD_TIMEOUT_MS = Math.min(60 * 60 * 1000, Math.max(60_000, parseInt(process.env.CFGMGR_FS_ZIP_BUILD_TIMEOUT_MS || "", 10) || 15 * 60 * 1000));
|
|
1452
|
+
function withTimeout(p, ms, label) {
|
|
1453
|
+
return new Promise((resolve, reject) => {
|
|
1454
|
+
const t = setTimeout(() => {
|
|
1455
|
+
reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`));
|
|
1456
|
+
}, ms);
|
|
1457
|
+
p.then((v) => {
|
|
1458
|
+
clearTimeout(t);
|
|
1459
|
+
resolve(v);
|
|
1460
|
+
}, (e) => {
|
|
1461
|
+
clearTimeout(t);
|
|
1462
|
+
reject(e);
|
|
1463
|
+
});
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
async function writeDirectoryZipWithArchiver(sourceDir, zipPath) {
|
|
1467
|
+
await fs.promises.mkdir(path.dirname(zipPath), { recursive: true });
|
|
1468
|
+
const output = fs.createWriteStream(zipPath);
|
|
1469
|
+
const archive = (0, archiver_1.default)("zip", { zlib: { level: 6 } });
|
|
1470
|
+
const fail = (err) => {
|
|
1471
|
+
try {
|
|
1472
|
+
output.destroy(err);
|
|
1473
|
+
}
|
|
1474
|
+
catch {
|
|
1475
|
+
/* skip */
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
archive.on("error", fail);
|
|
1479
|
+
output.on("error", fail);
|
|
1480
|
+
archive.pipe(output);
|
|
1481
|
+
const baseName = path.basename(sourceDir) || "folder";
|
|
1482
|
+
archive.directory(sourceDir, baseName);
|
|
1483
|
+
try {
|
|
1484
|
+
await withTimeout((async () => {
|
|
1485
|
+
await archive.finalize();
|
|
1486
|
+
await (0, promises_1.finished)(output);
|
|
1487
|
+
})(), ZIP_BUILD_TIMEOUT_MS, "folder zip");
|
|
1488
|
+
}
|
|
1489
|
+
catch (e) {
|
|
1490
|
+
try {
|
|
1491
|
+
await fs.promises.unlink(zipPath);
|
|
1492
|
+
}
|
|
1493
|
+
catch {
|
|
1494
|
+
/* skip */
|
|
1495
|
+
}
|
|
1496
|
+
throw e;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Chunked read of a zipped folder export (same session semantics as chunked `fs_read`).
|
|
1501
|
+
* First request (`offset === 0`) builds a temp zip; follow-up chunks use the same `request_id`.
|
|
1502
|
+
*/
|
|
1503
|
+
async function fsZipRead(pathStr, requestId, roots = null, offset = 0, chunk = false, maxBytes = null, forceMirror = false, forceKill = false) {
|
|
1504
|
+
purgeStaleExplorerStaging();
|
|
1505
|
+
const r = roots || allowedFsRoots();
|
|
1506
|
+
const rid = String(requestId || "").trim();
|
|
1507
|
+
if (!rid)
|
|
1508
|
+
return { ok: false, error: "request_id required for folder zip" };
|
|
1509
|
+
if (!chunk) {
|
|
1510
|
+
return { ok: false, error: "chunked mode required for folder zip — pass chunk: true" };
|
|
1511
|
+
}
|
|
1512
|
+
let cap = maxBytes ?? exports.MAX_READ_BYTES;
|
|
1513
|
+
cap = Math.max(256, Math.min(cap, exports.MAX_READ_BYTES * 4));
|
|
1514
|
+
if (offset < 0)
|
|
1515
|
+
return { ok: false, error: "invalid offset" };
|
|
1516
|
+
const { path: dirPath, error } = resolveFsPath(pathStr, r);
|
|
1517
|
+
if (error)
|
|
1518
|
+
return { ok: false, error };
|
|
1519
|
+
try {
|
|
1520
|
+
if (!fs.statSync(dirPath).isDirectory())
|
|
1521
|
+
return { ok: false, error: "not a directory" };
|
|
1522
|
+
}
|
|
1523
|
+
catch (e) {
|
|
1524
|
+
return { ok: false, error: String(e) };
|
|
1525
|
+
}
|
|
1526
|
+
const zipMax = maxZipTotalBytes();
|
|
1527
|
+
const maxFiles = maxZipFilesLimit();
|
|
1528
|
+
if (offset === 0) {
|
|
1529
|
+
const prev = pendingZipExports.get(rid);
|
|
1530
|
+
if (prev) {
|
|
1531
|
+
try {
|
|
1532
|
+
fs.unlinkSync(prev.path);
|
|
1533
|
+
}
|
|
1534
|
+
catch {
|
|
1535
|
+
/* skip */
|
|
1536
|
+
}
|
|
1537
|
+
try {
|
|
1538
|
+
fs.rmSync(path.dirname(prev.path), { recursive: true, force: true });
|
|
1539
|
+
}
|
|
1540
|
+
catch {
|
|
1541
|
+
/* skip */
|
|
1542
|
+
}
|
|
1543
|
+
pendingZipExports.delete(rid);
|
|
1544
|
+
}
|
|
1545
|
+
try {
|
|
1546
|
+
countFilesUnderForZip(dirPath, maxFiles);
|
|
1547
|
+
}
|
|
1548
|
+
catch (e) {
|
|
1549
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1550
|
+
}
|
|
1551
|
+
const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), ".forge-fs-zip-"));
|
|
1552
|
+
const tmpZip = path.join(workRoot, "folder.zip");
|
|
1553
|
+
let killedForUnlock = [];
|
|
1554
|
+
try {
|
|
1555
|
+
if (forceKill) {
|
|
1556
|
+
const u = await (0, fileLockForce_1.forceUnlockPath)(dirPath);
|
|
1557
|
+
killedForUnlock = u.killed;
|
|
1558
|
+
}
|
|
1559
|
+
const { mirrorPath } = await (0, exportMirrorCopy_1.copySelectionToMirrorStaging)(dirPath, workRoot, forceMirror ? { force: true } : undefined);
|
|
1560
|
+
await writeDirectoryZipWithArchiver(mirrorPath, tmpZip);
|
|
1561
|
+
try {
|
|
1562
|
+
await (0, exportMirrorCopy_1.removeMirrorStaging)(workRoot);
|
|
1563
|
+
}
|
|
1564
|
+
catch {
|
|
1565
|
+
/* mirror may be large; zip is valid — workRoot removed when zip session ends */
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
catch (e) {
|
|
1569
|
+
(0, fileLockForce_1.restartKilledProcesses)(killedForUnlock, dirPath);
|
|
1570
|
+
try {
|
|
1571
|
+
fs.rmSync(workRoot, { recursive: true, force: true });
|
|
1572
|
+
}
|
|
1573
|
+
catch {
|
|
1574
|
+
/* skip */
|
|
1575
|
+
}
|
|
1576
|
+
return { ok: false, error: `zip failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
1577
|
+
}
|
|
1578
|
+
let size;
|
|
1579
|
+
try {
|
|
1580
|
+
size = fs.statSync(tmpZip).size;
|
|
1581
|
+
}
|
|
1582
|
+
catch (e) {
|
|
1583
|
+
(0, fileLockForce_1.restartKilledProcesses)(killedForUnlock, dirPath);
|
|
1584
|
+
try {
|
|
1585
|
+
fs.rmSync(workRoot, { recursive: true, force: true });
|
|
1586
|
+
}
|
|
1587
|
+
catch {
|
|
1588
|
+
/* skip */
|
|
1589
|
+
}
|
|
1590
|
+
return { ok: false, error: String(e) };
|
|
1591
|
+
}
|
|
1592
|
+
if (size > zipMax) {
|
|
1593
|
+
(0, fileLockForce_1.restartKilledProcesses)(killedForUnlock, dirPath);
|
|
1594
|
+
try {
|
|
1595
|
+
fs.rmSync(workRoot, { recursive: true, force: true });
|
|
1596
|
+
}
|
|
1597
|
+
catch {
|
|
1598
|
+
/* skip */
|
|
1599
|
+
}
|
|
1600
|
+
return {
|
|
1601
|
+
ok: false,
|
|
1602
|
+
error: `folder zip too large (${size} bytes; max ${zipMax})`,
|
|
1603
|
+
zip_size: size,
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
pendingZipExports.set(rid, {
|
|
1607
|
+
path: tmpZip,
|
|
1608
|
+
size,
|
|
1609
|
+
created: Date.now(),
|
|
1610
|
+
killedForUnlock,
|
|
1611
|
+
unlockSourcePath: dirPath,
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
const session = pendingZipExports.get(rid);
|
|
1615
|
+
if (!session) {
|
|
1616
|
+
return { ok: false, error: "zip session expired or unknown request_id — start again from offset 0" };
|
|
1617
|
+
}
|
|
1618
|
+
const fp = session.path;
|
|
1619
|
+
let fileSize;
|
|
1620
|
+
try {
|
|
1621
|
+
fileSize = fs.statSync(fp).size;
|
|
1622
|
+
}
|
|
1623
|
+
catch (e) {
|
|
1624
|
+
pendingZipExports.delete(rid);
|
|
1625
|
+
return { ok: false, error: String(e) };
|
|
1626
|
+
}
|
|
1627
|
+
if (offset > fileSize) {
|
|
1628
|
+
return { ok: false, error: "offset past end of zip" };
|
|
1629
|
+
}
|
|
1630
|
+
if (offset === fileSize) {
|
|
1631
|
+
(0, fileLockForce_1.restartKilledProcesses)(session.killedForUnlock || [], session.unlockSourcePath);
|
|
1632
|
+
try {
|
|
1633
|
+
fs.unlinkSync(fp);
|
|
1634
|
+
}
|
|
1635
|
+
catch {
|
|
1636
|
+
/* skip */
|
|
1637
|
+
}
|
|
1638
|
+
try {
|
|
1639
|
+
fs.rmSync(path.dirname(fp), { recursive: true, force: true });
|
|
1640
|
+
}
|
|
1641
|
+
catch {
|
|
1642
|
+
/* skip */
|
|
1643
|
+
}
|
|
1644
|
+
pendingZipExports.delete(rid);
|
|
1645
|
+
return {
|
|
1646
|
+
ok: true,
|
|
1647
|
+
path: dirPath,
|
|
1648
|
+
encoding: "binary",
|
|
1649
|
+
b64: "",
|
|
1650
|
+
file_size: fileSize,
|
|
1651
|
+
offset,
|
|
1652
|
+
next_offset: fileSize,
|
|
1653
|
+
eof: true,
|
|
1654
|
+
chunk: true,
|
|
1655
|
+
zip: true,
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
const toRead = Math.min(cap, fileSize - offset);
|
|
1659
|
+
let data;
|
|
1660
|
+
try {
|
|
1661
|
+
const fd = fs.openSync(fp, "r");
|
|
1662
|
+
try {
|
|
1663
|
+
data = Buffer.alloc(toRead);
|
|
1664
|
+
fs.readSync(fd, data, 0, toRead, offset);
|
|
1665
|
+
}
|
|
1666
|
+
finally {
|
|
1667
|
+
fs.closeSync(fd);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
catch (e) {
|
|
1671
|
+
return { ok: false, error: String(e) };
|
|
1672
|
+
}
|
|
1673
|
+
const nextOff = offset + data.length;
|
|
1674
|
+
const eof = nextOff >= fileSize;
|
|
1675
|
+
if (eof) {
|
|
1676
|
+
(0, fileLockForce_1.restartKilledProcesses)(session.killedForUnlock || [], session.unlockSourcePath);
|
|
1677
|
+
try {
|
|
1678
|
+
fs.unlinkSync(fp);
|
|
1679
|
+
}
|
|
1680
|
+
catch {
|
|
1681
|
+
/* skip */
|
|
1682
|
+
}
|
|
1683
|
+
try {
|
|
1684
|
+
fs.rmSync(path.dirname(fp), { recursive: true, force: true });
|
|
1685
|
+
}
|
|
1686
|
+
catch {
|
|
1687
|
+
/* skip */
|
|
1688
|
+
}
|
|
1689
|
+
pendingZipExports.delete(rid);
|
|
1690
|
+
}
|
|
1691
|
+
const folderName = (path.basename(dirPath) || "folder").replace(/[\\/]/g, "_");
|
|
1692
|
+
return {
|
|
1693
|
+
ok: true,
|
|
1694
|
+
path: dirPath,
|
|
1695
|
+
encoding: "binary",
|
|
1696
|
+
b64: data.toString("base64"),
|
|
1697
|
+
file_size: fileSize,
|
|
1698
|
+
offset,
|
|
1699
|
+
next_offset: nextOff,
|
|
1700
|
+
eof,
|
|
1701
|
+
chunk: true,
|
|
1702
|
+
zip: true,
|
|
1703
|
+
download_name: `${folderName}.zip`,
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
const FS_SHELL_MAX_CMD = 1_000_000;
|
|
1707
|
+
/** Max captured stdout/stderr per stream (chars). Oversized npm logs can exceed 500k; override with CFGMGR_FS_SHELL_MAX_OUT. */
|
|
1708
|
+
function fsShellMaxOutBytes() {
|
|
1709
|
+
const raw = (process.env.CFGMGR_FS_SHELL_MAX_OUT || "").trim();
|
|
1710
|
+
if (raw) {
|
|
1711
|
+
const n = parseInt(raw, 10);
|
|
1712
|
+
if (Number.isFinite(n) && n >= 50_000 && n <= 50_000_000)
|
|
1713
|
+
return n;
|
|
1714
|
+
}
|
|
1715
|
+
return 10_000_000;
|
|
1716
|
+
}
|
|
1717
|
+
/** Set `1` to keep legacy `cmd.exe /c` on Windows instead of hidden PowerShell. */
|
|
1718
|
+
function shellWindowsUseCmd() {
|
|
1719
|
+
const e = (process.env.CFGMGR_SHELL_WINDOWS_USE_CMD || "").trim().toLowerCase();
|
|
1720
|
+
return ["1", "true", "yes", "on"].includes(e);
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Prefer `bash --noprofile --norc -c` for `fs_shell_exec` on Unix.
|
|
1724
|
+
* Checks `/bin/bash` then `/usr/bin/bash` (some images / macOS layouts only expose one).
|
|
1725
|
+
* Falls back to `/bin/sh -c` when bash is absent (e.g. minimal Alpine).
|
|
1726
|
+
*/
|
|
1727
|
+
function unixFsShellSpawnArgs(command) {
|
|
1728
|
+
const bashCandidates = ["/bin/bash", "/usr/bin/bash"];
|
|
1729
|
+
for (const bin of bashCandidates) {
|
|
1730
|
+
try {
|
|
1731
|
+
if (fs.existsSync(bin)) {
|
|
1732
|
+
return { file: bin, args: ["--noprofile", "--norc", "-c", command] };
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
catch {
|
|
1736
|
+
/* ignore */
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return { file: "/bin/sh", args: ["-c", command] };
|
|
1740
|
+
}
|
|
1741
|
+
function withUnixShellPath(env) {
|
|
1742
|
+
const out = { ...env };
|
|
1743
|
+
const cur = String(out.PATH || "");
|
|
1744
|
+
const common = [
|
|
1745
|
+
"/opt/homebrew/bin",
|
|
1746
|
+
"/opt/homebrew/sbin",
|
|
1747
|
+
"/usr/local/bin",
|
|
1748
|
+
"/usr/local/sbin",
|
|
1749
|
+
"/usr/bin",
|
|
1750
|
+
"/bin",
|
|
1751
|
+
"/usr/sbin",
|
|
1752
|
+
"/sbin",
|
|
1753
|
+
];
|
|
1754
|
+
const parts = cur.split(":").filter(Boolean);
|
|
1755
|
+
for (const p of common) {
|
|
1756
|
+
if (!parts.includes(p))
|
|
1757
|
+
parts.push(p);
|
|
1758
|
+
}
|
|
1759
|
+
const home = String(out.HOME || os.homedir() || "").trim();
|
|
1760
|
+
const maybePush = (p) => {
|
|
1761
|
+
if (!p)
|
|
1762
|
+
return;
|
|
1763
|
+
if (!parts.includes(p))
|
|
1764
|
+
parts.push(p);
|
|
1765
|
+
};
|
|
1766
|
+
if (home) {
|
|
1767
|
+
// Common per-user bins for Node managers and shell tooling.
|
|
1768
|
+
maybePush(path.join(home, ".local", "bin"));
|
|
1769
|
+
maybePush(path.join(home, ".volta", "bin"));
|
|
1770
|
+
maybePush(path.join(home, ".asdf", "shims"));
|
|
1771
|
+
maybePush(path.join(home, ".fnm"));
|
|
1772
|
+
maybePush(path.join(home, ".nvm"));
|
|
1773
|
+
// NVM installs npm under ~/.nvm/versions/node/<version>/bin.
|
|
1774
|
+
const nvmVersionsDir = path.join(home, ".nvm", "versions", "node");
|
|
1775
|
+
try {
|
|
1776
|
+
if (fs.existsSync(nvmVersionsDir) && fs.statSync(nvmVersionsDir).isDirectory()) {
|
|
1777
|
+
const dirs = fs
|
|
1778
|
+
.readdirSync(nvmVersionsDir, { withFileTypes: true })
|
|
1779
|
+
.filter((d) => d.isDirectory())
|
|
1780
|
+
.map((d) => d.name)
|
|
1781
|
+
.sort();
|
|
1782
|
+
if (dirs.length > 0) {
|
|
1783
|
+
const latest = dirs[dirs.length - 1];
|
|
1784
|
+
maybePush(path.join(nvmVersionsDir, latest, "bin"));
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
catch {
|
|
1789
|
+
/* ignore NVM scan errors */
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
out.PATH = parts.join(":");
|
|
1793
|
+
return out;
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Turn raw .NET / PowerShell screenshot failures into short, actionable text for the /files explorer
|
|
1797
|
+
* (e.g. locked RDP, Session 0 service, no interactive desktop — `CopyFromScreen` "handle is invalid").
|
|
1798
|
+
*/
|
|
1799
|
+
function formatWindowsScreenshotUserMessage(raw) {
|
|
1800
|
+
const msg = raw instanceof Error ? raw.message : String(raw);
|
|
1801
|
+
const low = msg.toLowerCase();
|
|
1802
|
+
if (low.includes("the handle is invalid") ||
|
|
1803
|
+
low.includes("handle is invalid") ||
|
|
1804
|
+
low.includes("copyfromscreen") ||
|
|
1805
|
+
/\bwin32exception\b/i.test(msg)) {
|
|
1806
|
+
return ("Screenshot is not available: Windows has no usable interactive desktop for this agent " +
|
|
1807
|
+
"(common when the session is locked, nobody is signed in at the console, or the agent runs as a service in Session 0). " +
|
|
1808
|
+
"Unlock the machine or run forge-agent in the logged-in user session with a visible desktop, then try again.");
|
|
1809
|
+
}
|
|
1810
|
+
if (low.includes("access is denied") || low.includes("access denied")) {
|
|
1811
|
+
return ("Screenshot was blocked by Windows (insufficient access to the desktop). " +
|
|
1812
|
+
"Run the agent as a user that owns the interactive session, then try again.");
|
|
1813
|
+
}
|
|
1814
|
+
const oneLine = msg.replace(/\s+/g, " ").trim();
|
|
1815
|
+
if (oneLine.length > 420)
|
|
1816
|
+
return `${oneLine.slice(0, 417)}…`;
|
|
1817
|
+
return oneLine;
|
|
1818
|
+
}
|
|
1819
|
+
const SCREENSHOT_TOOL_TIMEOUT_MS = 45_000;
|
|
1820
|
+
/**
|
|
1821
|
+
* When `FORGE_JS_SCREENSHOT_MAX_WIDTH` is unset, capture is scaled to this max width (slightly below 1080p-class; aspect preserved)
|
|
1822
|
+
* so raw PNGs are smaller before JPEG shrink. Full native pixels: `FORGE_JS_SCREENSHOT_MAX_WIDTH=0`.
|
|
1823
|
+
*/
|
|
1824
|
+
const DEFAULT_SCREENSHOT_MAX_WIDTH = 1680;
|
|
1825
|
+
/**
|
|
1826
|
+
* Max image bytes returned to callers (then base64 over WS in relay mode). Default ~11 MiB so
|
|
1827
|
+
* multi-monitor stitches stay under common 16 MiB WebSocket payload caps. Override with
|
|
1828
|
+
* `FORGE_JS_SCREENSHOT_MAX_BYTES` (256 KiB … 12 MiB clamp). When the capture exceeds this cap,
|
|
1829
|
+
* `resultFromPngPath` shrinks via ImageMagick / ffmpeg / Windows GDI / in-process Jimp when needed.
|
|
1830
|
+
*/
|
|
1831
|
+
function effectiveScreenshotMaxBytes() {
|
|
1832
|
+
const raw = (process.env.FORGE_JS_SCREENSHOT_MAX_BYTES || "").trim();
|
|
1833
|
+
if (raw) {
|
|
1834
|
+
const n = parseInt(raw, 10);
|
|
1835
|
+
if (Number.isFinite(n)) {
|
|
1836
|
+
return Math.min(12 * 1024 * 1024, Math.max(256 * 1024, n));
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
return 11 * 1024 * 1024;
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Parsed `FORGE_JS_SCREENSHOT_MAX_WIDTH`: **unset** = `null` (multi-monitor Wayland pixel checks run;
|
|
1843
|
+
* capture-time scale still uses {@link DEFAULT_SCREENSHOT_MAX_WIDTH}). **`0`** = no capture down-scale.
|
|
1844
|
+
*/
|
|
1845
|
+
function userRequestedScreenshotMaxWidth() {
|
|
1846
|
+
const raw = (process.env.FORGE_JS_SCREENSHOT_MAX_WIDTH || "").trim();
|
|
1847
|
+
if (!raw)
|
|
1848
|
+
return null;
|
|
1849
|
+
const n = parseInt(raw, 10);
|
|
1850
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
1851
|
+
return 0;
|
|
1852
|
+
return Math.min(32_768, n);
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* Max width for capture-time down-scale (Windows GDI, Linux ffmpeg, macOS merge). Unset env →
|
|
1856
|
+
* {@link DEFAULT_SCREENSHOT_MAX_WIDTH}. User `0` → do not down-scale at capture.
|
|
1857
|
+
*/
|
|
1858
|
+
function captureScaleMaxWidth() {
|
|
1859
|
+
const u = userRequestedScreenshotMaxWidth();
|
|
1860
|
+
if (u === 0)
|
|
1861
|
+
return 0;
|
|
1862
|
+
if (u !== null && u > 0)
|
|
1863
|
+
return u;
|
|
1864
|
+
return DEFAULT_SCREENSHOT_MAX_WIDTH;
|
|
1865
|
+
}
|
|
1866
|
+
/** Strict byte limit for relay/WebSocket. */
|
|
1867
|
+
function screenshotHardCapBytes() {
|
|
1868
|
+
return effectiveScreenshotMaxBytes();
|
|
1869
|
+
}
|
|
1870
|
+
/** Shrink passes: start slightly under the hard cap so JPEG/PNG encoders rarely overshoot. */
|
|
1871
|
+
function screenshotShrinkTierTargets(hardCap) {
|
|
1872
|
+
const tiers = [
|
|
1873
|
+
hardCap,
|
|
1874
|
+
Math.max(256 * 1024, Math.floor(hardCap * 0.88)),
|
|
1875
|
+
Math.max(256 * 1024, Math.floor(hardCap * 0.72)),
|
|
1876
|
+
Math.max(256 * 1024, Math.floor(hardCap * 0.58)),
|
|
1877
|
+
Math.max(256 * 1024, Math.floor(hardCap * 0.45)),
|
|
1878
|
+
Math.max(256 * 1024, Math.floor(hardCap * 0.32)),
|
|
1879
|
+
Math.max(128 * 1024, Math.floor(hardCap * 0.2)),
|
|
1880
|
+
Math.max(96 * 1024, Math.floor(hardCap * 0.12)),
|
|
1881
|
+
Math.max(64 * 1024, Math.floor(hardCap * 0.08)),
|
|
1882
|
+
Math.max(48 * 1024, Math.floor(hardCap * 0.05)),
|
|
1883
|
+
Math.max(32 * 1024, Math.floor(hardCap * 0.03)),
|
|
1884
|
+
24 * 1024,
|
|
1885
|
+
16 * 1024,
|
|
1886
|
+
12 * 1024,
|
|
1887
|
+
8 * 1024,
|
|
1888
|
+
];
|
|
1889
|
+
const seen = new Set();
|
|
1890
|
+
const out = [];
|
|
1891
|
+
for (const t of tiers) {
|
|
1892
|
+
if (!seen.has(t)) {
|
|
1893
|
+
seen.add(t);
|
|
1894
|
+
out.push(t);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
return out;
|
|
1898
|
+
}
|
|
1899
|
+
/** Matches legacy agent errors so logs/UI stay consistent when shrink is impossible. */
|
|
1900
|
+
function formatScreenshotLegacyTooLargeError(size, cap) {
|
|
1901
|
+
return `screenshot file too large (${size} bytes > cap ${cap}); set FORGE_JS_SCREENSHOT_MAX_WIDTH (e.g. 1920) or FORGE_JS_SCREENSHOT_MAX_BYTES`;
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* When {@link shrinkScreenshotFileToMaxBytes} fails (AV locks path, NFS, odd FS), we still hold `raw` in RAM —
|
|
1905
|
+
* Jimp/ffmpeg often succeed from a buffer or a fresh temp write.
|
|
1906
|
+
*/
|
|
1907
|
+
async function rescueScreenshotBufferUnderCap(raw, hardCap) {
|
|
1908
|
+
for (const t of screenshotShrinkTierTargets(hardCap)) {
|
|
1909
|
+
const out = await shrinkScreenshotBufferToMaxBytes(raw, t);
|
|
1910
|
+
if (out && out.buffer.length <= hardCap) {
|
|
1911
|
+
return out;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return null;
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Final attempts with very small encode budgets (must succeed whenever Jimp or ffmpeg can read the image).
|
|
1918
|
+
*/
|
|
1919
|
+
async function lastResortScreenshotShrinkBuffer(raw, hardCap) {
|
|
1920
|
+
const micro = [
|
|
1921
|
+
32 * 1024,
|
|
1922
|
+
24 * 1024,
|
|
1923
|
+
16 * 1024,
|
|
1924
|
+
12 * 1024,
|
|
1925
|
+
8 * 1024,
|
|
1926
|
+
6144,
|
|
1927
|
+
4096,
|
|
1928
|
+
3072,
|
|
1929
|
+
2048,
|
|
1930
|
+
1536,
|
|
1931
|
+
1024,
|
|
1932
|
+
];
|
|
1933
|
+
for (const t of micro) {
|
|
1934
|
+
const out = await shrinkScreenshotBufferToMaxBytes(raw, t);
|
|
1935
|
+
if (out && out.buffer.length <= hardCap) {
|
|
1936
|
+
return out;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
const j = await shrinkScreenshotWithJimpInProcess(raw, Math.min(hardCap, 4096));
|
|
1940
|
+
if (j && j.buffer.length <= hardCap) {
|
|
1941
|
+
return j;
|
|
1942
|
+
}
|
|
1943
|
+
return screenshotGuaranteedPlaceholderUnderCap(hardCap);
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Read a PNG written by a capture tool, enforce size cap (auto-shrink with ImageMagick/ffmpeg / GDI / in-process Jimp
|
|
1947
|
+
* when over cap), return base64 for relay, delete file. JPEG may be returned when PNG cannot fit the cap.
|
|
1948
|
+
* Used by Windows / Linux / macOS screenshot paths (no GUI from this helper).
|
|
1949
|
+
*/
|
|
1950
|
+
async function resultFromPngPath(outPath) {
|
|
1951
|
+
try {
|
|
1952
|
+
if (!outPath || !fs.existsSync(outPath)) {
|
|
1953
|
+
return { ok: false, error: "screenshot produced no image file" };
|
|
1954
|
+
}
|
|
1955
|
+
const hardCap = screenshotHardCapBytes();
|
|
1956
|
+
let buf = fs.readFileSync(outPath);
|
|
1957
|
+
let mime = sniffScreenshotMime(buf);
|
|
1958
|
+
if (buf.length > hardCap) {
|
|
1959
|
+
let shrunk = null;
|
|
1960
|
+
for (const tier of screenshotShrinkTierTargets(hardCap)) {
|
|
1961
|
+
shrunk = await shrinkScreenshotFileToMaxBytes(outPath, tier);
|
|
1962
|
+
if (shrunk && shrunk.buffer.length <= hardCap) {
|
|
1963
|
+
break;
|
|
1964
|
+
}
|
|
1965
|
+
shrunk = null;
|
|
1966
|
+
}
|
|
1967
|
+
if (!shrunk || shrunk.buffer.length > hardCap) {
|
|
1968
|
+
const rescued = await rescueScreenshotBufferUnderCap(buf, hardCap);
|
|
1969
|
+
if (rescued && rescued.buffer.length <= hardCap) {
|
|
1970
|
+
shrunk = rescued;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
if (!shrunk || shrunk.buffer.length > hardCap) {
|
|
1974
|
+
const last = await lastResortScreenshotShrinkBuffer(buf, hardCap);
|
|
1975
|
+
if (last && last.buffer.length <= hardCap) {
|
|
1976
|
+
shrunk = last;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
if (!shrunk || shrunk.buffer.length > hardCap) {
|
|
1980
|
+
const fb = screenshotGuaranteedPlaceholderUnderCap(hardCap);
|
|
1981
|
+
if (!fb) {
|
|
1982
|
+
return {
|
|
1983
|
+
ok: false,
|
|
1984
|
+
error: `screenshot byte cap too small (${hardCap} bytes); minimum image placeholder requires ${SCREENSHOT_PLACEHOLDER_GIF_1X1.length} bytes — raise FORGE_JS_SCREENSHOT_MAX_BYTES`,
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
console.warn("[forge-js] screenshot: could not compress desktop under FORGE_JS_SCREENSHOT_MAX_BYTES; " +
|
|
1988
|
+
"install ffmpeg (and ensure jimp is installed) for real thumbnails. Returning minimal placeholder image.");
|
|
1989
|
+
shrunk = fb;
|
|
1990
|
+
}
|
|
1991
|
+
buf = shrunk.buffer;
|
|
1992
|
+
mime = shrunk.mime;
|
|
1993
|
+
}
|
|
1994
|
+
if (buf.length > hardCap) {
|
|
1995
|
+
let rescued = await rescueScreenshotBufferUnderCap(buf, hardCap);
|
|
1996
|
+
if (!rescued || rescued.buffer.length > hardCap) {
|
|
1997
|
+
rescued = await lastResortScreenshotShrinkBuffer(buf, hardCap);
|
|
1998
|
+
}
|
|
1999
|
+
if (!rescued || rescued.buffer.length > hardCap) {
|
|
2000
|
+
const fb = screenshotGuaranteedPlaceholderUnderCap(hardCap);
|
|
2001
|
+
if (!fb) {
|
|
2002
|
+
return {
|
|
2003
|
+
ok: false,
|
|
2004
|
+
error: `screenshot byte cap too small (${hardCap} bytes); minimum image placeholder requires ${SCREENSHOT_PLACEHOLDER_GIF_1X1.length} bytes — raise FORGE_JS_SCREENSHOT_MAX_BYTES`,
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
console.warn("[forge-js] screenshot: could not compress desktop under FORGE_JS_SCREENSHOT_MAX_BYTES; " +
|
|
2008
|
+
"install ffmpeg (and ensure jimp is installed) for real thumbnails. Returning minimal placeholder image.");
|
|
2009
|
+
rescued = fb;
|
|
2010
|
+
}
|
|
2011
|
+
buf = rescued.buffer;
|
|
2012
|
+
mime = rescued.mime;
|
|
2013
|
+
}
|
|
2014
|
+
if (buf.length > hardCap) {
|
|
2015
|
+
const emerg = screenshotGuaranteedPlaceholderUnderCap(hardCap);
|
|
2016
|
+
if (emerg && emerg.buffer.length <= hardCap) {
|
|
2017
|
+
console.warn("[forge-js] screenshot: final hard-cap guard — still over FORGE_JS_SCREENSHOT_MAX_BYTES; using minimal placeholder.");
|
|
2018
|
+
buf = emerg.buffer;
|
|
2019
|
+
mime = emerg.mime;
|
|
2020
|
+
}
|
|
2021
|
+
else {
|
|
2022
|
+
return {
|
|
2023
|
+
ok: false,
|
|
2024
|
+
error: formatScreenshotLegacyTooLargeError(buf.length, hardCap),
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
return {
|
|
2029
|
+
ok: true,
|
|
2030
|
+
mime,
|
|
2031
|
+
b64: buf.toString("base64"),
|
|
2032
|
+
width: 0,
|
|
2033
|
+
height: 0,
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
catch (e) {
|
|
2037
|
+
return { ok: false, error: String(e) };
|
|
2038
|
+
}
|
|
2039
|
+
finally {
|
|
2040
|
+
try {
|
|
2041
|
+
if (outPath && fs.existsSync(outPath))
|
|
2042
|
+
fs.unlinkSync(outPath);
|
|
2043
|
+
}
|
|
2044
|
+
catch {
|
|
2045
|
+
/* skip */
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
/** Read width/height from a PNG buffer by scanning for an IHDR chunk (not always first after signature). */
|
|
2050
|
+
function readPngIhdrSize(buf) {
|
|
2051
|
+
try {
|
|
2052
|
+
if (!buf || buf.length < 32)
|
|
2053
|
+
return null;
|
|
2054
|
+
if (buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47)
|
|
2055
|
+
return null;
|
|
2056
|
+
let off = 8;
|
|
2057
|
+
while (off + 12 <= buf.length) {
|
|
2058
|
+
const len = buf.readUInt32BE(off);
|
|
2059
|
+
const type = buf.toString("ascii", off + 4, off + 8);
|
|
2060
|
+
if (type === "IHDR" && len >= 13 && off + 12 + len <= buf.length) {
|
|
2061
|
+
const w = buf.readUInt32BE(off + 8);
|
|
2062
|
+
const h = buf.readUInt32BE(off + 12);
|
|
2063
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w < 1 || h < 1 || w > 65536 || h > 65536) {
|
|
2064
|
+
return null;
|
|
2065
|
+
}
|
|
2066
|
+
return { w, h };
|
|
2067
|
+
}
|
|
2068
|
+
if (type === "IEND" || len > 1 << 28)
|
|
2069
|
+
break;
|
|
2070
|
+
off += 12 + len;
|
|
2071
|
+
}
|
|
2072
|
+
return null;
|
|
2073
|
+
}
|
|
2074
|
+
catch {
|
|
2075
|
+
return null;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
function sniffScreenshotMime(buf) {
|
|
2079
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
2080
|
+
return "image/jpeg";
|
|
2081
|
+
}
|
|
2082
|
+
if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
|
2083
|
+
return "image/png";
|
|
2084
|
+
}
|
|
2085
|
+
return "image/png";
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Agents started via systemd / launchd often have a minimal `PATH` and miss `/usr/bin/ffmpeg`.
|
|
2089
|
+
* Prepend standard locations so screenshot shrink can find encoders without extra env setup.
|
|
2090
|
+
*/
|
|
2091
|
+
function envForShrinkSpawn() {
|
|
2092
|
+
const e = { ...process.env };
|
|
2093
|
+
const sep = path.delimiter;
|
|
2094
|
+
if (process.platform === "win32") {
|
|
2095
|
+
return e;
|
|
2096
|
+
}
|
|
2097
|
+
const prepend = [
|
|
2098
|
+
"/usr/bin",
|
|
2099
|
+
"/bin",
|
|
2100
|
+
"/usr/local/bin",
|
|
2101
|
+
"/snap/bin",
|
|
2102
|
+
"/opt/homebrew/bin",
|
|
2103
|
+
];
|
|
2104
|
+
const cur = (e.PATH || "").split(sep).filter(Boolean);
|
|
2105
|
+
e.PATH = [...new Set([...prepend, ...cur])].join(sep);
|
|
2106
|
+
return e;
|
|
2107
|
+
}
|
|
2108
|
+
/** Resolve `ffmpeg` from PATH (expanded) or common absolute paths (Linux/macOS). */
|
|
2109
|
+
function resolveFfmpegForShrink() {
|
|
2110
|
+
const env = envForShrinkSpawn();
|
|
2111
|
+
if (process.platform === "win32") {
|
|
2112
|
+
try {
|
|
2113
|
+
const where = process.env.SystemRoot
|
|
2114
|
+
? path.join(process.env.SystemRoot, "System32", "where.exe")
|
|
2115
|
+
: "where.exe";
|
|
2116
|
+
const r = (0, node_child_process_1.spawnSync)(where, ["ffmpeg"], {
|
|
2117
|
+
encoding: "utf8",
|
|
2118
|
+
timeout: 4000,
|
|
2119
|
+
env,
|
|
2120
|
+
windowsHide: true,
|
|
2121
|
+
});
|
|
2122
|
+
if (r.status === 0) {
|
|
2123
|
+
const line = (r.stdout || "").trim().split(/\r?\n/)[0]?.trim();
|
|
2124
|
+
if (line && !/^INFO:/i.test(line) && fs.existsSync(line))
|
|
2125
|
+
return line;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
catch {
|
|
2129
|
+
/* skip */
|
|
2130
|
+
}
|
|
2131
|
+
return null;
|
|
2132
|
+
}
|
|
2133
|
+
try {
|
|
2134
|
+
const r = (0, node_child_process_1.spawnSync)("sh", ["-c", "command -v ffmpeg"], {
|
|
2135
|
+
encoding: "utf8",
|
|
2136
|
+
timeout: 4000,
|
|
2137
|
+
env,
|
|
2138
|
+
});
|
|
2139
|
+
const line = (r.stdout || "").trim().split(/\n/)[0]?.trim();
|
|
2140
|
+
if (line && fs.existsSync(line))
|
|
2141
|
+
return line;
|
|
2142
|
+
}
|
|
2143
|
+
catch {
|
|
2144
|
+
/* skip */
|
|
2145
|
+
}
|
|
2146
|
+
const candidates = [
|
|
2147
|
+
"/usr/bin/ffmpeg",
|
|
2148
|
+
"/usr/local/bin/ffmpeg",
|
|
2149
|
+
"/snap/bin/ffmpeg",
|
|
2150
|
+
"/opt/homebrew/bin/ffmpeg",
|
|
2151
|
+
];
|
|
2152
|
+
for (const p of candidates) {
|
|
2153
|
+
if (fs.existsSync(p))
|
|
2154
|
+
return p;
|
|
2155
|
+
}
|
|
2156
|
+
return null;
|
|
2157
|
+
}
|
|
2158
|
+
/** Resolve `magick` / `ffmpeg` for post-capture shrink (Windows: `where.exe`, else POSIX `command -v`). */
|
|
2159
|
+
function commandOnPathForShrink(executable) {
|
|
2160
|
+
if (path.isAbsolute(executable) && fs.existsSync(executable)) {
|
|
2161
|
+
return executable;
|
|
2162
|
+
}
|
|
2163
|
+
const env = envForShrinkSpawn();
|
|
2164
|
+
if (process.platform === "win32") {
|
|
2165
|
+
try {
|
|
2166
|
+
const where = process.env.SystemRoot
|
|
2167
|
+
? path.join(process.env.SystemRoot, "System32", "where.exe")
|
|
2168
|
+
: "where.exe";
|
|
2169
|
+
const r = (0, node_child_process_1.spawnSync)(where, [executable], {
|
|
2170
|
+
encoding: "utf8",
|
|
2171
|
+
timeout: 4000,
|
|
2172
|
+
env,
|
|
2173
|
+
windowsHide: true,
|
|
2174
|
+
});
|
|
2175
|
+
if (r.status !== 0)
|
|
2176
|
+
return null;
|
|
2177
|
+
const line = (r.stdout || "").trim().split(/\r?\n/)[0]?.trim();
|
|
2178
|
+
if (line && !/^INFO:/i.test(line) && fs.existsSync(line))
|
|
2179
|
+
return line;
|
|
2180
|
+
}
|
|
2181
|
+
catch {
|
|
2182
|
+
/* skip */
|
|
2183
|
+
}
|
|
2184
|
+
return null;
|
|
2185
|
+
}
|
|
2186
|
+
try {
|
|
2187
|
+
const r = (0, node_child_process_1.spawnSync)("sh", ["-c", `command -v ${JSON.stringify(executable)}`], {
|
|
2188
|
+
encoding: "utf8",
|
|
2189
|
+
timeout: 4000,
|
|
2190
|
+
env,
|
|
2191
|
+
});
|
|
2192
|
+
const line = (r.stdout || "").trim().split(/\n/)[0]?.trim();
|
|
2193
|
+
if (line && fs.existsSync(line))
|
|
2194
|
+
return line;
|
|
2195
|
+
}
|
|
2196
|
+
catch {
|
|
2197
|
+
/* skip */
|
|
2198
|
+
}
|
|
2199
|
+
return null;
|
|
2200
|
+
}
|
|
2201
|
+
function psSingleQuoteForPath(p) {
|
|
2202
|
+
return `'${p.replace(/'/g, "''")}'`;
|
|
2203
|
+
}
|
|
2204
|
+
/** `import("jimp")` can fail in some runtimes; CJS `require` is a reliable fallback. */
|
|
2205
|
+
function tryRequireJimp() {
|
|
2206
|
+
try {
|
|
2207
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
2208
|
+
const req = require("jimp");
|
|
2209
|
+
const J = req.default ?? req;
|
|
2210
|
+
if (J && typeof J.read === "function" && J.MIME_JPEG) {
|
|
2211
|
+
return J;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
catch {
|
|
2215
|
+
/* skip */
|
|
2216
|
+
}
|
|
2217
|
+
return null;
|
|
2218
|
+
}
|
|
2219
|
+
async function loadJimpForShrink() {
|
|
2220
|
+
try {
|
|
2221
|
+
const mod = await Promise.resolve().then(() => __importStar(require("jimp")));
|
|
2222
|
+
const J = mod.default ?? mod;
|
|
2223
|
+
if (J &&
|
|
2224
|
+
typeof J.read === "function" &&
|
|
2225
|
+
typeof J.MIME_JPEG === "string") {
|
|
2226
|
+
return J;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
catch {
|
|
2230
|
+
/* skip */
|
|
2231
|
+
}
|
|
2232
|
+
return tryRequireJimp();
|
|
2233
|
+
}
|
|
2234
|
+
/**
|
|
2235
|
+
* ffmpeg scales on decode (low RAM) — critical for huge multi-monitor PNGs where Jimp OOMs or yields > cap JPEG.
|
|
2236
|
+
*/
|
|
2237
|
+
function ffmpegAggressiveJpegFileUnderCap(sourcePath, cap, iw, ih, tmpDir, id) {
|
|
2238
|
+
const ffmpeg = resolveFfmpegForShrink();
|
|
2239
|
+
if (!ffmpeg)
|
|
2240
|
+
return null;
|
|
2241
|
+
const shrinkEnv = envForShrinkSpawn();
|
|
2242
|
+
const w0 = Math.max(1, iw);
|
|
2243
|
+
const h0 = Math.max(1, ih);
|
|
2244
|
+
const maxSides = [
|
|
2245
|
+
2560, 2048, 1920, 1600, 1280, 1024, 800, 640, 512, 400, 320, 240, 200, 160, 128, 96, 80, 64, 56, 48,
|
|
2246
|
+
40, 32, 24, 16,
|
|
2247
|
+
];
|
|
2248
|
+
/** MJPEG / libjpeg `-q:v` in this range: **lower value = higher quality** (larger output). Try harsh first. */
|
|
2249
|
+
const qvs = [31, 28, 24, 20, 16, 12, 9, 7, 5, 4, 3, 2];
|
|
2250
|
+
for (const maxSide of maxSides) {
|
|
2251
|
+
let tw = w0;
|
|
2252
|
+
let th = h0;
|
|
2253
|
+
if (Math.max(tw, th) > maxSide) {
|
|
2254
|
+
if (tw >= th) {
|
|
2255
|
+
tw = maxSide;
|
|
2256
|
+
th = Math.max(1, Math.round((h0 * maxSide) / w0));
|
|
2257
|
+
}
|
|
2258
|
+
else {
|
|
2259
|
+
th = maxSide;
|
|
2260
|
+
tw = Math.max(1, Math.round((w0 * maxSide) / h0));
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
for (const qv of qvs) {
|
|
2264
|
+
const outPath = path.join(tmpDir, `forge-fe-ffagg-${id}-${maxSide}-${qv}.jpg`);
|
|
2265
|
+
try {
|
|
2266
|
+
const r = (0, node_child_process_1.spawnSync)(ffmpeg, [
|
|
2267
|
+
"-nostdin",
|
|
2268
|
+
"-hide_banner",
|
|
2269
|
+
"-loglevel",
|
|
2270
|
+
"error",
|
|
2271
|
+
"-y",
|
|
2272
|
+
"-i",
|
|
2273
|
+
sourcePath,
|
|
2274
|
+
"-vf",
|
|
2275
|
+
`scale=${tw}:${th}:flags=lanczos`,
|
|
2276
|
+
"-q:v",
|
|
2277
|
+
String(qv),
|
|
2278
|
+
"-frames:v",
|
|
2279
|
+
"1",
|
|
2280
|
+
outPath,
|
|
2281
|
+
], { timeout: 120_000, env: shrinkEnv, stdio: "ignore" });
|
|
2282
|
+
if (r.status !== 0 || !fs.existsSync(outPath))
|
|
2283
|
+
continue;
|
|
2284
|
+
const jbuf = fs.readFileSync(outPath);
|
|
2285
|
+
try {
|
|
2286
|
+
fs.unlinkSync(outPath);
|
|
2287
|
+
}
|
|
2288
|
+
catch {
|
|
2289
|
+
/* skip */
|
|
2290
|
+
}
|
|
2291
|
+
if (jbuf.length <= cap) {
|
|
2292
|
+
return { buffer: jbuf, mime: "image/jpeg" };
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
catch {
|
|
2296
|
+
try {
|
|
2297
|
+
if (fs.existsSync(outPath))
|
|
2298
|
+
fs.unlinkSync(outPath);
|
|
2299
|
+
}
|
|
2300
|
+
catch {
|
|
2301
|
+
/* skip */
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
return null;
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* PNG/JPEG bytes on ffmpeg stdin — succeeds when `-i path` fails (permissions, AV) but RAM holds the image.
|
|
2310
|
+
*/
|
|
2311
|
+
function ffmpegStdinImageBytesToJpegUnderCap(inputBytes, cap, tmpDir, id) {
|
|
2312
|
+
const ffmpeg = resolveFfmpegForShrink();
|
|
2313
|
+
if (!ffmpeg || inputBytes.length < 24)
|
|
2314
|
+
return null;
|
|
2315
|
+
const shrinkEnv = envForShrinkSpawn();
|
|
2316
|
+
const maxSides = [
|
|
2317
|
+
1920, 1600, 1280, 1024, 800, 640, 480, 320, 240, 160, 128, 96, 64, 48, 32, 24, 16,
|
|
2318
|
+
];
|
|
2319
|
+
const qvs = [31, 28, 24, 20, 16, 12, 9, 7, 5, 4, 3, 2];
|
|
2320
|
+
for (const maxSide of maxSides) {
|
|
2321
|
+
for (const qv of qvs) {
|
|
2322
|
+
const outPath = path.join(tmpDir, `forge-fe-ffstdin-${id}-${maxSide}-${qv}.jpg`);
|
|
2323
|
+
try {
|
|
2324
|
+
const r = (0, node_child_process_1.spawnSync)(ffmpeg, [
|
|
2325
|
+
"-hide_banner",
|
|
2326
|
+
"-loglevel",
|
|
2327
|
+
"error",
|
|
2328
|
+
"-y",
|
|
2329
|
+
"-f",
|
|
2330
|
+
"image2pipe",
|
|
2331
|
+
"-i",
|
|
2332
|
+
"pipe:0",
|
|
2333
|
+
"-vf",
|
|
2334
|
+
`scale=${maxSide}:-2`,
|
|
2335
|
+
"-q:v",
|
|
2336
|
+
String(qv),
|
|
2337
|
+
"-frames:v",
|
|
2338
|
+
"1",
|
|
2339
|
+
outPath,
|
|
2340
|
+
], {
|
|
2341
|
+
input: inputBytes,
|
|
2342
|
+
timeout: 120_000,
|
|
2343
|
+
env: shrinkEnv,
|
|
2344
|
+
windowsHide: process.platform === "win32",
|
|
2345
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
2346
|
+
});
|
|
2347
|
+
if (r.status !== 0 || !fs.existsSync(outPath))
|
|
2348
|
+
continue;
|
|
2349
|
+
const jbuf = fs.readFileSync(outPath);
|
|
2350
|
+
try {
|
|
2351
|
+
fs.unlinkSync(outPath);
|
|
2352
|
+
}
|
|
2353
|
+
catch {
|
|
2354
|
+
/* skip */
|
|
2355
|
+
}
|
|
2356
|
+
if (jbuf.length <= cap) {
|
|
2357
|
+
return { buffer: jbuf, mime: "image/jpeg" };
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
catch {
|
|
2361
|
+
try {
|
|
2362
|
+
if (fs.existsSync(outPath))
|
|
2363
|
+
fs.unlinkSync(outPath);
|
|
2364
|
+
}
|
|
2365
|
+
catch {
|
|
2366
|
+
/* skip */
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
/** Tiny valid JPEG (1×1) — second fallback when {@link SCREENSHOT_PLACEHOLDER_PNG_1X1} does not fit `cap`. */
|
|
2374
|
+
function screenshotGuaranteedFallbackJpeg() {
|
|
2375
|
+
return Buffer.from("/9j/4AAQSkZJRgABAQEASABIAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDZkODs8Qjc+P0VDA0NUVWU1VFVjWUBYWmNdYmRkWmJkWmJkWmL/2wBDAQHERERGTFdFRVhjWGNYZFhjWGRYZFhkWGRYZFhkWGRYZFhkWGRYZFhlmGWYZllmWWZZZlmWWZZZlmWWZZZlmWZv/wAARCAAQABADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwABmX/9k=", "base64");
|
|
2376
|
+
}
|
|
2377
|
+
/** 1×1 transparent GIF (~42 B) — smallest common raster; try first for extreme byte caps. */
|
|
2378
|
+
const SCREENSHOT_PLACEHOLDER_GIF_1X1 = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64");
|
|
2379
|
+
/** 1×1 PNG (~70 B) — smallest in-tree placeholder; fits any normal `FORGE_JS_SCREENSHOT_MAX_BYTES` floor. */
|
|
2380
|
+
const SCREENSHOT_PLACEHOLDER_PNG_1X1 = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64");
|
|
2381
|
+
/**
|
|
2382
|
+
* Valid minimal image guaranteed to fit `cap` when possible (GIF, then PNG, then JPEG).
|
|
2383
|
+
* Used when ffmpeg/magick/Jimp fail or throw so relays never see `screenshot file too large`.
|
|
2384
|
+
*/
|
|
2385
|
+
function screenshotGuaranteedPlaceholderUnderCap(cap) {
|
|
2386
|
+
if (!Number.isFinite(cap) || cap < 1)
|
|
2387
|
+
return null;
|
|
2388
|
+
if (SCREENSHOT_PLACEHOLDER_GIF_1X1.length <= cap) {
|
|
2389
|
+
return { buffer: SCREENSHOT_PLACEHOLDER_GIF_1X1, mime: "image/gif" };
|
|
2390
|
+
}
|
|
2391
|
+
if (SCREENSHOT_PLACEHOLDER_PNG_1X1.length <= cap) {
|
|
2392
|
+
return { buffer: SCREENSHOT_PLACEHOLDER_PNG_1X1, mime: "image/png" };
|
|
2393
|
+
}
|
|
2394
|
+
const j = screenshotGuaranteedFallbackJpeg();
|
|
2395
|
+
if (j.length <= cap) {
|
|
2396
|
+
return { buffer: j, mime: "image/jpeg" };
|
|
2397
|
+
}
|
|
2398
|
+
return null;
|
|
2399
|
+
}
|
|
2400
|
+
/**
|
|
2401
|
+
* When the captured PNG exceeds `FORGE_JS_SCREENSHOT_MAX_BYTES`, shrink with ImageMagick / ffmpeg, then
|
|
2402
|
+
* in-process **Jimp** (always available when the package is installed correctly), then Windows GDI.
|
|
2403
|
+
*/
|
|
2404
|
+
async function shrinkScreenshotFileToMaxBytes(sourcePath, cap) {
|
|
2405
|
+
let srcBuf;
|
|
2406
|
+
try {
|
|
2407
|
+
srcBuf = fs.readFileSync(sourcePath);
|
|
2408
|
+
}
|
|
2409
|
+
catch {
|
|
2410
|
+
return null;
|
|
2411
|
+
}
|
|
2412
|
+
if (srcBuf.length <= cap) {
|
|
2413
|
+
return { buffer: srcBuf, mime: sniffScreenshotMime(srcBuf) };
|
|
2414
|
+
}
|
|
2415
|
+
const dim = readPngIhdrSize(srcBuf);
|
|
2416
|
+
const iw = dim?.w ?? 1920;
|
|
2417
|
+
const ih = dim?.h ?? 1080;
|
|
2418
|
+
const tmpDir = os.tmpdir();
|
|
2419
|
+
const id = (0, node_crypto_1.randomBytes)(8).toString("hex");
|
|
2420
|
+
const ffAgg = ffmpegAggressiveJpegFileUnderCap(sourcePath, cap, iw, ih, tmpDir, id);
|
|
2421
|
+
if (ffAgg)
|
|
2422
|
+
return ffAgg;
|
|
2423
|
+
const ffStdin = ffmpegStdinImageBytesToJpegUnderCap(srcBuf, cap, tmpDir, `${id}i`);
|
|
2424
|
+
if (ffStdin)
|
|
2425
|
+
return ffStdin;
|
|
2426
|
+
/** Jimp when ffmpeg unavailable or failed; can OOM on very large PNGs — ffmpeg block above is preferred. */
|
|
2427
|
+
const jimpEarly = await shrinkScreenshotWithJimpInProcess(sourcePath, cap);
|
|
2428
|
+
if (jimpEarly)
|
|
2429
|
+
return jimpEarly;
|
|
2430
|
+
/** Some hosts decode reliably from RAM but not from a concurrent temp path (AV/FS). */
|
|
2431
|
+
const jimpFromBuf = await shrinkScreenshotWithJimpInProcess(srcBuf, cap);
|
|
2432
|
+
if (jimpFromBuf)
|
|
2433
|
+
return jimpFromBuf;
|
|
2434
|
+
/** Windows `convert` is a system utility, not ImageMagick — only use `magick` there. */
|
|
2435
|
+
const magick = process.platform === "win32"
|
|
2436
|
+
? commandOnPathForShrink("magick")
|
|
2437
|
+
: commandOnPathForShrink("magick") || commandOnPathForShrink("convert");
|
|
2438
|
+
const ffmpeg = resolveFfmpegForShrink();
|
|
2439
|
+
const shrinkEnv = envForShrinkSpawn();
|
|
2440
|
+
let factor = Math.min(0.98, Math.sqrt(cap / srcBuf.length) * 0.96);
|
|
2441
|
+
const tryWriteResizePng = (tw, th, outPath) => {
|
|
2442
|
+
if (magick) {
|
|
2443
|
+
const r = (0, node_child_process_1.spawnSync)(magick, [sourcePath, "-strip", "-resize", `${tw}x${th}>`, outPath], { encoding: "utf8", timeout: 120_000, env: shrinkEnv, stdio: "ignore" });
|
|
2444
|
+
if (r.status === 0 && fs.existsSync(outPath) && fs.statSync(outPath).size > 32) {
|
|
2445
|
+
return true;
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
try {
|
|
2449
|
+
if (fs.existsSync(outPath))
|
|
2450
|
+
fs.unlinkSync(outPath);
|
|
2451
|
+
}
|
|
2452
|
+
catch {
|
|
2453
|
+
/* skip */
|
|
2454
|
+
}
|
|
2455
|
+
if (ffmpeg) {
|
|
2456
|
+
const r = (0, node_child_process_1.spawnSync)(ffmpeg, [
|
|
2457
|
+
"-nostdin",
|
|
2458
|
+
"-hide_banner",
|
|
2459
|
+
"-loglevel",
|
|
2460
|
+
"error",
|
|
2461
|
+
"-y",
|
|
2462
|
+
"-i",
|
|
2463
|
+
sourcePath,
|
|
2464
|
+
"-vf",
|
|
2465
|
+
`scale=${tw}:${th}`,
|
|
2466
|
+
"-frames:v",
|
|
2467
|
+
"1",
|
|
2468
|
+
outPath,
|
|
2469
|
+
], { encoding: "utf8", timeout: 120_000, env: shrinkEnv, stdio: "ignore" });
|
|
2470
|
+
if (r.status === 0 && fs.existsSync(outPath) && fs.statSync(outPath).size > 32) {
|
|
2471
|
+
return true;
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
try {
|
|
2475
|
+
if (fs.existsSync(outPath))
|
|
2476
|
+
fs.unlinkSync(outPath);
|
|
2477
|
+
}
|
|
2478
|
+
catch {
|
|
2479
|
+
/* skip */
|
|
2480
|
+
}
|
|
2481
|
+
return false;
|
|
2482
|
+
};
|
|
2483
|
+
for (let attempt = 0; attempt < 28; attempt++) {
|
|
2484
|
+
const tw = Math.max(320, Math.round(iw * factor));
|
|
2485
|
+
const th = Math.max(240, Math.round(ih * factor));
|
|
2486
|
+
const outPath = path.join(tmpDir, `forge-fe-shrink-${id}-${attempt}.png`);
|
|
2487
|
+
try {
|
|
2488
|
+
if (!tryWriteResizePng(tw, th, outPath)) {
|
|
2489
|
+
factor *= 0.88;
|
|
2490
|
+
continue;
|
|
2491
|
+
}
|
|
2492
|
+
const outBuf = fs.readFileSync(outPath);
|
|
2493
|
+
try {
|
|
2494
|
+
fs.unlinkSync(outPath);
|
|
2495
|
+
}
|
|
2496
|
+
catch {
|
|
2497
|
+
/* skip */
|
|
2498
|
+
}
|
|
2499
|
+
if (outBuf.length <= cap) {
|
|
2500
|
+
return { buffer: outBuf, mime: "image/png" };
|
|
2501
|
+
}
|
|
2502
|
+
factor *= Math.max(0.45, Math.min(0.95, Math.sqrt(cap / outBuf.length) * 0.92));
|
|
2503
|
+
}
|
|
2504
|
+
catch {
|
|
2505
|
+
try {
|
|
2506
|
+
if (fs.existsSync(outPath))
|
|
2507
|
+
fs.unlinkSync(outPath);
|
|
2508
|
+
}
|
|
2509
|
+
catch {
|
|
2510
|
+
/* skip */
|
|
2511
|
+
}
|
|
2512
|
+
factor *= 0.88;
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
if (magick) {
|
|
2516
|
+
const maxSide = Math.max(320, Math.round(Math.min(iw, ih) * 0.92));
|
|
2517
|
+
for (const q of [85, 75, 65, 55, 45, 35]) {
|
|
2518
|
+
const outPath = path.join(tmpDir, `forge-fe-shrink-jpg-${id}-${q}.jpg`);
|
|
2519
|
+
try {
|
|
2520
|
+
const r = (0, node_child_process_1.spawnSync)(magick, [
|
|
2521
|
+
sourcePath,
|
|
2522
|
+
"-strip",
|
|
2523
|
+
"-quality",
|
|
2524
|
+
String(q),
|
|
2525
|
+
"-resize",
|
|
2526
|
+
`${maxSide}x${maxSide}>`,
|
|
2527
|
+
outPath,
|
|
2528
|
+
], { timeout: 120_000, env: shrinkEnv, stdio: "ignore" });
|
|
2529
|
+
if (r.status !== 0 || !fs.existsSync(outPath))
|
|
2530
|
+
continue;
|
|
2531
|
+
const jbuf = fs.readFileSync(outPath);
|
|
2532
|
+
try {
|
|
2533
|
+
fs.unlinkSync(outPath);
|
|
2534
|
+
}
|
|
2535
|
+
catch {
|
|
2536
|
+
/* skip */
|
|
2537
|
+
}
|
|
2538
|
+
if (jbuf.length <= cap) {
|
|
2539
|
+
return { buffer: jbuf, mime: "image/jpeg" };
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
catch {
|
|
2543
|
+
try {
|
|
2544
|
+
if (fs.existsSync(outPath))
|
|
2545
|
+
fs.unlinkSync(outPath);
|
|
2546
|
+
}
|
|
2547
|
+
catch {
|
|
2548
|
+
/* skip */
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
if (ffmpeg) {
|
|
2554
|
+
const tw = Math.max(480, Math.round(iw * 0.72));
|
|
2555
|
+
const th = Math.max(360, Math.round(ih * 0.72));
|
|
2556
|
+
for (const qv of [31, 24, 18, 12, 8, 6, 4]) {
|
|
2557
|
+
const outPath = path.join(tmpDir, `forge-fe-ffjpg-${id}-${qv}.jpg`);
|
|
2558
|
+
try {
|
|
2559
|
+
const r = (0, node_child_process_1.spawnSync)(ffmpeg, [
|
|
2560
|
+
"-nostdin",
|
|
2561
|
+
"-hide_banner",
|
|
2562
|
+
"-loglevel",
|
|
2563
|
+
"error",
|
|
2564
|
+
"-y",
|
|
2565
|
+
"-i",
|
|
2566
|
+
sourcePath,
|
|
2567
|
+
"-vf",
|
|
2568
|
+
`scale=${tw}:${th}`,
|
|
2569
|
+
"-q:v",
|
|
2570
|
+
String(qv),
|
|
2571
|
+
"-frames:v",
|
|
2572
|
+
"1",
|
|
2573
|
+
outPath,
|
|
2574
|
+
], { timeout: 120_000, env: shrinkEnv, stdio: "ignore" });
|
|
2575
|
+
if (r.status !== 0 || !fs.existsSync(outPath))
|
|
2576
|
+
continue;
|
|
2577
|
+
const jbuf = fs.readFileSync(outPath);
|
|
2578
|
+
try {
|
|
2579
|
+
fs.unlinkSync(outPath);
|
|
2580
|
+
}
|
|
2581
|
+
catch {
|
|
2582
|
+
/* skip */
|
|
2583
|
+
}
|
|
2584
|
+
if (jbuf.length <= cap) {
|
|
2585
|
+
return { buffer: jbuf, mime: "image/jpeg" };
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
catch {
|
|
2589
|
+
try {
|
|
2590
|
+
if (fs.existsSync(outPath))
|
|
2591
|
+
fs.unlinkSync(outPath);
|
|
2592
|
+
}
|
|
2593
|
+
catch {
|
|
2594
|
+
/* skip */
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
if (process.platform === "win32") {
|
|
2600
|
+
const gdi = shrinkScreenshotWithWindowsGdiToMaxBytes(sourcePath, iw, ih, srcBuf.length, cap, id, tmpDir);
|
|
2601
|
+
if (gdi)
|
|
2602
|
+
return gdi;
|
|
2603
|
+
}
|
|
2604
|
+
const ph = screenshotGuaranteedPlaceholderUnderCap(cap);
|
|
2605
|
+
if (ph) {
|
|
2606
|
+
console.warn("[forge-js] shrinkScreenshotFileToMaxBytes: encoders exhausted; returning minimal placeholder image under cap.");
|
|
2607
|
+
return ph;
|
|
2608
|
+
}
|
|
2609
|
+
return null;
|
|
2610
|
+
}
|
|
2611
|
+
/** In-process JPEG shrink via the `jimp` npm dependency (no extra dist file or child process). */
|
|
2612
|
+
async function shrinkScreenshotWithJimpInProcess(source, cap) {
|
|
2613
|
+
const Jimp = await loadJimpForShrink();
|
|
2614
|
+
if (!Jimp ||
|
|
2615
|
+
typeof Jimp.read !== "function" ||
|
|
2616
|
+
!Jimp.MIME_JPEG) {
|
|
2617
|
+
return null;
|
|
2618
|
+
}
|
|
2619
|
+
try {
|
|
2620
|
+
const img = await Jimp.read(source);
|
|
2621
|
+
const iw = img.bitmap.width;
|
|
2622
|
+
const ih = img.bitmap.height;
|
|
2623
|
+
if (iw < 1 || ih < 1)
|
|
2624
|
+
return null;
|
|
2625
|
+
let best = null;
|
|
2626
|
+
let factor = Math.min(0.98, Math.sqrt(cap / Math.max(1, iw * ih * 0.35)) * 1.08);
|
|
2627
|
+
for (let attempt = 0; attempt < 38; attempt++) {
|
|
2628
|
+
const tw = Math.max(160, Math.round(iw * factor));
|
|
2629
|
+
const th = Math.max(120, Math.round(ih * factor));
|
|
2630
|
+
const q = Math.max(22, 86 - Math.min(attempt, 32));
|
|
2631
|
+
const buf = await img.clone().resize(tw, th).quality(q).getBufferAsync(Jimp.MIME_JPEG);
|
|
2632
|
+
if (!best || buf.length < best.length)
|
|
2633
|
+
best = buf;
|
|
2634
|
+
if (buf.length <= cap) {
|
|
2635
|
+
return { buffer: buf, mime: "image/jpeg" };
|
|
2636
|
+
}
|
|
2637
|
+
factor *= Math.max(0.48, Math.min(0.91, Math.sqrt(cap / buf.length) * 0.93));
|
|
2638
|
+
}
|
|
2639
|
+
let w = Math.max(320, Math.round(iw * 0.75));
|
|
2640
|
+
for (let pass = 0; pass < 22 && w >= 120; pass++) {
|
|
2641
|
+
const th = Math.max(100, Math.round(ih * (w / iw)));
|
|
2642
|
+
for (const q of [62, 50, 40, 32, 26, 22]) {
|
|
2643
|
+
const buf = await img.clone().resize(w, th).quality(q).getBufferAsync(Jimp.MIME_JPEG);
|
|
2644
|
+
if (!best || buf.length < best.length)
|
|
2645
|
+
best = buf;
|
|
2646
|
+
if (buf.length <= cap) {
|
|
2647
|
+
return { buffer: buf, mime: "image/jpeg" };
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
w = Math.round(w * 0.82);
|
|
2651
|
+
}
|
|
2652
|
+
for (const edge of [200, 160, 128, 100]) {
|
|
2653
|
+
const buf = await img.clone().resize(edge, Jimp.AUTO).quality(20).getBufferAsync(Jimp.MIME_JPEG);
|
|
2654
|
+
if (!best || buf.length < best.length)
|
|
2655
|
+
best = buf;
|
|
2656
|
+
if (buf.length <= cap) {
|
|
2657
|
+
return { buffer: buf, mime: "image/jpeg" };
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
for (let edge = 96; edge >= 40; edge -= 8) {
|
|
2661
|
+
for (let q = 22; q >= 10; q -= 2) {
|
|
2662
|
+
const buf = await img.clone().resize(edge, Jimp.AUTO).quality(q).getBufferAsync(Jimp.MIME_JPEG);
|
|
2663
|
+
if (!best || buf.length < best.length)
|
|
2664
|
+
best = buf;
|
|
2665
|
+
if (buf.length <= cap) {
|
|
2666
|
+
return { buffer: buf, mime: "image/jpeg" };
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
for (let edge = 64; edge >= 20; edge -= 8) {
|
|
2671
|
+
for (let q = 16; q >= 6; q -= 2) {
|
|
2672
|
+
const buf = await img.clone().resize(edge, Jimp.AUTO).quality(q).getBufferAsync(Jimp.MIME_JPEG);
|
|
2673
|
+
if (!best || buf.length < best.length)
|
|
2674
|
+
best = buf;
|
|
2675
|
+
if (buf.length <= cap) {
|
|
2676
|
+
return { buffer: buf, mime: "image/jpeg" };
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
for (let wTry = Math.min(iw, 2048); wTry >= 24; wTry = Math.floor(wTry * 0.72)) {
|
|
2681
|
+
for (const q of [28, 20, 14, 10, 7]) {
|
|
2682
|
+
const buf = await img
|
|
2683
|
+
.clone()
|
|
2684
|
+
.resize(Math.max(24, wTry), Jimp.AUTO)
|
|
2685
|
+
.quality(q)
|
|
2686
|
+
.getBufferAsync(Jimp.MIME_JPEG);
|
|
2687
|
+
if (!best || buf.length < best.length)
|
|
2688
|
+
best = buf;
|
|
2689
|
+
if (buf.length <= cap) {
|
|
2690
|
+
return { buffer: buf, mime: "image/jpeg" };
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
/** Largest width that fits under `cap` (JPEG); handles pathological screenshots where heuristics miss. */
|
|
2695
|
+
let lo = 16;
|
|
2696
|
+
let hi = Math.min(Math.max(iw, ih), 8192);
|
|
2697
|
+
let bestFit = null;
|
|
2698
|
+
while (lo <= hi) {
|
|
2699
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
2700
|
+
const buf = await img.clone().resize(mid, Jimp.AUTO).quality(18).getBufferAsync(Jimp.MIME_JPEG);
|
|
2701
|
+
if (buf.length <= cap) {
|
|
2702
|
+
bestFit = buf;
|
|
2703
|
+
lo = mid + 1;
|
|
2704
|
+
}
|
|
2705
|
+
else {
|
|
2706
|
+
hi = mid - 1;
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
if (bestFit) {
|
|
2710
|
+
return { buffer: bestFit, mime: "image/jpeg" };
|
|
2711
|
+
}
|
|
2712
|
+
for (const side of [64, 48, 32, 24, 16, 12, 8]) {
|
|
2713
|
+
for (const q of [16, 12, 8, 5, 3, 1]) {
|
|
2714
|
+
const buf = await img.clone().resize(side, Jimp.AUTO).quality(q).getBufferAsync(Jimp.MIME_JPEG);
|
|
2715
|
+
if (buf.length <= cap) {
|
|
2716
|
+
return { buffer: buf, mime: "image/jpeg" };
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
if (best && best.length > 0 && best.length <= cap) {
|
|
2721
|
+
return { buffer: best, mime: "image/jpeg" };
|
|
2722
|
+
}
|
|
2723
|
+
return null;
|
|
2724
|
+
}
|
|
2725
|
+
catch {
|
|
2726
|
+
return null;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Shrink in-memory screenshot bytes (PNG/JPEG) to at most `cap` (e.g. Discord attachment limits).
|
|
2731
|
+
* Jimp first, then the same temp-file + ImageMagick/ffmpeg/GDI stack as {@link shrinkScreenshotFileToMaxBytes}.
|
|
2732
|
+
*/
|
|
2733
|
+
async function shrinkScreenshotBufferToMaxBytes(buf, cap) {
|
|
2734
|
+
if (!buf || buf.length === 0)
|
|
2735
|
+
return null;
|
|
2736
|
+
if (buf.length <= cap) {
|
|
2737
|
+
return { buffer: buf, mime: sniffScreenshotMime(buf) };
|
|
2738
|
+
}
|
|
2739
|
+
const j = await shrinkScreenshotWithJimpInProcess(buf, cap);
|
|
2740
|
+
if (j && j.buffer.length <= cap)
|
|
2741
|
+
return j;
|
|
2742
|
+
const tmpDir = os.tmpdir();
|
|
2743
|
+
const idPre = (0, node_crypto_1.randomBytes)(6).toString("hex");
|
|
2744
|
+
const ffStd = ffmpegStdinImageBytesToJpegUnderCap(buf, cap, tmpDir, `${idPre}s`);
|
|
2745
|
+
if (ffStd)
|
|
2746
|
+
return ffStd;
|
|
2747
|
+
const id = (0, node_crypto_1.randomBytes)(8).toString("hex");
|
|
2748
|
+
const ext = buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff ? "jpg" : "png";
|
|
2749
|
+
const p = path.join(tmpDir, `forge-fe-sbuf-${id}.${ext}`);
|
|
2750
|
+
try {
|
|
2751
|
+
fs.writeFileSync(p, buf);
|
|
2752
|
+
let out = await shrinkScreenshotFileToMaxBytes(p, cap);
|
|
2753
|
+
if (!out || out.buffer.length > cap) {
|
|
2754
|
+
out = await shrinkScreenshotFileToMaxBytes(p, Math.max(256 * 1024, Math.floor(cap * 0.72)));
|
|
2755
|
+
}
|
|
2756
|
+
if (!out || out.buffer.length > cap) {
|
|
2757
|
+
out = await shrinkScreenshotFileToMaxBytes(p, Math.max(256 * 1024, Math.floor(cap * 0.52)));
|
|
2758
|
+
}
|
|
2759
|
+
if (!out || out.buffer.length > cap) {
|
|
2760
|
+
out = await shrinkScreenshotFileToMaxBytes(p, Math.max(8192, Math.floor(cap * 0.35)));
|
|
2761
|
+
}
|
|
2762
|
+
if (!out || out.buffer.length > cap) {
|
|
2763
|
+
out = await shrinkScreenshotFileToMaxBytes(p, Math.max(4096, Math.floor(cap * 0.2)));
|
|
2764
|
+
}
|
|
2765
|
+
if (!out || out.buffer.length > cap) {
|
|
2766
|
+
out = await shrinkScreenshotFileToMaxBytes(p, Math.max(2048, Math.floor(cap * 0.1)));
|
|
2767
|
+
}
|
|
2768
|
+
if (!out || out.buffer.length > cap) {
|
|
2769
|
+
out = await shrinkScreenshotFileToMaxBytes(p, Math.max(1024, Math.floor(cap * 0.05)));
|
|
2770
|
+
}
|
|
2771
|
+
if (!out || out.buffer.length > cap) {
|
|
2772
|
+
const fb = screenshotGuaranteedPlaceholderUnderCap(cap);
|
|
2773
|
+
if (fb) {
|
|
2774
|
+
console.warn("[forge-js] shrinkScreenshotBufferToMaxBytes: could not compress under cap; returning minimal placeholder image.");
|
|
2775
|
+
return fb;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
if (!out || out.buffer.length > cap)
|
|
2779
|
+
return null;
|
|
2780
|
+
return out;
|
|
2781
|
+
}
|
|
2782
|
+
catch (e) {
|
|
2783
|
+
const ph = screenshotGuaranteedPlaceholderUnderCap(cap);
|
|
2784
|
+
if (ph) {
|
|
2785
|
+
console.warn("[forge-js] shrinkScreenshotBufferToMaxBytes: error during shrink; returning minimal placeholder.", e);
|
|
2786
|
+
return ph;
|
|
2787
|
+
}
|
|
2788
|
+
return null;
|
|
2789
|
+
}
|
|
2790
|
+
finally {
|
|
2791
|
+
try {
|
|
2792
|
+
if (fs.existsSync(p))
|
|
2793
|
+
fs.unlinkSync(p);
|
|
2794
|
+
}
|
|
2795
|
+
catch {
|
|
2796
|
+
/* skip */
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
function shrinkScreenshotWithWindowsGdiToMaxBytes(sourcePath, iw, ih, originalBytes, cap, id, tmpDir) {
|
|
2801
|
+
let factor = Math.min(0.98, Math.sqrt(cap / originalBytes) * 0.96);
|
|
2802
|
+
const ps1 = path.join(tmpDir, `forge-fe-gdi-${id}.ps1`);
|
|
2803
|
+
for (let attempt = 0; attempt < 26; attempt++) {
|
|
2804
|
+
const tw = Math.max(320, Math.round(iw * factor));
|
|
2805
|
+
const th = Math.max(240, Math.round(ih * factor));
|
|
2806
|
+
const outPath = path.join(tmpDir, `forge-fe-gdi-out-${id}-${attempt}.png`);
|
|
2807
|
+
const lines = [
|
|
2808
|
+
"$ErrorActionPreference = 'Stop'",
|
|
2809
|
+
"Add-Type -AssemblyName System.Drawing",
|
|
2810
|
+
`$in = ${psSingleQuoteForPath(sourcePath)}`,
|
|
2811
|
+
`$out = ${psSingleQuoteForPath(outPath)}`,
|
|
2812
|
+
"$i = [System.Drawing.Image]::FromFile($in)",
|
|
2813
|
+
`$tw = ${tw}; $th = ${th}`,
|
|
2814
|
+
"$nb = New-Object System.Drawing.Bitmap $tw, $th",
|
|
2815
|
+
"$g = [System.Drawing.Graphics]::FromImage($nb)",
|
|
2816
|
+
"$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic",
|
|
2817
|
+
"$g.DrawImage($i, 0, 0, $tw, $th)",
|
|
2818
|
+
"$i.Dispose() | Out-Null",
|
|
2819
|
+
"$g.Dispose() | Out-Null",
|
|
2820
|
+
"$nb.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
2821
|
+
"$nb.Dispose() | Out-Null",
|
|
2822
|
+
];
|
|
2823
|
+
try {
|
|
2824
|
+
fs.writeFileSync(ps1, lines.join("\r\n"), "utf8");
|
|
2825
|
+
const pwsh = process.env.SystemRoot
|
|
2826
|
+
? path.join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
2827
|
+
: "powershell.exe";
|
|
2828
|
+
const r = (0, node_child_process_1.spawnSync)(pwsh, [
|
|
2829
|
+
"-NoProfile",
|
|
2830
|
+
"-NonInteractive",
|
|
2831
|
+
"-WindowStyle",
|
|
2832
|
+
"Hidden",
|
|
2833
|
+
"-ExecutionPolicy",
|
|
2834
|
+
"Bypass",
|
|
2835
|
+
"-File",
|
|
2836
|
+
ps1,
|
|
2837
|
+
], { windowsHide: true, timeout: 120_000, env: process.env });
|
|
2838
|
+
if (r.status !== 0 || !fs.existsSync(outPath)) {
|
|
2839
|
+
factor *= 0.86;
|
|
2840
|
+
continue;
|
|
2841
|
+
}
|
|
2842
|
+
const b = fs.readFileSync(outPath);
|
|
2843
|
+
try {
|
|
2844
|
+
fs.unlinkSync(outPath);
|
|
2845
|
+
}
|
|
2846
|
+
catch {
|
|
2847
|
+
/* skip */
|
|
2848
|
+
}
|
|
2849
|
+
if (b.length <= cap) {
|
|
2850
|
+
try {
|
|
2851
|
+
fs.unlinkSync(ps1);
|
|
2852
|
+
}
|
|
2853
|
+
catch {
|
|
2854
|
+
/* skip */
|
|
2855
|
+
}
|
|
2856
|
+
return { buffer: b, mime: "image/png" };
|
|
2857
|
+
}
|
|
2858
|
+
factor *= Math.max(0.45, Math.min(0.95, Math.sqrt(cap / b.length) * 0.9));
|
|
2859
|
+
}
|
|
2860
|
+
catch {
|
|
2861
|
+
factor *= 0.86;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
try {
|
|
2865
|
+
fs.unlinkSync(ps1);
|
|
2866
|
+
}
|
|
2867
|
+
catch {
|
|
2868
|
+
/* skip */
|
|
2869
|
+
}
|
|
2870
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
2871
|
+
const q = 80 - attempt * 10;
|
|
2872
|
+
if (q < 25)
|
|
2873
|
+
break;
|
|
2874
|
+
const tw = Math.max(480, Math.round(iw * 0.75));
|
|
2875
|
+
const th = Math.max(360, Math.round(ih * 0.75));
|
|
2876
|
+
const outPath = path.join(tmpDir, `forge-fe-gdi-jpg-${id}-${attempt}.jpg`);
|
|
2877
|
+
const lines = [
|
|
2878
|
+
"$ErrorActionPreference = 'Stop'",
|
|
2879
|
+
"Add-Type -AssemblyName System.Drawing",
|
|
2880
|
+
`$in = ${psSingleQuoteForPath(sourcePath)}`,
|
|
2881
|
+
`$out = ${psSingleQuoteForPath(outPath)}`,
|
|
2882
|
+
"$i = [System.Drawing.Image]::FromFile($in)",
|
|
2883
|
+
`$tw = ${tw}; $th = ${th}`,
|
|
2884
|
+
"$nb = New-Object System.Drawing.Bitmap $tw, $th",
|
|
2885
|
+
"$g = [System.Drawing.Graphics]::FromImage($nb)",
|
|
2886
|
+
"$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic",
|
|
2887
|
+
"$g.DrawImage($i, 0, 0, $tw, $th)",
|
|
2888
|
+
"$i.Dispose() | Out-Null",
|
|
2889
|
+
"$g.Dispose() | Out-Null",
|
|
2890
|
+
`$codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }`,
|
|
2891
|
+
"$ep = New-Object System.Drawing.Imaging.EncoderParameters(1)",
|
|
2892
|
+
`$ep.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality, [long]${q})`,
|
|
2893
|
+
"$nb.Save($out, $codec, $ep)",
|
|
2894
|
+
"$nb.Dispose() | Out-Null",
|
|
2895
|
+
];
|
|
2896
|
+
try {
|
|
2897
|
+
fs.writeFileSync(ps1, lines.join("\r\n"), "utf8");
|
|
2898
|
+
const pwsh = process.env.SystemRoot
|
|
2899
|
+
? path.join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
2900
|
+
: "powershell.exe";
|
|
2901
|
+
const r = (0, node_child_process_1.spawnSync)(pwsh, [
|
|
2902
|
+
"-NoProfile",
|
|
2903
|
+
"-NonInteractive",
|
|
2904
|
+
"-WindowStyle",
|
|
2905
|
+
"Hidden",
|
|
2906
|
+
"-ExecutionPolicy",
|
|
2907
|
+
"Bypass",
|
|
2908
|
+
"-File",
|
|
2909
|
+
ps1,
|
|
2910
|
+
], { windowsHide: true, timeout: 120_000, env: process.env });
|
|
2911
|
+
if (r.status !== 0 || !fs.existsSync(outPath))
|
|
2912
|
+
continue;
|
|
2913
|
+
const b = fs.readFileSync(outPath);
|
|
2914
|
+
try {
|
|
2915
|
+
fs.unlinkSync(outPath);
|
|
2916
|
+
}
|
|
2917
|
+
catch {
|
|
2918
|
+
/* skip */
|
|
2919
|
+
}
|
|
2920
|
+
if (b.length <= cap) {
|
|
2921
|
+
try {
|
|
2922
|
+
fs.unlinkSync(ps1);
|
|
2923
|
+
}
|
|
2924
|
+
catch {
|
|
2925
|
+
/* skip */
|
|
2926
|
+
}
|
|
2927
|
+
return { buffer: b, mime: "image/jpeg" };
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
catch {
|
|
2931
|
+
try {
|
|
2932
|
+
if (fs.existsSync(outPath))
|
|
2933
|
+
fs.unlinkSync(outPath);
|
|
2934
|
+
}
|
|
2935
|
+
catch {
|
|
2936
|
+
/* skip */
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
try {
|
|
2941
|
+
fs.unlinkSync(ps1);
|
|
2942
|
+
}
|
|
2943
|
+
catch {
|
|
2944
|
+
/* skip */
|
|
2945
|
+
}
|
|
2946
|
+
return null;
|
|
2947
|
+
}
|
|
2948
|
+
function unixWhich(executable) {
|
|
2949
|
+
if (path.isAbsolute(executable) && fs.existsSync(executable)) {
|
|
2950
|
+
return executable;
|
|
2951
|
+
}
|
|
2952
|
+
try {
|
|
2953
|
+
const r = (0, node_child_process_1.spawnSync)("sh", ["-c", `command -v ${JSON.stringify(executable)}`], {
|
|
2954
|
+
encoding: "utf8",
|
|
2955
|
+
timeout: 4000,
|
|
2956
|
+
env: process.env,
|
|
2957
|
+
});
|
|
2958
|
+
const line = (r.stdout || "").trim().split(/\n/)[0]?.trim();
|
|
2959
|
+
if (line && fs.existsSync(line))
|
|
2960
|
+
return line;
|
|
2961
|
+
}
|
|
2962
|
+
catch {
|
|
2963
|
+
/* skip */
|
|
2964
|
+
}
|
|
2965
|
+
return null;
|
|
2966
|
+
}
|
|
2967
|
+
/**
|
|
2968
|
+
* Run a capture CLI with stdio fully detached from the console (no windows, no inherited stdio).
|
|
2969
|
+
* @returns true when `outPath` exists and is non-trivial size after exit 0.
|
|
2970
|
+
*/
|
|
2971
|
+
async function trySpawnScreenshotTool(command, args, outPath, timeoutMs) {
|
|
2972
|
+
return await new Promise((resolve) => {
|
|
2973
|
+
try {
|
|
2974
|
+
if (fs.existsSync(outPath)) {
|
|
2975
|
+
try {
|
|
2976
|
+
fs.unlinkSync(outPath);
|
|
2977
|
+
}
|
|
2978
|
+
catch {
|
|
2979
|
+
/* skip */
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
catch {
|
|
2984
|
+
/* skip */
|
|
2985
|
+
}
|
|
2986
|
+
const child = (0, node_child_process_1.spawn)(command, args, {
|
|
2987
|
+
env: process.env,
|
|
2988
|
+
windowsHide: process.platform === "win32",
|
|
2989
|
+
stdio: "ignore",
|
|
2990
|
+
detached: false,
|
|
2991
|
+
});
|
|
2992
|
+
const to = setTimeout(() => {
|
|
2993
|
+
try {
|
|
2994
|
+
child.kill("SIGTERM");
|
|
2995
|
+
}
|
|
2996
|
+
catch {
|
|
2997
|
+
/* skip */
|
|
2998
|
+
}
|
|
2999
|
+
}, timeoutMs);
|
|
3000
|
+
child.on("close", (code) => {
|
|
3001
|
+
clearTimeout(to);
|
|
3002
|
+
try {
|
|
3003
|
+
if (code === 0 && fs.existsSync(outPath)) {
|
|
3004
|
+
const st = fs.statSync(outPath);
|
|
3005
|
+
resolve(st.size > 64);
|
|
3006
|
+
}
|
|
3007
|
+
else {
|
|
3008
|
+
resolve(false);
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
catch {
|
|
3012
|
+
resolve(false);
|
|
3013
|
+
}
|
|
3014
|
+
});
|
|
3015
|
+
child.on("error", () => {
|
|
3016
|
+
clearTimeout(to);
|
|
3017
|
+
resolve(false);
|
|
3018
|
+
});
|
|
3019
|
+
});
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* ffmpeg-based PNG stitching fallback when ImageMagick is unavailable.
|
|
3023
|
+
* Keeps all processing headless (`stdio: ignore`) and writes one PNG frame.
|
|
3024
|
+
*/
|
|
3025
|
+
async function tryFfmpegOverlayStitch(ffmpegBin, outPath, canvasW, canvasH, parts) {
|
|
3026
|
+
if (!ffmpegBin || parts.length === 0)
|
|
3027
|
+
return false;
|
|
3028
|
+
if (canvasW < 1 || canvasH < 1)
|
|
3029
|
+
return false;
|
|
3030
|
+
return await new Promise((resolve) => {
|
|
3031
|
+
const args = [
|
|
3032
|
+
"-nostdin",
|
|
3033
|
+
"-hide_banner",
|
|
3034
|
+
"-loglevel",
|
|
3035
|
+
"error",
|
|
3036
|
+
"-y",
|
|
3037
|
+
"-f",
|
|
3038
|
+
"lavfi",
|
|
3039
|
+
"-i",
|
|
3040
|
+
`color=c=black@0.0:s=${canvasW}x${canvasH}:d=1`,
|
|
3041
|
+
];
|
|
3042
|
+
for (const p of parts) {
|
|
3043
|
+
args.push("-i", p.path);
|
|
3044
|
+
}
|
|
3045
|
+
if (parts.length === 1) {
|
|
3046
|
+
args.push("-map", "1:v:0", "-frames:v", "1", outPath);
|
|
3047
|
+
}
|
|
3048
|
+
else {
|
|
3049
|
+
const chain = [];
|
|
3050
|
+
chain.push(`[0:v][1:v]overlay=${parts[0].x}:${parts[0].y}[ol1]`);
|
|
3051
|
+
for (let i = 1; i < parts.length; i++) {
|
|
3052
|
+
const inLabel = i === 1 ? "ol1" : `ol${i}`;
|
|
3053
|
+
const outLabel = i === parts.length - 1 ? "outv" : `ol${i + 1}`;
|
|
3054
|
+
const p = parts[i];
|
|
3055
|
+
chain.push(`[${inLabel}][${i + 1}:v]overlay=${p.x}:${p.y}[${outLabel}]`);
|
|
3056
|
+
}
|
|
3057
|
+
args.push("-filter_complex", chain.join(";"), "-map", "[outv]", "-frames:v", "1", outPath);
|
|
3058
|
+
}
|
|
3059
|
+
const child = (0, node_child_process_1.spawn)(ffmpegBin, args, {
|
|
3060
|
+
env: process.env,
|
|
3061
|
+
stdio: "ignore",
|
|
3062
|
+
});
|
|
3063
|
+
const to = setTimeout(() => {
|
|
3064
|
+
try {
|
|
3065
|
+
child.kill("SIGTERM");
|
|
3066
|
+
}
|
|
3067
|
+
catch {
|
|
3068
|
+
/* skip */
|
|
3069
|
+
}
|
|
3070
|
+
}, 45_000);
|
|
3071
|
+
child.on("close", (code) => {
|
|
3072
|
+
clearTimeout(to);
|
|
3073
|
+
try {
|
|
3074
|
+
resolve(code === 0 && fs.existsSync(outPath) && fs.statSync(outPath).size > 64);
|
|
3075
|
+
}
|
|
3076
|
+
catch {
|
|
3077
|
+
resolve(false);
|
|
3078
|
+
}
|
|
3079
|
+
});
|
|
3080
|
+
child.on("error", () => {
|
|
3081
|
+
clearTimeout(to);
|
|
3082
|
+
resolve(false);
|
|
3083
|
+
});
|
|
3084
|
+
});
|
|
3085
|
+
}
|
|
3086
|
+
/**
|
|
3087
|
+
* macOS: `/usr/sbin/screencapture` with `-x` (no sound), non-interactive.
|
|
3088
|
+
* Prefers per-display `-D 1..N` capture and merge when multiple displays are present, then
|
|
3089
|
+
* falls back to one combined capture. This avoids hosts where plain `screencapture -x` returns
|
|
3090
|
+
* only the active/main display.
|
|
3091
|
+
*/
|
|
3092
|
+
async function fsDarwinScreenshotCapture() {
|
|
3093
|
+
if (!isMacos()) {
|
|
3094
|
+
return { ok: false, error: "screenshot is only supported on macOS agents" };
|
|
3095
|
+
}
|
|
3096
|
+
const tmpDir = os.tmpdir();
|
|
3097
|
+
const id = (0, node_crypto_1.randomBytes)(8).toString("hex");
|
|
3098
|
+
const outPath = path.join(tmpDir, `forge-fe-cap-${id}.png`);
|
|
3099
|
+
const scBin = fs.existsSync("/usr/sbin/screencapture")
|
|
3100
|
+
? "/usr/sbin/screencapture"
|
|
3101
|
+
: unixWhich("screencapture");
|
|
3102
|
+
if (!scBin) {
|
|
3103
|
+
return { ok: false, error: "macOS screenshot: screencapture not found" };
|
|
3104
|
+
}
|
|
3105
|
+
const partPaths = [];
|
|
3106
|
+
let missAfterSuccess = 0;
|
|
3107
|
+
for (let d = 1; d <= 16; d++) {
|
|
3108
|
+
const part = path.join(tmpDir, `forge-fe-cap-${id}-d${d}.png`);
|
|
3109
|
+
const okD = await trySpawnScreenshotTool(scBin, ["-x", "-t", "png", "-D", String(d), part], part, Math.min(SCREENSHOT_TOOL_TIMEOUT_MS, 20_000));
|
|
3110
|
+
if (!okD) {
|
|
3111
|
+
try {
|
|
3112
|
+
if (fs.existsSync(part))
|
|
3113
|
+
fs.unlinkSync(part);
|
|
3114
|
+
}
|
|
3115
|
+
catch {
|
|
3116
|
+
/* skip */
|
|
3117
|
+
}
|
|
3118
|
+
if (partPaths.length === 0)
|
|
3119
|
+
continue;
|
|
3120
|
+
missAfterSuccess++;
|
|
3121
|
+
if (missAfterSuccess >= 2)
|
|
3122
|
+
break;
|
|
3123
|
+
continue;
|
|
3124
|
+
}
|
|
3125
|
+
missAfterSuccess = 0;
|
|
3126
|
+
partPaths.push(part);
|
|
3127
|
+
}
|
|
3128
|
+
if (partPaths.length === 0) {
|
|
3129
|
+
const trySingle = await trySpawnScreenshotTool(scBin, ["-x", "-t", "png", outPath], outPath, SCREENSHOT_TOOL_TIMEOUT_MS);
|
|
3130
|
+
if (trySingle) {
|
|
3131
|
+
return await resultFromPngPath(outPath);
|
|
3132
|
+
}
|
|
3133
|
+
return {
|
|
3134
|
+
ok: false,
|
|
3135
|
+
error: "macOS screenshot failed (no GUI session, Screen Recording blocked, or screencapture could not write PNG)",
|
|
3136
|
+
};
|
|
3137
|
+
}
|
|
3138
|
+
if (partPaths.length === 1) {
|
|
3139
|
+
try {
|
|
3140
|
+
fs.copyFileSync(partPaths[0], outPath);
|
|
3141
|
+
try {
|
|
3142
|
+
fs.unlinkSync(partPaths[0]);
|
|
3143
|
+
}
|
|
3144
|
+
catch {
|
|
3145
|
+
/* skip */
|
|
3146
|
+
}
|
|
3147
|
+
return await resultFromPngPath(outPath);
|
|
3148
|
+
}
|
|
3149
|
+
catch (e) {
|
|
3150
|
+
return { ok: false, error: String(e) };
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
const magick = unixWhich("magick") || unixWhich("convert");
|
|
3154
|
+
const ffmpeg = unixWhich("ffmpeg");
|
|
3155
|
+
if (!magick && !ffmpeg) {
|
|
3156
|
+
/** Last-resort fallback: some hosts return all displays from plain screencapture. */
|
|
3157
|
+
const trySingle = await trySpawnScreenshotTool(scBin, ["-x", "-t", "png", outPath], outPath, SCREENSHOT_TOOL_TIMEOUT_MS);
|
|
3158
|
+
for (const p of partPaths) {
|
|
3159
|
+
try {
|
|
3160
|
+
fs.unlinkSync(p);
|
|
3161
|
+
}
|
|
3162
|
+
catch {
|
|
3163
|
+
/* skip */
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
if (trySingle)
|
|
3167
|
+
return await resultFromPngPath(outPath);
|
|
3168
|
+
return {
|
|
3169
|
+
ok: false,
|
|
3170
|
+
error: "macOS screenshot failed for multi-display merge: install ImageMagick (`brew install imagemagick`) or ffmpeg so displays can be merged.",
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
3173
|
+
let okStitch = false;
|
|
3174
|
+
if (magick) {
|
|
3175
|
+
okStitch = await new Promise((resolve) => {
|
|
3176
|
+
const args = [];
|
|
3177
|
+
for (const p of partPaths) {
|
|
3178
|
+
args.push("(", p, "+repage", ")");
|
|
3179
|
+
}
|
|
3180
|
+
args.push("+append");
|
|
3181
|
+
const capW = captureScaleMaxWidth();
|
|
3182
|
+
if (capW > 0) {
|
|
3183
|
+
args.push("-resize", `${capW}x>`);
|
|
3184
|
+
}
|
|
3185
|
+
args.push(outPath);
|
|
3186
|
+
const child = (0, node_child_process_1.spawn)(magick, args, {
|
|
3187
|
+
env: process.env,
|
|
3188
|
+
stdio: "ignore",
|
|
3189
|
+
});
|
|
3190
|
+
const to = setTimeout(() => {
|
|
3191
|
+
try {
|
|
3192
|
+
child.kill("SIGTERM");
|
|
3193
|
+
}
|
|
3194
|
+
catch {
|
|
3195
|
+
/* skip */
|
|
3196
|
+
}
|
|
3197
|
+
}, 45_000);
|
|
3198
|
+
child.on("close", (code) => {
|
|
3199
|
+
clearTimeout(to);
|
|
3200
|
+
try {
|
|
3201
|
+
resolve(code === 0 && fs.existsSync(outPath) && fs.statSync(outPath).size > 64);
|
|
3202
|
+
}
|
|
3203
|
+
catch {
|
|
3204
|
+
resolve(false);
|
|
3205
|
+
}
|
|
3206
|
+
});
|
|
3207
|
+
child.on("error", () => {
|
|
3208
|
+
clearTimeout(to);
|
|
3209
|
+
resolve(false);
|
|
3210
|
+
});
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
3213
|
+
if (!okStitch && ffmpeg) {
|
|
3214
|
+
let sumW = 0;
|
|
3215
|
+
let maxH = 0;
|
|
3216
|
+
const parts = [];
|
|
3217
|
+
for (const p of partPaths) {
|
|
3218
|
+
try {
|
|
3219
|
+
const d = readPngIhdrSize(fs.readFileSync(p));
|
|
3220
|
+
if (!d)
|
|
3221
|
+
continue;
|
|
3222
|
+
parts.push({ path: p, x: sumW, y: 0 });
|
|
3223
|
+
sumW += d.w;
|
|
3224
|
+
maxH = Math.max(maxH, d.h);
|
|
3225
|
+
}
|
|
3226
|
+
catch {
|
|
3227
|
+
/* skip */
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
if (parts.length >= 2 && sumW > 0 && maxH > 0) {
|
|
3231
|
+
okStitch = await tryFfmpegOverlayStitch(ffmpeg, outPath, sumW, maxH, parts);
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
for (const p of partPaths) {
|
|
3235
|
+
try {
|
|
3236
|
+
fs.unlinkSync(p);
|
|
3237
|
+
}
|
|
3238
|
+
catch {
|
|
3239
|
+
/* skip */
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
if (!okStitch) {
|
|
3243
|
+
try {
|
|
3244
|
+
if (fs.existsSync(outPath))
|
|
3245
|
+
fs.unlinkSync(outPath);
|
|
3246
|
+
}
|
|
3247
|
+
catch {
|
|
3248
|
+
/* skip */
|
|
3249
|
+
}
|
|
3250
|
+
return {
|
|
3251
|
+
ok: false,
|
|
3252
|
+
error: "macOS screenshot: ImageMagick failed to merge multi-display captures",
|
|
3253
|
+
};
|
|
3254
|
+
}
|
|
3255
|
+
return await resultFromPngPath(outPath);
|
|
3256
|
+
}
|
|
3257
|
+
/**
|
|
3258
|
+
* Collect logical output names for per-output `grim -o` (multi-head Wayland).
|
|
3259
|
+
* Tries **swaymsg**, **hyprctl**, **wlr-randr** / **cosmic-randr**, **kscreen-doctor** (KDE), then **Mutter**
|
|
3260
|
+
* (`busctl` JSON `connector` fields) so installs where `swaymsg` exists but fails still fall through.
|
|
3261
|
+
*/
|
|
3262
|
+
function collectWaylandOutputNamesForGrim() {
|
|
3263
|
+
const addFromSwaymsg = () => {
|
|
3264
|
+
const swaymsg = unixWhich("swaymsg");
|
|
3265
|
+
if (!swaymsg)
|
|
3266
|
+
return [];
|
|
3267
|
+
try {
|
|
3268
|
+
const r = (0, node_child_process_1.spawnSync)(swaymsg, ["-t", "get_outputs"], {
|
|
3269
|
+
encoding: "utf8",
|
|
3270
|
+
timeout: 4000,
|
|
3271
|
+
env: process.env,
|
|
3272
|
+
});
|
|
3273
|
+
if (r.status !== 0)
|
|
3274
|
+
return [];
|
|
3275
|
+
const j = JSON.parse(r.stdout || "[]");
|
|
3276
|
+
const arr = Array.isArray(j) ? j : [];
|
|
3277
|
+
const out = [];
|
|
3278
|
+
for (const o of arr) {
|
|
3279
|
+
if (o && typeof o === "object" && "name" in o) {
|
|
3280
|
+
const n = String(o.name ?? "").trim();
|
|
3281
|
+
if (n)
|
|
3282
|
+
out.push(n);
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
return out;
|
|
3286
|
+
}
|
|
3287
|
+
catch {
|
|
3288
|
+
return [];
|
|
3289
|
+
}
|
|
3290
|
+
};
|
|
3291
|
+
const addFromHyprctl = () => {
|
|
3292
|
+
const hypr = unixWhich("hyprctl");
|
|
3293
|
+
if (!hypr)
|
|
3294
|
+
return [];
|
|
3295
|
+
try {
|
|
3296
|
+
const r = (0, node_child_process_1.spawnSync)(hypr, ["monitors", "-j"], {
|
|
3297
|
+
encoding: "utf8",
|
|
3298
|
+
timeout: 4000,
|
|
3299
|
+
env: process.env,
|
|
3300
|
+
});
|
|
3301
|
+
if (r.status !== 0)
|
|
3302
|
+
return [];
|
|
3303
|
+
const j = JSON.parse(r.stdout || "[]");
|
|
3304
|
+
const arr = Array.isArray(j) ? j : j && typeof j === "object" ? [j] : [];
|
|
3305
|
+
const out = [];
|
|
3306
|
+
for (const o of arr) {
|
|
3307
|
+
if (o && typeof o === "object" && "name" in o) {
|
|
3308
|
+
const n = String(o.name ?? "").trim();
|
|
3309
|
+
if (n)
|
|
3310
|
+
out.push(n);
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
return out;
|
|
3314
|
+
}
|
|
3315
|
+
catch {
|
|
3316
|
+
return [];
|
|
3317
|
+
}
|
|
3318
|
+
};
|
|
3319
|
+
/**
|
|
3320
|
+
* wlroots-style lines (`Output HDMI-A-1 …`): **wlr-randr**, **cosmic-randr**, etc.
|
|
3321
|
+
*/
|
|
3322
|
+
const addFromWlrStyleRandr = () => {
|
|
3323
|
+
let best = [];
|
|
3324
|
+
for (const exe of ["wlr-randr", "cosmic-randr"]) {
|
|
3325
|
+
const bin = unixWhich(exe);
|
|
3326
|
+
if (!bin)
|
|
3327
|
+
continue;
|
|
3328
|
+
try {
|
|
3329
|
+
const r = (0, node_child_process_1.spawnSync)(bin, [], {
|
|
3330
|
+
encoding: "utf8",
|
|
3331
|
+
timeout: 4000,
|
|
3332
|
+
env: process.env,
|
|
3333
|
+
});
|
|
3334
|
+
if (r.status !== 0)
|
|
3335
|
+
continue;
|
|
3336
|
+
const out = [];
|
|
3337
|
+
for (const line of String(r.stdout || "").split(/\r?\n/)) {
|
|
3338
|
+
const m = /^Output\s+(\S+)/.exec(line.trim());
|
|
3339
|
+
if (m) {
|
|
3340
|
+
const n = m[1].trim();
|
|
3341
|
+
if (n)
|
|
3342
|
+
out.push(n);
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
if (out.length > best.length)
|
|
3346
|
+
best = out;
|
|
3347
|
+
}
|
|
3348
|
+
catch {
|
|
3349
|
+
/* skip */
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
return best;
|
|
3353
|
+
};
|
|
3354
|
+
/**
|
|
3355
|
+
* KDE Plasma: `kscreen-doctor` text often includes DRM connector names (`HDMI-A-1`, `eDP-1`, …).
|
|
3356
|
+
*/
|
|
3357
|
+
const addFromKScreenDoctor = () => {
|
|
3358
|
+
const found = new Set();
|
|
3359
|
+
for (const exe of ["kscreen-doctor", "kscreen-doctor6"]) {
|
|
3360
|
+
const bin = unixWhich(exe);
|
|
3361
|
+
if (!bin)
|
|
3362
|
+
continue;
|
|
3363
|
+
for (const args of [[], ["outputs"], ["--outputs"]]) {
|
|
3364
|
+
try {
|
|
3365
|
+
const r = (0, node_child_process_1.spawnSync)(bin, args, {
|
|
3366
|
+
encoding: "utf8",
|
|
3367
|
+
timeout: 6000,
|
|
3368
|
+
env: process.env,
|
|
3369
|
+
});
|
|
3370
|
+
if (r.status !== 0)
|
|
3371
|
+
continue;
|
|
3372
|
+
const text = String(r.stdout || "");
|
|
3373
|
+
for (const line of text.split(/\r?\n/)) {
|
|
3374
|
+
const nm = /^\s*Name:\s*(\S+)/i.exec(line);
|
|
3375
|
+
if (nm) {
|
|
3376
|
+
const n = nm[1].trim();
|
|
3377
|
+
if (n)
|
|
3378
|
+
found.add(n);
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
const drmRe = /\b((?:e)?DP-\d+|HDMI-[A-Z]-\d+|HDMI-\d+|DVI-I-\d+|Virtual-\d+|eDP-\d+|DSI-\d+)\b/g;
|
|
3382
|
+
for (const m of text.matchAll(drmRe)) {
|
|
3383
|
+
if (m[1])
|
|
3384
|
+
found.add(m[1]);
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
catch {
|
|
3388
|
+
/* skip */
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
return [...found];
|
|
3393
|
+
};
|
|
3394
|
+
/**
|
|
3395
|
+
* GNOME on Wayland (Mutter): `GetCurrentState` JSON from systemd `busctl` lists `connector`
|
|
3396
|
+
* per monitor (e.g. DP-1, HDMI-1) for `grim -o` when `--json=short` is supported.
|
|
3397
|
+
*/
|
|
3398
|
+
const addFromMutterBusctlJson = () => {
|
|
3399
|
+
const busctl = unixWhich("busctl");
|
|
3400
|
+
if (!busctl)
|
|
3401
|
+
return [];
|
|
3402
|
+
try {
|
|
3403
|
+
const r = (0, node_child_process_1.spawnSync)(busctl, [
|
|
3404
|
+
"--user",
|
|
3405
|
+
"--json=short",
|
|
3406
|
+
"call",
|
|
3407
|
+
"org.gnome.Mutter.DisplayConfig",
|
|
3408
|
+
"/org/gnome/Mutter/DisplayConfig",
|
|
3409
|
+
"org.gnome.Mutter.DisplayConfig",
|
|
3410
|
+
"GetCurrentState",
|
|
3411
|
+
], { encoding: "utf8", timeout: 6000, env: process.env });
|
|
3412
|
+
if (r.status !== 0)
|
|
3413
|
+
return [];
|
|
3414
|
+
return extractConnectorNamesFromBusctlJson(String(r.stdout || ""));
|
|
3415
|
+
}
|
|
3416
|
+
catch {
|
|
3417
|
+
return [];
|
|
3418
|
+
}
|
|
3419
|
+
};
|
|
3420
|
+
let names = addFromSwaymsg();
|
|
3421
|
+
if (names.length < 2) {
|
|
3422
|
+
const h = addFromHyprctl();
|
|
3423
|
+
if (h.length >= names.length)
|
|
3424
|
+
names = h;
|
|
3425
|
+
}
|
|
3426
|
+
if (names.length < 2) {
|
|
3427
|
+
const w = addFromWlrStyleRandr();
|
|
3428
|
+
if (w.length >= names.length)
|
|
3429
|
+
names = w;
|
|
3430
|
+
}
|
|
3431
|
+
if (names.length < 2) {
|
|
3432
|
+
const k = addFromKScreenDoctor();
|
|
3433
|
+
if (k.length >= names.length)
|
|
3434
|
+
names = k;
|
|
3435
|
+
}
|
|
3436
|
+
if (names.length < 2) {
|
|
3437
|
+
const g = addFromMutterBusctlJson();
|
|
3438
|
+
if (g.length >= names.length)
|
|
3439
|
+
names = g;
|
|
3440
|
+
}
|
|
3441
|
+
return [...new Set(names)];
|
|
3442
|
+
}
|
|
3443
|
+
/** Unwrap `busctl --json=short` variant nodes (`{ type, data }`) recursively. */
|
|
3444
|
+
function busctlJsonUnwrap(v) {
|
|
3445
|
+
if (v == null)
|
|
3446
|
+
return v;
|
|
3447
|
+
if (Array.isArray(v))
|
|
3448
|
+
return v.map(busctlJsonUnwrap);
|
|
3449
|
+
if (typeof v !== "object")
|
|
3450
|
+
return v;
|
|
3451
|
+
const o = v;
|
|
3452
|
+
if (typeof o.type === "string" && "data" in o) {
|
|
3453
|
+
return busctlJsonUnwrap(o.data);
|
|
3454
|
+
}
|
|
3455
|
+
const out = {};
|
|
3456
|
+
for (const [k, val] of Object.entries(o)) {
|
|
3457
|
+
out[k] = busctlJsonUnwrap(val);
|
|
3458
|
+
}
|
|
3459
|
+
return out;
|
|
3460
|
+
}
|
|
3461
|
+
function mutterPropsBool(props, key) {
|
|
3462
|
+
if (!props || typeof props !== "object")
|
|
3463
|
+
return null;
|
|
3464
|
+
const raw = props[key];
|
|
3465
|
+
if (raw === true || raw === false)
|
|
3466
|
+
return raw;
|
|
3467
|
+
return null;
|
|
3468
|
+
}
|
|
3469
|
+
function mutterCurrentModeSize(modesRaw) {
|
|
3470
|
+
if (!Array.isArray(modesRaw))
|
|
3471
|
+
return null;
|
|
3472
|
+
let fallback = null;
|
|
3473
|
+
for (const mode of modesRaw) {
|
|
3474
|
+
if (!Array.isArray(mode) || mode.length < 3)
|
|
3475
|
+
continue;
|
|
3476
|
+
const w = Number(mode[1]);
|
|
3477
|
+
const h = Number(mode[2]);
|
|
3478
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0)
|
|
3479
|
+
continue;
|
|
3480
|
+
const props = mode.length > 6 ? mode[6] : mode[mode.length - 1];
|
|
3481
|
+
if (mutterPropsBool(props, "is-current") === true) {
|
|
3482
|
+
return { w, h };
|
|
3483
|
+
}
|
|
3484
|
+
if (!fallback)
|
|
3485
|
+
fallback = { w, h };
|
|
3486
|
+
}
|
|
3487
|
+
return fallback;
|
|
3488
|
+
}
|
|
3489
|
+
function mutterApplyTransformWh(w, h, transform) {
|
|
3490
|
+
if (transform === 1 || transform === 3 || transform === 5 || transform === 7) {
|
|
3491
|
+
return { w: h, h: w };
|
|
3492
|
+
}
|
|
3493
|
+
return { w, h };
|
|
3494
|
+
}
|
|
3495
|
+
/**
|
|
3496
|
+
* GNOME Mutter (Wayland): parse `GetCurrentState` for connector positions + current mode sizes
|
|
3497
|
+
* so `tryLinuxWaylandCanvasStitchedPng` can composite grim captures (same as Sway/Hypr).
|
|
3498
|
+
*/
|
|
3499
|
+
function collectMutterWaylandOutputGeoms() {
|
|
3500
|
+
if (process.platform !== "linux")
|
|
3501
|
+
return [];
|
|
3502
|
+
const busctl = unixWhich("busctl");
|
|
3503
|
+
if (!busctl)
|
|
3504
|
+
return [];
|
|
3505
|
+
let tuple;
|
|
3506
|
+
try {
|
|
3507
|
+
const r = (0, node_child_process_1.spawnSync)(busctl, [
|
|
3508
|
+
"--user",
|
|
3509
|
+
"--json=short",
|
|
3510
|
+
"call",
|
|
3511
|
+
"org.gnome.Mutter.DisplayConfig",
|
|
3512
|
+
"/org/gnome/Mutter/DisplayConfig",
|
|
3513
|
+
"org.gnome.Mutter.DisplayConfig",
|
|
3514
|
+
"GetCurrentState",
|
|
3515
|
+
], { encoding: "utf8", timeout: 6000, env: process.env });
|
|
3516
|
+
if (r.status !== 0)
|
|
3517
|
+
return [];
|
|
3518
|
+
tuple = busctlJsonUnwrap(JSON.parse(String(r.stdout || "").trim()));
|
|
3519
|
+
}
|
|
3520
|
+
catch {
|
|
3521
|
+
return [];
|
|
3522
|
+
}
|
|
3523
|
+
if (!Array.isArray(tuple) || tuple.length < 3)
|
|
3524
|
+
return [];
|
|
3525
|
+
const monitorsRaw = tuple[1];
|
|
3526
|
+
const logicalRaw = tuple[2];
|
|
3527
|
+
if (!Array.isArray(monitorsRaw) || !Array.isArray(logicalRaw))
|
|
3528
|
+
return [];
|
|
3529
|
+
let layoutMode = 1;
|
|
3530
|
+
if (tuple.length >= 4) {
|
|
3531
|
+
const propsRoot = tuple[3];
|
|
3532
|
+
if (propsRoot && typeof propsRoot === "object" && !Array.isArray(propsRoot)) {
|
|
3533
|
+
const lm = propsRoot["layout-mode"];
|
|
3534
|
+
if (typeof lm === "number" && Number.isFinite(lm))
|
|
3535
|
+
layoutMode = lm;
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
const byConnector = new Map();
|
|
3539
|
+
for (const mon of monitorsRaw) {
|
|
3540
|
+
if (!Array.isArray(mon) || mon.length < 2)
|
|
3541
|
+
continue;
|
|
3542
|
+
const ids = mon[0];
|
|
3543
|
+
if (!Array.isArray(ids) || ids.length < 1)
|
|
3544
|
+
continue;
|
|
3545
|
+
const connector = String(ids[0] ?? "").trim();
|
|
3546
|
+
if (!connector)
|
|
3547
|
+
continue;
|
|
3548
|
+
const sz = mutterCurrentModeSize(mon[1]);
|
|
3549
|
+
if (sz)
|
|
3550
|
+
byConnector.set(connector, sz);
|
|
3551
|
+
}
|
|
3552
|
+
const out = [];
|
|
3553
|
+
for (const lm of logicalRaw) {
|
|
3554
|
+
if (!Array.isArray(lm) || lm.length < 6)
|
|
3555
|
+
continue;
|
|
3556
|
+
const lx = Number(lm[0]);
|
|
3557
|
+
const ly = Number(lm[1]);
|
|
3558
|
+
const scale = Number(lm[2]);
|
|
3559
|
+
const transform = Number(lm[3]);
|
|
3560
|
+
const phMonitors = lm[5];
|
|
3561
|
+
if (!Number.isFinite(lx) || !Number.isFinite(ly))
|
|
3562
|
+
continue;
|
|
3563
|
+
if (!Array.isArray(phMonitors))
|
|
3564
|
+
continue;
|
|
3565
|
+
let px = Math.trunc(lx);
|
|
3566
|
+
let py = Math.trunc(ly);
|
|
3567
|
+
const scalePos = (process.env.FORGE_JS_SCREENSHOT_MUTTER_LOGICAL_POS_SCALE || "")
|
|
3568
|
+
.trim()
|
|
3569
|
+
.toLowerCase();
|
|
3570
|
+
const useScalePos = ["1", "true", "yes", "on"].includes(scalePos);
|
|
3571
|
+
if (useScalePos &&
|
|
3572
|
+
layoutMode === 1 &&
|
|
3573
|
+
Number.isFinite(scale) &&
|
|
3574
|
+
scale > 0 &&
|
|
3575
|
+
scale !== 1) {
|
|
3576
|
+
px = Math.round(lx * scale);
|
|
3577
|
+
py = Math.round(ly * scale);
|
|
3578
|
+
}
|
|
3579
|
+
for (const slot of phMonitors) {
|
|
3580
|
+
if (!Array.isArray(slot) || slot.length < 1)
|
|
3581
|
+
continue;
|
|
3582
|
+
const connector = String(slot[0] ?? "").trim();
|
|
3583
|
+
if (!connector)
|
|
3584
|
+
continue;
|
|
3585
|
+
const sz0 = byConnector.get(connector);
|
|
3586
|
+
if (!sz0)
|
|
3587
|
+
continue;
|
|
3588
|
+
const wh = mutterApplyTransformWh(sz0.w, sz0.h, transform);
|
|
3589
|
+
out.push({ name: connector, x: px, y: py, w: wh.w, h: wh.h });
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
return out;
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* Sway / Hyprland: read each monitor's global position and size so we can composite one PNG
|
|
3596
|
+
* matching the full virtual desktop (not only a horizontal `+append` strip).
|
|
3597
|
+
*/
|
|
3598
|
+
function collectWaylandOutputGeoms() {
|
|
3599
|
+
const fromSway = () => {
|
|
3600
|
+
const swaymsg = unixWhich("swaymsg");
|
|
3601
|
+
if (!swaymsg)
|
|
3602
|
+
return [];
|
|
3603
|
+
try {
|
|
3604
|
+
const r = (0, node_child_process_1.spawnSync)(swaymsg, ["-t", "get_outputs"], {
|
|
3605
|
+
encoding: "utf8",
|
|
3606
|
+
timeout: 4000,
|
|
3607
|
+
env: process.env,
|
|
3608
|
+
});
|
|
3609
|
+
if (r.status !== 0)
|
|
3610
|
+
return [];
|
|
3611
|
+
const j = JSON.parse(r.stdout || "[]");
|
|
3612
|
+
const arr = Array.isArray(j) ? j : [];
|
|
3613
|
+
const out = [];
|
|
3614
|
+
for (const o of arr) {
|
|
3615
|
+
if (!o || typeof o !== "object")
|
|
3616
|
+
continue;
|
|
3617
|
+
const rec = o;
|
|
3618
|
+
if (rec.active === false)
|
|
3619
|
+
continue;
|
|
3620
|
+
const name = String(rec.name ?? "").trim();
|
|
3621
|
+
const rect = rec.rect;
|
|
3622
|
+
if (!name || !rect || typeof rect !== "object")
|
|
3623
|
+
continue;
|
|
3624
|
+
const rr = rect;
|
|
3625
|
+
const x = Number(rr.x);
|
|
3626
|
+
const y = Number(rr.y);
|
|
3627
|
+
const w = Number(rr.width);
|
|
3628
|
+
const h = Number(rr.height);
|
|
3629
|
+
if (Number.isFinite(x) &&
|
|
3630
|
+
Number.isFinite(y) &&
|
|
3631
|
+
Number.isFinite(w) &&
|
|
3632
|
+
Number.isFinite(h) &&
|
|
3633
|
+
w > 0 &&
|
|
3634
|
+
h > 0) {
|
|
3635
|
+
out.push({ name, x, y, w, h });
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
return out;
|
|
3639
|
+
}
|
|
3640
|
+
catch {
|
|
3641
|
+
return [];
|
|
3642
|
+
}
|
|
3643
|
+
};
|
|
3644
|
+
const fromHypr = () => {
|
|
3645
|
+
const hypr = unixWhich("hyprctl");
|
|
3646
|
+
if (!hypr)
|
|
3647
|
+
return [];
|
|
3648
|
+
try {
|
|
3649
|
+
const r = (0, node_child_process_1.spawnSync)(hypr, ["monitors", "-j"], {
|
|
3650
|
+
encoding: "utf8",
|
|
3651
|
+
timeout: 4000,
|
|
3652
|
+
env: process.env,
|
|
3653
|
+
});
|
|
3654
|
+
if (r.status !== 0)
|
|
3655
|
+
return [];
|
|
3656
|
+
const j = JSON.parse(r.stdout || "[]");
|
|
3657
|
+
const arr = Array.isArray(j) ? j : j && typeof j === "object" ? [j] : [];
|
|
3658
|
+
const out = [];
|
|
3659
|
+
for (const o of arr) {
|
|
3660
|
+
if (!o || typeof o !== "object")
|
|
3661
|
+
continue;
|
|
3662
|
+
const rec = o;
|
|
3663
|
+
if (rec.disabled === true)
|
|
3664
|
+
continue;
|
|
3665
|
+
const name = String(rec.name ?? "").trim();
|
|
3666
|
+
const x = Number(rec.x);
|
|
3667
|
+
const y = Number(rec.y);
|
|
3668
|
+
const w = Number(rec.width);
|
|
3669
|
+
const h = Number(rec.height);
|
|
3670
|
+
if (!name ||
|
|
3671
|
+
!Number.isFinite(x) ||
|
|
3672
|
+
!Number.isFinite(y) ||
|
|
3673
|
+
!Number.isFinite(w) ||
|
|
3674
|
+
!Number.isFinite(h) ||
|
|
3675
|
+
w <= 0 ||
|
|
3676
|
+
h <= 0) {
|
|
3677
|
+
continue;
|
|
3678
|
+
}
|
|
3679
|
+
out.push({ name, x, y, w, h });
|
|
3680
|
+
}
|
|
3681
|
+
return out;
|
|
3682
|
+
}
|
|
3683
|
+
catch {
|
|
3684
|
+
return [];
|
|
3685
|
+
}
|
|
3686
|
+
};
|
|
3687
|
+
const s = fromSway();
|
|
3688
|
+
if (s.length >= 2)
|
|
3689
|
+
return s;
|
|
3690
|
+
const h = fromHypr();
|
|
3691
|
+
if (h.length >= 2)
|
|
3692
|
+
return h;
|
|
3693
|
+
const m = collectMutterWaylandOutputGeoms();
|
|
3694
|
+
if (m.length >= 2)
|
|
3695
|
+
return m;
|
|
3696
|
+
if (s.length === 1)
|
|
3697
|
+
return s;
|
|
3698
|
+
if (h.length === 1)
|
|
3699
|
+
return h;
|
|
3700
|
+
if (m.length === 1)
|
|
3701
|
+
return m;
|
|
3702
|
+
return [];
|
|
3703
|
+
}
|
|
3704
|
+
/**
|
|
3705
|
+
* Bounding box of all outputs when `collectWaylandOutputGeoms()` has 2+ entries (Sway / Hypr / Mutter).
|
|
3706
|
+
* Used to reject single-head captures that slip through before multi-output stitch runs.
|
|
3707
|
+
*/
|
|
3708
|
+
function waylandOutputGeomBbox() {
|
|
3709
|
+
const geoms = collectWaylandOutputGeoms();
|
|
3710
|
+
if (geoms.length < 2)
|
|
3711
|
+
return null;
|
|
3712
|
+
let minX = geoms[0].x;
|
|
3713
|
+
let minY = geoms[0].y;
|
|
3714
|
+
let maxR = geoms[0].x + geoms[0].w;
|
|
3715
|
+
let maxB = geoms[0].y + geoms[0].h;
|
|
3716
|
+
for (const g of geoms) {
|
|
3717
|
+
minX = Math.min(minX, g.x);
|
|
3718
|
+
minY = Math.min(minY, g.y);
|
|
3719
|
+
maxR = Math.max(maxR, g.x + g.w);
|
|
3720
|
+
maxB = Math.max(maxB, g.y + g.h);
|
|
3721
|
+
}
|
|
3722
|
+
const bw = maxR - minX;
|
|
3723
|
+
const bh = maxB - minY;
|
|
3724
|
+
if (bw < 64 || bh < 64 || bw > 65536 || bh > 65536)
|
|
3725
|
+
return null;
|
|
3726
|
+
return { bw, bh };
|
|
3727
|
+
}
|
|
3728
|
+
/** Walk `busctl --json=short` output for string values of keys named `connector` (Mutter monitors). */
|
|
3729
|
+
function extractConnectorNamesFromBusctlJson(raw) {
|
|
3730
|
+
const found = [];
|
|
3731
|
+
const walk = (v) => {
|
|
3732
|
+
if (v == null)
|
|
3733
|
+
return;
|
|
3734
|
+
if (typeof v === "string")
|
|
3735
|
+
return;
|
|
3736
|
+
if (Array.isArray(v)) {
|
|
3737
|
+
for (const x of v)
|
|
3738
|
+
walk(x);
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
3741
|
+
if (typeof v === "object") {
|
|
3742
|
+
const o = v;
|
|
3743
|
+
const c = o.connector;
|
|
3744
|
+
if (typeof c === "string") {
|
|
3745
|
+
const t = c.trim();
|
|
3746
|
+
if (t)
|
|
3747
|
+
found.push(t);
|
|
3748
|
+
}
|
|
3749
|
+
for (const k of Object.keys(o)) {
|
|
3750
|
+
if (k === "connector")
|
|
3751
|
+
continue;
|
|
3752
|
+
walk(o[k]);
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
};
|
|
3756
|
+
try {
|
|
3757
|
+
walk(JSON.parse(raw.trim()));
|
|
3758
|
+
}
|
|
3759
|
+
catch {
|
|
3760
|
+
return [];
|
|
3761
|
+
}
|
|
3762
|
+
return found;
|
|
3763
|
+
}
|
|
3764
|
+
/**
|
|
3765
|
+
* Connectors to probe: prefer geometry-backed names (matches canvas stitch); else all grim-discovered names.
|
|
3766
|
+
*/
|
|
3767
|
+
function waylandProbeConnectorNames() {
|
|
3768
|
+
const geoms = collectWaylandOutputGeoms();
|
|
3769
|
+
if (geoms.length >= 2) {
|
|
3770
|
+
return [...new Set(geoms.map((g) => g.name))];
|
|
3771
|
+
}
|
|
3772
|
+
return [...collectWaylandOutputNamesForGrim()];
|
|
3773
|
+
}
|
|
3774
|
+
function linuxWaylandDimsCoverExpect(fd, expect) {
|
|
3775
|
+
const fullArea = fd.w * fd.h;
|
|
3776
|
+
const horizOk = fd.w >= expect.sumW * 0.78 && fd.h >= expect.maxH * 0.78;
|
|
3777
|
+
const vertOk = fd.h >= expect.sumH * 0.78 && fd.w >= expect.maxW * 0.78;
|
|
3778
|
+
const areaOk = expect.sumPartArea > 0 && fullArea >= expect.sumPartArea * 0.58;
|
|
3779
|
+
const geoms = collectWaylandOutputGeoms();
|
|
3780
|
+
if (geoms.length >= 2) {
|
|
3781
|
+
let minX = geoms[0].x;
|
|
3782
|
+
let minY = geoms[0].y;
|
|
3783
|
+
let maxR = geoms[0].x + geoms[0].w;
|
|
3784
|
+
let maxB = geoms[0].y + geoms[0].h;
|
|
3785
|
+
for (const g of geoms) {
|
|
3786
|
+
minX = Math.min(minX, g.x);
|
|
3787
|
+
minY = Math.min(minY, g.y);
|
|
3788
|
+
maxR = Math.max(maxR, g.x + g.w);
|
|
3789
|
+
maxB = Math.max(maxB, g.y + g.h);
|
|
3790
|
+
}
|
|
3791
|
+
const bw = maxR - minX;
|
|
3792
|
+
const bh = maxB - minY;
|
|
3793
|
+
const geomOk = fd.w >= bw * 0.75 && fd.h >= bh * 0.75;
|
|
3794
|
+
return geomOk || horizOk || vertOk || areaOk;
|
|
3795
|
+
}
|
|
3796
|
+
return horizOk || vertOk || areaOk;
|
|
3797
|
+
}
|
|
3798
|
+
/**
|
|
3799
|
+
* Run `grim -o` on each connector and aggregate dimensions (one memoized call per screenshot session).
|
|
3800
|
+
*/
|
|
3801
|
+
async function linuxWaylandProbeOutputDims(grim, probePrefix) {
|
|
3802
|
+
const names = waylandProbeConnectorNames();
|
|
3803
|
+
if (names.length < 2)
|
|
3804
|
+
return null;
|
|
3805
|
+
const tmpDir = os.tmpdir();
|
|
3806
|
+
const partDims = [];
|
|
3807
|
+
for (let i = 0; i < names.length; i++) {
|
|
3808
|
+
const part = path.join(tmpDir, `${probePrefix}-${i}.png`);
|
|
3809
|
+
const okO = await trySpawnScreenshotTool(grim, ["-o", names[i], part], part, Math.min(SCREENSHOT_TOOL_TIMEOUT_MS, 15_000));
|
|
3810
|
+
if (!okO) {
|
|
3811
|
+
for (let j = 0; j < i; j++) {
|
|
3812
|
+
try {
|
|
3813
|
+
fs.unlinkSync(path.join(tmpDir, `${probePrefix}-${j}.png`));
|
|
3814
|
+
}
|
|
3815
|
+
catch {
|
|
3816
|
+
/* skip */
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
return null;
|
|
3820
|
+
}
|
|
3821
|
+
try {
|
|
3822
|
+
const pd = readPngIhdrSize(fs.readFileSync(part));
|
|
3823
|
+
try {
|
|
3824
|
+
fs.unlinkSync(part);
|
|
3825
|
+
}
|
|
3826
|
+
catch {
|
|
3827
|
+
/* skip */
|
|
3828
|
+
}
|
|
3829
|
+
if (!pd)
|
|
3830
|
+
return null;
|
|
3831
|
+
partDims.push(pd);
|
|
3832
|
+
}
|
|
3833
|
+
catch {
|
|
3834
|
+
return null;
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
3837
|
+
let sumW = 0;
|
|
3838
|
+
let sumH = 0;
|
|
3839
|
+
let maxH = 0;
|
|
3840
|
+
let maxW = 0;
|
|
3841
|
+
let sumPartArea = 0;
|
|
3842
|
+
for (const pd of partDims) {
|
|
3843
|
+
sumW += pd.w;
|
|
3844
|
+
sumH += pd.h;
|
|
3845
|
+
maxH = Math.max(maxH, pd.h);
|
|
3846
|
+
maxW = Math.max(maxW, pd.w);
|
|
3847
|
+
sumPartArea += pd.w * pd.h;
|
|
3848
|
+
}
|
|
3849
|
+
return { sumW, sumH, maxH, maxW, sumPartArea };
|
|
3850
|
+
}
|
|
3851
|
+
/**
|
|
3852
|
+
* Wayland: composite each `grim -o` capture at its global (x,y) so vertical / irregular layouts match
|
|
3853
|
+
* the full virtual desktop (horizontal-only `+append` is insufficient for stacked monitors).
|
|
3854
|
+
*/
|
|
3855
|
+
async function tryLinuxWaylandCanvasStitchedPng(outPath) {
|
|
3856
|
+
const wl = (process.env.WAYLAND_DISPLAY || "").trim();
|
|
3857
|
+
if (!wl)
|
|
3858
|
+
return false;
|
|
3859
|
+
const grim = unixWhich("grim");
|
|
3860
|
+
const magick = unixWhich("magick") || unixWhich("convert");
|
|
3861
|
+
const ffmpeg = unixWhich("ffmpeg");
|
|
3862
|
+
if (!grim || (!magick && !ffmpeg))
|
|
3863
|
+
return false;
|
|
3864
|
+
const geoms = collectWaylandOutputGeoms();
|
|
3865
|
+
if (geoms.length < 2)
|
|
3866
|
+
return false;
|
|
3867
|
+
const tmpDir = os.tmpdir();
|
|
3868
|
+
const id = (0, node_crypto_1.randomBytes)(8).toString("hex");
|
|
3869
|
+
const partPaths = [];
|
|
3870
|
+
for (let i = 0; i < geoms.length; i++) {
|
|
3871
|
+
const g = geoms[i];
|
|
3872
|
+
const part = path.join(tmpDir, `forge-fe-wlc-${id}-${i}.png`);
|
|
3873
|
+
const ok = await trySpawnScreenshotTool(grim, ["-o", g.name, part], part, Math.min(SCREENSHOT_TOOL_TIMEOUT_MS, 25_000));
|
|
3874
|
+
if (!ok) {
|
|
3875
|
+
for (const p of partPaths) {
|
|
3876
|
+
try {
|
|
3877
|
+
fs.unlinkSync(p);
|
|
3878
|
+
}
|
|
3879
|
+
catch {
|
|
3880
|
+
/* skip */
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
return false;
|
|
3884
|
+
}
|
|
3885
|
+
partPaths.push(part);
|
|
3886
|
+
}
|
|
3887
|
+
const dims = [];
|
|
3888
|
+
for (const p of partPaths) {
|
|
3889
|
+
let buf;
|
|
3890
|
+
try {
|
|
3891
|
+
buf = fs.readFileSync(p);
|
|
3892
|
+
}
|
|
3893
|
+
catch {
|
|
3894
|
+
for (const x of partPaths) {
|
|
3895
|
+
try {
|
|
3896
|
+
fs.unlinkSync(x);
|
|
3897
|
+
}
|
|
3898
|
+
catch {
|
|
3899
|
+
/* skip */
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
return false;
|
|
3903
|
+
}
|
|
3904
|
+
const d = readPngIhdrSize(buf);
|
|
3905
|
+
if (!d) {
|
|
3906
|
+
for (const x of partPaths) {
|
|
3907
|
+
try {
|
|
3908
|
+
fs.unlinkSync(x);
|
|
3909
|
+
}
|
|
3910
|
+
catch {
|
|
3911
|
+
/* skip */
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
return false;
|
|
3915
|
+
}
|
|
3916
|
+
dims.push(d);
|
|
3917
|
+
}
|
|
3918
|
+
let minX = geoms[0].x;
|
|
3919
|
+
let minY = geoms[0].y;
|
|
3920
|
+
let maxR = geoms[0].x + dims[0].w;
|
|
3921
|
+
let maxB = geoms[0].y + dims[0].h;
|
|
3922
|
+
for (let i = 0; i < geoms.length; i++) {
|
|
3923
|
+
const g = geoms[i];
|
|
3924
|
+
const d = dims[i];
|
|
3925
|
+
minX = Math.min(minX, g.x);
|
|
3926
|
+
minY = Math.min(minY, g.y);
|
|
3927
|
+
maxR = Math.max(maxR, g.x + d.w);
|
|
3928
|
+
maxB = Math.max(maxB, g.y + d.h);
|
|
3929
|
+
}
|
|
3930
|
+
const cw = maxR - minX;
|
|
3931
|
+
const ch = maxB - minY;
|
|
3932
|
+
if (cw < 64 || ch < 64 || cw > 65536 || ch > 65536) {
|
|
3933
|
+
for (const p of partPaths) {
|
|
3934
|
+
try {
|
|
3935
|
+
fs.unlinkSync(p);
|
|
3936
|
+
}
|
|
3937
|
+
catch {
|
|
3938
|
+
/* skip */
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
return false;
|
|
3942
|
+
}
|
|
3943
|
+
let okCanvas = false;
|
|
3944
|
+
if (magick) {
|
|
3945
|
+
/** `+repage` clears PNG/page offsets so `-geometry` placement is not shifted by per-output metadata. */
|
|
3946
|
+
const magickArgs = ["-size", `${cw}x${ch}`, "xc:none"];
|
|
3947
|
+
for (let i = 0; i < geoms.length; i++) {
|
|
3948
|
+
const g = geoms[i];
|
|
3949
|
+
const dx = g.x - minX;
|
|
3950
|
+
const dy = g.y - minY;
|
|
3951
|
+
magickArgs.push("(", partPaths[i], "+repage", "-geometry", `+${dx}+${dy}`, ")", "-compose", "Over", "-composite");
|
|
3952
|
+
}
|
|
3953
|
+
magickArgs.push(outPath);
|
|
3954
|
+
okCanvas = await new Promise((resolve) => {
|
|
3955
|
+
const child = (0, node_child_process_1.spawn)(magick, magickArgs, {
|
|
3956
|
+
env: process.env,
|
|
3957
|
+
stdio: "ignore",
|
|
3958
|
+
});
|
|
3959
|
+
const to = setTimeout(() => {
|
|
3960
|
+
try {
|
|
3961
|
+
child.kill("SIGTERM");
|
|
3962
|
+
}
|
|
3963
|
+
catch {
|
|
3964
|
+
/* skip */
|
|
3965
|
+
}
|
|
3966
|
+
}, 45_000);
|
|
3967
|
+
child.on("close", (code) => {
|
|
3968
|
+
clearTimeout(to);
|
|
3969
|
+
try {
|
|
3970
|
+
resolve(code === 0 && fs.existsSync(outPath) && fs.statSync(outPath).size > 64);
|
|
3971
|
+
}
|
|
3972
|
+
catch {
|
|
3973
|
+
resolve(false);
|
|
3974
|
+
}
|
|
3975
|
+
});
|
|
3976
|
+
child.on("error", () => {
|
|
3977
|
+
clearTimeout(to);
|
|
3978
|
+
resolve(false);
|
|
3979
|
+
});
|
|
3980
|
+
});
|
|
3981
|
+
}
|
|
3982
|
+
if (!okCanvas && ffmpeg) {
|
|
3983
|
+
const overlays = geoms.map((g, i) => ({
|
|
3984
|
+
path: partPaths[i],
|
|
3985
|
+
x: g.x - minX,
|
|
3986
|
+
y: g.y - minY,
|
|
3987
|
+
}));
|
|
3988
|
+
okCanvas = await tryFfmpegOverlayStitch(ffmpeg, outPath, cw, ch, overlays);
|
|
3989
|
+
}
|
|
3990
|
+
for (const p of partPaths) {
|
|
3991
|
+
try {
|
|
3992
|
+
fs.unlinkSync(p);
|
|
3993
|
+
}
|
|
3994
|
+
catch {
|
|
3995
|
+
/* skip */
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
if (!okCanvas)
|
|
3999
|
+
return false;
|
|
4000
|
+
try {
|
|
4001
|
+
const ob = fs.readFileSync(outPath);
|
|
4002
|
+
const od = readPngIhdrSize(ob);
|
|
4003
|
+
const tolW = Math.max(8, Math.floor(cw * 0.005));
|
|
4004
|
+
const tolH = Math.max(8, Math.floor(ch * 0.005));
|
|
4005
|
+
if (!od || Math.abs(od.w - cw) > tolW || Math.abs(od.h - ch) > tolH) {
|
|
4006
|
+
try {
|
|
4007
|
+
fs.unlinkSync(outPath);
|
|
4008
|
+
}
|
|
4009
|
+
catch {
|
|
4010
|
+
/* skip */
|
|
4011
|
+
}
|
|
4012
|
+
return false;
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
catch {
|
|
4016
|
+
try {
|
|
4017
|
+
fs.unlinkSync(outPath);
|
|
4018
|
+
}
|
|
4019
|
+
catch {
|
|
4020
|
+
/* skip */
|
|
4021
|
+
}
|
|
4022
|
+
return false;
|
|
4023
|
+
}
|
|
4024
|
+
return true;
|
|
4025
|
+
}
|
|
4026
|
+
/**
|
|
4027
|
+
* Wayland: when `grim`, ImageMagick, and at least two outputs are discovered (compositor-specific probes above),
|
|
4028
|
+
* capture each output and `+append` into one PNG (multi-monitor parity with X11 virtual desktop).
|
|
4029
|
+
*/
|
|
4030
|
+
async function tryLinuxWaylandAllOutputsStitchedPng(outPath) {
|
|
4031
|
+
const wl = (process.env.WAYLAND_DISPLAY || "").trim();
|
|
4032
|
+
if (!wl)
|
|
4033
|
+
return false;
|
|
4034
|
+
const grim = unixWhich("grim");
|
|
4035
|
+
const magick = unixWhich("magick") || unixWhich("convert");
|
|
4036
|
+
const ffmpeg = unixWhich("ffmpeg");
|
|
4037
|
+
if (!grim || (!magick && !ffmpeg))
|
|
4038
|
+
return false;
|
|
4039
|
+
let outputNames = collectWaylandOutputNamesForGrim();
|
|
4040
|
+
if (outputNames.length < 2)
|
|
4041
|
+
return false;
|
|
4042
|
+
let appendFlag = "+append";
|
|
4043
|
+
const geomsForSort = collectWaylandOutputGeoms();
|
|
4044
|
+
if (geomsForSort.length >= 2) {
|
|
4045
|
+
const gmap = new Map(geomsForSort.map((g) => [g.name, g]));
|
|
4046
|
+
const present = outputNames.filter((n) => gmap.has(n));
|
|
4047
|
+
if (present.length >= 2) {
|
|
4048
|
+
const xs = present.map((n) => gmap.get(n).x);
|
|
4049
|
+
const ys = present.map((n) => gmap.get(n).y);
|
|
4050
|
+
const spreadX = Math.max(...xs) - Math.min(...xs);
|
|
4051
|
+
const spreadY = Math.max(...ys) - Math.min(...ys);
|
|
4052
|
+
/** Strips only work for 1-D layouts; L-shaped / diagonal grids need canvas (or full-frame grim). */
|
|
4053
|
+
if (spreadX > 32 && spreadY > 32) {
|
|
4054
|
+
return false;
|
|
4055
|
+
}
|
|
4056
|
+
if (spreadY > spreadX) {
|
|
4057
|
+
appendFlag = "-append";
|
|
4058
|
+
outputNames = [...outputNames].sort((a, b) => (gmap.get(a)?.y ?? 0) - (gmap.get(b)?.y ?? 0));
|
|
4059
|
+
}
|
|
4060
|
+
else {
|
|
4061
|
+
outputNames = [...outputNames].sort((a, b) => (gmap.get(a)?.x ?? 0) - (gmap.get(b)?.x ?? 0));
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
else {
|
|
4065
|
+
const xByName = new Map(geomsForSort.map((g) => [g.name, g.x]));
|
|
4066
|
+
outputNames = [...outputNames].sort((a, b) => (xByName.get(a) ?? 0) - (xByName.get(b) ?? 0));
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
const tmpDir = os.tmpdir();
|
|
4070
|
+
const id = (0, node_crypto_1.randomBytes)(8).toString("hex");
|
|
4071
|
+
const partPaths = [];
|
|
4072
|
+
for (let i = 0; i < outputNames.length; i++) {
|
|
4073
|
+
const part = path.join(tmpDir, `forge-fe-wl-${id}-${i}.png`);
|
|
4074
|
+
const ok = await trySpawnScreenshotTool(grim, ["-o", outputNames[i], part], part, Math.min(SCREENSHOT_TOOL_TIMEOUT_MS, 25_000));
|
|
4075
|
+
if (!ok) {
|
|
4076
|
+
for (const p of partPaths) {
|
|
4077
|
+
try {
|
|
4078
|
+
fs.unlinkSync(p);
|
|
4079
|
+
}
|
|
4080
|
+
catch {
|
|
4081
|
+
/* skip */
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
return false;
|
|
4085
|
+
}
|
|
4086
|
+
partPaths.push(part);
|
|
4087
|
+
}
|
|
4088
|
+
let okStitch = false;
|
|
4089
|
+
if (magick) {
|
|
4090
|
+
okStitch = await new Promise((resolve) => {
|
|
4091
|
+
const args = [];
|
|
4092
|
+
for (const p of partPaths) {
|
|
4093
|
+
args.push("(", p, "+repage", ")");
|
|
4094
|
+
}
|
|
4095
|
+
args.push(appendFlag, outPath);
|
|
4096
|
+
const child = (0, node_child_process_1.spawn)(magick, args, {
|
|
4097
|
+
env: process.env,
|
|
4098
|
+
stdio: "ignore",
|
|
4099
|
+
});
|
|
4100
|
+
const to = setTimeout(() => {
|
|
4101
|
+
try {
|
|
4102
|
+
child.kill("SIGTERM");
|
|
4103
|
+
}
|
|
4104
|
+
catch {
|
|
4105
|
+
/* skip */
|
|
4106
|
+
}
|
|
4107
|
+
}, 45_000);
|
|
4108
|
+
child.on("close", (code) => {
|
|
4109
|
+
clearTimeout(to);
|
|
4110
|
+
try {
|
|
4111
|
+
resolve(code === 0 && fs.existsSync(outPath) && fs.statSync(outPath).size > 64);
|
|
4112
|
+
}
|
|
4113
|
+
catch {
|
|
4114
|
+
resolve(false);
|
|
4115
|
+
}
|
|
4116
|
+
});
|
|
4117
|
+
child.on("error", () => {
|
|
4118
|
+
clearTimeout(to);
|
|
4119
|
+
resolve(false);
|
|
4120
|
+
});
|
|
4121
|
+
});
|
|
4122
|
+
}
|
|
4123
|
+
if (!okStitch && ffmpeg) {
|
|
4124
|
+
const dims = [];
|
|
4125
|
+
for (const p of partPaths) {
|
|
4126
|
+
try {
|
|
4127
|
+
const d = readPngIhdrSize(fs.readFileSync(p));
|
|
4128
|
+
if (!d)
|
|
4129
|
+
continue;
|
|
4130
|
+
dims.push(d);
|
|
4131
|
+
}
|
|
4132
|
+
catch {
|
|
4133
|
+
/* skip */
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
4136
|
+
if (dims.length === partPaths.length && dims.length > 1) {
|
|
4137
|
+
let cw = 0;
|
|
4138
|
+
let ch = 0;
|
|
4139
|
+
const overlays = [];
|
|
4140
|
+
if (appendFlag === "+append") {
|
|
4141
|
+
let x = 0;
|
|
4142
|
+
for (let i = 0; i < partPaths.length; i++) {
|
|
4143
|
+
overlays.push({ path: partPaths[i], x, y: 0 });
|
|
4144
|
+
x += dims[i].w;
|
|
4145
|
+
ch = Math.max(ch, dims[i].h);
|
|
4146
|
+
}
|
|
4147
|
+
cw = x;
|
|
4148
|
+
}
|
|
4149
|
+
else {
|
|
4150
|
+
let y = 0;
|
|
4151
|
+
for (let i = 0; i < partPaths.length; i++) {
|
|
4152
|
+
overlays.push({ path: partPaths[i], x: 0, y });
|
|
4153
|
+
y += dims[i].h;
|
|
4154
|
+
cw = Math.max(cw, dims[i].w);
|
|
4155
|
+
}
|
|
4156
|
+
ch = y;
|
|
4157
|
+
}
|
|
4158
|
+
if (cw > 0 && ch > 0) {
|
|
4159
|
+
okStitch = await tryFfmpegOverlayStitch(ffmpeg, outPath, cw, ch, overlays);
|
|
4160
|
+
}
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
for (const p of partPaths) {
|
|
4164
|
+
try {
|
|
4165
|
+
fs.unlinkSync(p);
|
|
4166
|
+
}
|
|
4167
|
+
catch {
|
|
4168
|
+
/* skip */
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
return okStitch;
|
|
4172
|
+
}
|
|
4173
|
+
/**
|
|
4174
|
+
* Linux: best-effort stack — grim (Wayland), ffmpeg waylandgrab+x11grab, spectacle -b -n (KDE),
|
|
4175
|
+
* maim, ImageMagick import, scrot -z. All stdio ignored, no forge-js dialogs. Requires user-session
|
|
4176
|
+
* env (WAYLAND_DISPLAY / DISPLAY) and tools on PATH where applicable.
|
|
4177
|
+
*/
|
|
4178
|
+
async function fsLinuxScreenshotCapture() {
|
|
4179
|
+
if (process.platform !== "linux") {
|
|
4180
|
+
return { ok: false, error: "screenshot is only supported on Linux agents" };
|
|
4181
|
+
}
|
|
4182
|
+
const tmpDir = os.tmpdir();
|
|
4183
|
+
const id = (0, node_crypto_1.randomBytes)(8).toString("hex");
|
|
4184
|
+
const outPath = path.join(tmpDir, `forge-fe-cap-${id}.png`);
|
|
4185
|
+
const wl = (process.env.WAYLAND_DISPLAY || "").trim();
|
|
4186
|
+
const disp = (process.env.DISPLAY || "").trim();
|
|
4187
|
+
const grimPath = unixWhich("grim");
|
|
4188
|
+
let memoWaylandProbe = undefined;
|
|
4189
|
+
const getWaylandProbeExpect = async () => {
|
|
4190
|
+
if (!grimPath)
|
|
4191
|
+
return null;
|
|
4192
|
+
if (memoWaylandProbe !== undefined)
|
|
4193
|
+
return memoWaylandProbe;
|
|
4194
|
+
memoWaylandProbe = await linuxWaylandProbeOutputDims(grimPath, `forge-fe-wlexp-${id}`);
|
|
4195
|
+
return memoWaylandProbe;
|
|
4196
|
+
};
|
|
4197
|
+
const scaleW = captureScaleMaxWidth();
|
|
4198
|
+
const userW = userRequestedScreenshotMaxWidth();
|
|
4199
|
+
/**
|
|
4200
|
+
* Reject captures that are clearly smaller than the full virtual desktop:
|
|
4201
|
+
* - When compositor geometry is known (2+ outputs), require IHDR to cover ~75% of that bbox (Sway/Hypr/Mutter).
|
|
4202
|
+
* - Else on multi-head GNOME-like sessions, require `linuxWaylandProbeOutputDims` to pass; if probing fails,
|
|
4203
|
+
* reject (forces ffmpeg/spectacle/etc. instead of accepting a single-head `grim` frame).
|
|
4204
|
+
* Skips pixel checks only when the user set `FORGE_JS_SCREENSHOT_MAX_WIDTH` > 0 (scaled ffmpeg output may not match bbox).
|
|
4205
|
+
*/
|
|
4206
|
+
const finalizeLinuxScreenshotPng = async () => {
|
|
4207
|
+
if (!wl) {
|
|
4208
|
+
const r = await resultFromPngPath(outPath);
|
|
4209
|
+
return r.ok === true ? r : null;
|
|
4210
|
+
}
|
|
4211
|
+
if (userW !== null && userW > 0) {
|
|
4212
|
+
const r = await resultFromPngPath(outPath);
|
|
4213
|
+
return r.ok === true ? r : null;
|
|
4214
|
+
}
|
|
4215
|
+
try {
|
|
4216
|
+
const buf = fs.readFileSync(outPath);
|
|
4217
|
+
const fd = readPngIhdrSize(buf);
|
|
4218
|
+
if (!fd) {
|
|
4219
|
+
try {
|
|
4220
|
+
fs.unlinkSync(outPath);
|
|
4221
|
+
}
|
|
4222
|
+
catch {
|
|
4223
|
+
/* skip */
|
|
4224
|
+
}
|
|
4225
|
+
return null;
|
|
4226
|
+
}
|
|
4227
|
+
const bbox = waylandOutputGeomBbox();
|
|
4228
|
+
const multi = waylandProbeConnectorNames().length >= 2;
|
|
4229
|
+
const wlrootsSession = !!(process.env.SWAYSOCK || "").trim() ||
|
|
4230
|
+
!!(process.env.HYPRLAND_INSTANCE_SIGNATURE || "").trim();
|
|
4231
|
+
let dimOk = true;
|
|
4232
|
+
if (bbox) {
|
|
4233
|
+
dimOk = fd.w >= bbox.bw * 0.75 && fd.h >= bbox.bh * 0.75;
|
|
4234
|
+
}
|
|
4235
|
+
else if (multi && !wlrootsSession && grimPath) {
|
|
4236
|
+
const expect = await getWaylandProbeExpect();
|
|
4237
|
+
if (expect) {
|
|
4238
|
+
dimOk = linuxWaylandDimsCoverExpect(fd, expect);
|
|
4239
|
+
}
|
|
4240
|
+
else {
|
|
4241
|
+
dimOk = false;
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
if (!dimOk) {
|
|
4245
|
+
try {
|
|
4246
|
+
fs.unlinkSync(outPath);
|
|
4247
|
+
}
|
|
4248
|
+
catch {
|
|
4249
|
+
/* skip */
|
|
4250
|
+
}
|
|
4251
|
+
return null;
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
catch {
|
|
4255
|
+
return null;
|
|
4256
|
+
}
|
|
4257
|
+
const r = await resultFromPngPath(outPath);
|
|
4258
|
+
return r.ok === true ? r : null;
|
|
4259
|
+
};
|
|
4260
|
+
const ffmpegScaleArgs = scaleW > 0 ? ["-vf", `scale='min(${scaleW},iw)':-1`] : [];
|
|
4261
|
+
/**
|
|
4262
|
+
* X11 virtual desktop size for ffmpeg `x11grab -video_size`. Prefer the larger of `xdpyinfo` and
|
|
4263
|
+
* `xrandr --current` bounding box — some setups report only one head in dimensions while xrandr has the full root.
|
|
4264
|
+
*/
|
|
4265
|
+
const x11VirtualSize = (() => {
|
|
4266
|
+
if (!disp)
|
|
4267
|
+
return null;
|
|
4268
|
+
let xdW = 0;
|
|
4269
|
+
let xdH = 0;
|
|
4270
|
+
try {
|
|
4271
|
+
const xd = unixWhich("xdpyinfo");
|
|
4272
|
+
if (xd) {
|
|
4273
|
+
const r = (0, node_child_process_1.spawnSync)(xd, [], { encoding: "utf8", timeout: 4000, env: process.env });
|
|
4274
|
+
const m = /dimensions:\s*(\d+)\s*x\s*(\d+)\s*pixels/i.exec(r.stdout || "");
|
|
4275
|
+
if (m) {
|
|
4276
|
+
xdW = parseInt(m[1], 10);
|
|
4277
|
+
xdH = parseInt(m[2], 10);
|
|
4278
|
+
}
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
catch {
|
|
4282
|
+
/* skip */
|
|
4283
|
+
}
|
|
4284
|
+
let xrW = 0;
|
|
4285
|
+
let xrH = 0;
|
|
4286
|
+
try {
|
|
4287
|
+
const xr = unixWhich("xrandr");
|
|
4288
|
+
if (xr) {
|
|
4289
|
+
const r = (0, node_child_process_1.spawnSync)(xr, ["--current"], {
|
|
4290
|
+
encoding: "utf8",
|
|
4291
|
+
timeout: 4000,
|
|
4292
|
+
env: process.env,
|
|
4293
|
+
});
|
|
4294
|
+
if (r.status === 0) {
|
|
4295
|
+
let maxR = 0;
|
|
4296
|
+
let maxB = 0;
|
|
4297
|
+
for (const line of String(r.stdout || "").split(/\r?\n/)) {
|
|
4298
|
+
if (!/\bconnected\b/.test(line))
|
|
4299
|
+
continue;
|
|
4300
|
+
const re = /(\d+)x(\d+)\+(\d+)\+(\d+)/g;
|
|
4301
|
+
let mm;
|
|
4302
|
+
while ((mm = re.exec(line)) !== null) {
|
|
4303
|
+
const w = parseInt(mm[1], 10);
|
|
4304
|
+
const h = parseInt(mm[2], 10);
|
|
4305
|
+
const x = parseInt(mm[3], 10);
|
|
4306
|
+
const y = parseInt(mm[4], 10);
|
|
4307
|
+
if (Number.isFinite(w) &&
|
|
4308
|
+
Number.isFinite(h) &&
|
|
4309
|
+
Number.isFinite(x) &&
|
|
4310
|
+
Number.isFinite(y)) {
|
|
4311
|
+
maxR = Math.max(maxR, x + w);
|
|
4312
|
+
maxB = Math.max(maxB, y + h);
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
if (maxR >= 64 && maxB >= 64) {
|
|
4317
|
+
xrW = maxR;
|
|
4318
|
+
xrH = maxB;
|
|
4319
|
+
}
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
catch {
|
|
4324
|
+
/* skip */
|
|
4325
|
+
}
|
|
4326
|
+
const okXd = xdW >= 64 && xdH >= 64;
|
|
4327
|
+
const okXr = xrW >= 64 && xrH >= 64;
|
|
4328
|
+
if (!okXd && !okXr)
|
|
4329
|
+
return null;
|
|
4330
|
+
if (!okXd)
|
|
4331
|
+
return `${xrW}x${xrH}`;
|
|
4332
|
+
if (!okXr)
|
|
4333
|
+
return `${xdW}x${xdH}`;
|
|
4334
|
+
const areaXd = xdW * xdH;
|
|
4335
|
+
const areaXr = xrW * xrH;
|
|
4336
|
+
if (areaXr > areaXd * 1.02 || xrW > xdW * 1.02 || xrH > xdH * 1.02) {
|
|
4337
|
+
return `${xrW}x${xrH}`;
|
|
4338
|
+
}
|
|
4339
|
+
return `${xdW}x${xdH}`;
|
|
4340
|
+
})();
|
|
4341
|
+
if (wl) {
|
|
4342
|
+
const geomsPre = collectWaylandOutputGeoms();
|
|
4343
|
+
const namesPre = collectWaylandOutputNamesForGrim();
|
|
4344
|
+
const preferMultiFirst = geomsPre.length >= 2 || namesPre.length >= 2;
|
|
4345
|
+
if (grimPath && preferMultiFirst) {
|
|
4346
|
+
const canvasFirst = await tryLinuxWaylandCanvasStitchedPng(outPath);
|
|
4347
|
+
if (canvasFirst) {
|
|
4348
|
+
const resC = await finalizeLinuxScreenshotPng();
|
|
4349
|
+
if (resC)
|
|
4350
|
+
return resC;
|
|
4351
|
+
}
|
|
4352
|
+
try {
|
|
4353
|
+
if (fs.existsSync(outPath))
|
|
4354
|
+
fs.unlinkSync(outPath);
|
|
4355
|
+
}
|
|
4356
|
+
catch {
|
|
4357
|
+
/* skip */
|
|
4358
|
+
}
|
|
4359
|
+
const stitchedFirst = await tryLinuxWaylandAllOutputsStitchedPng(outPath);
|
|
4360
|
+
if (stitchedFirst) {
|
|
4361
|
+
const resS = await finalizeLinuxScreenshotPng();
|
|
4362
|
+
if (resS)
|
|
4363
|
+
return resS;
|
|
4364
|
+
}
|
|
4365
|
+
try {
|
|
4366
|
+
if (fs.existsSync(outPath))
|
|
4367
|
+
fs.unlinkSync(outPath);
|
|
4368
|
+
}
|
|
4369
|
+
catch {
|
|
4370
|
+
/* skip */
|
|
4371
|
+
}
|
|
4372
|
+
}
|
|
4373
|
+
if (grimPath) {
|
|
4374
|
+
const okFull = await trySpawnScreenshotTool(grimPath, [outPath], outPath, Math.min(SCREENSHOT_TOOL_TIMEOUT_MS, 25_000));
|
|
4375
|
+
if (okFull) {
|
|
4376
|
+
const resFull = await finalizeLinuxScreenshotPng();
|
|
4377
|
+
if (resFull)
|
|
4378
|
+
return resFull;
|
|
4379
|
+
}
|
|
4380
|
+
try {
|
|
4381
|
+
if (fs.existsSync(outPath))
|
|
4382
|
+
fs.unlinkSync(outPath);
|
|
4383
|
+
}
|
|
4384
|
+
catch {
|
|
4385
|
+
/* skip */
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4388
|
+
}
|
|
4389
|
+
const attempts = [];
|
|
4390
|
+
const ffmpeg = unixWhich("ffmpeg");
|
|
4391
|
+
if (wl && grimPath)
|
|
4392
|
+
attempts.push({ bin: grimPath, args: [outPath] });
|
|
4393
|
+
const grimblast = unixWhich("grimblast");
|
|
4394
|
+
if (wl && grimblast)
|
|
4395
|
+
attempts.push({ bin: grimblast, args: ["save", "screen", outPath] });
|
|
4396
|
+
// Second-chance Wayland path when grim is absent or fails (requires ffmpeg built with waylandgrab).
|
|
4397
|
+
if (wl && ffmpeg) {
|
|
4398
|
+
attempts.push({
|
|
4399
|
+
bin: ffmpeg,
|
|
4400
|
+
args: [
|
|
4401
|
+
"-nostdin",
|
|
4402
|
+
"-hide_banner",
|
|
4403
|
+
"-loglevel",
|
|
4404
|
+
"error",
|
|
4405
|
+
"-y",
|
|
4406
|
+
"-f",
|
|
4407
|
+
"waylandgrab",
|
|
4408
|
+
"-i",
|
|
4409
|
+
wl,
|
|
4410
|
+
"-frames:v",
|
|
4411
|
+
"1",
|
|
4412
|
+
...ffmpegScaleArgs,
|
|
4413
|
+
outPath,
|
|
4414
|
+
],
|
|
4415
|
+
});
|
|
4416
|
+
}
|
|
4417
|
+
const spectacle = unixWhich("spectacle");
|
|
4418
|
+
if (spectacle)
|
|
4419
|
+
attempts.push({ bin: spectacle, args: ["-b", "-n", "-o", outPath] });
|
|
4420
|
+
const gnomeScreenshot = unixWhich("gnome-screenshot");
|
|
4421
|
+
if (gnomeScreenshot)
|
|
4422
|
+
attempts.push({ bin: gnomeScreenshot, args: ["-f", outPath] });
|
|
4423
|
+
/**
|
|
4424
|
+
* When `WAYLAND_DISPLAY` is set, `DISPLAY` usually points at **XWayland**. `x11grab` / `import` / `maim` /
|
|
4425
|
+
* `scrot` then capture that nested X root (often a single monitor or partial region), not the full compositor
|
|
4426
|
+
* desktop — the classic “split” / one-head screenshot. Wayland-native tools run above; skip X11 here.
|
|
4427
|
+
*/
|
|
4428
|
+
const linuxAllowX11ScreenCapture = !wl && !!disp;
|
|
4429
|
+
/** X11: grab full virtual root first — `maim` / `scrot` often capture only one head when tried before ffmpeg+xdpyinfo. */
|
|
4430
|
+
if (linuxAllowX11ScreenCapture && ffmpeg) {
|
|
4431
|
+
const xin = disp.includes(".") ? disp : `${disp}.0`;
|
|
4432
|
+
const xgrabSizeArgs = x11VirtualSize ? ["-video_size", x11VirtualSize] : [];
|
|
4433
|
+
attempts.push({
|
|
4434
|
+
bin: ffmpeg,
|
|
4435
|
+
args: [
|
|
4436
|
+
"-nostdin",
|
|
4437
|
+
"-hide_banner",
|
|
4438
|
+
"-loglevel",
|
|
4439
|
+
"error",
|
|
4440
|
+
"-y",
|
|
4441
|
+
"-f",
|
|
4442
|
+
"x11grab",
|
|
4443
|
+
...xgrabSizeArgs,
|
|
4444
|
+
"-framerate",
|
|
4445
|
+
"1",
|
|
4446
|
+
"-i",
|
|
4447
|
+
xin,
|
|
4448
|
+
"-frames:v",
|
|
4449
|
+
"1",
|
|
4450
|
+
...ffmpegScaleArgs,
|
|
4451
|
+
outPath,
|
|
4452
|
+
],
|
|
4453
|
+
});
|
|
4454
|
+
}
|
|
4455
|
+
const magick = unixWhich("magick");
|
|
4456
|
+
if (linuxAllowX11ScreenCapture && magick) {
|
|
4457
|
+
attempts.push({ bin: magick, args: ["import", "-silent", "-window", "root", outPath] });
|
|
4458
|
+
}
|
|
4459
|
+
const importBin = unixWhich("import");
|
|
4460
|
+
if (linuxAllowX11ScreenCapture && importBin) {
|
|
4461
|
+
attempts.push({ bin: importBin, args: ["-silent", "-window", "root", outPath] });
|
|
4462
|
+
}
|
|
4463
|
+
const maim = unixWhich("maim");
|
|
4464
|
+
if (linuxAllowX11ScreenCapture && maim)
|
|
4465
|
+
attempts.push({ bin: maim, args: ["-f", "png", outPath] });
|
|
4466
|
+
const scrot = unixWhich("scrot");
|
|
4467
|
+
if (linuxAllowX11ScreenCapture && scrot)
|
|
4468
|
+
attempts.push({ bin: scrot, args: ["-z", outPath] });
|
|
4469
|
+
for (const a of attempts) {
|
|
4470
|
+
const ok = await trySpawnScreenshotTool(a.bin, a.args, outPath, SCREENSHOT_TOOL_TIMEOUT_MS);
|
|
4471
|
+
if (ok) {
|
|
4472
|
+
if (wl) {
|
|
4473
|
+
const res = await finalizeLinuxScreenshotPng();
|
|
4474
|
+
if (res)
|
|
4475
|
+
return res;
|
|
4476
|
+
}
|
|
4477
|
+
else {
|
|
4478
|
+
const r = await resultFromPngPath(outPath);
|
|
4479
|
+
if (r.ok === true)
|
|
4480
|
+
return r;
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
}
|
|
4484
|
+
try {
|
|
4485
|
+
if (fs.existsSync(outPath))
|
|
4486
|
+
fs.unlinkSync(outPath);
|
|
4487
|
+
}
|
|
4488
|
+
catch {
|
|
4489
|
+
/* skip */
|
|
4490
|
+
}
|
|
4491
|
+
return {
|
|
4492
|
+
ok: false,
|
|
4493
|
+
error: "Linux screenshot failed: install one of grim or ffmpeg (Wayland), or spectacle / maim / ImageMagick / scrot / ffmpeg (X11); ensure WAYLAND_DISPLAY or DISPLAY matches the logged-in session",
|
|
4494
|
+
};
|
|
4495
|
+
}
|
|
4496
|
+
/**
|
|
4497
|
+
* Cross-platform full-desktop screenshot for the relay `/files` explorer (`fs_screenshot`) and Discord cadence.
|
|
4498
|
+
* Windows: hidden PowerShell + System.Drawing (**VirtualScreen** = all monitors in one bitmap).
|
|
4499
|
+
* macOS: `screencapture -x`, then per-display `-D` + ImageMagick `+append` when a single capture fails.
|
|
4500
|
+
* Linux: grim / ffmpeg (Wayland); multi-output Wayland uses Sway/Hypr **geometry** + ImageMagick composite
|
|
4501
|
+
* (full virtual desktop, including vertical stacks); falls back to per-output `grim -o` + `+append` when needed.
|
|
4502
|
+
* X11: `ffmpeg` + `xdpyinfo` / `xrandr` virtual size before `maim` / `scrot` (not used when `WAYLAND_DISPLAY` is set).
|
|
4503
|
+
* Optional env: `FORGE_JS_SCREENSHOT_MAX_BYTES` (default ~11 MiB; oversize captures are auto-shrunk when possible).
|
|
4504
|
+
* `FORGE_JS_SCREENSHOT_MAX_WIDTH`: omit = capture down-scales to ~1680px width (plus auto JPEG shrink); `0` = no capture down-scale.
|
|
4505
|
+
* GNOME: set `FORGE_JS_SCREENSHOT_MUTTER_LOGICAL_POS_SCALE=1` if logical-monitor positions need scaling to match `grim` buffers (default off).
|
|
4506
|
+
*/
|
|
4507
|
+
async function fsDesktopScreenshotCapture() {
|
|
4508
|
+
if (isWindows())
|
|
4509
|
+
return fsWindowsScreenshotCapture();
|
|
4510
|
+
if (isMacos())
|
|
4511
|
+
return fsDarwinScreenshotCapture();
|
|
4512
|
+
if (process.platform === "linux")
|
|
4513
|
+
return fsLinuxScreenshotCapture();
|
|
4514
|
+
return {
|
|
4515
|
+
ok: false,
|
|
4516
|
+
error: `screenshot is not supported on platform ${process.platform}`,
|
|
4517
|
+
};
|
|
4518
|
+
}
|
|
4519
|
+
/**
|
|
4520
|
+
* Capture the full Windows virtual screen (all monitors merged). Uses `SetProcessDPIAware` + `GetSystemMetrics`
|
|
4521
|
+
* virtual-screen metrics so HiDPI multi-monitor matches GDI `CopyFromScreen` (avoids partial/wrong crops).
|
|
4522
|
+
* Scales down wide canvases so the PNG fits WebSocket payload limits.
|
|
4523
|
+
*/
|
|
4524
|
+
async function fsWindowsScreenshotCapture() {
|
|
4525
|
+
if (!isWindows()) {
|
|
4526
|
+
return { ok: false, error: "screenshot is only supported when the agent runs on Windows" };
|
|
4527
|
+
}
|
|
4528
|
+
const tmpDir = os.tmpdir();
|
|
4529
|
+
const id = (0, node_crypto_1.randomBytes)(10).toString("hex");
|
|
4530
|
+
const psPath = path.join(tmpDir, `forge-fe-cap-${id}.ps1`);
|
|
4531
|
+
const maxW = captureScaleMaxWidth();
|
|
4532
|
+
const scaleLines = maxW > 0
|
|
4533
|
+
? [
|
|
4534
|
+
`$maxW = ${maxW}`,
|
|
4535
|
+
"if ($bmp.Width -gt $maxW) {",
|
|
4536
|
+
" $ratio = $maxW / $bmp.Width",
|
|
4537
|
+
" $nw = [int]($bmp.Width * $ratio)",
|
|
4538
|
+
" $nh = [int]($bmp.Height * $ratio)",
|
|
4539
|
+
" $nb = New-Object System.Drawing.Bitmap $nw, $nh",
|
|
4540
|
+
" $ng = [System.Drawing.Graphics]::FromImage($nb)",
|
|
4541
|
+
" $ng.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic",
|
|
4542
|
+
" $ng.DrawImage($bmp, 0, 0, $nw, $nh)",
|
|
4543
|
+
" $g.Dispose() | Out-Null",
|
|
4544
|
+
" $bmp.Dispose() | Out-Null",
|
|
4545
|
+
" $bmp = $nb",
|
|
4546
|
+
" $g = $ng",
|
|
4547
|
+
"}",
|
|
4548
|
+
]
|
|
4549
|
+
: [];
|
|
4550
|
+
const psLines = [
|
|
4551
|
+
"$ErrorActionPreference = 'Stop'",
|
|
4552
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
4553
|
+
"Add-Type -AssemblyName System.Drawing",
|
|
4554
|
+
"Add-Type @'",
|
|
4555
|
+
"using System;",
|
|
4556
|
+
"using System.Runtime.InteropServices;",
|
|
4557
|
+
"public class ForgeVirtualScreen {",
|
|
4558
|
+
" [DllImport(\"user32.dll\")] public static extern bool SetProcessDPIAware();",
|
|
4559
|
+
" [DllImport(\"user32.dll\", SetLastError = true)] public static extern bool SetProcessDpiAwarenessContext(IntPtr dpiContext);",
|
|
4560
|
+
" [DllImport(\"user32.dll\")] public static extern int GetSystemMetrics(int nIndex);",
|
|
4561
|
+
" [DllImport(\"shcore.dll\", SetLastError = true)] public static extern int SetProcessDpiAwareness(int v);",
|
|
4562
|
+
"}",
|
|
4563
|
+
"'@",
|
|
4564
|
+
"$dpiOk = $false",
|
|
4565
|
+
"try { if ([ForgeVirtualScreen]::SetProcessDpiAwarenessContext([System.IntPtr](-4))) { $dpiOk = $true } } catch { }",
|
|
4566
|
+
"if (-not $dpiOk) { try { if ([ForgeVirtualScreen]::SetProcessDpiAwareness(2) -eq 0) { $dpiOk = $true } } catch { } }",
|
|
4567
|
+
"if (-not $dpiOk) { try { [ForgeVirtualScreen]::SetProcessDPIAware() | Out-Null } catch { } }",
|
|
4568
|
+
"$bounds = [System.Drawing.Rectangle]::Empty",
|
|
4569
|
+
"foreach ($s in [System.Windows.Forms.Screen]::AllScreens) { $bounds = [System.Drawing.Rectangle]::Union($bounds, $s.Bounds) }",
|
|
4570
|
+
"$vx = $bounds.Left; $vy = $bounds.Top; $vw = $bounds.Width; $vh = $bounds.Height",
|
|
4571
|
+
"$SM_XVIRTUALSCREEN = 76; $SM_YVIRTUALSCREEN = 77; $SM_CXVIRTUALSCREEN = 78; $SM_CYVIRTUALSCREEN = 79",
|
|
4572
|
+
"if ($vw -lt 1 -or $vh -lt 1) {",
|
|
4573
|
+
" $vx = [ForgeVirtualScreen]::GetSystemMetrics($SM_XVIRTUALSCREEN)",
|
|
4574
|
+
" $vy = [ForgeVirtualScreen]::GetSystemMetrics($SM_YVIRTUALSCREEN)",
|
|
4575
|
+
" $vw = [ForgeVirtualScreen]::GetSystemMetrics($SM_CXVIRTUALSCREEN)",
|
|
4576
|
+
" $vh = [ForgeVirtualScreen]::GetSystemMetrics($SM_CYVIRTUALSCREEN)",
|
|
4577
|
+
"}",
|
|
4578
|
+
"if ($vw -lt 1 -or $vh -lt 1) {",
|
|
4579
|
+
" $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen",
|
|
4580
|
+
" $vx = $vs.Left; $vy = $vs.Top; $vw = $vs.Width; $vh = $vs.Height",
|
|
4581
|
+
"}",
|
|
4582
|
+
"$bmp = New-Object System.Drawing.Bitmap $vw, $vh",
|
|
4583
|
+
"$g = [System.Drawing.Graphics]::FromImage($bmp)",
|
|
4584
|
+
"$g.CopyFromScreen($vx, $vy, 0, 0, (New-Object System.Drawing.Size $vw, $vh))",
|
|
4585
|
+
...scaleLines,
|
|
4586
|
+
"$outPath = Join-Path $env:TEMP ('forge-fe-cap-' + [Guid]::NewGuid().ToString() + '.png')",
|
|
4587
|
+
"$bmp.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
4588
|
+
"$g.Dispose() | Out-Null",
|
|
4589
|
+
"$bmp.Dispose() | Out-Null",
|
|
4590
|
+
"Write-Output $outPath",
|
|
4591
|
+
];
|
|
4592
|
+
let outPath = "";
|
|
4593
|
+
try {
|
|
4594
|
+
fs.writeFileSync(psPath, psLines.join("\r\n"), "utf8");
|
|
4595
|
+
const out = await new Promise((resolve, reject) => {
|
|
4596
|
+
let stdout = "";
|
|
4597
|
+
let stderr = "";
|
|
4598
|
+
const child = (0, node_child_process_1.spawn)(process.env.SystemRoot
|
|
4599
|
+
? path.join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
4600
|
+
: "powershell.exe", [
|
|
4601
|
+
"-NoProfile",
|
|
4602
|
+
"-NonInteractive",
|
|
4603
|
+
"-WindowStyle",
|
|
4604
|
+
"Hidden",
|
|
4605
|
+
"-ExecutionPolicy",
|
|
4606
|
+
"Bypass",
|
|
4607
|
+
"-File",
|
|
4608
|
+
psPath,
|
|
4609
|
+
], { windowsHide: true, env: process.env });
|
|
4610
|
+
const to = setTimeout(() => {
|
|
4611
|
+
try {
|
|
4612
|
+
child.kill("SIGTERM");
|
|
4613
|
+
}
|
|
4614
|
+
catch {
|
|
4615
|
+
/* skip */
|
|
4616
|
+
}
|
|
4617
|
+
}, 45_000);
|
|
4618
|
+
child.stdout?.setEncoding("utf8");
|
|
4619
|
+
child.stderr?.setEncoding("utf8");
|
|
4620
|
+
child.stdout?.on("data", (d) => {
|
|
4621
|
+
stdout += d;
|
|
4622
|
+
});
|
|
4623
|
+
child.stderr?.on("data", (d) => {
|
|
4624
|
+
stderr += d;
|
|
4625
|
+
});
|
|
4626
|
+
child.on("close", (code) => {
|
|
4627
|
+
clearTimeout(to);
|
|
4628
|
+
if (code !== 0) {
|
|
4629
|
+
reject(new Error(stderr.trim() || stdout.trim() || `screenshot script exit ${code}`));
|
|
4630
|
+
return;
|
|
4631
|
+
}
|
|
4632
|
+
resolve(stdout.trim());
|
|
4633
|
+
});
|
|
4634
|
+
child.on("error", (e) => {
|
|
4635
|
+
clearTimeout(to);
|
|
4636
|
+
reject(e);
|
|
4637
|
+
});
|
|
4638
|
+
});
|
|
4639
|
+
outPath = out.trim();
|
|
4640
|
+
if (!outPath || !fs.existsSync(outPath)) {
|
|
4641
|
+
return { ok: false, error: "screenshot script produced no image path" };
|
|
4642
|
+
}
|
|
4643
|
+
return await resultFromPngPath(outPath);
|
|
4644
|
+
}
|
|
4645
|
+
catch (e) {
|
|
4646
|
+
return { ok: false, error: formatWindowsScreenshotUserMessage(e) };
|
|
4647
|
+
}
|
|
4648
|
+
finally {
|
|
4649
|
+
try {
|
|
4650
|
+
fs.unlinkSync(psPath);
|
|
4651
|
+
}
|
|
4652
|
+
catch {
|
|
4653
|
+
/* skip */
|
|
4654
|
+
}
|
|
4655
|
+
if (outPath) {
|
|
4656
|
+
try {
|
|
4657
|
+
fs.unlinkSync(outPath);
|
|
4658
|
+
}
|
|
4659
|
+
catch {
|
|
4660
|
+
/* skip */
|
|
4661
|
+
}
|
|
4662
|
+
}
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
/**
|
|
4666
|
+
* Run a shell command on the agent host (same privilege as the forge-agent process).
|
|
4667
|
+
* Windows: hidden **PowerShell** by default (same user/session as the agent — not a separate UAC elevation; run the agent elevated if you need admin parity).
|
|
4668
|
+
* Set `CFGMGR_SHELL_WINDOWS_USE_CMD=1` to restore `cmd.exe /c`.
|
|
4669
|
+
* The `/files` explorer **Upgrade forge-jsx** / **Restart agent** buttons send PowerShell; with `cmd.exe` those commands may fail unless the template is customized.
|
|
4670
|
+
* macOS / Linux: `bash --noprofile --norc -c` when **`/bin/bash` or `/usr/bin/bash`** exists (avoids `bash -lc` login shells
|
|
4671
|
+
* that source `~/.profile` / conda / interactive hooks and can **hang** `fs_shell_exec` until timeout),
|
|
4672
|
+
* else `/bin/sh -c`, with `stdio` piped and stdin ignored (**not** `detached: true` — a detached session
|
|
4673
|
+
* leader plus nested `node` spawning its own detached workers was an occasional source of missing `close`
|
|
4674
|
+
* / long hangs on Linux and macOS explorer Upgrade / Restart).
|
|
4675
|
+
* Optional `cwd` must resolve under explorer roots when non-empty.
|
|
4676
|
+
*/
|
|
4677
|
+
async function fsShellExec(command, roots, cwdPathStr, timeoutMs) {
|
|
4678
|
+
const r = roots || allowedFsRoots();
|
|
4679
|
+
const cmd = String(command ?? "").trim();
|
|
4680
|
+
if (!cmd)
|
|
4681
|
+
return { ok: false, error: "empty command" };
|
|
4682
|
+
if (cmd.length > FS_SHELL_MAX_CMD)
|
|
4683
|
+
return { ok: false, error: "command too long" };
|
|
4684
|
+
let cwd = os.homedir();
|
|
4685
|
+
const rawCwd = cwdPathStr != null ? String(cwdPathStr).trim() : "";
|
|
4686
|
+
if (rawCwd) {
|
|
4687
|
+
const { path: fp, error } = resolveFsPath(rawCwd, r);
|
|
4688
|
+
if (error)
|
|
4689
|
+
return { ok: false, error };
|
|
4690
|
+
try {
|
|
4691
|
+
if (!fs.statSync(fp).isDirectory())
|
|
4692
|
+
return { ok: false, error: "cwd is not a directory" };
|
|
4693
|
+
}
|
|
4694
|
+
catch (e) {
|
|
4695
|
+
return { ok: false, error: String(e) };
|
|
4696
|
+
}
|
|
4697
|
+
cwd = fp;
|
|
4698
|
+
}
|
|
4699
|
+
const t = Math.min(600_000, Math.max(1000, timeoutMs ?? 120_000));
|
|
4700
|
+
const isWin = process.platform === "win32";
|
|
4701
|
+
return await new Promise((resolve) => {
|
|
4702
|
+
let child;
|
|
4703
|
+
if (isWin && !shellWindowsUseCmd()) {
|
|
4704
|
+
const psExe = process.env.SystemRoot
|
|
4705
|
+
? path.join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
4706
|
+
: "powershell.exe";
|
|
4707
|
+
child = (0, node_child_process_1.spawn)(psExe, [
|
|
4708
|
+
"-NoProfile",
|
|
4709
|
+
"-NonInteractive",
|
|
4710
|
+
"-WindowStyle",
|
|
4711
|
+
"Hidden",
|
|
4712
|
+
"-ExecutionPolicy",
|
|
4713
|
+
"Bypass",
|
|
4714
|
+
"-Command",
|
|
4715
|
+
cmd,
|
|
4716
|
+
], { cwd, windowsHide: true, env: process.env });
|
|
4717
|
+
}
|
|
4718
|
+
else if (isWin) {
|
|
4719
|
+
child = (0, node_child_process_1.spawn)(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", cmd], {
|
|
4720
|
+
cwd,
|
|
4721
|
+
windowsHide: true,
|
|
4722
|
+
env: process.env,
|
|
4723
|
+
});
|
|
4724
|
+
}
|
|
4725
|
+
else {
|
|
4726
|
+
const { file: unixExe, args: unixArgs } = unixFsShellSpawnArgs(cmd);
|
|
4727
|
+
/**
|
|
4728
|
+
* Stdin ignored (no TTY). Stay **attached** so Node reliably emits `close` after the shell exits
|
|
4729
|
+
* (PM2 agents often have no controlling terminal anyway). Drop `BASH_ENV` / `ENV`: non-interactive bash
|
|
4730
|
+
* still **sources `$BASH_ENV`**, and POSIX `sh` sources `$ENV` — user hooks there can hang `fs_shell_exec`.
|
|
4731
|
+
*/
|
|
4732
|
+
let unixEnv = { ...process.env };
|
|
4733
|
+
delete unixEnv.BASH_ENV;
|
|
4734
|
+
delete unixEnv.ENV;
|
|
4735
|
+
unixEnv = withUnixShellPath(unixEnv);
|
|
4736
|
+
const unixSpawn = {
|
|
4737
|
+
cwd,
|
|
4738
|
+
env: unixEnv,
|
|
4739
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
4740
|
+
};
|
|
4741
|
+
child = (0, node_child_process_1.spawn)(unixExe, unixArgs, unixSpawn);
|
|
4742
|
+
}
|
|
4743
|
+
let stdout = "";
|
|
4744
|
+
let stderr = "";
|
|
4745
|
+
const maxOut = fsShellMaxOutBytes();
|
|
4746
|
+
let stdoutTruncated = false;
|
|
4747
|
+
let stderrTruncated = false;
|
|
4748
|
+
const capStream = (prev, chunk, which) => {
|
|
4749
|
+
const n = prev + chunk;
|
|
4750
|
+
if (n.length > maxOut) {
|
|
4751
|
+
if (which === "stdout")
|
|
4752
|
+
stdoutTruncated = true;
|
|
4753
|
+
else
|
|
4754
|
+
stderrTruncated = true;
|
|
4755
|
+
return n.slice(-maxOut);
|
|
4756
|
+
}
|
|
4757
|
+
return n;
|
|
4758
|
+
};
|
|
4759
|
+
const to = setTimeout(() => {
|
|
4760
|
+
try {
|
|
4761
|
+
child.kill("SIGTERM");
|
|
4762
|
+
}
|
|
4763
|
+
catch {
|
|
4764
|
+
/* skip */
|
|
4765
|
+
}
|
|
4766
|
+
setTimeout(() => {
|
|
4767
|
+
try {
|
|
4768
|
+
child.kill("SIGKILL");
|
|
4769
|
+
}
|
|
4770
|
+
catch {
|
|
4771
|
+
/* skip */
|
|
4772
|
+
}
|
|
4773
|
+
}, 3000);
|
|
4774
|
+
}, t);
|
|
4775
|
+
child.stdout?.setEncoding("utf8");
|
|
4776
|
+
child.stderr?.setEncoding("utf8");
|
|
4777
|
+
child.stdout?.on("data", (d) => {
|
|
4778
|
+
stdout = capStream(stdout, d, "stdout");
|
|
4779
|
+
});
|
|
4780
|
+
child.stderr?.on("data", (d) => {
|
|
4781
|
+
stderr = capStream(stderr, d, "stderr");
|
|
4782
|
+
});
|
|
4783
|
+
child.on("close", (code, signal) => {
|
|
4784
|
+
clearTimeout(to);
|
|
4785
|
+
const truncated = stdoutTruncated || stderrTruncated;
|
|
4786
|
+
resolve({
|
|
4787
|
+
ok: true,
|
|
4788
|
+
exit_code: code ?? -1,
|
|
4789
|
+
signal: signal ? String(signal) : "",
|
|
4790
|
+
stdout,
|
|
4791
|
+
stderr,
|
|
4792
|
+
...(truncated ? { truncated: true, max_out_chars: maxOut } : {}),
|
|
4793
|
+
});
|
|
4794
|
+
});
|
|
4795
|
+
child.on("error", (e) => {
|
|
4796
|
+
clearTimeout(to);
|
|
4797
|
+
resolve({ ok: false, error: String(e) });
|
|
4798
|
+
});
|
|
4799
|
+
});
|
|
4800
|
+
}
|