bridgerator 1.0.7 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -15
- package/assets/bridgerator.ico +0 -0
- package/assets/icon-16.png +0 -0
- package/assets/icon-20.png +0 -0
- package/assets/icon-24.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon-48.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/logo.svg +1 -0
- package/install.ps1 +465 -43
- package/npm-shrinkwrap.json +8621 -0
- package/package.json +55 -49
- package/recipes/backup-folder.json +31 -0
- package/recipes/clean-downloads.json +48 -0
- package/recipes/disk-report.json +66 -0
- package/recipes/sort-folder.json +49 -0
- package/recipes/update-apps.json +25 -0
- package/src/ask-lib.ps1 +230 -0
- package/src/ask.mjs +51 -0
- package/src/ask.ps1 +17 -0
- package/src/automations.mjs +673 -0
- package/src/batch.mjs +221 -0
- package/src/bridge.mjs +743 -29
- package/src/cli.mjs +561 -53
- package/src/confirm.mjs +418 -0
- package/src/dc-config-hooks.mjs +18 -8
- package/src/dc-isolate.mjs +5 -0
- package/src/delegate.mjs +555 -0
- package/src/docs-tool.mjs +494 -0
- package/src/doctor.mjs +200 -0
- package/src/events.mjs +21 -0
- package/src/extra-tools.mjs +122 -0
- package/src/guard.mjs +34 -0
- package/src/notice.mjs +86 -23
- package/src/pair.mjs +41 -6
- package/src/pc-check.mjs +112 -0
- package/src/pc-check.ps1 +103 -0
- package/src/pc-text.mjs +94 -0
- package/src/pdf-font.mjs +245 -0
- package/src/photos-tool.mjs +187 -0
- package/src/photos.ps1 +145 -0
- package/src/plan.mjs +90 -0
- package/src/policy.mjs +293 -109
- package/src/queue.mjs +194 -0
- package/src/recent.mjs +48 -0
- package/src/recipes.mjs +108 -0
- package/src/recycle.mjs +185 -0
- package/src/recycle.ps1 +110 -0
- package/src/screen-tool.mjs +185 -0
- package/src/screen.ps1 +285 -0
- package/src/search-tool.mjs +189 -0
- package/src/search.ps1 +129 -0
- package/src/settings-tool.mjs +186 -0
- package/src/setup-tool.mjs +129 -0
- package/src/software-tool.mjs +185 -0
- package/src/telemetry.mjs +86 -0
- package/src/tool-lib.mjs +177 -0
- package/src/tool-text.mjs +45 -0
- package/src/tray.mjs +97 -0
- package/src/tray.ps1 +359 -0
- package/src/undo.mjs +313 -0
- package/src/win.mjs +17 -0
- package/ui/app.css +388 -0
- package/ui/app.js +868 -0
- package/ui/i18n/en.json +598 -0
- package/ui/i18n/ru.json +598 -0
- package/ui/index.html +26 -0
- package/vendor/webview2/LICENSE.txt +27 -0
- package/vendor/webview2/Microsoft.Web.WebView2.Core.dll +0 -0
- package/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll +0 -0
- package/vendor/webview2/NOTICE.txt +85 -0
- package/vendor/webview2/SHA256SUMS.txt +4 -0
- package/vendor/webview2/SOURCE.txt +1 -0
- package/vendor/webview2/arm64/WebView2Loader.dll +0 -0
- package/vendor/webview2/x64/WebView2Loader.dll +0 -0
package/src/recent.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// "Recent on this PC": the last 100 tool calls WITH their command / path, for the person at the PC.
|
|
2
|
+
// In memory; with "Keep a local history" on also in %APPDATA%\Bridgerator\recent.jsonl (inside the
|
|
3
|
+
// always-excluded folder), entries older than 7 days dropped. Never sent to the server.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export const KEEP = 100;
|
|
7
|
+
export const DAYS = 7;
|
|
8
|
+
const short = (s, n = 300) => { s = String(s ?? ''); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
|
|
9
|
+
|
|
10
|
+
export function target(a = {}) {
|
|
11
|
+
if (!a || typeof a !== 'object') return '';
|
|
12
|
+
if (a.source && a.destination) return short(`${a.source} → ${a.destination}`);
|
|
13
|
+
const t = a.command ?? a.input ?? a.path ?? a.file_path ?? (Array.isArray(a.paths) ? a.paths.join(', ') : undefined) ?? a.pattern ?? a.title ?? a.id;
|
|
14
|
+
return t === undefined ? '' : short(typeof t === 'string' ? t : JSON.stringify(t));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createRecent(file, { keepFile = false, now = Date.now } = {}) {
|
|
18
|
+
let list = [], keep = keepFile;
|
|
19
|
+
const fresh = (e) => now() - e.t < DAYS * 86400000;
|
|
20
|
+
const prune = () => {
|
|
21
|
+
if (!keep) return;
|
|
22
|
+
try {
|
|
23
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
24
|
+
const ok = lines.filter((l) => { try { return fresh(JSON.parse(l)); } catch { return false; } });
|
|
25
|
+
if (ok.length !== lines.length || ok.length > KEEP * 10) fs.writeFileSync(file, ok.slice(-KEEP * 10).join('\n') + (ok.length ? '\n' : ''));
|
|
26
|
+
} catch { /* no file */ }
|
|
27
|
+
};
|
|
28
|
+
if (keep) { prune(); try { list = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)).filter(fresh).slice(-KEEP); } catch { /* none */ } }
|
|
29
|
+
let lastPrune = now();
|
|
30
|
+
return {
|
|
31
|
+
record(name, args, outcome, extra) {
|
|
32
|
+
const e = { t: now(), tool: String(name), target: target(args), outcome, ...(extra && typeof extra === 'object' ? extra : {}) };
|
|
33
|
+
list.push(e); if (list.length > KEEP) list.shift();
|
|
34
|
+
if (keep) {
|
|
35
|
+
try { fs.appendFileSync(file, JSON.stringify(e) + '\n'); } catch { /* read-only */ }
|
|
36
|
+
if (now() - lastPrune > 86400000) { lastPrune = now(); prune(); }
|
|
37
|
+
}
|
|
38
|
+
return e;
|
|
39
|
+
},
|
|
40
|
+
list: () => [...list].reverse(),
|
|
41
|
+
clear() { list = []; try { fs.rmSync(file, { force: true }); } catch { /* none */ } },
|
|
42
|
+
setKeep(v) { keep = !!v; if (!keep) { try { fs.rmSync(file, { force: true }); } catch { /* none */ } } else for (const e of list) try { fs.appendFileSync(file, JSON.stringify(e) + '\n'); } catch { /* */ } },
|
|
43
|
+
get keep() { return keep; },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Result of a call → ok | error | refused | not confirmed | paused. */
|
|
48
|
+
export const outcomeOf = (r) => (r?.paused ? 'paused' : r?.notConfirmed ? 'not confirmed' : r?.refused ? 'refused' : r?.isError ? 'error' : 'ok');
|
package/src/recipes.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Automations and recipes"). Ready-made PowerShell scripts
|
|
2
|
+
// (recipes/*.json in the package): the AI runs one with parameters or saves it as an automation.
|
|
3
|
+
// A recipe becomes ONE plain script: a `$P = @{…}` block with the checked parameters (single-quoted
|
|
4
|
+
// literals, every quote character doubled), then the recipe's own lines. Paths go through the same
|
|
5
|
+
// folder rules as the file tools (tool-lib guard); the whole script then through policy.check like a
|
|
6
|
+
// terminal command (bridge.mjs / automations.mjs), so an excluded folder can't hide in a parameter.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { expandVars, norm } from './policy.mjs';
|
|
12
|
+
import { ToolError, fail, guard, excludedInside } from './tool-lib.mjs';
|
|
13
|
+
|
|
14
|
+
export const RECIPE_IDS = ['backup-folder', 'clean-downloads', 'sort-folder', 'disk-report', 'update-apps'];
|
|
15
|
+
const DIR = new URL('../recipes/', import.meta.url);
|
|
16
|
+
let cache = null;
|
|
17
|
+
export function loadRecipes() {
|
|
18
|
+
cache ||= RECIPE_IDS.map((id) => JSON.parse(fs.readFileSync(new URL(`${id}.json`, DIR), 'utf8')));
|
|
19
|
+
return cache;
|
|
20
|
+
}
|
|
21
|
+
const byId = (id) => loadRecipes().find((r) => r.id === id) || fail('NO_RECIPE', `There is no recipe "${id}".`, `Use one of: ${RECIPE_IDS.join(', ')} (action "recipes" lists them).`);
|
|
22
|
+
|
|
23
|
+
/** What the AI gets from action "recipes" (English, parameters with types and defaults). */
|
|
24
|
+
export function recipeList(lang = 'en') {
|
|
25
|
+
const l = (o) => (o && (o[lang] || o.en)) || '';
|
|
26
|
+
return loadRecipes().map((r) => ({
|
|
27
|
+
id: r.id, title: l(r.title), description: l(r.description), suggestedSchedule: r.schedule,
|
|
28
|
+
params: r.params.map((p) => ({ name: p.name, type: p.type, ...(p.required ? { required: true } : {}), ...(p.default !== undefined ? { default: p.default } : {}), ...(p.values ? { values: p.values } : {}), ...(p.min !== undefined ? { min: p.min, max: p.max } : {}), description: l(p.description) })),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
export const recipeTitle = (id) => { const r = loadRecipes().find((x) => x.id === id); return r ? r.title : null; };
|
|
32
|
+
|
|
33
|
+
// PowerShell single-quoted literal. PowerShell treats ' ‘ ’ ‚ ‛ all as single quotes; doubling each
|
|
34
|
+
// one keeps it literal. Control characters are refused before this (no line breaks in a value).
|
|
35
|
+
export const psQuote = (s) => `'${String(s).replace(/['‘’‚‛]/g, (c) => c + c)}'`;
|
|
36
|
+
const CTRL = /[\u0000-\u001f\u007f\u2028\u2029]/;
|
|
37
|
+
|
|
38
|
+
// The Recycle Bin helper of the bridge itself (recycle.ps1, IFileOperation without UI; an item that
|
|
39
|
+
// would be deleted for good is left alone). Copied into the script: a scheduled run needs no package.
|
|
40
|
+
export function recycleLib() {
|
|
41
|
+
const t = fs.readFileSync(fileURLToPath(new URL('./recycle.ps1', import.meta.url)), 'utf8');
|
|
42
|
+
const m = /Add-CachedType @'\r?\n([\s\S]*?)\r?\n'@/.exec(t);
|
|
43
|
+
if (!m || !/class BrRecycle\b/.test(m[1])) throw new ToolError('INTERNAL', 'The Recycle Bin helper of this Bridgerator version could not be read.', 'Update Bridgerator.');
|
|
44
|
+
// Plain Add-Type (not ask-lib's Add-CachedType): its cache is in Bridgerator's own folder, which a
|
|
45
|
+
// recipe / scheduled script may never name (policy.check refuses such a script).
|
|
46
|
+
return `Add-Type -TypeDefinition @'\n${m[1]}\n'@`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Relative folder path → "<depth>:<first 16 hex of SHA-256 of it in lower case>" (the recipe's walk computes the same). */
|
|
50
|
+
export const skipId = (rel) => `${rel.split(/[\\/]/).length}:${crypto.createHash('sha256').update(rel.toLowerCase(), 'utf8').digest('hex').slice(0, 16)}`;
|
|
51
|
+
|
|
52
|
+
function readParam(ctx, spec, v, all, skips = {}) {
|
|
53
|
+
const bad = (why) => fail('BAD_ARGS', `${spec.name}: ${why}.`);
|
|
54
|
+
if (spec.type === 'bool') { if (typeof v !== 'boolean') bad('must be true or false'); return v; }
|
|
55
|
+
if (spec.type === 'int') { if (!Number.isInteger(v) || v < spec.min || v > spec.max) bad(`must be a whole number from ${spec.min} to ${spec.max}`); return v; }
|
|
56
|
+
if (spec.type === 'enum') { if (!spec.values.includes(v)) bad(`must be one of ${spec.values.join(', ')}`); return v; }
|
|
57
|
+
if (spec.type === 'path') {
|
|
58
|
+
if (typeof v !== 'string' || !v.trim() || v.length > 1000 || CTRL.test(v)) bad('must be a full folder or file path on one line');
|
|
59
|
+
// A folder parameter is used as a whole tree (copied, walked, recycled below it): it must not contain an
|
|
60
|
+
// excluded / Bridgerator folder (FOLDER_HAS_EXCLUDED), except tree "skip": the script skips them itself.
|
|
61
|
+
const write = spec.access === 'write', folder = !spec.ext;
|
|
62
|
+
const abs = guard(ctx, expandVars(v.trim()), { write, tree: folder && spec.tree !== 'skip' });
|
|
63
|
+
if (folder && spec.tree === 'skip') skips[spec.name] = excludedInside(ctx, abs, { write }).map(skipId);
|
|
64
|
+
if (spec.mustExist) { let ok = false; try { ok = fs.statSync(abs).isDirectory(); } catch { /* missing */ } if (!ok) fail('NOT_FOUND', `${spec.name}: the folder "${abs}" does not exist.`, 'Check the path (list the folder first).'); }
|
|
65
|
+
if (spec.ext && path.extname(abs).toLowerCase() !== spec.ext) bad(`must end with ${spec.ext}`);
|
|
66
|
+
if (spec.notInside && all[spec.notInside]) {
|
|
67
|
+
const a = norm(abs), b = norm(all[spec.notInside]);
|
|
68
|
+
if (a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep)) bad(`must not be inside ${spec.notInside} (or contain it)`);
|
|
69
|
+
}
|
|
70
|
+
return abs;
|
|
71
|
+
}
|
|
72
|
+
bad('unknown type');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* → { recipe: id, params: {checked values}, script }. ctx: { cfg, policy } (folder rules).
|
|
77
|
+
* Throws ToolError on a wrong / missing parameter or a path the folder rules refuse.
|
|
78
|
+
*/
|
|
79
|
+
export function buildRecipe(ctx, id, input = {}) {
|
|
80
|
+
const r = byId(id);
|
|
81
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) fail('BAD_ARGS', 'params must be an object.');
|
|
82
|
+
const unknown = Object.keys(input).filter((k) => !r.params.some((p) => p.name === k));
|
|
83
|
+
if (unknown.length) fail('BAD_ARGS', `Unknown parameter(s) for ${id}: ${unknown.join(', ')}.`, `Parameters: ${r.params.map((p) => p.name).join(', ')}.`);
|
|
84
|
+
const out = {}, skips = {};
|
|
85
|
+
for (const spec of r.params) {
|
|
86
|
+
let v = input[spec.name];
|
|
87
|
+
if (v === undefined || v === null || v === '') {
|
|
88
|
+
if (spec.required) fail('BAD_ARGS', `${spec.name} is required for ${id}.`);
|
|
89
|
+
if (spec.default === undefined) { out[spec.name] = null; continue; }
|
|
90
|
+
v = spec.default;
|
|
91
|
+
}
|
|
92
|
+
out[spec.name] = readParam(ctx, spec, v, out, skips);
|
|
93
|
+
}
|
|
94
|
+
const lit = (v) => (v === null ? '$null' : typeof v === 'boolean' ? (v ? '$true' : '$false') : typeof v === 'number' ? String(v) : psQuote(v));
|
|
95
|
+
const body = r.script.join('\n').replace('#RECYCLE#', () => recycleLib());
|
|
96
|
+
const script = [
|
|
97
|
+
`# Recipe "${r.id}" (${r.title.en}). Parameters:`,
|
|
98
|
+
"$ErrorActionPreference = 'Continue'",
|
|
99
|
+
'$P = @{',
|
|
100
|
+
...r.params.map((p) => ` ${p.name} = ${lit(out[p.name])}`),
|
|
101
|
+
// Excluded folders below a tree "skip" folder as "<depth>:<hash of the relative path>" (skipId): the
|
|
102
|
+
// script never names them (it may not mention an excluded folder or Bridgerator).
|
|
103
|
+
...Object.entries(skips).map(([k, l]) => ` ${k}Skip = @(${l.map(psQuote).join(', ')})`),
|
|
104
|
+
'}',
|
|
105
|
+
body,
|
|
106
|
+
].join('\n');
|
|
107
|
+
return { recipe: r.id, params: out, script };
|
|
108
|
+
}
|
package/src/recycle.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Recycle Bin"). A delete command the AI runs in the terminal
|
|
2
|
+
// goes to the Recycle Bin instead, when Bridgerator can read it for sure: ONE delete statement
|
|
3
|
+
// (Remove-Item / ri / rm / del / erase / rd / rmdir, optionally inside `cmd /c`) with plain paths or a
|
|
4
|
+
// wildcard in the last part. Anything else (pipes, ;, &&, variables, loops, scripts, other programs
|
|
5
|
+
// that delete) runs as written — a safety net, not a guarantee (said so in the window and on the site).
|
|
6
|
+
// The move itself: recycle.ps1 (IFileOperation; an item the shell would delete for good is left alone
|
|
7
|
+
// and reported 'nobin' → the bridge asks on the PC before a permanent delete).
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { POWERSHELL, psArgs } from './win.mjs';
|
|
14
|
+
import { runFilesPs, stripOurs } from './ask.mjs';
|
|
15
|
+
|
|
16
|
+
const VERBS = new Set(['remove-item', 'ri', 'rm', 'del', 'erase', 'rd', 'rmdir']);
|
|
17
|
+
const MAX_TARGETS = 1000;
|
|
18
|
+
const RECURSE = new Set(['-recurse', '-r', '-rf', '-fr', '--recursive', '/s']);
|
|
19
|
+
const QUIET = new Set(['-force', '-f', '--force', '-v', '-verbose', '/q', '/f', '/a', '-confirm:$false']);
|
|
20
|
+
|
|
21
|
+
function tokenize(s) {
|
|
22
|
+
const out = [];
|
|
23
|
+
let cur = '', q = null, quoted = false;
|
|
24
|
+
const push = () => { if (cur || quoted) out.push({ v: cur, quoted }); cur = ''; quoted = false; };
|
|
25
|
+
for (const ch of s) {
|
|
26
|
+
if (q) { if (ch === q) q = null; else cur += ch; continue; }
|
|
27
|
+
if (ch === '"' || ch === "'") { q = ch; quoted = true; continue; }
|
|
28
|
+
if (/\s/.test(ch)) { push(); continue; }
|
|
29
|
+
if (ch === ',') { push(); out.push({ v: ',', sep: true }); continue; }
|
|
30
|
+
cur += ch;
|
|
31
|
+
}
|
|
32
|
+
if (q) return null; // unbalanced quote
|
|
33
|
+
push();
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const hasWild = (s) => /[*?]/.test(s);
|
|
38
|
+
const wildRe = (seg) => new RegExp(`^${seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i');
|
|
39
|
+
const kindOf = (p) => { try { const s = fs.lstatSync(p); return s.isDirectory() ? 'dir' : 'file'; } catch { return null; } };
|
|
40
|
+
const isEmptyDir = (p) => { try { return fs.readdirSync(p).length === 0; } catch { return false; } };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One delete command → { verb, targets: [absolute paths], recurse } when Bridgerator can do exactly
|
|
44
|
+
* what it asks (each target exists, folders only with a recurse flag or empty), else null (the
|
|
45
|
+
* command then runs as written).
|
|
46
|
+
*/
|
|
47
|
+
export function parseDelete(command, cwd = os.homedir(), home = os.homedir()) {
|
|
48
|
+
let s = String(command ?? '').trim();
|
|
49
|
+
if (!s || s.length > 4000) return null;
|
|
50
|
+
const wrapped = /^cmd(?:\.exe)?\s+\/[ck]\s+(.+)$/i.exec(s);
|
|
51
|
+
if (wrapped) { s = wrapped[1].trim(); if (/^"[^"]*"$/.test(s)) s = s.slice(1, -1); }
|
|
52
|
+
s = s.replace(/\s-Confirm:\$false\b/gi, ' -confirm:$false');
|
|
53
|
+
// Only plain text: no statements, pipes, redirects, variables, sub-expressions, escapes, line breaks.
|
|
54
|
+
if (/[;&|<>`\r\n{}()@%]/.test(s) || /\$(?!false\b)/i.test(s)) return null;
|
|
55
|
+
const tok = tokenize(s);
|
|
56
|
+
if (!tok || !tok.length) return null;
|
|
57
|
+
const verb = tok[0].v.toLowerCase().replace(/\.exe$/, '');
|
|
58
|
+
if (tok[0].quoted || !VERBS.has(verb)) return null;
|
|
59
|
+
const cmdStyle = ['del', 'erase', 'rd', 'rmdir'].includes(verb);
|
|
60
|
+
let recurse = false, literal = false, force = false, allAttrs = false;
|
|
61
|
+
const raw = [];
|
|
62
|
+
for (let i = 1; i < tok.length; i++) {
|
|
63
|
+
const t = tok[i];
|
|
64
|
+
if (t.sep) continue;
|
|
65
|
+
const low = t.v.toLowerCase();
|
|
66
|
+
if (!t.quoted && /^(-|\/[a-z]|\/[a-z]:)/i.test(t.v) && !/^\/[a-z]:?[\\/]/i.test(t.v)) {
|
|
67
|
+
if (RECURSE.has(low)) { recurse = true; continue; }
|
|
68
|
+
if (low === '-force' || low === '/f') force = true;
|
|
69
|
+
if (low === '/a') allAttrs = true;
|
|
70
|
+
if (/^\/a:[a-z-]+$/.test(low)) { allAttrs = 'filter'; continue; }
|
|
71
|
+
if (QUIET.has(low)) continue;
|
|
72
|
+
if (low === '-path') continue;
|
|
73
|
+
if (low === '-literalpath' || low === '-lp') { literal = true; continue; }
|
|
74
|
+
if (low === '-erroraction' || low === '-ea') { i++; continue; }
|
|
75
|
+
return null; // a flag we don't know: run as written
|
|
76
|
+
}
|
|
77
|
+
raw.push(t.v);
|
|
78
|
+
}
|
|
79
|
+
if (!raw.length || (cmdStyle && (verb === 'del' || verb === 'erase') && recurse)) return null; // del /s *.tmp: every subfolder
|
|
80
|
+
const targets = [];
|
|
81
|
+
// Wildcard matches the command itself would skip: hidden / system items (del without /a,
|
|
82
|
+
// Remove-Item without -Force) and read-only files (without /f or -Force). Checked by recycle.ps1.
|
|
83
|
+
const skip = cmdStyle ? `${allAttrs === true ? '' : 'hs'}${force ? '' : 'r'}` : force ? '' : 'hsr';
|
|
84
|
+
const wild = [];
|
|
85
|
+
for (let p of raw) {
|
|
86
|
+
if (/^[\\/]{2}/.test(p) || /^[a-z][a-z0-9+.-]*:\/\//i.test(p)) return null;
|
|
87
|
+
if (!literal && /[[\]]/.test(p)) return null;
|
|
88
|
+
if (p === '~' || /^~[\\/]/.test(p)) p = path.join(home, p.slice(1));
|
|
89
|
+
const abs = path.resolve(cwd, p);
|
|
90
|
+
if (!literal && hasWild(abs)) {
|
|
91
|
+
const dir = path.dirname(abs), seg = path.basename(abs);
|
|
92
|
+
// rd / rmdir take no wildcards; /a:<attributes> filters we don't copy: run as written.
|
|
93
|
+
if (hasWild(dir) || verb === 'rd' || verb === 'rmdir' || allAttrs === 'filter') return null;
|
|
94
|
+
let names;
|
|
95
|
+
try { names = fs.readdirSync(dir); } catch { return null; }
|
|
96
|
+
const re = wildRe(seg);
|
|
97
|
+
const hits = names.filter((n) => re.test(n)).map((n) => path.join(dir, n));
|
|
98
|
+
if (!hits.length) return null;
|
|
99
|
+
for (const h of hits) {
|
|
100
|
+
const k = kindOf(h);
|
|
101
|
+
if (k === 'dir') { if (verb === 'del' || verb === 'erase') continue; if (!recurse && !isEmptyDir(h)) return null; }
|
|
102
|
+
targets.push(h);
|
|
103
|
+
wild.push(h);
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const k = kindOf(abs);
|
|
108
|
+
if (!k) return null;
|
|
109
|
+
if (k === 'dir' && (verb === 'del' || verb === 'erase')) return null; // del <folder> deletes the files inside
|
|
110
|
+
if (k === 'dir' && !recurse && !isEmptyDir(abs)) return null;
|
|
111
|
+
if (k === 'file' && (verb === 'rd' || verb === 'rmdir')) return null;
|
|
112
|
+
targets.push(abs);
|
|
113
|
+
}
|
|
114
|
+
const uniq = [...new Set(targets)];
|
|
115
|
+
if (!uniq.length || uniq.length > MAX_TARGETS) return null;
|
|
116
|
+
return { verb, targets: uniq, recurse, ...(wild.length && skip ? { skip: Object.fromEntries(wild.map((h) => [h, skip])) } : {}) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const LIB = fileURLToPath(new URL('./ask-lib.ps1', import.meta.url));
|
|
120
|
+
const PS = fileURLToPath(new URL('./recycle.ps1', import.meta.url));
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Moves the paths to the Recycle Bin → [{ p, r: 'recycled' | 'nobin' | 'missing' | 'skipped' | 'error:…' }].
|
|
124
|
+
* skip: { path: 'hsr' } — leave that item alone ('skipped') when it is hidden (h) / system (s) / read-only (r).
|
|
125
|
+
*/
|
|
126
|
+
export function recycle(paths, { timeoutMs = 120000, skip = {} } = {}) {
|
|
127
|
+
if (process.platform !== 'win32') return Promise.resolve(paths.map((p) => ({ p, r: 'nobin' })));
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
const fail = (why) => resolve(paths.map((p) => ({ p, r: `error:${why}` })));
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let done = false;
|
|
132
|
+
const child = spawn(POWERSHELL, psArgs(runFilesPs(LIB, PS)), { windowsHide: true, env: stripOurs(process.env), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
133
|
+
const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } if (!done) { done = true; fail('timeout'); } }, timeoutMs);
|
|
134
|
+
child.stdout.on('data', (b) => { chunks.push(b); }); // decoded once at the end: a UTF-8 character may span chunks
|
|
135
|
+
child.stderr.on('data', () => {});
|
|
136
|
+
child.on('error', (e) => { clearTimeout(timer); if (!done) { done = true; fail(e.code || 'spawn'); } });
|
|
137
|
+
child.on('close', () => {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
if (done) return;
|
|
140
|
+
done = true;
|
|
141
|
+
try {
|
|
142
|
+
const r = JSON.parse(Buffer.concat(chunks).toString('utf8').trim().split(/\r?\n/).pop());
|
|
143
|
+
const list = Array.isArray(r) ? r : [r];
|
|
144
|
+
resolve(paths.map((p) => list.find((x) => x && x.p === p) || { p, r: 'error:no-answer' }));
|
|
145
|
+
} catch { fail('bad-answer'); }
|
|
146
|
+
});
|
|
147
|
+
// PowerShell gone early (EPIPE): the error is reported through 'close' / the timeout, never thrown.
|
|
148
|
+
child.stdin.on('error', () => {});
|
|
149
|
+
child.stdin.end(JSON.stringify(paths.map((p) => ({ p, skip: typeof skip[p] === 'string' ? skip[p] : '' }))));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Undo of a delete: the restored item's copy leaves the Recycle Bin too ----------------------
|
|
154
|
+
// Each recycled item is a pair in <drive>\$Recycle.Bin\<SID>\: $I<id> (original path + deletion time)
|
|
155
|
+
// and $R<id> (the item itself). Only this user's folder is readable, so only their items match.
|
|
156
|
+
const FILETIME_EPOCH_MS = 11644473600000;
|
|
157
|
+
/** $I file → { path, t } (format 1: Vista–8, 2: Windows 10+), or null. */
|
|
158
|
+
export function readBinInfo(buf) {
|
|
159
|
+
try {
|
|
160
|
+
const v = Number(buf.readBigInt64LE(0));
|
|
161
|
+
const t = Number(buf.readBigInt64LE(16) / 10000n) - FILETIME_EPOCH_MS;
|
|
162
|
+
const p = v === 2 ? buf.toString('utf16le', 28, 28 + (buf.readUInt32LE(24) - 1) * 2) : v === 1 ? buf.toString('utf16le', 24, 24 + 520) : null;
|
|
163
|
+
return p ? { path: p.replace(/\0.*$/s, ''), t } : null;
|
|
164
|
+
} catch { return null; }
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Removes from the Recycle Bin exactly the copies this bridge made: items = [{ path, bin }], bin = the
|
|
168
|
+
* $R path recycle.ps1 reported when it recycled `path`. Removed only when that pair is still there and
|
|
169
|
+
* its $I names that same original path; an item without a recorded copy is left alone (never another
|
|
170
|
+
* bin entry — it may be the person's own later deletion). → the paths whose bin copy was removed. Never throws.
|
|
171
|
+
*/
|
|
172
|
+
export function purgeFromBin(items) {
|
|
173
|
+
const gone = [];
|
|
174
|
+
for (const it of Array.isArray(items) ? items : []) {
|
|
175
|
+
try {
|
|
176
|
+
const r = typeof it?.bin === 'string' ? path.resolve(it.bin) : '', n = path.basename(r);
|
|
177
|
+
if (!/^\$R/i.test(n) || path.basename(path.dirname(path.dirname(r))).toLowerCase() !== '$recycle.bin') continue;
|
|
178
|
+
const i = path.join(path.dirname(r), `$I${n.slice(2)}`);
|
|
179
|
+
const info = readBinInfo(fs.readFileSync(i));
|
|
180
|
+
if (!info || path.resolve(info.path).toLowerCase() !== path.resolve(String(it.path)).toLowerCase()) continue;
|
|
181
|
+
fs.rmSync(r, { recursive: true, force: true }); fs.rmSync(i, { force: true }); gone.push(it.path);
|
|
182
|
+
} catch { /* gone already / in use: stays in the bin */ }
|
|
183
|
+
}
|
|
184
|
+
return gone;
|
|
185
|
+
}
|
package/src/recycle.ps1
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Bridgerator: move files / folders to the Recycle Bin (never a permanent delete). Loaded after
|
|
2
|
+
# ask-lib.ps1 (Add-CachedType). Reads a JSON list of absolute paths on stdin, prints one JSON line:
|
|
3
|
+
# [{p, r}] with r = 'recycled' | 'nobin' (this drive has no Recycle Bin / the item is too big for it:
|
|
4
|
+
# nothing was deleted) | 'missing' | 'error:<hresult>'; a recycled item also carries bin = the path of
|
|
5
|
+
# its copy in the Recycle Bin ($R...), so Undo later removes exactly that copy and no other.
|
|
6
|
+
# IFileOperation with FOFX_RECYCLEONDELETE and no UI; a progress sink checks PreDeleteItem: without
|
|
7
|
+
# TSF_DELETE_RECYCLE_IF_POSSIBLE the shell would delete for good, so the item is aborted instead.
|
|
8
|
+
$ErrorActionPreference = 'Stop'
|
|
9
|
+
[Console]::InputEncoding = [Text.UTF8Encoding]::new($false)
|
|
10
|
+
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
|
11
|
+
Add-CachedType @'
|
|
12
|
+
using System; using System.IO; using System.Runtime.InteropServices;
|
|
13
|
+
[ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
14
|
+
public interface IBrShellItem { void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv); void GetParent(out IBrShellItem ppsi); void GetDisplayName(uint sigdn, out IntPtr name); void GetAttributes(uint mask, out uint attrs); void Compare(IBrShellItem psi, uint hint, out int order); }
|
|
15
|
+
[ComImport, Guid("04b0f1a7-9490-44bc-96e1-4296a31252e2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
16
|
+
public interface IBrSink {
|
|
17
|
+
[PreserveSig] int StartOperations();
|
|
18
|
+
[PreserveSig] int FinishOperations(int hr);
|
|
19
|
+
[PreserveSig] int PreRenameItem(uint f, IBrShellItem i, [MarshalAs(UnmanagedType.LPWStr)] string n);
|
|
20
|
+
[PreserveSig] int PostRenameItem(uint f, IBrShellItem i, [MarshalAs(UnmanagedType.LPWStr)] string n, int hr, IBrShellItem c);
|
|
21
|
+
[PreserveSig] int PreMoveItem(uint f, IBrShellItem i, IBrShellItem d, [MarshalAs(UnmanagedType.LPWStr)] string n);
|
|
22
|
+
[PreserveSig] int PostMoveItem(uint f, IBrShellItem i, IBrShellItem d, [MarshalAs(UnmanagedType.LPWStr)] string n, int hr, IBrShellItem c);
|
|
23
|
+
[PreserveSig] int PreCopyItem(uint f, IBrShellItem i, IBrShellItem d, [MarshalAs(UnmanagedType.LPWStr)] string n);
|
|
24
|
+
[PreserveSig] int PostCopyItem(uint f, IBrShellItem i, IBrShellItem d, [MarshalAs(UnmanagedType.LPWStr)] string n, int hr, IBrShellItem c);
|
|
25
|
+
[PreserveSig] int PreDeleteItem(uint f, IBrShellItem i);
|
|
26
|
+
[PreserveSig] int PostDeleteItem(uint f, IBrShellItem i, int hr, IBrShellItem c);
|
|
27
|
+
[PreserveSig] int PreNewItem(uint f, IBrShellItem d, [MarshalAs(UnmanagedType.LPWStr)] string n);
|
|
28
|
+
[PreserveSig] int PostNewItem(uint f, IBrShellItem d, [MarshalAs(UnmanagedType.LPWStr)] string n, [MarshalAs(UnmanagedType.LPWStr)] string t, uint a, int hr, IBrShellItem c);
|
|
29
|
+
[PreserveSig] int UpdateProgress(uint total, uint sofar);
|
|
30
|
+
[PreserveSig] int ResetTimer();
|
|
31
|
+
[PreserveSig] int PauseTimer();
|
|
32
|
+
[PreserveSig] int ResumeTimer();
|
|
33
|
+
}
|
|
34
|
+
[ComImport, Guid("947aab5f-0a5c-4c13-b4d6-4bf7836fc9f8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
35
|
+
public interface IBrFileOperation {
|
|
36
|
+
uint Advise(IBrSink s); void Unadvise(uint c); void SetOperationFlags(uint f);
|
|
37
|
+
void SetProgressMessage([MarshalAs(UnmanagedType.LPWStr)] string m); void SetProgressDialog([MarshalAs(UnmanagedType.IUnknown)] object d);
|
|
38
|
+
void SetProperties([MarshalAs(UnmanagedType.IUnknown)] object p); void SetOwnerWindow(IntPtr h);
|
|
39
|
+
void ApplyPropertiesToItem(IBrShellItem i); void ApplyPropertiesToItems([MarshalAs(UnmanagedType.IUnknown)] object i);
|
|
40
|
+
void RenameItem(IBrShellItem i, [MarshalAs(UnmanagedType.LPWStr)] string n, IBrSink s); void RenameItems([MarshalAs(UnmanagedType.IUnknown)] object i, [MarshalAs(UnmanagedType.LPWStr)] string n);
|
|
41
|
+
void MoveItem(IBrShellItem i, IBrShellItem d, [MarshalAs(UnmanagedType.LPWStr)] string n, IBrSink s); void MoveItems([MarshalAs(UnmanagedType.IUnknown)] object i, IBrShellItem d);
|
|
42
|
+
void CopyItem(IBrShellItem i, IBrShellItem d, [MarshalAs(UnmanagedType.LPWStr)] string n, IBrSink s); void CopyItems([MarshalAs(UnmanagedType.IUnknown)] object i, IBrShellItem d);
|
|
43
|
+
void DeleteItem(IBrShellItem i, IBrSink s); void DeleteItems([MarshalAs(UnmanagedType.IUnknown)] object i);
|
|
44
|
+
void NewItem(IBrShellItem d, uint a, [MarshalAs(UnmanagedType.LPWStr)] string n, [MarshalAs(UnmanagedType.LPWStr)] string t, IBrSink s);
|
|
45
|
+
void PerformOperations(); [return: MarshalAs(UnmanagedType.Bool)] bool GetAnyOperationsAborted();
|
|
46
|
+
}
|
|
47
|
+
public class BrRecycleSink : IBrSink {
|
|
48
|
+
public bool Seen, ToBin; public int Hr; public string Bin;
|
|
49
|
+
public int PreDeleteItem(uint f, IBrShellItem i) { Seen = true; ToBin = (f & 0x80) != 0; return ToBin ? 0 : unchecked((int)0x80004004); }
|
|
50
|
+
public int PostDeleteItem(uint f, IBrShellItem i, int hr, IBrShellItem c) {
|
|
51
|
+
Hr = hr;
|
|
52
|
+
// c = the item now in the Recycle Bin (null when deleted for good): its file-system path (SIGDN_FILESYSPATH).
|
|
53
|
+
if (c != null) { try { IntPtr n; c.GetDisplayName(0x80058000, out n); Bin = Marshal.PtrToStringUni(n); Marshal.FreeCoTaskMem(n); } catch { Bin = null; } }
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
public int StartOperations() { return 0; } public int FinishOperations(int hr) { return 0; }
|
|
57
|
+
public int PreRenameItem(uint f, IBrShellItem i, string n) { return 0; } public int PostRenameItem(uint f, IBrShellItem i, string n, int hr, IBrShellItem c) { return 0; }
|
|
58
|
+
public int PreMoveItem(uint f, IBrShellItem i, IBrShellItem d, string n) { return 0; } public int PostMoveItem(uint f, IBrShellItem i, IBrShellItem d, string n, int hr, IBrShellItem c) { return 0; }
|
|
59
|
+
public int PreCopyItem(uint f, IBrShellItem i, IBrShellItem d, string n) { return 0; } public int PostCopyItem(uint f, IBrShellItem i, IBrShellItem d, string n, int hr, IBrShellItem c) { return 0; }
|
|
60
|
+
public int PreNewItem(uint f, IBrShellItem d, string n) { return 0; } public int PostNewItem(uint f, IBrShellItem d, string n, string t, uint a, int hr, IBrShellItem c) { return 0; }
|
|
61
|
+
public int UpdateProgress(uint t, uint s) { return 0; } public int ResetTimer() { return 0; } public int PauseTimer() { return 0; } public int ResumeTimer() { return 0; }
|
|
62
|
+
}
|
|
63
|
+
public static class BrRecycle {
|
|
64
|
+
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
|
|
65
|
+
static extern void SHCreateItemFromParsingName(string path, IntPtr pbc, [MarshalAs(UnmanagedType.LPStruct)] Guid riid, [MarshalAs(UnmanagedType.Interface)] out IBrShellItem item);
|
|
66
|
+
// FOF_SILENT | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_NOCONFIRMMKDIR | FOF_ALLOWUNDO | FOFX_RECYCLEONDELETE | FOFX_EARLYFAILURE
|
|
67
|
+
const uint FLAGS = 0x0004 | 0x0010 | 0x0400 | 0x0200 | 0x0040 | 0x00080000 | 0x00100000;
|
|
68
|
+
public static string LastBin;
|
|
69
|
+
public static string One(string path) {
|
|
70
|
+
LastBin = null;
|
|
71
|
+
if (!File.Exists(path) && !Directory.Exists(path)) return "missing";
|
|
72
|
+
try {
|
|
73
|
+
var root = Path.GetPathRoot(path);
|
|
74
|
+
if (string.IsNullOrEmpty(root) || root.StartsWith(@"\\")) return "nobin";
|
|
75
|
+
var dt = new DriveInfo(root).DriveType;
|
|
76
|
+
if (dt != DriveType.Fixed && dt != DriveType.Removable) return "nobin";
|
|
77
|
+
} catch { return "nobin"; }
|
|
78
|
+
var op = (IBrFileOperation)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("3ad05575-8857-4850-9277-11b85bdb8e09")));
|
|
79
|
+
var sink = new BrRecycleSink();
|
|
80
|
+
try {
|
|
81
|
+
op.SetOperationFlags(FLAGS);
|
|
82
|
+
IBrShellItem item; SHCreateItemFromParsingName(path, IntPtr.Zero, typeof(IBrShellItem).GUID, out item);
|
|
83
|
+
op.DeleteItem(item, sink);
|
|
84
|
+
try { op.PerformOperations(); } catch (COMException e) { if (sink.Seen && !sink.ToBin) return "nobin"; return "error:" + e.ErrorCode.ToString("x8"); }
|
|
85
|
+
if (sink.Seen && !sink.ToBin) return "nobin";
|
|
86
|
+
if (sink.Hr < 0) return "error:" + sink.Hr.ToString("x8");
|
|
87
|
+
if (!sink.Seen || File.Exists(path) || Directory.Exists(path)) return op.GetAnyOperationsAborted() ? "nobin" : "error:not-deleted";
|
|
88
|
+
LastBin = sink.Bin;
|
|
89
|
+
return "recycled";
|
|
90
|
+
} catch (Exception e) { return "error:" + e.GetType().Name; }
|
|
91
|
+
finally { Marshal.ReleaseComObject(op); }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
'@
|
|
95
|
+
$list = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
|
96
|
+
# Each item: a path, or {p, skip}: skip = letters h / s / r - a hidden / system / read-only item is left
|
|
97
|
+
# alone ('skipped'), as the command itself would (a wildcard without -Force / /a / /f).
|
|
98
|
+
function Test-Skip([string]$path, [string]$skip) {
|
|
99
|
+
if (-not $skip) { return $false }
|
|
100
|
+
try { $a = [IO.File]::GetAttributes($path) } catch { return $false }
|
|
101
|
+
return (($skip.Contains('h') -and ($a -band [IO.FileAttributes]::Hidden)) -or ($skip.Contains('s') -and ($a -band [IO.FileAttributes]::System)) -or ($skip.Contains('r') -and ($a -band [IO.FileAttributes]::ReadOnly)))
|
|
102
|
+
}
|
|
103
|
+
$out = @(foreach ($x in @($list)) {
|
|
104
|
+
if ($x -is [string]) { $p = $x; $skip = '' } else { $p = [string]$x.p; $skip = [string]$x.skip }
|
|
105
|
+
if (Test-Skip $p $skip) { @{ p = $p; r = 'skipped' } } else {
|
|
106
|
+
$r = [BrRecycle]::One($p)
|
|
107
|
+
if ($r -eq 'recycled' -and [BrRecycle]::LastBin) { @{ p = $p; r = $r; bin = [BrRecycle]::LastBin } } else { @{ p = $p; r = $r } }
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
[Console]::Out.WriteLine((ConvertTo-Json -InputObject @($out) -Compress -Depth 3))
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Extra tools", screen). `screen`: the list of open windows, a
|
|
2
|
+
// screenshot of the whole screen / one monitor / one window, and text recognition (Windows' built-in
|
|
3
|
+
// OCR, the languages installed on the PC) of the screen, a window or an image file. Everything runs in
|
|
4
|
+
// screen.ps1 (user32 + System.Drawing + Windows.Media.Ocr); no extra programs, nothing downloaded.
|
|
5
|
+
// Every screenshot shows a notification on the PC ("the AI took a screenshot") unless the person turned
|
|
6
|
+
// that off (screen.notify). Screenshots can be switched off completely (screen.enabled).
|
|
7
|
+
// A locked screen / sign-in screen / UAC prompt can't be captured: an honest error, never a black picture.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { showNotice } from './notice.mjs';
|
|
12
|
+
import { fail, guard, freeName, needAction, str, int, psJson, ToolError } from './tool-lib.mjs';
|
|
13
|
+
|
|
14
|
+
const PS = fileURLToPath(new URL('./screen.ps1', import.meta.url));
|
|
15
|
+
export const MAX_SIDE = 1600; // long side of the picture sent to the AI
|
|
16
|
+
export const MAX_BYTES = 900 * 1024; // JPEG bytes (base64 ≈ 1.2 MB)
|
|
17
|
+
const MAX_B64 = Math.ceil(MAX_BYTES / 3) * 4 + 4;
|
|
18
|
+
const MAX_TEXT = 60000;
|
|
19
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tif', '.tiff']);
|
|
20
|
+
const ACTIONS = ['windows', 'capture', 'ocr'];
|
|
21
|
+
|
|
22
|
+
export const defs = [{
|
|
23
|
+
name: 'screen',
|
|
24
|
+
title: 'Screen and text recognition',
|
|
25
|
+
description: 'Screenshots and text recognition on this PC. action "windows": open windows (id, title, program). '
|
|
26
|
+
+ '"capture": a picture of the whole screen, one monitor (monitor) or one window (window = part of its title, or window_id); save_to also keeps a full-size copy. '
|
|
27
|
+
+ '"ocr": the text on the screen, a window or an image file (path), recognized by Windows (languages installed on the PC; language like "en-US"). '
|
|
28
|
+
+ 'The person sees a notification on the PC for each screenshot. A locked screen can\'t be captured.',
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: 'object', additionalProperties: false, required: ['action'],
|
|
31
|
+
properties: {
|
|
32
|
+
action: { type: 'string', enum: ACTIONS },
|
|
33
|
+
window: { type: 'string', maxLength: 200, description: 'Part of the window title (any case)' },
|
|
34
|
+
window_id: { type: 'integer', minimum: 1, description: 'Window id from action "windows"' },
|
|
35
|
+
monitor: { type: 'integer', minimum: 1, maximum: 16, description: 'Monitor number; without it: all monitors' },
|
|
36
|
+
language: { type: 'string', maxLength: 20, description: 'OCR language tag, e.g. "en-US", "ru"' },
|
|
37
|
+
path: { type: 'string', maxLength: 1000, description: 'Image file for ocr instead of the screen' },
|
|
38
|
+
save_to: { type: 'string', maxLength: 1000, description: 'Full path of a .png or .jpg for a full-size copy (capture)' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
42
|
+
}];
|
|
43
|
+
|
|
44
|
+
export const settings = [
|
|
45
|
+
{ key: 'screen.enabled', default: true, widensWhen: true, label: { en: 'Screenshots and screen text recognition', ru: 'Снимки экрана и распознавание текста' }, hint: { en: 'The AI can take a picture of the screen or a window.', ru: 'ИИ может сделать снимок экрана или окна.' } },
|
|
46
|
+
{ key: 'screen.notify', default: true, widensWhen: false, label: { en: 'Notify me about every screenshot', ru: 'Уведомлять о каждом снимке экрана' }, hint: { en: 'A notification "the AI took a screenshot" appears on the PC.', ru: 'На ПК появляется уведомление «ИИ сделал снимок экрана».' } },
|
|
47
|
+
];
|
|
48
|
+
const DEFAULT = Object.fromEntries(settings.map((s) => [s.key, s.default]));
|
|
49
|
+
function setting(ctx, key) {
|
|
50
|
+
if (ctx.extra?.value) return ctx.extra.value(ctx.cfg, key);
|
|
51
|
+
const [g, k] = key.split('.');
|
|
52
|
+
const v = ctx.cfg?.[g]?.[k];
|
|
53
|
+
return typeof v === 'boolean' ? v : DEFAULT[key];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const clean = (s, n = 200) => String(s ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').trim().slice(0, n);
|
|
57
|
+
const run = (ctx, env, timeout = 60000) => psJson(ctx, { files: [PS], env, timeout });
|
|
58
|
+
const check = (r) => { if (!r || r.ok !== true) throw new ToolError(r?.code || 'PS_FAILED', MESSAGES[r?.code] || clean(r?.message, 300) || 'Windows could not do it.', HINTS[r?.code] || ''); return r; };
|
|
59
|
+
const MESSAGES = {
|
|
60
|
+
SCREEN_LOCKED: 'The screen is locked (or the sign-in screen / a Windows security prompt is open), so no screenshot can be taken until the person unlocks the PC.',
|
|
61
|
+
};
|
|
62
|
+
const HINTS = {
|
|
63
|
+
SCREEN_LOCKED: 'Ask the person to unlock the PC and try again.',
|
|
64
|
+
NO_WINDOW: 'Call screen with action "windows" to see the open windows.',
|
|
65
|
+
NO_MONITOR: 'Leave monitor out to capture all monitors.',
|
|
66
|
+
NO_OCR_LANG: 'The person can add a language with text recognition in Windows Settings → Time & language → Language & region (Add a language).',
|
|
67
|
+
BAD_LANG: 'Use one of the installed languages, or leave language out.',
|
|
68
|
+
BAD_IMAGE: 'Use a PNG, JPEG, BMP, GIF or TIFF file.',
|
|
69
|
+
IMAGE_TOO_LARGE: 'Use a smaller image or capture one window instead of all monitors.',
|
|
70
|
+
EXISTS: 'Call again: a new free name is chosen.',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Part of a title → one window: exact title first, then the topmost window whose title contains it. */
|
|
74
|
+
export function pickWindow(list, query) {
|
|
75
|
+
const q = clean(query).toLowerCase();
|
|
76
|
+
if (!q) fail('BAD_ARGS', 'window must name a part of a window title.');
|
|
77
|
+
const hits = list.filter((w) => String(w.title).toLowerCase().includes(q));
|
|
78
|
+
if (!hits.length) {
|
|
79
|
+
const titles = list.slice(0, 15).map((w) => `"${clean(w.title, 80)}"`).join(', ');
|
|
80
|
+
fail('NO_WINDOW', `No open window has "${clean(query, 80)}" in its title.${titles ? ` Open windows: ${titles}.` : ''}`, HINTS.NO_WINDOW);
|
|
81
|
+
}
|
|
82
|
+
const exact = hits.find((w) => String(w.title).toLowerCase() === q);
|
|
83
|
+
return { win: exact || hits[0], others: hits.length - 1 };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const kb = (n) => `${Math.max(1, Math.round(n / 1024))} KB`;
|
|
87
|
+
|
|
88
|
+
// The capture runs in a second PowerShell: it checks the id still belongs to the same program.
|
|
89
|
+
const winEnv = (win) => ({ BRX_HWND: String(win.id), ...(Number.isInteger(win.pid) && win.pid > 0 ? { BRX_PID: String(win.pid) } : {}) });
|
|
90
|
+
async function target(ctx, args) {
|
|
91
|
+
const windowQuery = str(args, 'window', { max: 200 });
|
|
92
|
+
const windowId = args.window_id === undefined ? undefined : int(args, 'window_id', { min: 1, max: Number.MAX_SAFE_INTEGER });
|
|
93
|
+
const monitor = int(args, 'monitor', { min: 1, max: 16, def: 0 });
|
|
94
|
+
if ((windowQuery || windowId) && monitor) fail('BAD_ARGS', 'Use either window / window_id or monitor, not both.');
|
|
95
|
+
if (windowId || windowQuery) {
|
|
96
|
+
const r = check(await run(ctx, { BRX_ACTION: 'windows' }));
|
|
97
|
+
const list = Array.isArray(r.windows) ? r.windows : [];
|
|
98
|
+
if (windowId) {
|
|
99
|
+
const win = list.find((w) => w.id === windowId);
|
|
100
|
+
if (!win) fail('NO_WINDOW', 'That window is not open any more.', HINTS.NO_WINDOW);
|
|
101
|
+
return { env: winEnv(win), what: 'window', win, others: 0 };
|
|
102
|
+
}
|
|
103
|
+
const { win, others } = pickWindow(list, windowQuery);
|
|
104
|
+
return { env: winEnv(win), what: 'window', win, others };
|
|
105
|
+
}
|
|
106
|
+
return { env: monitor ? { BRX_MONITOR: String(monitor) } : {}, what: monitor ? 'monitor' : 'screen', monitor };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function saveTarget(ctx, p) {
|
|
110
|
+
if (p === undefined) return undefined;
|
|
111
|
+
const abs = guard(ctx, p, { write: true });
|
|
112
|
+
if (!['.png', '.jpg', '.jpeg'].includes(path.extname(abs).toLowerCase())) fail('BAD_PATH', 'save_to must end with .png or .jpg.');
|
|
113
|
+
if (!fs.existsSync(path.dirname(abs))) fail('BAD_PATH', `The folder "${path.dirname(abs)}" does not exist.`, 'Create the folder first or choose another one.');
|
|
114
|
+
return freeName(abs);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function indicate(ctx, t, ocr) {
|
|
118
|
+
if (!setting(ctx, 'screen.notify')) return;
|
|
119
|
+
try {
|
|
120
|
+
(ctx.notice ?? showNotice)({ kind: 'screenshot', what: t.what, monitor: t.monitor, title: t.win?.title, ocr }, ctx.log || (() => {}));
|
|
121
|
+
} catch (e) { ctx.log?.(`[bridgerator] screenshot notice failed: ${e?.message || e}`); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const describeTarget = (t) => (t.what === 'window' ? `window "${clean(t.win.title, 120)}"` : t.what === 'monitor' ? `monitor ${t.monitor}` : 'the whole screen');
|
|
125
|
+
|
|
126
|
+
function linesText(r) {
|
|
127
|
+
let s = (Array.isArray(r.lines) ? r.lines : []).map((l) => clean(l, 2000)).join('\n');
|
|
128
|
+
if (s.length > MAX_TEXT) s = `${s.slice(0, MAX_TEXT)}\n[… cut at ${MAX_TEXT} characters]`;
|
|
129
|
+
return s;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function handle(name, args, ctx) {
|
|
133
|
+
const action = needAction(args, ACTIONS);
|
|
134
|
+
const lang = str(args, 'language', { max: 20 });
|
|
135
|
+
if (lang && !/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(lang)) fail('BAD_ARGS', 'language must be a tag like "en-US" or "ru".');
|
|
136
|
+
|
|
137
|
+
// OCR of an image file is not a screenshot: no screen setting, no notice — the folder rules apply.
|
|
138
|
+
if (action === 'ocr' && args.path !== undefined) {
|
|
139
|
+
const file = guard(ctx, str(args, 'path', { required: true, max: 1000 }));
|
|
140
|
+
if (!IMAGE_EXT.has(path.extname(file).toLowerCase())) fail('BAD_IMAGE', 'This is not an image file.', HINTS.BAD_IMAGE);
|
|
141
|
+
const st = fs.statSync(file);
|
|
142
|
+
if (!st.isFile()) fail('BAD_PATH', 'path must be an image file, not a folder.');
|
|
143
|
+
if (st.size > 50 * 1024 * 1024) fail('IMAGE_TOO_LARGE', 'The image is larger than 50 MB.', HINTS.IMAGE_TOO_LARGE);
|
|
144
|
+
const r = check(await run(ctx, { BRX_ACTION: 'ocr', BRX_FILE: file, ...(lang ? { BRX_LANG: lang } : {}) }, 120000));
|
|
145
|
+
const body = linesText(r);
|
|
146
|
+
return { content: [{ type: 'text', text: `Text recognized in ${path.basename(file)} (${r.width}×${r.height}, language ${clean(r.language, 20)}):\n${body || '[no text found]'}` }] };
|
|
147
|
+
}
|
|
148
|
+
if (args.path !== undefined) fail('BAD_ARGS', 'path is only for action "ocr".');
|
|
149
|
+
|
|
150
|
+
if (!setting(ctx, 'screen.enabled')) {
|
|
151
|
+
fail('SCREEN_OFF', 'Screenshots are turned off on this PC.', 'The person can turn them on in the Bridgerator window (Settings), or you can ask with bridgerator_settings (screen.enabled); the person confirms it on the PC.');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (action === 'windows') {
|
|
155
|
+
const r = check(await run(ctx, { BRX_ACTION: 'windows' }));
|
|
156
|
+
const windows = (Array.isArray(r.windows) ? r.windows : []).slice(0, 100).map((w) => ({ id: w.id, title: clean(w.title), program: clean(w.process, 60), ...(w.minimized ? { minimized: true } : {}) }));
|
|
157
|
+
return { content: [{ type: 'text', text: JSON.stringify({ windows }, null, 2) }] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (action === 'ocr' && args.save_to !== undefined) fail('BAD_ARGS', 'save_to is only for action "capture".');
|
|
161
|
+
const t = await target(ctx, args);
|
|
162
|
+
const save = action === 'capture' ? saveTarget(ctx, args.save_to) : undefined;
|
|
163
|
+
const env = { BRX_ACTION: action, ...t.env, ...(lang ? { BRX_LANG: lang } : {}), ...(save ? { BRX_SAVE: save } : {}),
|
|
164
|
+
...(action === 'capture' ? { BRX_IMAGE: '1', BRX_MAXSIDE: String(MAX_SIDE), BRX_MAXBYTES: String(MAX_BYTES) } : {}) };
|
|
165
|
+
// The person is told about every picture taken (after the capture, so the notice is not in it) — also
|
|
166
|
+
// when a later step failed (taken) or the run did not answer (it may have taken one).
|
|
167
|
+
let raw;
|
|
168
|
+
try { raw = await run(ctx, env, action === 'ocr' ? 120000 : 60000); } catch (e) { indicate(ctx, t, action === 'ocr'); throw e; }
|
|
169
|
+
if (raw?.ok === true || raw?.taken === true) indicate(ctx, t, action === 'ocr');
|
|
170
|
+
const r = check(raw);
|
|
171
|
+
const notes = [
|
|
172
|
+
...(t.others ? [`${t.others} other window(s) also match "${clean(args.window, 80)}"; use window_id to pick another one.`] : []),
|
|
173
|
+
...(r.minimized ? ['The window is minimized: Windows may give an empty or old picture of it.'] : []),
|
|
174
|
+
];
|
|
175
|
+
if (action === 'ocr') {
|
|
176
|
+
const body = linesText(r);
|
|
177
|
+
return { content: [{ type: 'text', text: `Text on ${describeTarget(t)} (${r.width}×${r.height}, language ${clean(r.language, 20)}):\n${body || '[no text found]'}${notes.length ? `\n\n${notes.join(' ')}` : ''}` }] };
|
|
178
|
+
}
|
|
179
|
+
if (typeof r.data !== 'string' || !r.data || r.data.length > MAX_B64 || !/^[A-Za-z0-9+/]+=*$/.test(r.data.slice(-64))) {
|
|
180
|
+
fail('IMAGE_TOO_LARGE', 'The screenshot could not be made small enough to send.', HINTS.IMAGE_TOO_LARGE);
|
|
181
|
+
}
|
|
182
|
+
const head = `Screenshot of ${describeTarget(t)}: ${r.width}×${r.height}, sent as ${r.outWidth}×${r.outHeight} JPEG (${kb(r.bytes || r.data.length * 0.75)}).`;
|
|
183
|
+
const saved = r.saved ? ` Full-size copy saved: ${r.saved}` : '';
|
|
184
|
+
return { content: [{ type: 'text', text: `${head}${saved}${notes.length ? ` ${notes.join(' ')}` : ''}` }, { type: 'image', data: r.data, mimeType: 'image/jpeg' }] };
|
|
185
|
+
}
|