novac 2.2.0 → 2.2.2
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/LICENSE +0 -0
- package/README.md +0 -0
- package/bin/novac +6 -3
- package/bin/nvc +0 -0
- package/bin/nvml +0 -0
- package/demo.nv +0 -0
- package/demo_builtins.nv +0 -0
- package/demo_http.nv +0 -0
- package/examples/bf.nv +5 -13
- package/examples/math.nv +2 -2
- package/kits/kitffmpeg/kitdef.js +1174 -0
- package/kits/libos/kitdef.js +3135 -0
- package/kits/libtasker/kitdef.js +125 -0
- package/package.json +1 -1
- package/scripts/update-bin.js +0 -0
- package/src/core/executor.js +7 -4
- package/src/core/lexer.js +2 -2
- package/src/index.js +0 -0
- package/novac/LICENSE +0 -21
- package/novac/README.md +0 -1823
- package/novac/bin/novac +0 -950
- package/novac/bin/nvc +0 -522
- package/novac/bin/nvml +0 -542
- package/novac/demo.nv +0 -245
- package/novac/demo_builtins.nv +0 -209
- package/novac/demo_http.nv +0 -62
- package/novac/examples/bf.nv +0 -69
- package/novac/examples/math.nv +0 -21
- package/novac/kits/kitai/kitdef.js +0 -2185
- package/novac/kits/kitansi/kitdef.js +0 -1402
- package/novac/kits/kitformat/kitdef.js +0 -1485
- package/novac/kits/kitgps/kitdef.js +0 -1862
- package/novac/kits/kitlibfs/kitdef.js +0 -231
- package/novac/kits/kitlibproc/kitdef.js +0 -78
- package/novac/kits/kitmatrix/ex.js +0 -19
- package/novac/kits/kitmatrix/kitdef.js +0 -960
- package/novac/kits/kitmpatch/kitdef.js +0 -906
- package/novac/kits/kitnovacweb/README.md +0 -1572
- package/novac/kits/kitnovacweb/demo.nv +0 -12
- package/novac/kits/kitnovacweb/demo.nvml +0 -71
- package/novac/kits/kitnovacweb/index.nova +0 -12
- package/novac/kits/kitnovacweb/kitdef.js +0 -692
- package/novac/kits/kitnovacweb/nova.kit.json +0 -8
- package/novac/kits/kitnovacweb/nvml/executor.js +0 -739
- package/novac/kits/kitnovacweb/nvml/index.js +0 -67
- package/novac/kits/kitnovacweb/nvml/lexer.js +0 -263
- package/novac/kits/kitnovacweb/nvml/parser.js +0 -508
- package/novac/kits/kitnovacweb/nvml/renderer.js +0 -924
- package/novac/kits/kitparse/kitdef.js +0 -1688
- package/novac/kits/kitregex++/kitdef.js +0 -1353
- package/novac/kits/kitrequire/kitdef.js +0 -1599
- package/novac/kits/kitx11/kitdef.js +0 -1
- package/novac/kits/kitx11/kitx11.js +0 -2472
- package/novac/kits/kitx11/kitx11_conn.js +0 -948
- package/novac/kits/kitx11/kitx11_worker.js +0 -121
- package/novac/kits/libtea/tf.js +0 -2691
- package/novac/kits/libterm/ex.js +0 -285
- package/novac/kits/libterm/kitdef.js +0 -1927
- package/novac/node_modules/chalk/license +0 -9
- package/novac/node_modules/chalk/package.json +0 -83
- package/novac/node_modules/chalk/readme.md +0 -297
- package/novac/node_modules/chalk/source/index.d.ts +0 -325
- package/novac/node_modules/chalk/source/index.js +0 -225
- package/novac/node_modules/chalk/source/utilities.js +0 -33
- package/novac/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +0 -236
- package/novac/node_modules/chalk/source/vendor/ansi-styles/index.js +0 -223
- package/novac/node_modules/chalk/source/vendor/supports-color/browser.d.ts +0 -1
- package/novac/node_modules/chalk/source/vendor/supports-color/browser.js +0 -34
- package/novac/node_modules/chalk/source/vendor/supports-color/index.d.ts +0 -55
- package/novac/node_modules/chalk/source/vendor/supports-color/index.js +0 -190
- package/novac/node_modules/commander/LICENSE +0 -22
- package/novac/node_modules/commander/Readme.md +0 -1176
- package/novac/node_modules/commander/esm.mjs +0 -16
- package/novac/node_modules/commander/index.js +0 -24
- package/novac/node_modules/commander/lib/argument.js +0 -150
- package/novac/node_modules/commander/lib/command.js +0 -2777
- package/novac/node_modules/commander/lib/error.js +0 -39
- package/novac/node_modules/commander/lib/help.js +0 -747
- package/novac/node_modules/commander/lib/option.js +0 -380
- package/novac/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/novac/node_modules/commander/package-support.json +0 -19
- package/novac/node_modules/commander/package.json +0 -82
- package/novac/node_modules/commander/typings/esm.d.mts +0 -3
- package/novac/node_modules/commander/typings/index.d.ts +0 -1113
- package/novac/node_modules/node-addon-api/LICENSE.md +0 -9
- package/novac/node_modules/node-addon-api/README.md +0 -95
- package/novac/node_modules/node-addon-api/common.gypi +0 -21
- package/novac/node_modules/node-addon-api/except.gypi +0 -25
- package/novac/node_modules/node-addon-api/index.js +0 -14
- package/novac/node_modules/node-addon-api/napi-inl.deprecated.h +0 -186
- package/novac/node_modules/node-addon-api/napi-inl.h +0 -7165
- package/novac/node_modules/node-addon-api/napi.h +0 -3364
- package/novac/node_modules/node-addon-api/node_addon_api.gyp +0 -42
- package/novac/node_modules/node-addon-api/node_api.gyp +0 -9
- package/novac/node_modules/node-addon-api/noexcept.gypi +0 -26
- package/novac/node_modules/node-addon-api/nothing.c +0 -0
- package/novac/node_modules/node-addon-api/package-support.json +0 -21
- package/novac/node_modules/node-addon-api/package.json +0 -480
- package/novac/node_modules/node-addon-api/tools/README.md +0 -73
- package/novac/node_modules/node-addon-api/tools/check-napi.js +0 -99
- package/novac/node_modules/node-addon-api/tools/clang-format.js +0 -71
- package/novac/node_modules/node-addon-api/tools/conversion.js +0 -301
- package/novac/node_modules/serialize-javascript/LICENSE +0 -27
- package/novac/node_modules/serialize-javascript/README.md +0 -149
- package/novac/node_modules/serialize-javascript/index.js +0 -297
- package/novac/node_modules/serialize-javascript/package.json +0 -33
- package/novac/package.json +0 -27
- package/novac/scripts/update-bin.js +0 -24
- package/novac/src/core/bstd.js +0 -1035
- package/novac/src/core/config.js +0 -155
- package/novac/src/core/describe.js +0 -187
- package/novac/src/core/emitter.js +0 -499
- package/novac/src/core/error.js +0 -86
- package/novac/src/core/executor.js +0 -5606
- package/novac/src/core/formatter.js +0 -686
- package/novac/src/core/lexer.js +0 -1026
- package/novac/src/core/nova_builtins.js +0 -717
- package/novac/src/core/nova_thread_worker.js +0 -166
- package/novac/src/core/parser.js +0 -2181
- package/novac/src/core/types.js +0 -112
- package/novac/src/index.js +0 -28
- package/novac/src/runtime/stdlib.js +0 -244
|
@@ -0,0 +1,3135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ============================================================
|
|
4
|
+
// kitos.js — Full OS Integration Kit for Nova
|
|
5
|
+
// 20 categories of native OS interaction, all cross-platform.
|
|
6
|
+
// Platforms: Linux, macOS, Windows, Android (Termux)
|
|
7
|
+
//
|
|
8
|
+
// import "kitos"
|
|
9
|
+
//
|
|
10
|
+
// Categories exported as namespaced objects:
|
|
11
|
+
// Notify, TTS, Dialog, Menu, Keyboard, Mouse, Screen,
|
|
12
|
+
// Audio, Camera, Power, Brightness, Wifi, Bluetooth,
|
|
13
|
+
// Volume, Trash, Open, Autostart, A11y, IdleTime,
|
|
14
|
+
// Sensors, Location, Vibrate
|
|
15
|
+
//
|
|
16
|
+
// module.exports = { kitdef: { Notify, TTS, Dialog, ... } }
|
|
17
|
+
// ============================================================
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { execSync, spawnSync, exec, spawn } = require('child_process');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
// ── Platform detection ────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const PLATFORM = (() => {
|
|
29
|
+
const p = os.platform();
|
|
30
|
+
if (p === 'darwin') return 'macos';
|
|
31
|
+
if (p === 'win32') return 'windows';
|
|
32
|
+
if (p === 'linux') {
|
|
33
|
+
// Termux detection: $PREFIX points into /data/data/com.termux
|
|
34
|
+
if (process.env.PREFIX && process.env.PREFIX.includes('com.termux')) return 'android';
|
|
35
|
+
return 'linux';
|
|
36
|
+
}
|
|
37
|
+
return p;
|
|
38
|
+
})();
|
|
39
|
+
|
|
40
|
+
const IS_MACOS = PLATFORM === 'macos';
|
|
41
|
+
const IS_WINDOWS = PLATFORM === 'windows';
|
|
42
|
+
const IS_LINUX = PLATFORM === 'linux';
|
|
43
|
+
const IS_ANDROID = PLATFORM === 'android';
|
|
44
|
+
|
|
45
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run a command synchronously, return { code, stdout, stderr }.
|
|
49
|
+
* Never throws.
|
|
50
|
+
*/
|
|
51
|
+
function _run(cmd, opts = {}) {
|
|
52
|
+
try {
|
|
53
|
+
const stdout = execSync(cmd, {
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
timeout: opts.timeout ?? 15_000,
|
|
56
|
+
env: { ...process.env, ...opts.env },
|
|
57
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
58
|
+
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
59
|
+
}).trimEnd();
|
|
60
|
+
return { code: 0, stdout, stderr: '' };
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return {
|
|
63
|
+
code: e.status ?? 1,
|
|
64
|
+
stdout: (e.stdout ?? '').trimEnd(),
|
|
65
|
+
stderr: (e.stderr ?? '').trimEnd(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Run and return stdout string only ('' on failure). */
|
|
71
|
+
function _out(cmd, opts = {}) { return _run(cmd, opts).stdout; }
|
|
72
|
+
|
|
73
|
+
/** Spawn and return { code, stdout, stderr }. Never throws. */
|
|
74
|
+
function _spawn(bin, args = [], opts = {}) {
|
|
75
|
+
const r = spawnSync(bin, args, {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
timeout: opts.timeout ?? 15_000,
|
|
78
|
+
env: { ...process.env, ...opts.env },
|
|
79
|
+
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
code: r.status ?? 1,
|
|
83
|
+
stdout: (r.stdout ?? '').trimEnd(),
|
|
84
|
+
stderr: (r.stderr ?? '').trimEnd(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Escape a string for use inside an AppleScript string literal. */
|
|
89
|
+
function _escAS(s) { return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }
|
|
90
|
+
|
|
91
|
+
/** Escape a string for safe insertion into a shell command. */
|
|
92
|
+
function _esc(s) { return `'${String(s).replace(/'/g, "'\\''")}'`; }
|
|
93
|
+
|
|
94
|
+
/** Run an AppleScript string. Returns stdout. */
|
|
95
|
+
function _osascript(script) {
|
|
96
|
+
return _spawn('osascript', ['-e', script]).stdout;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Run a PowerShell command string. Returns stdout. */
|
|
100
|
+
function _pwsh(code) {
|
|
101
|
+
return _spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', code]).stdout;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Detect which Linux dialog tool is available. */
|
|
105
|
+
function _linuxDialog() {
|
|
106
|
+
for (const t of ['zenity', 'yad', 'kdialog', 'qarma']) {
|
|
107
|
+
if (_run(`which ${t}`).code === 0) return t;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Which Linux notification tool is available. */
|
|
113
|
+
function _linuxNotify() {
|
|
114
|
+
if (_run('which notify-send').code === 0) return 'notify-send';
|
|
115
|
+
if (_run('which dunstify').code === 0) return 'dunstify';
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
120
|
+
// 1. NOTIFY — Desktop Notifications
|
|
121
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
122
|
+
|
|
123
|
+
const Notify = {
|
|
124
|
+
/**
|
|
125
|
+
* Send a desktop notification.
|
|
126
|
+
* @param {string} title
|
|
127
|
+
* @param {string} [body='']
|
|
128
|
+
* @param {object} [opts]
|
|
129
|
+
* @param {'low'|'normal'|'critical'} [opts.urgency='normal'] Linux only
|
|
130
|
+
* @param {number} [opts.timeout] ms (Linux), ignored elsewhere
|
|
131
|
+
* @param {string} [opts.icon] icon name or path (Linux)
|
|
132
|
+
* @param {string} [opts.sound] sound name (macOS)
|
|
133
|
+
* @param {string} [opts.subtitle] (macOS)
|
|
134
|
+
* @param {string} [opts.id] notification id (Linux)
|
|
135
|
+
*/
|
|
136
|
+
send(title, body = '', opts = {}) {
|
|
137
|
+
title = String(title); body = String(body);
|
|
138
|
+
|
|
139
|
+
if (IS_MACOS) {
|
|
140
|
+
const t = _escAS(title);
|
|
141
|
+
const b = _escAS(body);
|
|
142
|
+
const sub = opts.subtitle ? `with subtitle "${_escAS(opts.subtitle)}"` : '';
|
|
143
|
+
const snd = opts.sound ? `sound name "${_escAS(opts.sound)}"` : '';
|
|
144
|
+
_osascript(
|
|
145
|
+
`display notification "${b}" with title "${t}" ${sub} ${snd}`
|
|
146
|
+
);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (IS_WINDOWS) {
|
|
151
|
+
// PowerShell toast via BurntToast-free method (WinRT)
|
|
152
|
+
const t = title.replace(/'/g, "''"), b2 = body.replace(/'/g, "''");
|
|
153
|
+
_pwsh(`
|
|
154
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
155
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
|
156
|
+
$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
157
|
+
$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${t}')) | Out-Null
|
|
158
|
+
$xml.GetElementsByTagName('text')[1].AppendChild($xml.CreateTextNode('${b2}')) | Out-Null
|
|
159
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
|
160
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Nova').Show($toast)
|
|
161
|
+
`);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (IS_ANDROID) {
|
|
166
|
+
const args = ['termux-notification', '--title', title, '--content', body];
|
|
167
|
+
if (opts.id) args.push('--id', String(opts.id));
|
|
168
|
+
if (opts.icon) args.push('--icon', opts.icon);
|
|
169
|
+
_spawn(args[0], args.slice(1));
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Linux
|
|
174
|
+
const tool = _linuxNotify();
|
|
175
|
+
if (!tool) return false;
|
|
176
|
+
const args = [title, body];
|
|
177
|
+
if (opts.urgency) args.unshift('--urgency=' + opts.urgency);
|
|
178
|
+
if (opts.timeout) args.unshift('--expire-time=' + opts.timeout);
|
|
179
|
+
if (opts.icon) args.unshift('--icon=' + opts.icon);
|
|
180
|
+
if (opts.id) args.unshift('--replace-id=' + opts.id);
|
|
181
|
+
_spawn(tool, args);
|
|
182
|
+
return true;
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
/** Remove a notification by id (Linux / Android only). */
|
|
186
|
+
remove(id) {
|
|
187
|
+
if (IS_ANDROID) { _spawn('termux-notification-remove', [String(id)]); return; }
|
|
188
|
+
if (IS_LINUX) { _run(`notify-send --close=${id}`); }
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
/** List active notifications (Android only). */
|
|
192
|
+
list() {
|
|
193
|
+
if (IS_ANDROID) return JSON.parse(_out('termux-notification-list') || '[]');
|
|
194
|
+
return [];
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
platform: PLATFORM,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
201
|
+
// 2. TTS — Text-to-Speech
|
|
202
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
203
|
+
|
|
204
|
+
const TTS = {
|
|
205
|
+
/**
|
|
206
|
+
* Speak text aloud (synchronous by default).
|
|
207
|
+
* @param {string} text
|
|
208
|
+
* @param {object} [opts]
|
|
209
|
+
* @param {string} [opts.voice] voice name
|
|
210
|
+
* @param {number} [opts.rate] words-per-minute or rate (platform-specific)
|
|
211
|
+
* @param {number} [opts.pitch] pitch adjustment (Linux espeak)
|
|
212
|
+
* @param {boolean} [opts.async] if true, don't wait
|
|
213
|
+
*/
|
|
214
|
+
speak(text, opts = {}) {
|
|
215
|
+
text = String(text);
|
|
216
|
+
|
|
217
|
+
if (IS_MACOS) {
|
|
218
|
+
const args = [text];
|
|
219
|
+
if (opts.voice) args.push('-v', opts.voice);
|
|
220
|
+
if (opts.rate) args.push('-r', String(opts.rate));
|
|
221
|
+
opts.async ? spawn('say', args, { detached: true, stdio: 'ignore' }).unref()
|
|
222
|
+
: _spawn('say', args);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (IS_WINDOWS) {
|
|
227
|
+
const rate = opts.rate ? `$s.Rate=${opts.rate};` : '';
|
|
228
|
+
const voice = opts.voice ? `$s.SelectVoice('${opts.voice.replace(/'/g, "''")}');` : '';
|
|
229
|
+
const cmd = `Add-Type -AssemblyName System.speech; $s=New-Object System.Speech.Synthesis.SpeechSynthesizer; ${voice}${rate}$s.Speak('${text.replace(/'/g, "''")}')`;
|
|
230
|
+
opts.async ? spawn('powershell', ['-NoProfile', '-Command', cmd], { detached: true, stdio: 'ignore' }).unref()
|
|
231
|
+
: _pwsh(cmd);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (IS_ANDROID) {
|
|
236
|
+
const args = ['termux-tts-speak'];
|
|
237
|
+
if (opts.rate) args.push('-r', String(opts.rate));
|
|
238
|
+
if (opts.pitch) args.push('-p', String(opts.pitch));
|
|
239
|
+
if (opts.voice) args.push('-l', opts.voice);
|
|
240
|
+
args.push(text);
|
|
241
|
+
opts.async ? spawn(args[0], args.slice(1), { detached: true, stdio: 'ignore' }).unref()
|
|
242
|
+
: _spawn(args[0], args.slice(1));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Linux — try espeak, espeak-ng, festival, spd-say
|
|
247
|
+
if (_run('which espeak-ng').code === 0) {
|
|
248
|
+
const args = [text];
|
|
249
|
+
if (opts.voice) args.push('-v', opts.voice);
|
|
250
|
+
if (opts.rate) args.push('-s', String(opts.rate));
|
|
251
|
+
if (opts.pitch) args.push('-p', String(opts.pitch));
|
|
252
|
+
opts.async ? spawn('espeak-ng', args, { detached: true, stdio: 'ignore' }).unref()
|
|
253
|
+
: _spawn('espeak-ng', args);
|
|
254
|
+
} else if (_run('which espeak').code === 0) {
|
|
255
|
+
const args = [text];
|
|
256
|
+
if (opts.voice) args.push('-v', opts.voice);
|
|
257
|
+
if (opts.rate) args.push('-s', String(opts.rate));
|
|
258
|
+
opts.async ? spawn('espeak', args, { detached: true, stdio: 'ignore' }).unref()
|
|
259
|
+
: _spawn('espeak', args);
|
|
260
|
+
} else if (_run('which spd-say').code === 0) {
|
|
261
|
+
_spawn('spd-say', [text]);
|
|
262
|
+
} else if (_run('which festival').code === 0) {
|
|
263
|
+
const r = spawnSync('festival', ['--pipe'], { input: `(SayText "${text.replace(/"/g, '\\"')}")`, encoding: 'utf8' });
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
/** List available voices. */
|
|
268
|
+
voices() {
|
|
269
|
+
if (IS_MACOS) {
|
|
270
|
+
return _out('say -v ?').split('\n')
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
.map(l => { const m = l.match(/^(\S+)\s+(\S+)/); return m ? { name: m[1], lang: m[2] } : null; })
|
|
273
|
+
.filter(Boolean);
|
|
274
|
+
}
|
|
275
|
+
if (IS_WINDOWS) {
|
|
276
|
+
return _pwsh('Add-Type -AssemblyName System.speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }')
|
|
277
|
+
.split('\n').map(s => s.trim()).filter(Boolean).map(n => ({ name: n }));
|
|
278
|
+
}
|
|
279
|
+
if (IS_ANDROID) {
|
|
280
|
+
return []; // Termux TTS uses Android TTS engine
|
|
281
|
+
}
|
|
282
|
+
// espeak-ng
|
|
283
|
+
return _out('espeak-ng --voices').split('\n').slice(1)
|
|
284
|
+
.filter(Boolean)
|
|
285
|
+
.map(l => { const p = l.trim().split(/\s+/); return { name: p[4], lang: p[1] }; });
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
/** Stop any ongoing speech (best-effort). */
|
|
289
|
+
stop() {
|
|
290
|
+
if (IS_MACOS) _run('killall say 2>/dev/null');
|
|
291
|
+
if (IS_WINDOWS) _pwsh('Add-Type -AssemblyName System.speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).SpeakAsyncCancelAll()');
|
|
292
|
+
if (IS_ANDROID) _spawn('termux-tts-stop');
|
|
293
|
+
if (IS_LINUX) _run('killall espeak espeak-ng spd-say 2>/dev/null');
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
298
|
+
// 3. DIALOG — Native OS Dialogs
|
|
299
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
300
|
+
|
|
301
|
+
const Dialog = {
|
|
302
|
+
/**
|
|
303
|
+
* Show an alert dialog.
|
|
304
|
+
* @param {string} message
|
|
305
|
+
* @param {string} [title='Alert']
|
|
306
|
+
*/
|
|
307
|
+
alert(message, title = 'Alert') {
|
|
308
|
+
message = String(message); title = String(title);
|
|
309
|
+
if (IS_MACOS) {
|
|
310
|
+
_osascript(`display alert "${_escAS(title)}" message "${_escAS(message)}"`);
|
|
311
|
+
} else if (IS_WINDOWS) {
|
|
312
|
+
_pwsh(`[System.Windows.Forms.MessageBox]::Show('${message.replace(/'/g, "''")}', '${title.replace(/'/g, "''")}') | Out-Null`);
|
|
313
|
+
} else if (IS_ANDROID) {
|
|
314
|
+
_spawn('termux-dialog', ['confirm', '-t', title, '-i', message]);
|
|
315
|
+
} else {
|
|
316
|
+
const t = _linuxDialog();
|
|
317
|
+
if (t === 'zenity') _spawn('zenity', ['--info', `--title=${title}`, `--text=${message}`]);
|
|
318
|
+
else if (t === 'kdialog') _spawn('kdialog', ['--msgbox', message, '--title', title]);
|
|
319
|
+
else if (t === 'yad') _spawn('yad', ['--info', `--title=${title}`, `--text=${message}`]);
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Show a confirm (yes/no) dialog.
|
|
325
|
+
* @returns {boolean}
|
|
326
|
+
*/
|
|
327
|
+
confirm(message, title = 'Confirm') {
|
|
328
|
+
message = String(message); title = String(title);
|
|
329
|
+
if (IS_MACOS) {
|
|
330
|
+
const r = _osascript(`display alert "${_escAS(title)}" message "${_escAS(message)}" buttons {"Cancel","OK"} default button "OK"`);
|
|
331
|
+
return r.includes('OK');
|
|
332
|
+
}
|
|
333
|
+
if (IS_WINDOWS) {
|
|
334
|
+
const r = _pwsh(`[System.Windows.Forms.MessageBox]::Show('${message.replace(/'/g, "''")}', '${title.replace(/'/g, "''")}', 'YesNo')`);
|
|
335
|
+
return r.trim() === 'Yes';
|
|
336
|
+
}
|
|
337
|
+
if (IS_ANDROID) {
|
|
338
|
+
const r = _spawn('termux-dialog', ['confirm', '-t', title, '-i', message]);
|
|
339
|
+
try { return JSON.parse(r.stdout).text === 'yes'; } catch { return false; }
|
|
340
|
+
}
|
|
341
|
+
const t = _linuxDialog();
|
|
342
|
+
if (t === 'zenity') return _spawn('zenity', ['--question', `--title=${title}`, `--text=${message}`]).code === 0;
|
|
343
|
+
if (t === 'kdialog') return _spawn('kdialog', ['--yesno', message, '--title', title]).code === 0;
|
|
344
|
+
if (t === 'yad') return _spawn('yad', ['--question', `--title=${title}`, `--text=${message}`]).code === 0;
|
|
345
|
+
return false;
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Show a text prompt dialog.
|
|
350
|
+
* @param {string} message
|
|
351
|
+
* @param {string} [defaultValue='']
|
|
352
|
+
* @returns {string|null} null if cancelled
|
|
353
|
+
*/
|
|
354
|
+
prompt(message, defaultValue = '', title = 'Input') {
|
|
355
|
+
message = String(message); defaultValue = String(defaultValue);
|
|
356
|
+
if (IS_MACOS) {
|
|
357
|
+
const r = _osascript(
|
|
358
|
+
`set result to display dialog "${_escAS(message)}" default answer "${_escAS(defaultValue)}" with title "${_escAS(title)}" buttons {"Cancel","OK"} default button "OK"\n` +
|
|
359
|
+
`return text returned of result`
|
|
360
|
+
);
|
|
361
|
+
return r || null;
|
|
362
|
+
}
|
|
363
|
+
if (IS_WINDOWS) {
|
|
364
|
+
const r = _pwsh(
|
|
365
|
+
`Add-Type -AssemblyName Microsoft.VisualBasic; ` +
|
|
366
|
+
`[Microsoft.VisualBasic.Interaction]::InputBox('${message.replace(/'/g, "''")}', '${title.replace(/'/g, "''")}', '${defaultValue.replace(/'/g, "''")}')`
|
|
367
|
+
);
|
|
368
|
+
return r.trim() === '' ? null : r.trim();
|
|
369
|
+
}
|
|
370
|
+
if (IS_ANDROID) {
|
|
371
|
+
const r = _spawn('termux-dialog', ['text', '-t', title, '-i', message, '--hint', defaultValue]);
|
|
372
|
+
try { const j = JSON.parse(r.stdout); return j.text ?? null; } catch { return null; }
|
|
373
|
+
}
|
|
374
|
+
const t = _linuxDialog();
|
|
375
|
+
if (t === 'zenity') {
|
|
376
|
+
const r = _spawn('zenity', ['--entry', `--title=${title}`, `--text=${message}`, `--entry-text=${defaultValue}`]);
|
|
377
|
+
return r.code === 0 ? r.stdout.trim() : null;
|
|
378
|
+
}
|
|
379
|
+
if (t === 'kdialog') {
|
|
380
|
+
const r = _spawn('kdialog', ['--inputbox', message, defaultValue, '--title', title]);
|
|
381
|
+
return r.code === 0 ? r.stdout.trim() : null;
|
|
382
|
+
}
|
|
383
|
+
if (t === 'yad') {
|
|
384
|
+
const r = _spawn('yad', ['--entry', `--title=${title}`, `--text=${message}`, `--entry-text=${defaultValue}`]);
|
|
385
|
+
return r.code === 0 ? r.stdout.trim() : null;
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Show a native file picker dialog.
|
|
392
|
+
* @param {object} [opts]
|
|
393
|
+
* @param {boolean} [opts.multiple=false]
|
|
394
|
+
* @param {string} [opts.initialDir]
|
|
395
|
+
* @param {string} [opts.filter] e.g. '*.txt'
|
|
396
|
+
* @param {string} [opts.title]
|
|
397
|
+
* @returns {string|string[]|null}
|
|
398
|
+
*/
|
|
399
|
+
filePicker(opts = {}) {
|
|
400
|
+
const title = opts.title ?? 'Select File';
|
|
401
|
+
const initDir= opts.initialDir ?? os.homedir();
|
|
402
|
+
const multi = opts.multiple ?? false;
|
|
403
|
+
|
|
404
|
+
if (IS_MACOS) {
|
|
405
|
+
const multiClause = multi ? 'with multiple selections allowed' : '';
|
|
406
|
+
const r = _osascript(
|
|
407
|
+
`set f to choose file with prompt "${_escAS(title)}" default location "${_escAS(initDir)}" ${multiClause}\n` +
|
|
408
|
+
`POSIX path of f`
|
|
409
|
+
);
|
|
410
|
+
if (!r) return null;
|
|
411
|
+
return multi ? r.split('\n').filter(Boolean) : r.trim();
|
|
412
|
+
}
|
|
413
|
+
if (IS_WINDOWS) {
|
|
414
|
+
const code = `
|
|
415
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
416
|
+
$d = New-Object System.Windows.Forms.OpenFileDialog
|
|
417
|
+
$d.Title = '${title.replace(/'/g, "''")}'; $d.InitialDirectory = '${initDir.replace(/'/g, "''")}'
|
|
418
|
+
${multi ? "$d.Multiselect = $true" : ''}
|
|
419
|
+
if ($d.ShowDialog() -eq 'OK') { $d.FileNames -join [char]10 } else { '' }
|
|
420
|
+
`;
|
|
421
|
+
const r = _pwsh(code).trim();
|
|
422
|
+
if (!r) return null;
|
|
423
|
+
const files = r.split('\n').map(s => s.trim()).filter(Boolean);
|
|
424
|
+
return multi ? files : files[0];
|
|
425
|
+
}
|
|
426
|
+
if (IS_ANDROID) {
|
|
427
|
+
const r = _spawn('termux-storage-get', ['/tmp/kitos_file_pick.tmp']);
|
|
428
|
+
return r.code === 0 ? '/tmp/kitos_file_pick.tmp' : null;
|
|
429
|
+
}
|
|
430
|
+
const t = _linuxDialog();
|
|
431
|
+
if (t === 'zenity') {
|
|
432
|
+
const args = ['--file-selection', `--title=${title}`, `--filename=${initDir}/`];
|
|
433
|
+
if (multi) args.push('--multiple', '--separator=\n');
|
|
434
|
+
const r = _spawn('zenity', args);
|
|
435
|
+
if (r.code !== 0) return null;
|
|
436
|
+
const files = r.stdout.split('\n').filter(Boolean);
|
|
437
|
+
return multi ? files : files[0];
|
|
438
|
+
}
|
|
439
|
+
if (t === 'kdialog') {
|
|
440
|
+
const r = _spawn('kdialog', ['--getopenfilename', initDir, opts.filter ?? '*', '--title', title]);
|
|
441
|
+
return r.code === 0 ? r.stdout.trim() : null;
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Show a folder/directory picker.
|
|
448
|
+
* @returns {string|null}
|
|
449
|
+
*/
|
|
450
|
+
folderPicker(opts = {}) {
|
|
451
|
+
const title = opts.title ?? 'Select Folder';
|
|
452
|
+
const initDir= opts.initialDir ?? os.homedir();
|
|
453
|
+
|
|
454
|
+
if (IS_MACOS) {
|
|
455
|
+
const r = _osascript(`POSIX path of (choose folder with prompt "${_escAS(title)}" default location "${_escAS(initDir)}")`);
|
|
456
|
+
return r ? r.trim() : null;
|
|
457
|
+
}
|
|
458
|
+
if (IS_WINDOWS) {
|
|
459
|
+
const r = _pwsh(`
|
|
460
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
461
|
+
$d = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
462
|
+
$d.Description = '${title.replace(/'/g, "''")}'; $d.SelectedPath = '${initDir.replace(/'/g, "''")}'
|
|
463
|
+
if ($d.ShowDialog() -eq 'OK') { $d.SelectedPath } else { '' }
|
|
464
|
+
`).trim();
|
|
465
|
+
return r || null;
|
|
466
|
+
}
|
|
467
|
+
const t = _linuxDialog();
|
|
468
|
+
if (t === 'zenity') {
|
|
469
|
+
const r = _spawn('zenity', ['--file-selection', '--directory', `--title=${title}`, `--filename=${initDir}/`]);
|
|
470
|
+
return r.code === 0 ? r.stdout.trim() : null;
|
|
471
|
+
}
|
|
472
|
+
if (t === 'kdialog') {
|
|
473
|
+
const r = _spawn('kdialog', ['--getexistingdirectory', initDir, '--title', title]);
|
|
474
|
+
return r.code === 0 ? r.stdout.trim() : null;
|
|
475
|
+
}
|
|
476
|
+
return null;
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Show a color picker dialog.
|
|
481
|
+
* @returns {{ hex: string, r: number, g: number, b: number }|null}
|
|
482
|
+
*/
|
|
483
|
+
colorPicker(opts = {}) {
|
|
484
|
+
const initial = opts.color ?? '#ffffff';
|
|
485
|
+
if (IS_MACOS) {
|
|
486
|
+
// AppleScript doesn't have a native color picker, use choose color
|
|
487
|
+
const r = _osascript(`set c to choose color default color {65535, 65535, 65535}\nreturn c`);
|
|
488
|
+
// Returns e.g. "{65535, 32768, 0}"
|
|
489
|
+
const m = r.match(/(\d+),\s*(\d+),\s*(\d+)/);
|
|
490
|
+
if (!m) return null;
|
|
491
|
+
const [rv, gv, bv] = [Math.round(+m[1]/257), Math.round(+m[2]/257), Math.round(+m[3]/257)];
|
|
492
|
+
const hex = '#' + [rv, gv, bv].map(v => v.toString(16).padStart(2,'0')).join('');
|
|
493
|
+
return { hex, r: rv, g: gv, b: bv };
|
|
494
|
+
}
|
|
495
|
+
if (IS_WINDOWS) {
|
|
496
|
+
const r = _pwsh(`
|
|
497
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
498
|
+
$d = New-Object System.Windows.Forms.ColorDialog
|
|
499
|
+
if ($d.ShowDialog() -eq 'OK') { "$($d.Color.R) $($d.Color.G) $($d.Color.B)" } else { '' }
|
|
500
|
+
`).trim();
|
|
501
|
+
if (!r) return null;
|
|
502
|
+
const [rv, gv, bv] = r.split(' ').map(Number);
|
|
503
|
+
const hex = '#' + [rv, gv, bv].map(v => v.toString(16).padStart(2,'0')).join('');
|
|
504
|
+
return { hex, r: rv, g: gv, b: bv };
|
|
505
|
+
}
|
|
506
|
+
const t = _linuxDialog();
|
|
507
|
+
if (t === 'zenity') {
|
|
508
|
+
const r = _spawn('zenity', ['--color-selection', `--color=${initial}`]);
|
|
509
|
+
if (r.code !== 0) return null;
|
|
510
|
+
const s = r.stdout.trim(); // "rgb(r,g,b)" or "#RRGGBB"
|
|
511
|
+
const m = s.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/) ?? s.match(/#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i);
|
|
512
|
+
if (!m) return null;
|
|
513
|
+
const [rv, gv, bv] = s.startsWith('rgb') ? [+m[1], +m[2], +m[3]] : [parseInt(m[1],16), parseInt(m[2],16), parseInt(m[3],16)];
|
|
514
|
+
const hex = '#' + [rv, gv, bv].map(v => v.toString(16).padStart(2,'0')).join('');
|
|
515
|
+
return { hex, r: rv, g: gv, b: bv };
|
|
516
|
+
}
|
|
517
|
+
if (t === 'kdialog') {
|
|
518
|
+
const r = _spawn('kdialog', ['--getcolor', '--default', initial]);
|
|
519
|
+
if (r.code !== 0) return null;
|
|
520
|
+
const hex = r.stdout.trim();
|
|
521
|
+
const rv = parseInt(hex.slice(1,3),16), gv = parseInt(hex.slice(3,5),16), bv = parseInt(hex.slice(5,7),16);
|
|
522
|
+
return { hex, r: rv, g: gv, b: bv };
|
|
523
|
+
}
|
|
524
|
+
return null;
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Show a date picker dialog.
|
|
529
|
+
* @returns {string|null} ISO date string 'YYYY-MM-DD'
|
|
530
|
+
*/
|
|
531
|
+
datePicker(opts = {}) {
|
|
532
|
+
const title = opts.title ?? 'Select Date';
|
|
533
|
+
if (IS_ANDROID) {
|
|
534
|
+
const r = _spawn('termux-dialog', ['datepicker', '-t', title]);
|
|
535
|
+
try { const j = JSON.parse(r.stdout); return j.text ?? null; } catch { return null; }
|
|
536
|
+
}
|
|
537
|
+
const t = _linuxDialog();
|
|
538
|
+
if (t === 'yad') {
|
|
539
|
+
const r = _spawn('yad', ['--calendar', `--title=${title}`, '--date-format=%Y-%m-%d']);
|
|
540
|
+
return r.code === 0 ? r.stdout.trim() : null;
|
|
541
|
+
}
|
|
542
|
+
if (t === 'zenity') {
|
|
543
|
+
const r = _spawn('zenity', ['--calendar', `--title=${title}`, '--date-format=%Y-%m-%d']);
|
|
544
|
+
return r.code === 0 ? r.stdout.trim() : null;
|
|
545
|
+
}
|
|
546
|
+
if (IS_MACOS) {
|
|
547
|
+
// Fallback: prompt
|
|
548
|
+
return Dialog.prompt('Enter date (YYYY-MM-DD)', new Date().toISOString().slice(0,10), title);
|
|
549
|
+
}
|
|
550
|
+
if (IS_WINDOWS) {
|
|
551
|
+
return Dialog.prompt('Enter date (YYYY-MM-DD)', new Date().toISOString().slice(0,10), title);
|
|
552
|
+
}
|
|
553
|
+
return null;
|
|
554
|
+
},
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Show a list selection dialog.
|
|
558
|
+
* @param {string[]} items
|
|
559
|
+
* @param {object} [opts]
|
|
560
|
+
* @param {boolean} [opts.multiple=false]
|
|
561
|
+
* @returns {string|string[]|null}
|
|
562
|
+
*/
|
|
563
|
+
selectList(items, opts = {}) {
|
|
564
|
+
const title = opts.title ?? 'Select';
|
|
565
|
+
const prompt = opts.prompt ?? 'Choose an item:';
|
|
566
|
+
const multi = opts.multiple ?? false;
|
|
567
|
+
|
|
568
|
+
if (IS_MACOS) {
|
|
569
|
+
const list = items.map(i => `"${_escAS(i)}"`).join(', ');
|
|
570
|
+
const r = _osascript(
|
|
571
|
+
`choose from list {${list}} with title "${_escAS(title)}" with prompt "${_escAS(prompt)}"` +
|
|
572
|
+
(multi ? ' with multiple selections allowed' : '')
|
|
573
|
+
);
|
|
574
|
+
if (!r || r === 'false') return null;
|
|
575
|
+
const selected = r.split(', ');
|
|
576
|
+
return multi ? selected : selected[0];
|
|
577
|
+
}
|
|
578
|
+
if (IS_WINDOWS) {
|
|
579
|
+
const list = items.map(i => `'${i.replace(/'/g, "''")}'`).join(',');
|
|
580
|
+
const r = _pwsh(`
|
|
581
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
582
|
+
$f = New-Object System.Windows.Forms.Form; $f.Text = '${title.replace(/'/g, "''")}'; $f.Width = 400; $f.Height = 500; $f.StartPosition = 'CenterScreen'
|
|
583
|
+
$lb = New-Object System.Windows.Forms.ListBox; $lb.Dock = 'Fill'; $lb.SelectionMode = '${multi ? 'MultiSimple' : 'One'}'
|
|
584
|
+
@(${list}) | ForEach-Object { $lb.Items.Add($_) | Out-Null }
|
|
585
|
+
$ok = New-Object System.Windows.Forms.Button; $ok.Text = 'OK'; $ok.DialogResult = 'OK'; $ok.Dock = 'Bottom'
|
|
586
|
+
$f.Controls.AddRange(@($lb, $ok)); $f.AcceptButton = $ok
|
|
587
|
+
if ($f.ShowDialog() -eq 'OK') { $lb.SelectedItems -join '\\n' } else { '' }
|
|
588
|
+
`).trim();
|
|
589
|
+
if (!r) return null;
|
|
590
|
+
const selected = r.split('\\n').filter(Boolean);
|
|
591
|
+
return multi ? selected : selected[0];
|
|
592
|
+
}
|
|
593
|
+
if (IS_ANDROID) {
|
|
594
|
+
const r = _spawn('termux-dialog', ['radio', '-t', title, '-v', items.join(',')]);
|
|
595
|
+
try { const j = JSON.parse(r.stdout); return j.text ?? null; } catch { return null; }
|
|
596
|
+
}
|
|
597
|
+
const t = _linuxDialog();
|
|
598
|
+
if (t === 'zenity') {
|
|
599
|
+
const colArgs = items.flatMap(i => [multi ? 'TRUE' : 'FALSE', i]);
|
|
600
|
+
const r = _spawn('zenity', ['--list', `--title=${title}`, `--text=${prompt}`,
|
|
601
|
+
'--column=Select', '--column=Item', '--checklist', ...colArgs]);
|
|
602
|
+
if (r.code !== 0) return null;
|
|
603
|
+
const sel = r.stdout.trim().split('|').filter(Boolean);
|
|
604
|
+
return multi ? sel : sel[0] ?? null;
|
|
605
|
+
}
|
|
606
|
+
if (t === 'kdialog') {
|
|
607
|
+
const itemArgs = items.flatMap(i => [i, i, 'off']);
|
|
608
|
+
const r = _spawn('kdialog', [multi ? '--checklist' : '--menu', prompt, '--title', title, ...itemArgs]);
|
|
609
|
+
if (r.code !== 0) return null;
|
|
610
|
+
const sel = r.stdout.trim().split('\n').filter(Boolean);
|
|
611
|
+
return multi ? sel : sel[0] ?? null;
|
|
612
|
+
}
|
|
613
|
+
return null;
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
/** Show a progress bar (returns a handle with .update(n, text) and .close()). */
|
|
617
|
+
progress(title = 'Loading...', total = 100) {
|
|
618
|
+
if (IS_MACOS) {
|
|
619
|
+
// Display a progress bar via AppleScript (display dialog workaround)
|
|
620
|
+
let current = 0;
|
|
621
|
+
const close = () => {};
|
|
622
|
+
const update = (n, text) => { current = n; };
|
|
623
|
+
return { update, close, get value() { return current; } };
|
|
624
|
+
}
|
|
625
|
+
if (IS_LINUX || IS_ANDROID) {
|
|
626
|
+
const t = _linuxDialog();
|
|
627
|
+
if (t === 'zenity') {
|
|
628
|
+
const child = spawn('zenity', ['--progress', `--title=${title}`, '--percentage=0', '--auto-close'], { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
629
|
+
return {
|
|
630
|
+
update(n, text = '') { child.stdin.write(`${Math.round(n)}\\n# ${text}\\n`); },
|
|
631
|
+
close() { child.stdin.end(); child.kill(); },
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return { update() {}, close() {} };
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
640
|
+
// 4. MENU — System Tray / Menu Bar / Context Menu
|
|
641
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
642
|
+
|
|
643
|
+
const Menu = {
|
|
644
|
+
/**
|
|
645
|
+
* Show a context/popup menu and return selected item.
|
|
646
|
+
* On platforms without native menus, falls back to Dialog.selectList.
|
|
647
|
+
* @param {string[]} items
|
|
648
|
+
* @param {object} [opts]
|
|
649
|
+
* @returns {string|null}
|
|
650
|
+
*/
|
|
651
|
+
context(items, opts = {}) {
|
|
652
|
+
const title = opts.title ?? 'Menu';
|
|
653
|
+
if (IS_MACOS) {
|
|
654
|
+
const list = items.map(i => `"${_escAS(i)}"`).join(', ');
|
|
655
|
+
const r = _osascript(`choose from list {${list}} with title "${_escAS(title)}"`);
|
|
656
|
+
return (!r || r === 'false') ? null : r.trim();
|
|
657
|
+
}
|
|
658
|
+
if (IS_LINUX) {
|
|
659
|
+
const t = _linuxDialog();
|
|
660
|
+
if (t === 'zenity') {
|
|
661
|
+
const args = ['--list', `--title=${title}`, '--column=Item', ...items];
|
|
662
|
+
const r = _spawn('zenity', args);
|
|
663
|
+
return r.code === 0 ? r.stdout.trim() : null;
|
|
664
|
+
}
|
|
665
|
+
if (t === 'yad') {
|
|
666
|
+
const args = ['--list', `--title=${title}`, '--column=Item', ...items, '--no-headers'];
|
|
667
|
+
const r = _spawn('yad', args);
|
|
668
|
+
return r.code === 0 ? r.stdout.trim().replace(/\|$/, '') : null;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return Dialog.selectList(items, opts);
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Spawn a persistent system tray icon (Linux only via yad --notification).
|
|
676
|
+
* Returns a handle with .update(tooltip) and .destroy().
|
|
677
|
+
*/
|
|
678
|
+
tray(icon, tooltip = '', menuItems = []) {
|
|
679
|
+
if (IS_LINUX && _run('which yad').code === 0) {
|
|
680
|
+
const menu = menuItems.map(m => `${m.label}!${m.cmd ?? 'echo ' + m.label}`).join('!');
|
|
681
|
+
const args = ['--notification', `--image=${icon}`, `--text=${tooltip}`];
|
|
682
|
+
if (menu) args.push(`--menu=${menu}`);
|
|
683
|
+
const child = spawn('yad', args, { detached: true, stdio: 'ignore' });
|
|
684
|
+
child.unref();
|
|
685
|
+
return {
|
|
686
|
+
update(tip) { /* yad --notification doesn't support live update easily */ },
|
|
687
|
+
destroy() { child.kill(); },
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
if (IS_MACOS) {
|
|
691
|
+
// BitBar/xbar style — can only open/write to plugin directory
|
|
692
|
+
return { update() {}, destroy() {} };
|
|
693
|
+
}
|
|
694
|
+
return { update() {}, destroy() {} };
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
699
|
+
// 5. KEYBOARD — Simulate Keystrokes / Hotkeys
|
|
700
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
701
|
+
|
|
702
|
+
const Keyboard = {
|
|
703
|
+
/**
|
|
704
|
+
* Type a string of text.
|
|
705
|
+
* @param {string} text
|
|
706
|
+
*/
|
|
707
|
+
type(text) {
|
|
708
|
+
text = String(text);
|
|
709
|
+
if (IS_MACOS) {
|
|
710
|
+
_osascript(`tell application "System Events" to keystroke "${_escAS(text)}"`);
|
|
711
|
+
} else if (IS_WINDOWS) {
|
|
712
|
+
_pwsh(`
|
|
713
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
714
|
+
[System.Windows.Forms.SendKeys]::SendWait('${text.replace(/[+^%~(){}]/g, '{$&}').replace(/'/g, "''")}')
|
|
715
|
+
`);
|
|
716
|
+
} else if (IS_LINUX) {
|
|
717
|
+
if (_run('which xdotool').code === 0) _spawn('xdotool', ['type', '--clearmodifiers', text]);
|
|
718
|
+
} else if (IS_ANDROID) {
|
|
719
|
+
_spawn('termux-clipboard-set', [text]);
|
|
720
|
+
// Cannot type directly without accessibility service
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Press a key or key combination.
|
|
726
|
+
* @param {string} key e.g. 'Return', 'ctrl+c', 'cmd+shift+4', 'F5', 'Escape'
|
|
727
|
+
*/
|
|
728
|
+
press(key) {
|
|
729
|
+
key = String(key);
|
|
730
|
+
if (IS_MACOS) {
|
|
731
|
+
// Map common names to AppleScript key codes / keystroke format
|
|
732
|
+
const AS_KEYS = { Return: 'return', Escape: 'escape', Delete: 'delete', Tab: 'tab', Space: 'space',
|
|
733
|
+
F1:'f1',F2:'f2',F3:'f3',F4:'f4',F5:'f5',F6:'f6',F7:'f7',F8:'f8',F9:'f9',F10:'f10',F11:'f11',F12:'f12' };
|
|
734
|
+
const parts = key.toLowerCase().split('+');
|
|
735
|
+
const main = parts[parts.length - 1];
|
|
736
|
+
const mods = parts.slice(0, -1);
|
|
737
|
+
const modStr = mods.map(m => ({
|
|
738
|
+
ctrl: 'control down', cmd: 'command down', shift: 'shift down', alt: 'option down', option: 'option down',
|
|
739
|
+
}[m] ?? '')).filter(Boolean).join(', ');
|
|
740
|
+
const asKey = AS_KEYS[parts[parts.length - 1]] ?? main;
|
|
741
|
+
_osascript(`tell application "System Events" to keystroke "${asKey}" using {${modStr}}`);
|
|
742
|
+
} else if (IS_WINDOWS) {
|
|
743
|
+
const WIN_KEYS = { ctrl: '^', shift: '+', alt: '%', Return: '~', Escape: '{ESC}', Tab: '{TAB}',
|
|
744
|
+
Delete: '{DELETE}', BackSpace: '{BACKSPACE}', Up: '{UP}', Down: '{DOWN}', Left: '{LEFT}', Right: '{RIGHT}' };
|
|
745
|
+
let combo = key;
|
|
746
|
+
for (const [k, v] of Object.entries(WIN_KEYS)) combo = combo.replace(new RegExp(k, 'gi'), v);
|
|
747
|
+
_pwsh(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${combo.replace(/'/g, "''")}')`);
|
|
748
|
+
} else if (IS_LINUX) {
|
|
749
|
+
if (_run('which xdotool').code === 0) _spawn('xdotool', ['key', '--clearmodifiers', key.replace(/cmd/gi, 'super')]);
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Hold a key down, run fn, then release (macOS / Linux xdotool).
|
|
755
|
+
* @param {string} key
|
|
756
|
+
* @param {Function} fn
|
|
757
|
+
*/
|
|
758
|
+
hold(key, fn) {
|
|
759
|
+
if (IS_LINUX && _run('which xdotool').code === 0) {
|
|
760
|
+
_spawn('xdotool', ['keydown', key]);
|
|
761
|
+
try { fn(); } finally { _spawn('xdotool', ['keyup', key]); }
|
|
762
|
+
} else {
|
|
763
|
+
fn();
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
|
|
767
|
+
/** Get the clipboard text content. */
|
|
768
|
+
getClipboard() {
|
|
769
|
+
if (IS_MACOS) return _out('pbpaste');
|
|
770
|
+
if (IS_WINDOWS) return _pwsh('Get-Clipboard').trim();
|
|
771
|
+
if (IS_ANDROID) return _out('termux-clipboard-get');
|
|
772
|
+
if (IS_LINUX) {
|
|
773
|
+
if (_run('which xclip').code === 0) return _out('xclip -selection clipboard -o');
|
|
774
|
+
if (_run('which xsel').code === 0) return _out('xsel --clipboard --output');
|
|
775
|
+
if (_run('which wl-paste').code === 0) return _out('wl-paste');
|
|
776
|
+
}
|
|
777
|
+
return '';
|
|
778
|
+
},
|
|
779
|
+
|
|
780
|
+
/** Set clipboard text content. */
|
|
781
|
+
setClipboard(text) {
|
|
782
|
+
text = String(text);
|
|
783
|
+
if (IS_MACOS) { const r = spawnSync('pbcopy', [], { input: text, encoding: 'utf8' }); return; }
|
|
784
|
+
if (IS_WINDOWS) { _pwsh(`Set-Clipboard '${text.replace(/'/g, "''")}'`); return; }
|
|
785
|
+
if (IS_ANDROID) { _spawn('termux-clipboard-set', [text]); return; }
|
|
786
|
+
if (IS_LINUX) {
|
|
787
|
+
if (_run('which xclip').code === 0) { spawnSync('xclip', ['-selection', 'clipboard'], { input: text, encoding: 'utf8' }); return; }
|
|
788
|
+
if (_run('which xsel').code === 0) { spawnSync('xsel', ['--clipboard', '--input'], { input: text, encoding: 'utf8' }); return; }
|
|
789
|
+
if (_run('which wl-copy').code === 0) { spawnSync('wl-copy', [], { input: text, encoding: 'utf8' }); }
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
795
|
+
// 6. MOUSE — Cursor Control
|
|
796
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
797
|
+
|
|
798
|
+
const Mouse = {
|
|
799
|
+
/**
|
|
800
|
+
* Move the cursor to absolute screen coordinates.
|
|
801
|
+
* @param {number} x
|
|
802
|
+
* @param {number} y
|
|
803
|
+
*/
|
|
804
|
+
move(x, y) {
|
|
805
|
+
x = Math.round(x); y = Math.round(y);
|
|
806
|
+
if (IS_MACOS) _spawn('cliclick', [`m:${x},${y}`]);
|
|
807
|
+
else if (IS_WINDOWS) _pwsh(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Cursor]::Position = New-Object System.Drawing.Point(${x}, ${y})`);
|
|
808
|
+
else if (IS_LINUX && _run('which xdotool').code === 0) _spawn('xdotool', ['mousemove', String(x), String(y)]);
|
|
809
|
+
},
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Click at current position or given coordinates.
|
|
813
|
+
* @param {number} [x]
|
|
814
|
+
* @param {number} [y]
|
|
815
|
+
* @param {'left'|'right'|'middle'} [button='left']
|
|
816
|
+
*/
|
|
817
|
+
click(x, y, button = 'left') {
|
|
818
|
+
const btn = { left: 1, middle: 2, right: 3 };
|
|
819
|
+
if (x !== undefined) Mouse.move(x, y);
|
|
820
|
+
if (IS_MACOS) {
|
|
821
|
+
const action = { left: 'c', right: 'rc', middle: 'mc' }[button] ?? 'c';
|
|
822
|
+
if (x !== undefined) _spawn('cliclick', [`${action}:${Math.round(x)},${Math.round(y)}`]);
|
|
823
|
+
else _spawn('cliclick', [action]);
|
|
824
|
+
} else if (IS_WINDOWS) {
|
|
825
|
+
const btnNum = btn[button] ?? 1;
|
|
826
|
+
_pwsh(`
|
|
827
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
828
|
+
$pos = [System.Windows.Forms.Cursor]::Position
|
|
829
|
+
Add-Type @'
|
|
830
|
+
using System; using System.Runtime.InteropServices;
|
|
831
|
+
public class Mouse { [DllImport("user32.dll")] public static extern void mouse_event(int f, int x, int y, int d, int e); }
|
|
832
|
+
'@
|
|
833
|
+
[Mouse]::mouse_event(${btnNum === 1 ? 2 : btnNum === 2 ? 8 : 32}, 0, 0, 0, 0)
|
|
834
|
+
[Mouse]::mouse_event(${btnNum === 1 ? 4 : btnNum === 2 ? 16 : 64}, 0, 0, 0, 0)
|
|
835
|
+
`);
|
|
836
|
+
} else if (IS_LINUX && _run('which xdotool').code === 0) {
|
|
837
|
+
_spawn('xdotool', ['click', String(btn[button] ?? 1)]);
|
|
838
|
+
}
|
|
839
|
+
},
|
|
840
|
+
|
|
841
|
+
/** Double-click. */
|
|
842
|
+
doubleClick(x, y) {
|
|
843
|
+
if (IS_MACOS) {
|
|
844
|
+
if (x !== undefined) _spawn('cliclick', [`dc:${Math.round(x)},${Math.round(y)}`]);
|
|
845
|
+
else _spawn('cliclick', ['dc']);
|
|
846
|
+
} else {
|
|
847
|
+
Mouse.click(x, y); Mouse.click(x, y);
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
|
|
851
|
+
/** Right-click. */
|
|
852
|
+
rightClick(x, y) { Mouse.click(x, y, 'right'); },
|
|
853
|
+
|
|
854
|
+
/** Scroll up or down. */
|
|
855
|
+
scroll(direction = 'down', amount = 3) {
|
|
856
|
+
if (IS_MACOS) _spawn('cliclick', [`kd:${direction === 'down' ? 'page-down' : 'page-up'}`]);
|
|
857
|
+
else if (IS_LINUX && _run('which xdotool').code === 0) {
|
|
858
|
+
const btn = direction === 'down' ? 5 : 4;
|
|
859
|
+
for (let i = 0; i < amount; i++) _spawn('xdotool', ['click', String(btn)]);
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
|
|
863
|
+
/** Get current cursor position. */
|
|
864
|
+
position() {
|
|
865
|
+
if (IS_MACOS) {
|
|
866
|
+
const r = _osascript('tell application "System Events" to return position of mouse cursor');
|
|
867
|
+
const m = r.match(/(\d+),\s*(\d+)/);
|
|
868
|
+
return m ? { x: +m[1], y: +m[2] } : { x: 0, y: 0 };
|
|
869
|
+
}
|
|
870
|
+
if (IS_WINDOWS) {
|
|
871
|
+
const r = _pwsh(`$p=[System.Windows.Forms.Cursor]::Position; "$($p.X),$($p.Y)"`).trim();
|
|
872
|
+
const [x, y] = r.split(',').map(Number);
|
|
873
|
+
return { x, y };
|
|
874
|
+
}
|
|
875
|
+
if (IS_LINUX && _run('which xdotool').code === 0) {
|
|
876
|
+
const r = _out('xdotool getmouselocation --shell');
|
|
877
|
+
const m = r.match(/X=(\d+)\s+Y=(\d+)/);
|
|
878
|
+
return m ? { x: +m[1], y: +m[2] } : { x: 0, y: 0 };
|
|
879
|
+
}
|
|
880
|
+
return { x: 0, y: 0 };
|
|
881
|
+
},
|
|
882
|
+
|
|
883
|
+
/** Drag from (x1,y1) to (x2,y2). */
|
|
884
|
+
drag(x1, y1, x2, y2) {
|
|
885
|
+
if (IS_LINUX && _run('which xdotool').code === 0) {
|
|
886
|
+
_spawn('xdotool', ['mousemove', String(x1), String(y1)]);
|
|
887
|
+
_spawn('xdotool', ['mousedown', '1']);
|
|
888
|
+
_spawn('xdotool', ['mousemove', String(x2), String(y2)]);
|
|
889
|
+
_spawn('xdotool', ['mouseup', '1']);
|
|
890
|
+
} else if (IS_MACOS) {
|
|
891
|
+
_spawn('cliclick', [`dd:${x1},${y1}`, `du:${x2},${y2}`]);
|
|
892
|
+
}
|
|
893
|
+
},
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
897
|
+
// 7. SCREEN — Screenshots / Display Info
|
|
898
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
899
|
+
|
|
900
|
+
const Screen = {
|
|
901
|
+
/**
|
|
902
|
+
* Take a screenshot.
|
|
903
|
+
* @param {string} [outputPath] destination file (PNG)
|
|
904
|
+
* @param {object} [opts]
|
|
905
|
+
* @param {object} [opts.region] { x, y, w, h }
|
|
906
|
+
* @returns {string} path to screenshot file
|
|
907
|
+
*/
|
|
908
|
+
screenshot(outputPath, opts = {}) {
|
|
909
|
+
outputPath = outputPath ?? path.join(os.tmpdir(), `kitos_screenshot_${Date.now()}.png`);
|
|
910
|
+
|
|
911
|
+
if (IS_MACOS) {
|
|
912
|
+
const args = ['-x']; // silent
|
|
913
|
+
if (opts.region) {
|
|
914
|
+
const { x, y, w, h } = opts.region;
|
|
915
|
+
args.push('-R', `${x},${y},${w},${h}`);
|
|
916
|
+
}
|
|
917
|
+
args.push(outputPath);
|
|
918
|
+
_spawn('screencapture', args);
|
|
919
|
+
} else if (IS_WINDOWS) {
|
|
920
|
+
_pwsh(`
|
|
921
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
922
|
+
Add-Type -AssemblyName System.Drawing
|
|
923
|
+
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
|
924
|
+
${opts.region ? `$bounds = New-Object System.Drawing.Rectangle(${opts.region.x},${opts.region.y},${opts.region.w},${opts.region.h})` : ''}
|
|
925
|
+
$bmp = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height)
|
|
926
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
927
|
+
$g.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
|
928
|
+
$bmp.Save('${outputPath.replace(/'/g, "''")}')
|
|
929
|
+
$g.Dispose(); $bmp.Dispose()
|
|
930
|
+
`);
|
|
931
|
+
} else if (IS_ANDROID) {
|
|
932
|
+
_spawn('termux-screenshot', ['-f', outputPath]);
|
|
933
|
+
} else if (IS_LINUX) {
|
|
934
|
+
if (_run('which scrot').code === 0) {
|
|
935
|
+
const args = [outputPath];
|
|
936
|
+
if (opts.region) {
|
|
937
|
+
const { x, y, w, h } = opts.region;
|
|
938
|
+
args.unshift(`-a`, `${x},${y},${w},${h}`);
|
|
939
|
+
}
|
|
940
|
+
_spawn('scrot', args);
|
|
941
|
+
} else if (_run('which gnome-screenshot').code === 0) {
|
|
942
|
+
_spawn('gnome-screenshot', ['-f', outputPath]);
|
|
943
|
+
} else if (_run('which import').code === 0) { // ImageMagick
|
|
944
|
+
_spawn('import', ['-window', 'root', outputPath]);
|
|
945
|
+
} else if (_run('which spectacle').code === 0) {
|
|
946
|
+
_spawn('spectacle', ['-n', '-o', outputPath]);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return outputPath;
|
|
950
|
+
},
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Get all monitor/display info.
|
|
954
|
+
* @returns {Array<{ id, width, height, x, y, primary, dpi }>}
|
|
955
|
+
*/
|
|
956
|
+
monitors() {
|
|
957
|
+
if (IS_MACOS) {
|
|
958
|
+
const r = _out('system_profiler SPDisplaysDataType');
|
|
959
|
+
// Parse resolution lines: e.g. "Resolution: 2560 x 1440"
|
|
960
|
+
const monitors = [];
|
|
961
|
+
const lines = r.split('\n');
|
|
962
|
+
let current = null;
|
|
963
|
+
for (const line of lines) {
|
|
964
|
+
if (line.match(/\s+Resolution:\s+(\d+) x (\d+)/)) {
|
|
965
|
+
const m = line.match(/(\d+) x (\d+)/);
|
|
966
|
+
if (m) monitors.push({ width: +m[1], height: +m[2], x: 0, y: 0, primary: monitors.length === 0 });
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return monitors.length ? monitors : [{ width: 1920, height: 1080, x: 0, y: 0, primary: true }];
|
|
970
|
+
}
|
|
971
|
+
if (IS_WINDOWS) {
|
|
972
|
+
const r = _pwsh(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::AllScreens | ForEach-Object { "$($_.Bounds.X),$($_.Bounds.Y),$($_.Bounds.Width),$($_.Bounds.Height),$($_.Primary)" }`);
|
|
973
|
+
return r.trim().split('\n').filter(Boolean).map((line, i) => {
|
|
974
|
+
const [x, y, w, h, primary] = line.trim().split(',');
|
|
975
|
+
return { id: i, x: +x, y: +y, width: +w, height: +h, primary: primary === 'True' };
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
if (IS_ANDROID) {
|
|
979
|
+
const r = _out('termux-display-info 2>/dev/null');
|
|
980
|
+
try { return [JSON.parse(r)]; } catch { return [{ width: 1080, height: 1920, primary: true }]; }
|
|
981
|
+
}
|
|
982
|
+
if (IS_LINUX) {
|
|
983
|
+
const r = _out('xrandr 2>/dev/null');
|
|
984
|
+
const monitors = [];
|
|
985
|
+
for (const m of r.matchAll(/(\S+) connected(?: primary)? (\d+)x(\d+)\+(\d+)\+(\d+)/g)) {
|
|
986
|
+
monitors.push({ id: m[1], width: +m[2], height: +m[3], x: +m[4], y: +m[5], primary: r.includes(`${m[1]} connected primary`) });
|
|
987
|
+
}
|
|
988
|
+
return monitors.length ? monitors : [{ width: 1920, height: 1080, primary: true }];
|
|
989
|
+
}
|
|
990
|
+
return [];
|
|
991
|
+
},
|
|
992
|
+
|
|
993
|
+
/** Get primary screen dimensions. */
|
|
994
|
+
size() {
|
|
995
|
+
const m = Screen.monitors().find(m => m.primary) ?? Screen.monitors()[0];
|
|
996
|
+
return m ? { width: m.width, height: m.height } : { width: 0, height: 0 };
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Get the pixel color at screen coordinates.
|
|
1001
|
+
* @returns {{ r: number, g: number, b: number, hex: string }|null}
|
|
1002
|
+
*/
|
|
1003
|
+
pixelColor(x, y) {
|
|
1004
|
+
if (IS_MACOS) {
|
|
1005
|
+
// Use screencapture + sips to read one pixel
|
|
1006
|
+
const tmp = path.join(os.tmpdir(), 'kitos_px.png');
|
|
1007
|
+
_spawn('screencapture', ['-x', '-R', `${x},${y},1,1`, tmp]);
|
|
1008
|
+
const r = _out(`sips -g all ${tmp} 2>/dev/null`);
|
|
1009
|
+
fs.unlinkSync(tmp);
|
|
1010
|
+
// Parse pixel data (hex) from sips
|
|
1011
|
+
const m = r.match(/pixelColor: ([0-9a-f]+)/i);
|
|
1012
|
+
if (!m) return null;
|
|
1013
|
+
const hex = '#' + m[1].slice(0, 6);
|
|
1014
|
+
return { r: parseInt(hex.slice(1,3),16), g: parseInt(hex.slice(3,5),16), b: parseInt(hex.slice(5,7),16), hex };
|
|
1015
|
+
}
|
|
1016
|
+
if (IS_LINUX && _run('which import').code === 0) {
|
|
1017
|
+
const r = _out(`import -window root -crop 1x1+${x}+${y} txt:- 2>/dev/null`);
|
|
1018
|
+
const m = r.match(/#([0-9A-F]{6})/i);
|
|
1019
|
+
if (!m) return null;
|
|
1020
|
+
const hex = '#' + m[1];
|
|
1021
|
+
return { r: parseInt(hex.slice(1,3),16), g: parseInt(hex.slice(3,5),16), b: parseInt(hex.slice(5,7),16), hex };
|
|
1022
|
+
}
|
|
1023
|
+
return null;
|
|
1024
|
+
},
|
|
1025
|
+
|
|
1026
|
+
/** Get DPI of primary monitor. */
|
|
1027
|
+
dpi() {
|
|
1028
|
+
if (IS_MACOS) {
|
|
1029
|
+
const r = _out('system_profiler SPDisplaysDataType');
|
|
1030
|
+
const m = r.match(/(\d+) dpi/i);
|
|
1031
|
+
return m ? +m[1] : 96;
|
|
1032
|
+
}
|
|
1033
|
+
if (IS_LINUX) {
|
|
1034
|
+
const r = _out('xdpyinfo 2>/dev/null');
|
|
1035
|
+
const m = r.match(/(\d+)x(\d+) dots per inch/);
|
|
1036
|
+
return m ? +m[1] : 96;
|
|
1037
|
+
}
|
|
1038
|
+
return 96;
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1043
|
+
// 8. AUDIO — Sound Playback / Beep / Volume
|
|
1044
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1045
|
+
|
|
1046
|
+
const Audio = {
|
|
1047
|
+
/**
|
|
1048
|
+
* Play a sound file.
|
|
1049
|
+
* @param {string} filePath
|
|
1050
|
+
* @param {object} [opts]
|
|
1051
|
+
* @param {boolean} [opts.async]
|
|
1052
|
+
* @param {number} [opts.volume] 0.0–1.0
|
|
1053
|
+
*/
|
|
1054
|
+
play(filePath, opts = {}) {
|
|
1055
|
+
filePath = path.resolve(filePath);
|
|
1056
|
+
if (IS_MACOS) {
|
|
1057
|
+
const args = [filePath];
|
|
1058
|
+
if (opts.volume !== undefined) args.push('--volume', String(Math.round(opts.volume * 100)));
|
|
1059
|
+
opts.async ? spawn('afplay', args, { detached: true, stdio: 'ignore' }).unref()
|
|
1060
|
+
: _spawn('afplay', args);
|
|
1061
|
+
} else if (IS_WINDOWS) {
|
|
1062
|
+
const cmd = `(New-Object Media.SoundPlayer '${filePath.replace(/'/g, "''")}').PlaySync()`;
|
|
1063
|
+
opts.async ? spawn('powershell', ['-NoProfile', '-Command', cmd], { detached: true, stdio: 'ignore' }).unref()
|
|
1064
|
+
: _pwsh(cmd);
|
|
1065
|
+
} else if (IS_ANDROID) {
|
|
1066
|
+
_spawn('termux-media-player', ['play', filePath]);
|
|
1067
|
+
} else if (IS_LINUX) {
|
|
1068
|
+
for (const player of ['paplay', 'aplay', 'mpg123', 'ffplay', 'mplayer', 'cvlc']) {
|
|
1069
|
+
if (_run(`which ${player}`).code === 0) {
|
|
1070
|
+
const args = player === 'ffplay' ? ['-nodisp', '-autoexit', filePath]
|
|
1071
|
+
: player === 'cvlc' ? ['--play-and-exit', filePath]
|
|
1072
|
+
: [filePath];
|
|
1073
|
+
opts.async ? spawn(player, args, { detached: true, stdio: 'ignore' }).unref()
|
|
1074
|
+
: _spawn(player, args);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
},
|
|
1080
|
+
|
|
1081
|
+
/** Play a system beep / bell sound. */
|
|
1082
|
+
beep(count = 1) {
|
|
1083
|
+
for (let i = 0; i < count; i++) {
|
|
1084
|
+
if (IS_MACOS) _osascript('beep');
|
|
1085
|
+
else if (IS_WINDOWS) _pwsh('[Console]::Beep(800, 200)');
|
|
1086
|
+
else if (IS_ANDROID) _spawn('termux-vibrate', ['-d', '100']);
|
|
1087
|
+
else { process.stdout.write('\x07'); }
|
|
1088
|
+
}
|
|
1089
|
+
},
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Get system volume (0–100).
|
|
1093
|
+
* @returns {number}
|
|
1094
|
+
*/
|
|
1095
|
+
getVolume() {
|
|
1096
|
+
if (IS_MACOS) {
|
|
1097
|
+
const r = _out('osascript -e "output volume of (get volume settings)"');
|
|
1098
|
+
return parseInt(r) || 0;
|
|
1099
|
+
}
|
|
1100
|
+
if (IS_WINDOWS) {
|
|
1101
|
+
const r = _pwsh(`(Get-AudioDevice -Playback).Volume`);
|
|
1102
|
+
return Math.round(parseFloat(r) * 100) || 0;
|
|
1103
|
+
}
|
|
1104
|
+
if (IS_ANDROID) {
|
|
1105
|
+
try { return JSON.parse(_out('termux-volume')).find(v => v.stream === 'music')?.volume ?? 50; } catch { return 50; }
|
|
1106
|
+
}
|
|
1107
|
+
if (IS_LINUX) {
|
|
1108
|
+
const r = _out('amixer sget Master 2>/dev/null || pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null');
|
|
1109
|
+
const m = r.match(/(\d+)%/);
|
|
1110
|
+
return m ? +m[1] : 0;
|
|
1111
|
+
}
|
|
1112
|
+
return 0;
|
|
1113
|
+
},
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Set system volume.
|
|
1117
|
+
* @param {number} level 0–100
|
|
1118
|
+
*/
|
|
1119
|
+
setVolume(level) {
|
|
1120
|
+
level = Math.max(0, Math.min(100, Math.round(level)));
|
|
1121
|
+
if (IS_MACOS) {
|
|
1122
|
+
_osascript(`set volume output volume ${level}`);
|
|
1123
|
+
} else if (IS_WINDOWS) {
|
|
1124
|
+
_pwsh(`$obj = New-Object -ComObject WScript.Shell; for($i=0;$i -lt 50;$i++){$obj.SendKeys([char]174)}; for($i=0;$i -lt [math]::Round(${level}/2);$i++){$obj.SendKeys([char]175)}`);
|
|
1125
|
+
} else if (IS_ANDROID) {
|
|
1126
|
+
_spawn('termux-volume', ['music', String(level)]);
|
|
1127
|
+
} else if (IS_LINUX) {
|
|
1128
|
+
if (_run('which pactl').code === 0) _spawn('pactl', ['set-sink-volume', '@DEFAULT_SINK@', `${level}%`]);
|
|
1129
|
+
else _spawn('amixer', ['sset', 'Master', `${level}%`]);
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1132
|
+
|
|
1133
|
+
/** Get mute state. */
|
|
1134
|
+
isMuted() {
|
|
1135
|
+
if (IS_MACOS) return _out('osascript -e "output muted of (get volume settings)"').trim() === 'true';
|
|
1136
|
+
if (IS_LINUX) {
|
|
1137
|
+
const r = _out('amixer sget Master 2>/dev/null');
|
|
1138
|
+
return r.includes('[off]');
|
|
1139
|
+
}
|
|
1140
|
+
return false;
|
|
1141
|
+
},
|
|
1142
|
+
|
|
1143
|
+
/** Mute audio. */
|
|
1144
|
+
mute() {
|
|
1145
|
+
if (IS_MACOS) _osascript('set volume with output muted');
|
|
1146
|
+
else if (IS_WINDOWS) _pwsh('(New-Object -ComObject WScript.Shell).SendKeys([char]173)');
|
|
1147
|
+
else if (IS_LINUX) {
|
|
1148
|
+
if (_run('which pactl').code === 0) _spawn('pactl', ['set-sink-mute', '@DEFAULT_SINK@', '1']);
|
|
1149
|
+
else _spawn('amixer', ['sset', 'Master', 'mute']);
|
|
1150
|
+
}
|
|
1151
|
+
},
|
|
1152
|
+
|
|
1153
|
+
/** Unmute audio. */
|
|
1154
|
+
unmute() {
|
|
1155
|
+
if (IS_MACOS) _osascript('set volume without output muted');
|
|
1156
|
+
else if (IS_WINDOWS) _pwsh('(New-Object -ComObject WScript.Shell).SendKeys([char]173)');
|
|
1157
|
+
else if (IS_LINUX) {
|
|
1158
|
+
if (_run('which pactl').code === 0) _spawn('pactl', ['set-sink-mute', '@DEFAULT_SINK@', '0']);
|
|
1159
|
+
else _spawn('amixer', ['sset', 'Master', 'unmute']);
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
|
|
1163
|
+
/** Toggle mute. */
|
|
1164
|
+
toggleMute() { Audio.isMuted() ? Audio.unmute() : Audio.mute(); },
|
|
1165
|
+
|
|
1166
|
+
/** List audio output devices. */
|
|
1167
|
+
devices() {
|
|
1168
|
+
if (IS_MACOS) {
|
|
1169
|
+
return _out('system_profiler SPAudioDataType').split('\n')
|
|
1170
|
+
.filter(l => l.match(/^\s{6}\S/))
|
|
1171
|
+
.map(l => l.trim());
|
|
1172
|
+
}
|
|
1173
|
+
if (IS_LINUX && _run('which pactl').code === 0) {
|
|
1174
|
+
return _out('pactl list sinks short').split('\n').filter(Boolean).map(l => {
|
|
1175
|
+
const p = l.trim().split('\t');
|
|
1176
|
+
return { id: p[0], name: p[1], driver: p[2], state: p[4] };
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
if (IS_ANDROID) {
|
|
1180
|
+
try { return JSON.parse(_out('termux-volume')); } catch { return []; }
|
|
1181
|
+
}
|
|
1182
|
+
return [];
|
|
1183
|
+
},
|
|
1184
|
+
|
|
1185
|
+
/** Stop media playback (Android). */
|
|
1186
|
+
stop() {
|
|
1187
|
+
if (IS_ANDROID) _spawn('termux-media-player', ['stop']);
|
|
1188
|
+
if (IS_MACOS) _run('killall afplay 2>/dev/null');
|
|
1189
|
+
},
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1193
|
+
// 9. CAMERA — Webcam / Photo Capture
|
|
1194
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1195
|
+
|
|
1196
|
+
const Camera = {
|
|
1197
|
+
/**
|
|
1198
|
+
* Capture a photo from the default camera.
|
|
1199
|
+
* @param {string} [outputPath]
|
|
1200
|
+
* @param {object} [opts]
|
|
1201
|
+
* @param {string} [opts.device] camera device (Linux)
|
|
1202
|
+
* @returns {string} path to captured image
|
|
1203
|
+
*/
|
|
1204
|
+
capture(outputPath, opts = {}) {
|
|
1205
|
+
outputPath = outputPath ?? path.join(os.tmpdir(), `kitos_cam_${Date.now()}.jpg`);
|
|
1206
|
+
if (IS_ANDROID) {
|
|
1207
|
+
_spawn('termux-camera-photo', [outputPath]);
|
|
1208
|
+
return outputPath;
|
|
1209
|
+
}
|
|
1210
|
+
if (IS_MACOS) {
|
|
1211
|
+
// imagesnap (brew install imagesnap)
|
|
1212
|
+
if (_run('which imagesnap').code === 0) {
|
|
1213
|
+
_spawn('imagesnap', ['-q', outputPath]);
|
|
1214
|
+
return outputPath;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (IS_LINUX) {
|
|
1218
|
+
const device = opts.device ?? '/dev/video0';
|
|
1219
|
+
if (_run('which ffmpeg').code === 0) {
|
|
1220
|
+
_spawn('ffmpeg', ['-y', '-f', 'v4l2', '-i', device, '-frames:v', '1', outputPath]);
|
|
1221
|
+
return outputPath;
|
|
1222
|
+
}
|
|
1223
|
+
if (_run('which fswebcam').code === 0) {
|
|
1224
|
+
_spawn('fswebcam', ['-d', device, '-r', '1280x720', '--no-banner', outputPath]);
|
|
1225
|
+
return outputPath;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return outputPath;
|
|
1229
|
+
},
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Capture video.
|
|
1233
|
+
* @param {string} outputPath
|
|
1234
|
+
* @param {number} [duration=5] seconds
|
|
1235
|
+
* @param {object} [opts]
|
|
1236
|
+
* @returns {string} path to video file
|
|
1237
|
+
*/
|
|
1238
|
+
captureVideo(outputPath, duration = 5, opts = {}) {
|
|
1239
|
+
outputPath = outputPath ?? path.join(os.tmpdir(), `kitos_vid_${Date.now()}.mp4`);
|
|
1240
|
+
if (IS_ANDROID) {
|
|
1241
|
+
_spawn('termux-camera-video', [outputPath]);
|
|
1242
|
+
return outputPath;
|
|
1243
|
+
}
|
|
1244
|
+
if (IS_LINUX && _run('which ffmpeg').code === 0) {
|
|
1245
|
+
const device = opts.device ?? '/dev/video0';
|
|
1246
|
+
_spawn('ffmpeg', ['-y', '-f', 'v4l2', '-i', device, '-t', String(duration), outputPath]);
|
|
1247
|
+
}
|
|
1248
|
+
return outputPath;
|
|
1249
|
+
},
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* List available camera devices.
|
|
1253
|
+
* @returns {string[]}
|
|
1254
|
+
*/
|
|
1255
|
+
devices() {
|
|
1256
|
+
if (IS_ANDROID) {
|
|
1257
|
+
try { return JSON.parse(_out('termux-camera-info') || '[]'); } catch { return []; }
|
|
1258
|
+
}
|
|
1259
|
+
if (IS_MACOS && _run('which imagesnap').code === 0) {
|
|
1260
|
+
return _out('imagesnap -l').split('\n').filter(l => l.startsWith('[') || l.match(/^\s+\S/)).map(l => l.trim()).filter(Boolean);
|
|
1261
|
+
}
|
|
1262
|
+
if (IS_LINUX) {
|
|
1263
|
+
const devices = [];
|
|
1264
|
+
for (let i = 0; i < 8; i++) {
|
|
1265
|
+
if (fs.existsSync(`/dev/video${i}`)) devices.push(`/dev/video${i}`);
|
|
1266
|
+
}
|
|
1267
|
+
return devices;
|
|
1268
|
+
}
|
|
1269
|
+
return [];
|
|
1270
|
+
},
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1274
|
+
// 10. POWER — Battery / Sleep / Shutdown
|
|
1275
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1276
|
+
|
|
1277
|
+
const Power = {
|
|
1278
|
+
/**
|
|
1279
|
+
* Get battery information.
|
|
1280
|
+
* @returns {{ level: number, charging: boolean, full: boolean, timeRemaining: number|null }|null}
|
|
1281
|
+
*/
|
|
1282
|
+
battery() {
|
|
1283
|
+
if (IS_MACOS) {
|
|
1284
|
+
const r = _out('pmset -g batt');
|
|
1285
|
+
const levelM = r.match(/(\d+)%/);
|
|
1286
|
+
const chargeM = r.match(/AC Power/) ?? r.match(/charging/i);
|
|
1287
|
+
const timeM = r.match(/(\d+):(\d+) remaining/);
|
|
1288
|
+
return {
|
|
1289
|
+
level: levelM ? +levelM[1] : null,
|
|
1290
|
+
charging: !!chargeM,
|
|
1291
|
+
full: r.includes('fully charged'),
|
|
1292
|
+
timeRemaining: timeM ? +timeM[1] * 60 + +timeM[2] : null,
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
if (IS_WINDOWS) {
|
|
1296
|
+
const r = _pwsh(`(Get-WmiObject Win32_Battery) | Select-Object EstimatedChargeRemaining, BatteryStatus | ConvertTo-Csv -NoTypeInformation`);
|
|
1297
|
+
const lines = r.trim().split('\n');
|
|
1298
|
+
if (lines.length < 2) return null;
|
|
1299
|
+
const [level, status] = lines[1].replace(/"/g, '').split(',').map(s => s.trim());
|
|
1300
|
+
return { level: +level, charging: status === '2', full: status === '1', timeRemaining: null };
|
|
1301
|
+
}
|
|
1302
|
+
if (IS_ANDROID) {
|
|
1303
|
+
try { return JSON.parse(_out('termux-battery-status')); } catch { return null; }
|
|
1304
|
+
}
|
|
1305
|
+
if (IS_LINUX) {
|
|
1306
|
+
const battDir = '/sys/class/power_supply';
|
|
1307
|
+
if (!fs.existsSync(battDir)) return null;
|
|
1308
|
+
const batts = fs.readdirSync(battDir).filter(d => d.startsWith('BAT'));
|
|
1309
|
+
if (!batts.length) return null;
|
|
1310
|
+
const batt = path.join(battDir, batts[0]);
|
|
1311
|
+
const read = (f) => { try { return fs.readFileSync(path.join(batt, f), 'utf8').trim(); } catch { return null; } };
|
|
1312
|
+
const cap = read('capacity');
|
|
1313
|
+
const status = read('status');
|
|
1314
|
+
return {
|
|
1315
|
+
level: cap ? +cap : null,
|
|
1316
|
+
charging: status === 'Charging',
|
|
1317
|
+
full: status === 'Full',
|
|
1318
|
+
timeRemaining: null,
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
return null;
|
|
1322
|
+
},
|
|
1323
|
+
|
|
1324
|
+
/** Put the system to sleep. */
|
|
1325
|
+
sleep() {
|
|
1326
|
+
if (IS_MACOS) _run('pmset sleepnow');
|
|
1327
|
+
else if (IS_WINDOWS) _pwsh('Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Application]::SetSuspendState("Suspend",$false,$false)');
|
|
1328
|
+
else if (IS_LINUX) _run('systemctl suspend');
|
|
1329
|
+
else if (IS_ANDROID) _spawn('termux-toast', ['Sleep not supported on Android']);
|
|
1330
|
+
},
|
|
1331
|
+
|
|
1332
|
+
/** Shut down the system. */
|
|
1333
|
+
shutdown(delay = 0) {
|
|
1334
|
+
if (IS_MACOS) _run(`shutdown -h +${delay}`);
|
|
1335
|
+
else if (IS_WINDOWS) _run(`shutdown /s /t ${delay * 60}`);
|
|
1336
|
+
else if (IS_LINUX) _run(`shutdown -h +${delay}`);
|
|
1337
|
+
},
|
|
1338
|
+
|
|
1339
|
+
/** Restart the system. */
|
|
1340
|
+
restart(delay = 0) {
|
|
1341
|
+
if (IS_MACOS) _run(`shutdown -r +${delay}`);
|
|
1342
|
+
else if (IS_WINDOWS) _run(`shutdown /r /t ${delay * 60}`);
|
|
1343
|
+
else if (IS_LINUX) _run(`shutdown -r +${delay}`);
|
|
1344
|
+
},
|
|
1345
|
+
|
|
1346
|
+
/** Lock the screen. */
|
|
1347
|
+
lock() {
|
|
1348
|
+
if (IS_MACOS) {
|
|
1349
|
+
_run('/System/Library/CoreServices/"Menu Extras"/User.menu/Contents/Resources/CGSession -suspend 2>/dev/null || pmset displaysleepnow');
|
|
1350
|
+
} else if (IS_WINDOWS) {
|
|
1351
|
+
_run('rundll32.exe user32.dll,LockWorkStation');
|
|
1352
|
+
} else if (IS_ANDROID) {
|
|
1353
|
+
_spawn('termux-toast', ['Screen lock not directly supported']);
|
|
1354
|
+
} else if (IS_LINUX) {
|
|
1355
|
+
const lockers = ['gnome-screensaver-command -l', 'xdg-screensaver lock', 'loginctl lock-session', 'xscreensaver-command -lock', 'i3lock'];
|
|
1356
|
+
for (const cmd of lockers) { if (_run(cmd).code === 0) break; }
|
|
1357
|
+
}
|
|
1358
|
+
},
|
|
1359
|
+
|
|
1360
|
+
/** Prevent the system from sleeping (returns a stopper function). */
|
|
1361
|
+
preventSleep(reason = 'Nova process') {
|
|
1362
|
+
let child;
|
|
1363
|
+
if (IS_MACOS) {
|
|
1364
|
+
child = spawn('caffeinate', ['-d', '-i'], { detached: true, stdio: 'ignore' });
|
|
1365
|
+
child.unref();
|
|
1366
|
+
} else if (IS_WINDOWS) {
|
|
1367
|
+
child = spawn('powershell', ['-NoProfile', '-Command',
|
|
1368
|
+
`while ($true) { (New-Object -ComObject WScript.Shell).SendKeys([char]0); Start-Sleep -Seconds 60 }`
|
|
1369
|
+
], { detached: true, stdio: 'ignore' });
|
|
1370
|
+
child.unref();
|
|
1371
|
+
} else if (IS_LINUX) {
|
|
1372
|
+
if (_run('which systemd-inhibit').code === 0) {
|
|
1373
|
+
child = spawn('systemd-inhibit', ['--what=sleep:idle', '--why=' + reason, 'sleep', 'infinity'], { detached: true, stdio: 'ignore' });
|
|
1374
|
+
child.unref();
|
|
1375
|
+
} else if (_run('which caffeine').code === 0) {
|
|
1376
|
+
child = spawn('caffeine', [], { detached: true, stdio: 'ignore' });
|
|
1377
|
+
child.unref();
|
|
1378
|
+
}
|
|
1379
|
+
} else if (IS_ANDROID) {
|
|
1380
|
+
_spawn('termux-wake-lock');
|
|
1381
|
+
}
|
|
1382
|
+
return () => {
|
|
1383
|
+
if (child) { try { child.kill(); } catch {} }
|
|
1384
|
+
if (IS_ANDROID) _spawn('termux-wake-unlock');
|
|
1385
|
+
};
|
|
1386
|
+
},
|
|
1387
|
+
|
|
1388
|
+
/** Cancel a scheduled shutdown. */
|
|
1389
|
+
cancelShutdown() {
|
|
1390
|
+
if (IS_MACOS || IS_LINUX) _run('shutdown -c 2>/dev/null');
|
|
1391
|
+
else if (IS_WINDOWS) _run('shutdown /a');
|
|
1392
|
+
},
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1396
|
+
// 11. BRIGHTNESS — Display Brightness
|
|
1397
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1398
|
+
|
|
1399
|
+
const Brightness = {
|
|
1400
|
+
/**
|
|
1401
|
+
* Get display brightness (0–100).
|
|
1402
|
+
* @param {number} [monitor=0] monitor index
|
|
1403
|
+
* @returns {number}
|
|
1404
|
+
*/
|
|
1405
|
+
get(monitor = 0) {
|
|
1406
|
+
if (IS_MACOS && _run('which brightness').code === 0) {
|
|
1407
|
+
const r = _out('brightness -l');
|
|
1408
|
+
const m = r.match(/display \d+: brightness ([\d.]+)/);
|
|
1409
|
+
return m ? Math.round(parseFloat(m[1]) * 100) : 100;
|
|
1410
|
+
}
|
|
1411
|
+
if (IS_WINDOWS) {
|
|
1412
|
+
const r = _pwsh('(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightness).CurrentBrightness');
|
|
1413
|
+
return parseInt(r) || 100;
|
|
1414
|
+
}
|
|
1415
|
+
if (IS_ANDROID) {
|
|
1416
|
+
try { return JSON.parse(_out('termux-brightness'))?.brightness ?? 100; } catch { return 100; }
|
|
1417
|
+
}
|
|
1418
|
+
if (IS_LINUX) {
|
|
1419
|
+
// Try /sys/class/backlight
|
|
1420
|
+
const bl = '/sys/class/backlight';
|
|
1421
|
+
if (fs.existsSync(bl)) {
|
|
1422
|
+
const devices = fs.readdirSync(bl);
|
|
1423
|
+
if (devices.length > 0) {
|
|
1424
|
+
const dev = path.join(bl, devices[monitor] ?? devices[0]);
|
|
1425
|
+
const cur = +fs.readFileSync(path.join(dev, 'brightness'), 'utf8').trim();
|
|
1426
|
+
const max = +fs.readFileSync(path.join(dev, 'max_brightness'), 'utf8').trim();
|
|
1427
|
+
return Math.round((cur / max) * 100);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
if (_run('which xrandr').code === 0) {
|
|
1431
|
+
const r = _out('xrandr --verbose 2>/dev/null');
|
|
1432
|
+
const m = r.match(/Brightness: ([\d.]+)/);
|
|
1433
|
+
return m ? Math.round(parseFloat(m[1]) * 100) : 100;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
return 100;
|
|
1437
|
+
},
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Set display brightness.
|
|
1441
|
+
* @param {number} level 0–100
|
|
1442
|
+
* @param {number} [monitor=0]
|
|
1443
|
+
*/
|
|
1444
|
+
set(level, monitor = 0) {
|
|
1445
|
+
level = Math.max(0, Math.min(100, Math.round(level)));
|
|
1446
|
+
if (IS_MACOS && _run('which brightness').code === 0) {
|
|
1447
|
+
_spawn('brightness', [String(level / 100)]);
|
|
1448
|
+
} else if (IS_WINDOWS) {
|
|
1449
|
+
_pwsh(`(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1, ${level})`);
|
|
1450
|
+
} else if (IS_ANDROID) {
|
|
1451
|
+
_spawn('termux-brightness', [String(Math.round(level * 2.55))]);
|
|
1452
|
+
} else if (IS_LINUX) {
|
|
1453
|
+
const bl = '/sys/class/backlight';
|
|
1454
|
+
if (fs.existsSync(bl)) {
|
|
1455
|
+
const devices = fs.readdirSync(bl);
|
|
1456
|
+
if (devices.length > 0) {
|
|
1457
|
+
const dev = path.join(bl, devices[monitor] ?? devices[0]);
|
|
1458
|
+
const max = +fs.readFileSync(path.join(dev, 'max_brightness'), 'utf8').trim();
|
|
1459
|
+
const val = Math.round((level / 100) * max);
|
|
1460
|
+
try { fs.writeFileSync(path.join(dev, 'brightness'), String(val)); return; } catch {}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
if (_run('which xrandr').code === 0) {
|
|
1464
|
+
const r = _out('xrandr --listactivemonitors 2>/dev/null');
|
|
1465
|
+
const m = r.match(/\d+:\s+\S+\s+(\S+)/g);
|
|
1466
|
+
const displayName = m ? m[monitor ?? 0]?.split(/\s+/).pop() : null;
|
|
1467
|
+
if (displayName) _spawn('xrandr', ['--output', displayName, '--brightness', String(level / 100)]);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
},
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1474
|
+
// 12. WIFI — Wireless Network
|
|
1475
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1476
|
+
|
|
1477
|
+
const Wifi = {
|
|
1478
|
+
/**
|
|
1479
|
+
* Get current SSID.
|
|
1480
|
+
* @returns {string|null}
|
|
1481
|
+
*/
|
|
1482
|
+
currentSSID() {
|
|
1483
|
+
if (IS_MACOS) {
|
|
1484
|
+
return _out('/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I 2>/dev/null').match(/\s+SSID: (.+)/)?.[1]?.trim() ?? null;
|
|
1485
|
+
}
|
|
1486
|
+
if (IS_WINDOWS) {
|
|
1487
|
+
return _out('netsh wlan show interfaces').match(/\s+SSID\s+:\s+(.+)/)?.[1]?.trim() ?? null;
|
|
1488
|
+
}
|
|
1489
|
+
if (IS_ANDROID) {
|
|
1490
|
+
try { return JSON.parse(_out('termux-wifi-connectioninfo'))?.ssid ?? null; } catch { return null; }
|
|
1491
|
+
}
|
|
1492
|
+
if (IS_LINUX) {
|
|
1493
|
+
return (_out('iwgetid -r 2>/dev/null').trim() ||
|
|
1494
|
+
_out('nmcli -t -f active,ssid dev wifi 2>/dev/null').split('\n').find(l => l.startsWith('yes:'))?.split(':')[1]) ?? null;
|
|
1495
|
+
}
|
|
1496
|
+
return null;
|
|
1497
|
+
},
|
|
1498
|
+
|
|
1499
|
+
/**
|
|
1500
|
+
* Get detailed connection info.
|
|
1501
|
+
* @returns {object|null}
|
|
1502
|
+
*/
|
|
1503
|
+
connectionInfo() {
|
|
1504
|
+
if (IS_ANDROID) {
|
|
1505
|
+
try { return JSON.parse(_out('termux-wifi-connectioninfo')); } catch { return null; }
|
|
1506
|
+
}
|
|
1507
|
+
if (IS_MACOS) {
|
|
1508
|
+
const r = _out('/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I 2>/dev/null');
|
|
1509
|
+
const info = {};
|
|
1510
|
+
for (const m of r.matchAll(/\s+(\S+): (.+)/g)) info[m[1].trim()] = m[2].trim();
|
|
1511
|
+
return info;
|
|
1512
|
+
}
|
|
1513
|
+
if (IS_LINUX) {
|
|
1514
|
+
return { ssid: Wifi.currentSSID(), signal: Wifi.signalStrength() };
|
|
1515
|
+
}
|
|
1516
|
+
return null;
|
|
1517
|
+
},
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Scan for available WiFi networks.
|
|
1521
|
+
* @returns {Array<{ ssid, signal, security, channel }>}
|
|
1522
|
+
*/
|
|
1523
|
+
scan() {
|
|
1524
|
+
if (IS_ANDROID) {
|
|
1525
|
+
try { return JSON.parse(_out('termux-wifi-scaninfo') || '[]'); } catch { return []; }
|
|
1526
|
+
}
|
|
1527
|
+
if (IS_MACOS) {
|
|
1528
|
+
const r = _out('/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -s 2>/dev/null');
|
|
1529
|
+
return r.split('\n').slice(1).filter(Boolean).map(l => {
|
|
1530
|
+
const parts = l.trim().split(/\s+/);
|
|
1531
|
+
return { ssid: parts[0], bssid: parts[1], signal: +parts[2], channel: +parts[3], security: parts.slice(5).join(' ') };
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
if (IS_LINUX) {
|
|
1535
|
+
const r = _out('nmcli -t -f SSID,SIGNAL,SECURITY,CHAN dev wifi list 2>/dev/null');
|
|
1536
|
+
return r.split('\n').filter(Boolean).map(l => {
|
|
1537
|
+
const [ssid, signal, security, channel] = l.split(':');
|
|
1538
|
+
return { ssid, signal: +signal, security, channel: +channel };
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
if (IS_WINDOWS) {
|
|
1542
|
+
const r = _out('netsh wlan show networks mode=bssid');
|
|
1543
|
+
const networks = [];
|
|
1544
|
+
let current = {};
|
|
1545
|
+
for (const line of r.split('\n')) {
|
|
1546
|
+
const m = line.match(/\s+SSID\s+\d+\s+:\s+(.+)/); if (m) current = { ssid: m[1].trim() };
|
|
1547
|
+
const s = line.match(/\s+Signal\s+:\s+(\d+)%/); if (s) current.signal = +s[1];
|
|
1548
|
+
const a = line.match(/\s+Authentication\s+:\s+(.+)/); if (a) { current.security = a[1].trim(); networks.push(current); }
|
|
1549
|
+
}
|
|
1550
|
+
return networks;
|
|
1551
|
+
}
|
|
1552
|
+
return [];
|
|
1553
|
+
},
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* Get signal strength in dBm or percent.
|
|
1557
|
+
* @returns {number|null}
|
|
1558
|
+
*/
|
|
1559
|
+
signalStrength() {
|
|
1560
|
+
if (IS_ANDROID) {
|
|
1561
|
+
try { return JSON.parse(_out('termux-wifi-connectioninfo'))?.rssi ?? null; } catch { return null; }
|
|
1562
|
+
}
|
|
1563
|
+
if (IS_LINUX) {
|
|
1564
|
+
const r = _out('iwconfig 2>/dev/null');
|
|
1565
|
+
const m = r.match(/Signal level=([\-\d]+) dBm/);
|
|
1566
|
+
return m ? +m[1] : null;
|
|
1567
|
+
}
|
|
1568
|
+
return null;
|
|
1569
|
+
},
|
|
1570
|
+
|
|
1571
|
+
/** Enable WiFi (Android + Linux nmcli). */
|
|
1572
|
+
enable() {
|
|
1573
|
+
if (IS_ANDROID) _spawn('termux-wifi-enable', ['true']);
|
|
1574
|
+
else if (IS_LINUX && _run('which nmcli').code === 0) _spawn('nmcli', ['radio', 'wifi', 'on']);
|
|
1575
|
+
else if (IS_WINDOWS) _run('netsh interface set interface Wi-Fi admin=enabled');
|
|
1576
|
+
},
|
|
1577
|
+
|
|
1578
|
+
/** Disable WiFi. */
|
|
1579
|
+
disable() {
|
|
1580
|
+
if (IS_ANDROID) _spawn('termux-wifi-enable', ['false']);
|
|
1581
|
+
else if (IS_LINUX && _run('which nmcli').code === 0) _spawn('nmcli', ['radio', 'wifi', 'off']);
|
|
1582
|
+
else if (IS_WINDOWS) _run('netsh interface set interface Wi-Fi admin=disabled');
|
|
1583
|
+
},
|
|
1584
|
+
};
|
|
1585
|
+
|
|
1586
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1587
|
+
// 13. BLUETOOTH — Bluetooth Control
|
|
1588
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1589
|
+
|
|
1590
|
+
const Bluetooth = {
|
|
1591
|
+
/**
|
|
1592
|
+
* Toggle Bluetooth on/off.
|
|
1593
|
+
* @param {boolean} on
|
|
1594
|
+
*/
|
|
1595
|
+
setEnabled(on) {
|
|
1596
|
+
if (IS_MACOS) _run(`blueutil --power ${on ? 1 : 0} 2>/dev/null || echo "install blueutil: brew install blueutil"`);
|
|
1597
|
+
else if (IS_WINDOWS) _pwsh(`Add-Type -AssemblyName System.Runtime.WindowsRuntime; [Windows.Devices.Radios.Radio,Windows.Devices.Radios, ContentType=WindowsRuntime] | Out-Null`);
|
|
1598
|
+
else if (IS_ANDROID) _spawn('termux-bluetooth-scan', on ? ['-le'] : []);
|
|
1599
|
+
else if (IS_LINUX) _run(`rfkill ${on ? 'unblock' : 'block'} bluetooth 2>/dev/null`);
|
|
1600
|
+
},
|
|
1601
|
+
|
|
1602
|
+
/** Is Bluetooth enabled? */
|
|
1603
|
+
isEnabled() {
|
|
1604
|
+
if (IS_MACOS) return _out('blueutil --power 2>/dev/null').trim() === '1';
|
|
1605
|
+
if (IS_LINUX) return _out('rfkill list bluetooth 2>/dev/null').includes('Soft blocked: no');
|
|
1606
|
+
if (IS_ANDROID) { try { return JSON.parse(_out('termux-bluetooth-info 2>/dev/null') || '{}').enabled ?? false; } catch { return false; } }
|
|
1607
|
+
return false;
|
|
1608
|
+
},
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* List paired devices.
|
|
1612
|
+
* @returns {Array<{ id, name, connected, paired }>}
|
|
1613
|
+
*/
|
|
1614
|
+
pairedDevices() {
|
|
1615
|
+
if (IS_MACOS) {
|
|
1616
|
+
const r = _out('system_profiler SPBluetoothDataType 2>/dev/null');
|
|
1617
|
+
const devices = [];
|
|
1618
|
+
for (const m of r.matchAll(/(\S[\w\s]+):\s*\n\s+Address: ([\w:]+)/g)) {
|
|
1619
|
+
devices.push({ name: m[1].trim(), id: m[2], paired: true, connected: r.includes(`${m[1].trim()}`) });
|
|
1620
|
+
}
|
|
1621
|
+
return devices;
|
|
1622
|
+
}
|
|
1623
|
+
if (IS_LINUX && _run('which bluetoothctl').code === 0) {
|
|
1624
|
+
const r = _out('bluetoothctl devices');
|
|
1625
|
+
return r.split('\n').filter(Boolean).map(l => {
|
|
1626
|
+
const m = l.match(/Device ([\w:]+) (.+)/);
|
|
1627
|
+
return m ? { id: m[1], name: m[2], paired: true } : null;
|
|
1628
|
+
}).filter(Boolean);
|
|
1629
|
+
}
|
|
1630
|
+
if (IS_ANDROID) {
|
|
1631
|
+
try { return JSON.parse(_out('termux-bluetooth-paired-devices 2>/dev/null') || '[]'); } catch { return []; }
|
|
1632
|
+
}
|
|
1633
|
+
return [];
|
|
1634
|
+
},
|
|
1635
|
+
|
|
1636
|
+
/** Scan for nearby Bluetooth devices (Linux bluetoothctl). */
|
|
1637
|
+
scan(durationMs = 5000) {
|
|
1638
|
+
if (IS_LINUX && _run('which bluetoothctl').code === 0) {
|
|
1639
|
+
_run(`bluetoothctl scan on & sleep ${Math.round(durationMs/1000)} && bluetoothctl scan off`);
|
|
1640
|
+
return Bluetooth.pairedDevices();
|
|
1641
|
+
}
|
|
1642
|
+
if (IS_ANDROID) {
|
|
1643
|
+
try { return JSON.parse(_out('termux-bluetooth-scan -d 5000 2>/dev/null') || '[]'); } catch { return []; }
|
|
1644
|
+
}
|
|
1645
|
+
return [];
|
|
1646
|
+
},
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Connect to a device by MAC address.
|
|
1650
|
+
* @param {string} address e.g. 'AA:BB:CC:DD:EE:FF'
|
|
1651
|
+
*/
|
|
1652
|
+
connect(address) {
|
|
1653
|
+
if (IS_LINUX && _run('which bluetoothctl').code === 0) {
|
|
1654
|
+
_run(`bluetoothctl connect ${address}`);
|
|
1655
|
+
} else if (IS_ANDROID) {
|
|
1656
|
+
_spawn('termux-bluetooth-connect', [address]);
|
|
1657
|
+
}
|
|
1658
|
+
},
|
|
1659
|
+
|
|
1660
|
+
/** Disconnect from a device. */
|
|
1661
|
+
disconnect(address) {
|
|
1662
|
+
if (IS_LINUX && _run('which bluetoothctl').code === 0) {
|
|
1663
|
+
_run(`bluetoothctl disconnect ${address}`);
|
|
1664
|
+
}
|
|
1665
|
+
},
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1669
|
+
// 14. VOLUME — Fine-grained audio volume control
|
|
1670
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1671
|
+
|
|
1672
|
+
const Volume = {
|
|
1673
|
+
/** Get volume for a specific stream type (Android: music, ring, alarm, notification). */
|
|
1674
|
+
getStream(stream = 'music') {
|
|
1675
|
+
if (IS_ANDROID) {
|
|
1676
|
+
try {
|
|
1677
|
+
const vols = JSON.parse(_out('termux-volume'));
|
|
1678
|
+
const s = vols.find(v => v.stream === stream);
|
|
1679
|
+
return s ? s.volume : null;
|
|
1680
|
+
} catch { return null; }
|
|
1681
|
+
}
|
|
1682
|
+
return Audio.getVolume();
|
|
1683
|
+
},
|
|
1684
|
+
|
|
1685
|
+
/** Set volume for a specific stream type. */
|
|
1686
|
+
setStream(stream, level) {
|
|
1687
|
+
if (IS_ANDROID) { _spawn('termux-volume', [stream, String(level)]); return; }
|
|
1688
|
+
Audio.setVolume(level);
|
|
1689
|
+
},
|
|
1690
|
+
|
|
1691
|
+
/** Get microphone input level (0–100). */
|
|
1692
|
+
getMicLevel() {
|
|
1693
|
+
if (IS_LINUX && _run('which amixer').code === 0) {
|
|
1694
|
+
const r = _out('amixer sget Capture 2>/dev/null || amixer sget "Mic" 2>/dev/null');
|
|
1695
|
+
const m = r.match(/(\d+)%/);
|
|
1696
|
+
return m ? +m[1] : null;
|
|
1697
|
+
}
|
|
1698
|
+
if (IS_MACOS) {
|
|
1699
|
+
const r = _out('osascript -e "input volume of (get volume settings)"').trim();
|
|
1700
|
+
return parseInt(r) ?? null;
|
|
1701
|
+
}
|
|
1702
|
+
if (IS_WINDOWS) {
|
|
1703
|
+
return parseInt(_pwsh(`[math]::Round((Get-AudioDevice -Microphone).Volume * 100)`) ?? '0');
|
|
1704
|
+
}
|
|
1705
|
+
return null;
|
|
1706
|
+
},
|
|
1707
|
+
|
|
1708
|
+
/** Set microphone input level (0–100). */
|
|
1709
|
+
setMicLevel(level) {
|
|
1710
|
+
level = Math.max(0, Math.min(100, Math.round(level)));
|
|
1711
|
+
if (IS_MACOS) _osascript(`set volume input volume ${level}`);
|
|
1712
|
+
else if (IS_LINUX && _run('which amixer').code === 0) {
|
|
1713
|
+
_spawn('amixer', ['sset', 'Capture', `${level}%`]);
|
|
1714
|
+
} else if (IS_WINDOWS) {
|
|
1715
|
+
_pwsh(`(Get-AudioDevice -Microphone).Volume = ${level / 100}`);
|
|
1716
|
+
}
|
|
1717
|
+
},
|
|
1718
|
+
|
|
1719
|
+
/** List output devices. */
|
|
1720
|
+
outputDevices() { return Audio.devices(); },
|
|
1721
|
+
|
|
1722
|
+
/** Switch default audio output device (Linux PulseAudio). */
|
|
1723
|
+
switchOutput(sinkName) {
|
|
1724
|
+
if (IS_LINUX && _run('which pactl').code === 0) {
|
|
1725
|
+
_spawn('pactl', ['set-default-sink', sinkName]);
|
|
1726
|
+
}
|
|
1727
|
+
},
|
|
1728
|
+
|
|
1729
|
+
// All basic ops delegate to Audio
|
|
1730
|
+
get: () => Audio.getVolume(),
|
|
1731
|
+
set: (l) => Audio.setVolume(l),
|
|
1732
|
+
mute: () => Audio.mute(),
|
|
1733
|
+
unmute: () => Audio.unmute(),
|
|
1734
|
+
toggle: () => Audio.toggleMute(),
|
|
1735
|
+
isMuted: () => Audio.isMuted(),
|
|
1736
|
+
};
|
|
1737
|
+
|
|
1738
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1739
|
+
// 15. TRASH — Send Files to Trash
|
|
1740
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1741
|
+
|
|
1742
|
+
const Trash = {
|
|
1743
|
+
/**
|
|
1744
|
+
* Move a file or directory to the system trash.
|
|
1745
|
+
* @param {string} filePath
|
|
1746
|
+
* @returns {boolean}
|
|
1747
|
+
*/
|
|
1748
|
+
send(filePath) {
|
|
1749
|
+
filePath = path.resolve(filePath);
|
|
1750
|
+
if (IS_MACOS) {
|
|
1751
|
+
const r = _osascript(`tell application "Finder" to delete POSIX file "${_escAS(filePath)}"`);
|
|
1752
|
+
return !r.includes('error');
|
|
1753
|
+
}
|
|
1754
|
+
if (IS_WINDOWS) {
|
|
1755
|
+
const r = _pwsh(`
|
|
1756
|
+
Add-Type -AssemblyName Microsoft.VisualBasic
|
|
1757
|
+
[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile('${filePath.replace(/'/g, "''")}', 'OnlyErrorDialogs', 'SendToRecycleBin')
|
|
1758
|
+
`);
|
|
1759
|
+
return r.trim() === '';
|
|
1760
|
+
}
|
|
1761
|
+
if (IS_ANDROID) {
|
|
1762
|
+
// Termux has no trash; move to ~/_trash
|
|
1763
|
+
const trashDir = path.join(os.homedir(), '_trash');
|
|
1764
|
+
fs.mkdirSync(trashDir, { recursive: true });
|
|
1765
|
+
fs.renameSync(filePath, path.join(trashDir, path.basename(filePath) + '.' + Date.now()));
|
|
1766
|
+
return true;
|
|
1767
|
+
}
|
|
1768
|
+
if (IS_LINUX) {
|
|
1769
|
+
// Try trash-cli
|
|
1770
|
+
if (_run('which trash-put').code === 0) return _spawn('trash-put', [filePath]).code === 0;
|
|
1771
|
+
if (_run('which gio').code === 0) return _spawn('gio', ['trash', filePath]).code === 0;
|
|
1772
|
+
// XDG Trash spec fallback
|
|
1773
|
+
const trashDir = path.join(os.homedir(), '.local', 'share', 'Trash');
|
|
1774
|
+
const files = path.join(trashDir, 'files');
|
|
1775
|
+
const info = path.join(trashDir, 'info');
|
|
1776
|
+
fs.mkdirSync(files, { recursive: true });
|
|
1777
|
+
fs.mkdirSync(info, { recursive: true });
|
|
1778
|
+
const name = path.basename(filePath);
|
|
1779
|
+
const dest = path.join(files, name);
|
|
1780
|
+
fs.renameSync(filePath, dest);
|
|
1781
|
+
const trashInfo = `[Trash Info]\nPath=${filePath}\nDeletionDate=${new Date().toISOString().replace(/\..+/, '')}\n`;
|
|
1782
|
+
fs.writeFileSync(path.join(info, name + '.trashinfo'), trashInfo, 'utf8');
|
|
1783
|
+
return true;
|
|
1784
|
+
}
|
|
1785
|
+
return false;
|
|
1786
|
+
},
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* List items in the trash.
|
|
1790
|
+
* @returns {Array<{ name, path, date }>}
|
|
1791
|
+
*/
|
|
1792
|
+
list() {
|
|
1793
|
+
if (IS_MACOS) {
|
|
1794
|
+
const trashPath = path.join(os.homedir(), '.Trash');
|
|
1795
|
+
return fs.readdirSync(trashPath).map(f => ({ name: f, path: path.join(trashPath, f) }));
|
|
1796
|
+
}
|
|
1797
|
+
if (IS_LINUX) {
|
|
1798
|
+
if (_run('which trash-list').code === 0) {
|
|
1799
|
+
return _out('trash-list').split('\n').filter(Boolean).map(l => {
|
|
1800
|
+
const m = l.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (.+)/);
|
|
1801
|
+
return m ? { date: m[1], path: m[2], name: path.basename(m[2]) } : null;
|
|
1802
|
+
}).filter(Boolean);
|
|
1803
|
+
}
|
|
1804
|
+
const infoDir = path.join(os.homedir(), '.local', 'share', 'Trash', 'info');
|
|
1805
|
+
if (!fs.existsSync(infoDir)) return [];
|
|
1806
|
+
return fs.readdirSync(infoDir).map(f => ({ name: f.replace('.trashinfo', '') }));
|
|
1807
|
+
}
|
|
1808
|
+
if (IS_WINDOWS) {
|
|
1809
|
+
const r = _pwsh(`(New-Object -ComObject Shell.Application).NameSpace(10).Items() | ForEach-Object { $_.Path }`);
|
|
1810
|
+
return r.split('\n').filter(Boolean).map(p => ({ name: path.basename(p.trim()), path: p.trim() }));
|
|
1811
|
+
}
|
|
1812
|
+
if (IS_ANDROID) {
|
|
1813
|
+
const td = path.join(os.homedir(), '_trash');
|
|
1814
|
+
if (!fs.existsSync(td)) return [];
|
|
1815
|
+
return fs.readdirSync(td).map(f => ({ name: f, path: path.join(td, f) }));
|
|
1816
|
+
}
|
|
1817
|
+
return [];
|
|
1818
|
+
},
|
|
1819
|
+
|
|
1820
|
+
/** Empty the trash. */
|
|
1821
|
+
empty() {
|
|
1822
|
+
if (IS_MACOS) {
|
|
1823
|
+
_osascript('tell application "Finder" to empty trash');
|
|
1824
|
+
} else if (IS_LINUX) {
|
|
1825
|
+
if (_run('which trash-empty').code === 0) _run('trash-empty');
|
|
1826
|
+
else {
|
|
1827
|
+
const td = path.join(os.homedir(), '.local', 'share', 'Trash');
|
|
1828
|
+
_run(`rm -rf "${td}/files/"* "${td}/info/"* 2>/dev/null`);
|
|
1829
|
+
}
|
|
1830
|
+
} else if (IS_WINDOWS) {
|
|
1831
|
+
_pwsh('(New-Object -ComObject Shell.Application).NameSpace(10).Items() | ForEach-Object { Remove-Item $_.Path -Recurse -Force }');
|
|
1832
|
+
} else if (IS_ANDROID) {
|
|
1833
|
+
const td = path.join(os.homedir(), '_trash');
|
|
1834
|
+
_run(`rm -rf ${_esc(td)}/* 2>/dev/null`);
|
|
1835
|
+
}
|
|
1836
|
+
},
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1840
|
+
// 16. OPEN — Open Files / URLs / Apps
|
|
1841
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1842
|
+
|
|
1843
|
+
const Open = {
|
|
1844
|
+
/**
|
|
1845
|
+
* Open a file, URL, or application with the system default handler.
|
|
1846
|
+
* @param {string} target file path, URL, or app name
|
|
1847
|
+
* @param {object} [opts]
|
|
1848
|
+
* @param {string} [opts.app] specific app to open with
|
|
1849
|
+
* @param {boolean} [opts.reveal] reveal in file manager instead of opening
|
|
1850
|
+
*/
|
|
1851
|
+
open(target, opts = {}) {
|
|
1852
|
+
target = String(target);
|
|
1853
|
+
if (opts.reveal) return Open.reveal(target);
|
|
1854
|
+
|
|
1855
|
+
if (IS_MACOS) {
|
|
1856
|
+
const args = [target];
|
|
1857
|
+
if (opts.app) args.unshift('-a', opts.app);
|
|
1858
|
+
spawn('open', args, { detached: true, stdio: 'ignore' }).unref();
|
|
1859
|
+
} else if (IS_WINDOWS) {
|
|
1860
|
+
if (opts.app) spawn('cmd', ['/c', 'start', '', opts.app, target], { detached: true, stdio: 'ignore' }).unref();
|
|
1861
|
+
else spawn('cmd', ['/c', 'start', '', target], { detached: true, stdio: 'ignore' }).unref();
|
|
1862
|
+
} else if (IS_ANDROID) {
|
|
1863
|
+
_spawn('termux-open', [target]);
|
|
1864
|
+
} else if (IS_LINUX) {
|
|
1865
|
+
const opener = _run('which xdg-open').code === 0 ? 'xdg-open'
|
|
1866
|
+
: _run('which gio').code === 0 ? 'gio'
|
|
1867
|
+
: _run('which mimeopen').code === 0 ? 'mimeopen'
|
|
1868
|
+
: null;
|
|
1869
|
+
if (opener) spawn(opener, opener === 'gio' ? ['open', target] : [target], { detached: true, stdio: 'ignore' }).unref();
|
|
1870
|
+
}
|
|
1871
|
+
},
|
|
1872
|
+
|
|
1873
|
+
/** Open a URL in the default browser. */
|
|
1874
|
+
url(url) { Open.open(url); },
|
|
1875
|
+
|
|
1876
|
+
/** Open a file with its default application. */
|
|
1877
|
+
file(filePath) { Open.open(path.resolve(filePath)); },
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* Reveal a file in Finder / Explorer / Nautilus / Files.
|
|
1881
|
+
* @param {string} filePath
|
|
1882
|
+
*/
|
|
1883
|
+
reveal(filePath) {
|
|
1884
|
+
filePath = path.resolve(filePath);
|
|
1885
|
+
if (IS_MACOS) {
|
|
1886
|
+
_osascript(`tell application "Finder" to reveal POSIX file "${_escAS(filePath)}"\ntell application "Finder" to activate`);
|
|
1887
|
+
} else if (IS_WINDOWS) {
|
|
1888
|
+
_run(`explorer /select,"${filePath.replace(/"/g, '\\"')}"`);
|
|
1889
|
+
} else if (IS_LINUX) {
|
|
1890
|
+
const parent = path.dirname(filePath);
|
|
1891
|
+
if (_run('which nautilus').code === 0) spawn('nautilus', ['--select', filePath], { detached: true, stdio: 'ignore' }).unref();
|
|
1892
|
+
else if (_run('which dolphin').code === 0) spawn('dolphin', ['--select', filePath], { detached: true, stdio: 'ignore' }).unref();
|
|
1893
|
+
else if (_run('which thunar').code === 0) spawn('thunar', [parent], { detached: true, stdio: 'ignore' }).unref();
|
|
1894
|
+
else spawn('xdg-open', [parent], { detached: true, stdio: 'ignore' }).unref();
|
|
1895
|
+
}
|
|
1896
|
+
},
|
|
1897
|
+
|
|
1898
|
+
/** Open a terminal window (optionally with a command). */
|
|
1899
|
+
terminal(cmd, opts = {}) {
|
|
1900
|
+
if (IS_MACOS) {
|
|
1901
|
+
if (cmd) _osascript(`tell application "Terminal" to do script "${_escAS(cmd)}"\ntell application "Terminal" to activate`);
|
|
1902
|
+
else _osascript('tell application "Terminal" to activate');
|
|
1903
|
+
} else if (IS_WINDOWS) {
|
|
1904
|
+
if (cmd) spawn('cmd', ['/c', 'start', 'cmd', '/k', cmd], { detached: true, stdio: 'ignore' }).unref();
|
|
1905
|
+
else spawn('cmd', ['/c', 'start', 'cmd'], { detached: true, stdio: 'ignore' }).unref();
|
|
1906
|
+
} else if (IS_LINUX) {
|
|
1907
|
+
const terms = ['gnome-terminal', 'konsole', 'xterm', 'xfce4-terminal', 'lxterminal', 'alacritty', 'kitty'];
|
|
1908
|
+
for (const t of terms) {
|
|
1909
|
+
if (_run(`which ${t}`).code === 0) {
|
|
1910
|
+
const args = cmd ? (t === 'gnome-terminal' ? ['--', 'bash', '-c', cmd] : ['-e', cmd]) : [];
|
|
1911
|
+
spawn(t, args, { detached: true, stdio: 'ignore' }).unref();
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
},
|
|
1917
|
+
};
|
|
1918
|
+
|
|
1919
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1920
|
+
// 17. AUTOSTART — Startup Items
|
|
1921
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1922
|
+
|
|
1923
|
+
const Autostart = {
|
|
1924
|
+
/**
|
|
1925
|
+
* Add a startup item.
|
|
1926
|
+
* @param {string} name identifier / display name
|
|
1927
|
+
* @param {string} command command to run (full path recommended)
|
|
1928
|
+
* @param {object} [opts]
|
|
1929
|
+
* @param {boolean} [opts.hidden] (macOS Login Item)
|
|
1930
|
+
*/
|
|
1931
|
+
add(name, command, opts = {}) {
|
|
1932
|
+
name = String(name); command = String(command);
|
|
1933
|
+
if (IS_MACOS) {
|
|
1934
|
+
// Create a LaunchAgent plist in ~/Library/LaunchAgents
|
|
1935
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `com.nova.${name}.plist`);
|
|
1936
|
+
const args = command.split(' ');
|
|
1937
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1938
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1939
|
+
<plist version="1.0"><dict>
|
|
1940
|
+
<key>Label</key><string>com.nova.${name}</string>
|
|
1941
|
+
<key>ProgramArguments</key><array>${args.map(a => `<string>${a}</string>`).join('')}</array>
|
|
1942
|
+
<key>RunAtLoad</key><true/>
|
|
1943
|
+
${opts.hidden ? '<key>LSUIElement</key><true/>' : ''}
|
|
1944
|
+
</dict></plist>`;
|
|
1945
|
+
fs.writeFileSync(plistPath, plist, 'utf8');
|
|
1946
|
+
_run(`launchctl load "${plistPath}" 2>/dev/null`);
|
|
1947
|
+
return true;
|
|
1948
|
+
}
|
|
1949
|
+
if (IS_WINDOWS) {
|
|
1950
|
+
_run(`reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "${name}" /t REG_SZ /d "${command.replace(/"/g, '\\"')}" /f`);
|
|
1951
|
+
return true;
|
|
1952
|
+
}
|
|
1953
|
+
if (IS_ANDROID) {
|
|
1954
|
+
// Termux:Boot
|
|
1955
|
+
const bootDir = path.join(os.homedir(), '.termux', 'boot');
|
|
1956
|
+
fs.mkdirSync(bootDir, { recursive: true });
|
|
1957
|
+
const scriptPath = path.join(bootDir, `${name}.sh`);
|
|
1958
|
+
fs.writeFileSync(scriptPath, `#!/data/data/com.termux/files/usr/bin/bash\n${command}\n`, 'utf8');
|
|
1959
|
+
fs.chmodSync(scriptPath, 0o755);
|
|
1960
|
+
return true;
|
|
1961
|
+
}
|
|
1962
|
+
if (IS_LINUX) {
|
|
1963
|
+
const autostartDir = path.join(os.homedir(), '.config', 'autostart');
|
|
1964
|
+
fs.mkdirSync(autostartDir, { recursive: true });
|
|
1965
|
+
const desktop = `[Desktop Entry]\nType=Application\nName=${name}\nExec=${command}\nX-GNOME-Autostart-enabled=true\nHidden=false\n`;
|
|
1966
|
+
fs.writeFileSync(path.join(autostartDir, `${name}.desktop`), desktop, 'utf8');
|
|
1967
|
+
return true;
|
|
1968
|
+
}
|
|
1969
|
+
return false;
|
|
1970
|
+
},
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* Remove a startup item.
|
|
1974
|
+
* @param {string} name
|
|
1975
|
+
*/
|
|
1976
|
+
remove(name) {
|
|
1977
|
+
name = String(name);
|
|
1978
|
+
if (IS_MACOS) {
|
|
1979
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `com.nova.${name}.plist`);
|
|
1980
|
+
if (fs.existsSync(plistPath)) {
|
|
1981
|
+
_run(`launchctl unload "${plistPath}" 2>/dev/null`);
|
|
1982
|
+
fs.unlinkSync(plistPath);
|
|
1983
|
+
return true;
|
|
1984
|
+
}
|
|
1985
|
+
return false;
|
|
1986
|
+
}
|
|
1987
|
+
if (IS_WINDOWS) {
|
|
1988
|
+
_run(`reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "${name}" /f 2>nul`);
|
|
1989
|
+
return true;
|
|
1990
|
+
}
|
|
1991
|
+
if (IS_ANDROID) {
|
|
1992
|
+
const scriptPath = path.join(os.homedir(), '.termux', 'boot', `${name}.sh`);
|
|
1993
|
+
if (fs.existsSync(scriptPath)) { fs.unlinkSync(scriptPath); return true; }
|
|
1994
|
+
return false;
|
|
1995
|
+
}
|
|
1996
|
+
if (IS_LINUX) {
|
|
1997
|
+
const desktop = path.join(os.homedir(), '.config', 'autostart', `${name}.desktop`);
|
|
1998
|
+
if (fs.existsSync(desktop)) { fs.unlinkSync(desktop); return true; }
|
|
1999
|
+
return false;
|
|
2000
|
+
}
|
|
2001
|
+
return false;
|
|
2002
|
+
},
|
|
2003
|
+
|
|
2004
|
+
/**
|
|
2005
|
+
* List all startup items added via kitos.
|
|
2006
|
+
* @returns {Array<{ name, command, enabled }>}
|
|
2007
|
+
*/
|
|
2008
|
+
list() {
|
|
2009
|
+
if (IS_MACOS) {
|
|
2010
|
+
const dir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
2011
|
+
if (!fs.existsSync(dir)) return [];
|
|
2012
|
+
return fs.readdirSync(dir).filter(f => f.startsWith('com.nova.') && f.endsWith('.plist')).map(f => ({ name: f.replace(/^com\.nova\./, '').replace(/\.plist$/, ''), file: path.join(dir, f) }));
|
|
2013
|
+
}
|
|
2014
|
+
if (IS_LINUX || IS_ANDROID) {
|
|
2015
|
+
const dir = IS_ANDROID
|
|
2016
|
+
? path.join(os.homedir(), '.termux', 'boot')
|
|
2017
|
+
: path.join(os.homedir(), '.config', 'autostart');
|
|
2018
|
+
if (!fs.existsSync(dir)) return [];
|
|
2019
|
+
return fs.readdirSync(dir).map(f => ({ name: f.replace(/\.(desktop|sh)$/, ''), file: path.join(dir, f) }));
|
|
2020
|
+
}
|
|
2021
|
+
if (IS_WINDOWS) {
|
|
2022
|
+
const r = _run('reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"').stdout;
|
|
2023
|
+
return r.split('\n').filter(l => l.trim() && !l.includes('HKEY')).map(l => {
|
|
2024
|
+
const m = l.trim().match(/^(\S+)\s+REG_SZ\s+(.+)/);
|
|
2025
|
+
return m ? { name: m[1], command: m[2] } : null;
|
|
2026
|
+
}).filter(Boolean);
|
|
2027
|
+
}
|
|
2028
|
+
return [];
|
|
2029
|
+
},
|
|
2030
|
+
|
|
2031
|
+
/** Enable/disable a startup item (Linux .desktop files). */
|
|
2032
|
+
setEnabled(name, enabled) {
|
|
2033
|
+
if (IS_LINUX) {
|
|
2034
|
+
const desktop = path.join(os.homedir(), '.config', 'autostart', `${name}.desktop`);
|
|
2035
|
+
if (!fs.existsSync(desktop)) return false;
|
|
2036
|
+
let content = fs.readFileSync(desktop, 'utf8');
|
|
2037
|
+
content = content.replace(/X-GNOME-Autostart-enabled=\w+/, `X-GNOME-Autostart-enabled=${enabled ? 'true' : 'false'}`);
|
|
2038
|
+
content = content.replace(/Hidden=\w+/, `Hidden=${enabled ? 'false' : 'true'}`);
|
|
2039
|
+
fs.writeFileSync(desktop, content, 'utf8');
|
|
2040
|
+
return true;
|
|
2041
|
+
}
|
|
2042
|
+
return false;
|
|
2043
|
+
},
|
|
2044
|
+
};
|
|
2045
|
+
|
|
2046
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2047
|
+
// 18. A11Y — Accessibility
|
|
2048
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2049
|
+
|
|
2050
|
+
const A11y = {
|
|
2051
|
+
/**
|
|
2052
|
+
* Announce a message via the screen reader.
|
|
2053
|
+
* @param {string} message
|
|
2054
|
+
* @param {boolean} [interrupt=false]
|
|
2055
|
+
*/
|
|
2056
|
+
announce(message, interrupt = false) {
|
|
2057
|
+
message = String(message);
|
|
2058
|
+
if (IS_MACOS) {
|
|
2059
|
+
_osascript(`tell application "VoiceOver" to output "${_escAS(message)}"`);
|
|
2060
|
+
} else if (IS_LINUX) {
|
|
2061
|
+
// AT-SPI / spd-say
|
|
2062
|
+
if (_run('which spd-say').code === 0) {
|
|
2063
|
+
_spawn('spd-say', interrupt ? ['-C', message] : [message]);
|
|
2064
|
+
} else if (_run('which espeakup').code === 0) {
|
|
2065
|
+
_spawn('espeakup', ['--device', 'default', message]);
|
|
2066
|
+
}
|
|
2067
|
+
} else if (IS_WINDOWS) {
|
|
2068
|
+
_pwsh(`Add-Type -AssemblyName System.speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Speak('${message.replace(/'/g, "''")}'); $s.Dispose()`);
|
|
2069
|
+
} else if (IS_ANDROID) {
|
|
2070
|
+
TTS.speak(message);
|
|
2071
|
+
}
|
|
2072
|
+
},
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* Get focused element info (best-effort via platform tools).
|
|
2076
|
+
* @returns {object|null}
|
|
2077
|
+
*/
|
|
2078
|
+
focusedElement() {
|
|
2079
|
+
if (IS_LINUX && _run('which xdotool').code === 0) {
|
|
2080
|
+
const winId = _out('xdotool getactivewindow 2>/dev/null').trim();
|
|
2081
|
+
const name = _out(`xdotool getwindowname ${winId} 2>/dev/null`).trim();
|
|
2082
|
+
const pid = _out(`xdotool getwindowpid ${winId} 2>/dev/null`).trim();
|
|
2083
|
+
return { windowId: winId, title: name, pid: +pid };
|
|
2084
|
+
}
|
|
2085
|
+
if (IS_MACOS) {
|
|
2086
|
+
const r = _osascript(
|
|
2087
|
+
'tell application "System Events" to return {name, description} of first UI element whose focused is true of (first process whose frontmost is true)'
|
|
2088
|
+
);
|
|
2089
|
+
return { raw: r };
|
|
2090
|
+
}
|
|
2091
|
+
return null;
|
|
2092
|
+
},
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* Get the active window title.
|
|
2096
|
+
* @returns {string|null}
|
|
2097
|
+
*/
|
|
2098
|
+
activeWindowTitle() {
|
|
2099
|
+
if (IS_MACOS) {
|
|
2100
|
+
return _osascript('tell application "System Events" to return name of first process whose frontmost is true').trim() || null;
|
|
2101
|
+
}
|
|
2102
|
+
if (IS_LINUX && _run('which xdotool').code === 0) {
|
|
2103
|
+
return _out('xdotool getactivewindow getwindowname 2>/dev/null').trim() || null;
|
|
2104
|
+
}
|
|
2105
|
+
if (IS_WINDOWS) {
|
|
2106
|
+
return _pwsh('(Get-Process | Where-Object {$_.MainWindowTitle -ne ""} | Where-Object {$_.MainWindowHandle -ne 0} | Sort-Object CPU -desc | Select-Object -First 1).MainWindowTitle').trim() || null;
|
|
2107
|
+
}
|
|
2108
|
+
return null;
|
|
2109
|
+
},
|
|
2110
|
+
|
|
2111
|
+
/**
|
|
2112
|
+
* List running application windows.
|
|
2113
|
+
* @returns {Array<{ pid, name, title }>}
|
|
2114
|
+
*/
|
|
2115
|
+
windows() {
|
|
2116
|
+
if (IS_LINUX && _run('which wmctrl').code === 0) {
|
|
2117
|
+
return _out('wmctrl -l -p').split('\n').filter(Boolean).map(l => {
|
|
2118
|
+
const [id, desktop, pid, host, ...title] = l.trim().split(/\s+/);
|
|
2119
|
+
return { id, desktop, pid: +pid, host, title: title.join(' ') };
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
if (IS_MACOS) {
|
|
2123
|
+
const r = _osascript('tell application "System Events" to return {name, unix id} of every process whose background only is false');
|
|
2124
|
+
return r ? [{ raw: r }] : [];
|
|
2125
|
+
}
|
|
2126
|
+
if (IS_WINDOWS) {
|
|
2127
|
+
return _pwsh('Get-Process | Where-Object {$_.MainWindowTitle -ne ""} | ForEach-Object { "$($_.Id)|$($_.ProcessName)|$($_.MainWindowTitle)" }')
|
|
2128
|
+
.split('\n').filter(Boolean).map(l => {
|
|
2129
|
+
const [pid, name, title] = l.split('|');
|
|
2130
|
+
return { pid: +pid, name, title };
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2133
|
+
return [];
|
|
2134
|
+
},
|
|
2135
|
+
};
|
|
2136
|
+
|
|
2137
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2138
|
+
// 19. IDLETIME — User Idle Detection
|
|
2139
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2140
|
+
|
|
2141
|
+
const IdleTime = {
|
|
2142
|
+
/**
|
|
2143
|
+
* Get the number of milliseconds the user has been idle.
|
|
2144
|
+
* @returns {number}
|
|
2145
|
+
*/
|
|
2146
|
+
get() {
|
|
2147
|
+
if (IS_MACOS) {
|
|
2148
|
+
const r = _out('ioreg -c IOHIDSystem 2>/dev/null | awk \'/HIDIdleTime/{print $NF; exit}\'');
|
|
2149
|
+
// Value in nanoseconds
|
|
2150
|
+
return r ? Math.round(+r / 1_000_000) : 0;
|
|
2151
|
+
}
|
|
2152
|
+
if (IS_LINUX) {
|
|
2153
|
+
if (_run('which xprintidle').code === 0) {
|
|
2154
|
+
return +(_out('xprintidle').trim()) || 0;
|
|
2155
|
+
}
|
|
2156
|
+
if (_run('which xssstate').code === 0) {
|
|
2157
|
+
return +(_out('xssstate -i').trim()) || 0;
|
|
2158
|
+
}
|
|
2159
|
+
// Read /proc/uptime and last input from xinput or loginctl
|
|
2160
|
+
return 0;
|
|
2161
|
+
}
|
|
2162
|
+
if (IS_WINDOWS) {
|
|
2163
|
+
const r = _pwsh(`
|
|
2164
|
+
Add-Type @'
|
|
2165
|
+
using System; using System.Runtime.InteropServices;
|
|
2166
|
+
public class Idle {
|
|
2167
|
+
[DllImport("user32.dll")] static extern bool GetLastInputInfo(ref LASTINPUTINFO p);
|
|
2168
|
+
[StructLayout(LayoutKind.Sequential)] struct LASTINPUTINFO { public uint cbSize; public int dwTime; }
|
|
2169
|
+
public static int IdleMs() {
|
|
2170
|
+
var l = new LASTINPUTINFO(); l.cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf(l);
|
|
2171
|
+
GetLastInputInfo(ref l); return Environment.TickCount - l.dwTime;
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
'@
|
|
2175
|
+
[Idle]::IdleMs()
|
|
2176
|
+
`).trim();
|
|
2177
|
+
return +r || 0;
|
|
2178
|
+
}
|
|
2179
|
+
if (IS_ANDROID) {
|
|
2180
|
+
// No direct API; return 0
|
|
2181
|
+
return 0;
|
|
2182
|
+
}
|
|
2183
|
+
return 0;
|
|
2184
|
+
},
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Is the user idle for more than a given threshold?
|
|
2188
|
+
* @param {number} thresholdMs
|
|
2189
|
+
* @returns {boolean}
|
|
2190
|
+
*/
|
|
2191
|
+
isIdle(thresholdMs) { return IdleTime.get() >= thresholdMs; },
|
|
2192
|
+
|
|
2193
|
+
/**
|
|
2194
|
+
* Get last input timestamp.
|
|
2195
|
+
* @returns {Date}
|
|
2196
|
+
*/
|
|
2197
|
+
lastInputAt() { return new Date(Date.now() - IdleTime.get()); },
|
|
2198
|
+
|
|
2199
|
+
/**
|
|
2200
|
+
* Watch idle state, calling onIdle when idle for thresholdMs, onActive when active again.
|
|
2201
|
+
* @param {number} thresholdMs
|
|
2202
|
+
* @param {Function} onIdle
|
|
2203
|
+
* @param {Function} [onActive]
|
|
2204
|
+
* @param {number} [pollMs=5000]
|
|
2205
|
+
* @returns {{ stop: Function }}
|
|
2206
|
+
*/
|
|
2207
|
+
watch(thresholdMs, onIdle, onActive, pollMs = 5000) {
|
|
2208
|
+
let wasIdle = false;
|
|
2209
|
+
const timer = setInterval(() => {
|
|
2210
|
+
const idle = IdleTime.isIdle(thresholdMs);
|
|
2211
|
+
if (idle && !wasIdle) { wasIdle = true; onIdle(); }
|
|
2212
|
+
else if (!idle && wasIdle) { wasIdle = false; if (onActive) onActive(); }
|
|
2213
|
+
}, pollMs);
|
|
2214
|
+
return { stop: () => clearInterval(timer) };
|
|
2215
|
+
},
|
|
2216
|
+
};
|
|
2217
|
+
|
|
2218
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2219
|
+
// 20. SENSORS — Hardware Sensors (CPU temp, fan, GPU)
|
|
2220
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2221
|
+
|
|
2222
|
+
const Sensors = {
|
|
2223
|
+
/**
|
|
2224
|
+
* Get CPU temperature in Celsius.
|
|
2225
|
+
* @returns {number|null}
|
|
2226
|
+
*/
|
|
2227
|
+
cpuTemp() {
|
|
2228
|
+
if (IS_MACOS) {
|
|
2229
|
+
// iStats (gem install iStats) or osx-cpu-temp
|
|
2230
|
+
if (_run('which istats').code === 0) {
|
|
2231
|
+
const r = _out('istats cpu temp --value-only 2>/dev/null');
|
|
2232
|
+
return parseFloat(r) || null;
|
|
2233
|
+
}
|
|
2234
|
+
if (_run('which osx-cpu-temp').code === 0) {
|
|
2235
|
+
return parseFloat(_out('osx-cpu-temp')) || null;
|
|
2236
|
+
}
|
|
2237
|
+
// Try powermetrics (requires sudo)
|
|
2238
|
+
return null;
|
|
2239
|
+
}
|
|
2240
|
+
if (IS_LINUX) {
|
|
2241
|
+
// /sys/class/thermal
|
|
2242
|
+
const thermal = '/sys/class/thermal';
|
|
2243
|
+
if (fs.existsSync(thermal)) {
|
|
2244
|
+
const zones = fs.readdirSync(thermal).filter(d => d.startsWith('thermal_zone'));
|
|
2245
|
+
for (const zone of zones) {
|
|
2246
|
+
const type = _out(`cat /sys/class/thermal/${zone}/type 2>/dev/null`).trim();
|
|
2247
|
+
if (!type.includes('cpu') && !type.includes('x86') && !type.includes('acpi')) continue;
|
|
2248
|
+
const temp = _out(`cat /sys/class/thermal/${zone}/temp 2>/dev/null`).trim();
|
|
2249
|
+
if (temp) return +temp / 1000;
|
|
2250
|
+
}
|
|
2251
|
+
// Fallback: first zone
|
|
2252
|
+
if (zones.length > 0) {
|
|
2253
|
+
const temp = _out(`cat /sys/class/thermal/${zones[0]}/temp 2>/dev/null`).trim();
|
|
2254
|
+
if (temp) return +temp / 1000;
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
// lm-sensors
|
|
2258
|
+
if (_run('which sensors').code === 0) {
|
|
2259
|
+
const r = _out('sensors 2>/dev/null');
|
|
2260
|
+
const m = r.match(/Core 0:\s+\+?([\d.]+)°C/);
|
|
2261
|
+
return m ? parseFloat(m[1]) : null;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
if (IS_WINDOWS) {
|
|
2265
|
+
const r = _pwsh('(Get-WmiObject -Namespace root/wmi -Class MSAcpi_ThermalZoneTemperature).CurrentTemperature');
|
|
2266
|
+
return r ? Math.round((+r.trim() - 2732) / 10) : null;
|
|
2267
|
+
}
|
|
2268
|
+
if (IS_ANDROID) {
|
|
2269
|
+
const paths = ['/sys/class/thermal/thermal_zone0/temp', '/sys/devices/virtual/thermal/thermal_zone0/temp'];
|
|
2270
|
+
for (const p of paths) {
|
|
2271
|
+
if (fs.existsSync(p)) {
|
|
2272
|
+
const raw = +fs.readFileSync(p, 'utf8').trim();
|
|
2273
|
+
return raw > 1000 ? raw / 1000 : raw;
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
return null;
|
|
2278
|
+
},
|
|
2279
|
+
|
|
2280
|
+
/**
|
|
2281
|
+
* Get all available temperatures.
|
|
2282
|
+
* @returns {Array<{ name, temp, high, critical }>}
|
|
2283
|
+
*/
|
|
2284
|
+
temperatures() {
|
|
2285
|
+
if (IS_LINUX && _run('which sensors').code === 0) {
|
|
2286
|
+
const r = _out('sensors -j 2>/dev/null');
|
|
2287
|
+
try {
|
|
2288
|
+
const data = JSON.parse(r);
|
|
2289
|
+
const result = [];
|
|
2290
|
+
for (const [chip, sensors] of Object.entries(data)) {
|
|
2291
|
+
for (const [sensor, values] of Object.entries(sensors)) {
|
|
2292
|
+
if (typeof values !== 'object') continue;
|
|
2293
|
+
const temp = Object.values(values).find(v => String(v).match(/\d+\.\d+/) && typeof v === 'number');
|
|
2294
|
+
if (temp) result.push({ chip, sensor, temp });
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
return result;
|
|
2298
|
+
} catch {}
|
|
2299
|
+
}
|
|
2300
|
+
if (IS_ANDROID) {
|
|
2301
|
+
const base = '/sys/class/thermal';
|
|
2302
|
+
if (!fs.existsSync(base)) return [];
|
|
2303
|
+
return fs.readdirSync(base).filter(d => d.startsWith('thermal_zone')).map(zone => {
|
|
2304
|
+
const type = _out(`cat ${base}/${zone}/type 2>/dev/null`).trim();
|
|
2305
|
+
const temp = _out(`cat ${base}/${zone}/temp 2>/dev/null`).trim();
|
|
2306
|
+
return temp ? { name: type || zone, temp: +temp > 1000 ? +temp/1000 : +temp } : null;
|
|
2307
|
+
}).filter(Boolean);
|
|
2308
|
+
}
|
|
2309
|
+
const cpu = Sensors.cpuTemp();
|
|
2310
|
+
return cpu !== null ? [{ name: 'CPU', temp: cpu }] : [];
|
|
2311
|
+
},
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* Get GPU temperature in Celsius.
|
|
2315
|
+
* @returns {number|null}
|
|
2316
|
+
*/
|
|
2317
|
+
gpuTemp() {
|
|
2318
|
+
if (IS_LINUX) {
|
|
2319
|
+
// NVIDIA
|
|
2320
|
+
if (_run('which nvidia-smi').code === 0) {
|
|
2321
|
+
const r = _out('nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader,nounits 2>/dev/null');
|
|
2322
|
+
return r ? +r.trim() : null;
|
|
2323
|
+
}
|
|
2324
|
+
// AMD via sensors
|
|
2325
|
+
if (_run('which sensors').code === 0) {
|
|
2326
|
+
const r = _out('sensors 2>/dev/null');
|
|
2327
|
+
const m = r.match(/edge:\s+\+?([\d.]+)°C/);
|
|
2328
|
+
return m ? parseFloat(m[1]) : null;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
if (IS_WINDOWS) {
|
|
2332
|
+
const r = _pwsh('(Get-WmiObject -Namespace root/OpenHardwareMonitor -Class Sensor | Where-Object {$_.SensorType -eq "Temperature" -and $_.Name -like "*GPU*"} | Select-Object -First 1).Value');
|
|
2333
|
+
return r ? parseFloat(r) : null;
|
|
2334
|
+
}
|
|
2335
|
+
return null;
|
|
2336
|
+
},
|
|
2337
|
+
|
|
2338
|
+
/**
|
|
2339
|
+
* Get CPU fan speed(s) in RPM.
|
|
2340
|
+
* @returns {number|null}
|
|
2341
|
+
*/
|
|
2342
|
+
fanSpeed() {
|
|
2343
|
+
if (IS_MACOS && _run('which istats').code === 0) {
|
|
2344
|
+
const r = _out('istats fan speed --value-only 2>/dev/null');
|
|
2345
|
+
return parseFloat(r) || null;
|
|
2346
|
+
}
|
|
2347
|
+
if (IS_LINUX && _run('which sensors').code === 0) {
|
|
2348
|
+
const r = _out('sensors 2>/dev/null');
|
|
2349
|
+
const m = r.match(/fan\d+:\s+([\d]+) RPM/i);
|
|
2350
|
+
return m ? +m[1] : null;
|
|
2351
|
+
}
|
|
2352
|
+
if (IS_WINDOWS) {
|
|
2353
|
+
const r = _pwsh('(Get-WmiObject Win32_Fan).DesiredSpeed');
|
|
2354
|
+
return r ? +r.trim() : null;
|
|
2355
|
+
}
|
|
2356
|
+
return null;
|
|
2357
|
+
},
|
|
2358
|
+
|
|
2359
|
+
/**
|
|
2360
|
+
* Get all sensor readings.
|
|
2361
|
+
* @returns {object}
|
|
2362
|
+
*/
|
|
2363
|
+
all() {
|
|
2364
|
+
return {
|
|
2365
|
+
cpuTemp: Sensors.cpuTemp(),
|
|
2366
|
+
gpuTemp: Sensors.gpuTemp(),
|
|
2367
|
+
fanSpeed: Sensors.fanSpeed(),
|
|
2368
|
+
temps: Sensors.temperatures(),
|
|
2369
|
+
};
|
|
2370
|
+
},
|
|
2371
|
+
};
|
|
2372
|
+
|
|
2373
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2374
|
+
// 21. LOCATION — GPS / IP Geolocation
|
|
2375
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2376
|
+
|
|
2377
|
+
const Location = {
|
|
2378
|
+
/**
|
|
2379
|
+
* Get current location.
|
|
2380
|
+
* Falls back from GPS → CoreLocation → IP-based.
|
|
2381
|
+
* @param {object} [opts]
|
|
2382
|
+
* @param {boolean} [opts.ipFallback=true]
|
|
2383
|
+
* @returns {Promise<{ lat, lon, accuracy, source }>}
|
|
2384
|
+
*/
|
|
2385
|
+
async get(opts = {}) {
|
|
2386
|
+
const ipFallback = opts.ipFallback !== false;
|
|
2387
|
+
|
|
2388
|
+
if (IS_ANDROID) {
|
|
2389
|
+
try {
|
|
2390
|
+
const r = _out('termux-location -r once -p gps 2>/dev/null');
|
|
2391
|
+
const j = JSON.parse(r);
|
|
2392
|
+
if (j.latitude !== undefined) return { lat: j.latitude, lon: j.longitude, accuracy: j.accuracy, altitude: j.altitude, source: 'gps' };
|
|
2393
|
+
} catch {}
|
|
2394
|
+
// Network fallback
|
|
2395
|
+
try {
|
|
2396
|
+
const r = _out('termux-location -r once -p network 2>/dev/null');
|
|
2397
|
+
const j = JSON.parse(r);
|
|
2398
|
+
if (j.latitude !== undefined) return { lat: j.latitude, lon: j.longitude, accuracy: j.accuracy, source: 'network' };
|
|
2399
|
+
} catch {}
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
if (IS_MACOS && _run('which CoreLocationCLI').code === 0) {
|
|
2403
|
+
try {
|
|
2404
|
+
const r = _out('CoreLocationCLI -format "%latitude,%longitude" 2>/dev/null');
|
|
2405
|
+
const [lat, lon] = r.split(',').map(Number);
|
|
2406
|
+
if (!isNaN(lat)) return { lat, lon, accuracy: null, source: 'corelocation' };
|
|
2407
|
+
} catch {}
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
if (ipFallback) return Location.ipLocation();
|
|
2411
|
+
return null;
|
|
2412
|
+
},
|
|
2413
|
+
|
|
2414
|
+
/**
|
|
2415
|
+
* Get location via IP geolocation API.
|
|
2416
|
+
* @returns {Promise<{ lat, lon, city, country, ip, source }>}
|
|
2417
|
+
*/
|
|
2418
|
+
async ipLocation() {
|
|
2419
|
+
const http = require('http');
|
|
2420
|
+
const https = require('https');
|
|
2421
|
+
return new Promise((resolve) => {
|
|
2422
|
+
const req = https.get('https://ipapi.co/json/', { timeout: 5000 }, (res) => {
|
|
2423
|
+
let data = '';
|
|
2424
|
+
res.on('data', c => data += c);
|
|
2425
|
+
res.on('end', () => {
|
|
2426
|
+
try {
|
|
2427
|
+
const j = JSON.parse(data);
|
|
2428
|
+
resolve({ lat: j.latitude, lon: j.longitude, city: j.city, country: j.country_name, ip: j.ip, source: 'ip' });
|
|
2429
|
+
} catch { resolve(null); }
|
|
2430
|
+
});
|
|
2431
|
+
});
|
|
2432
|
+
req.on('error', () => {
|
|
2433
|
+
// Fallback: ip-api.com (HTTP)
|
|
2434
|
+
const r2 = http.get('http://ip-api.com/json/', { timeout: 5000 }, (res) => {
|
|
2435
|
+
let data = '';
|
|
2436
|
+
res.on('data', c => data += c);
|
|
2437
|
+
res.on('end', () => {
|
|
2438
|
+
try {
|
|
2439
|
+
const j = JSON.parse(data);
|
|
2440
|
+
resolve({ lat: j.lat, lon: j.lon, city: j.city, country: j.country, ip: j.query, source: 'ip' });
|
|
2441
|
+
} catch { resolve(null); }
|
|
2442
|
+
});
|
|
2443
|
+
});
|
|
2444
|
+
r2.on('error', () => resolve(null));
|
|
2445
|
+
});
|
|
2446
|
+
});
|
|
2447
|
+
},
|
|
2448
|
+
|
|
2449
|
+
/**
|
|
2450
|
+
* Calculate distance between two coordinates in km (Haversine).
|
|
2451
|
+
* @param {number} lat1
|
|
2452
|
+
* @param {number} lon1
|
|
2453
|
+
* @param {number} lat2
|
|
2454
|
+
* @param {number} lon2
|
|
2455
|
+
* @returns {number}
|
|
2456
|
+
*/
|
|
2457
|
+
distance(lat1, lon1, lat2, lon2) {
|
|
2458
|
+
const R = 6371;
|
|
2459
|
+
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
2460
|
+
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
2461
|
+
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2;
|
|
2462
|
+
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
2463
|
+
},
|
|
2464
|
+
};
|
|
2465
|
+
|
|
2466
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2467
|
+
// 22. VIBRATE — Haptic Feedback
|
|
2468
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2469
|
+
|
|
2470
|
+
const Vibrate = {
|
|
2471
|
+
/**
|
|
2472
|
+
* Vibrate the device.
|
|
2473
|
+
* @param {number} [durationMs=500]
|
|
2474
|
+
* @param {object} [opts]
|
|
2475
|
+
* @param {number} [opts.force=1.0] 0.0–1.0 intensity (Android only)
|
|
2476
|
+
*/
|
|
2477
|
+
vibrate(durationMs = 500, opts = {}) {
|
|
2478
|
+
durationMs = Math.max(1, Math.round(durationMs));
|
|
2479
|
+
if (IS_ANDROID) {
|
|
2480
|
+
const args = ['termux-vibrate', '-d', String(durationMs)];
|
|
2481
|
+
if (opts.force !== undefined) args.push('-f');
|
|
2482
|
+
_spawn(args[0], args.slice(1));
|
|
2483
|
+
} else if (IS_LINUX) {
|
|
2484
|
+
// Some Linux devices support vibration via /sys/class/leds
|
|
2485
|
+
const vibPath = '/sys/class/leds/vibrator';
|
|
2486
|
+
if (fs.existsSync(vibPath)) {
|
|
2487
|
+
try {
|
|
2488
|
+
fs.writeFileSync(path.join(vibPath, 'brightness'), '255');
|
|
2489
|
+
setTimeout(() => { try { fs.writeFileSync(path.join(vibPath, 'brightness'), '0'); } catch {} }, durationMs);
|
|
2490
|
+
} catch {}
|
|
2491
|
+
}
|
|
2492
|
+
// Fallback: beep (at least give audio feedback)
|
|
2493
|
+
else Audio.beep(1);
|
|
2494
|
+
} else {
|
|
2495
|
+
// macOS / Windows: audio bell as fallback
|
|
2496
|
+
Audio.beep(1);
|
|
2497
|
+
}
|
|
2498
|
+
},
|
|
2499
|
+
|
|
2500
|
+
/**
|
|
2501
|
+
* Vibrate a pattern.
|
|
2502
|
+
* @param {number[]} pattern alternating on/off durations in ms e.g. [100, 200, 100]
|
|
2503
|
+
*/
|
|
2504
|
+
pattern(pattern) {
|
|
2505
|
+
if (IS_ANDROID) {
|
|
2506
|
+
// Termux doesn't support pattern directly; simulate with delays
|
|
2507
|
+
let delay = 0;
|
|
2508
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
2509
|
+
if (i % 2 === 0) {
|
|
2510
|
+
setTimeout(() => Vibrate.vibrate(pattern[i]), delay);
|
|
2511
|
+
}
|
|
2512
|
+
delay += pattern[i];
|
|
2513
|
+
}
|
|
2514
|
+
} else {
|
|
2515
|
+
for (let i = 0; i < pattern.length; i += 2) {
|
|
2516
|
+
if (i < pattern.length) setTimeout(() => Vibrate.vibrate(pattern[i]), i * 500);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
},
|
|
2520
|
+
|
|
2521
|
+
/** Check if vibration is supported. */
|
|
2522
|
+
isSupported() {
|
|
2523
|
+
if (IS_ANDROID) return _run('which termux-vibrate').code === 0;
|
|
2524
|
+
if (IS_LINUX) return fs.existsSync('/sys/class/leds/vibrator');
|
|
2525
|
+
return false;
|
|
2526
|
+
},
|
|
2527
|
+
};
|
|
2528
|
+
|
|
2529
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2530
|
+
// KITX11 — Low-level X11 window management (Linux only)
|
|
2531
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2532
|
+
|
|
2533
|
+
const X11 = {
|
|
2534
|
+
/**
|
|
2535
|
+
* Is X11 available?
|
|
2536
|
+
* @returns {boolean}
|
|
2537
|
+
*/
|
|
2538
|
+
isAvailable() {
|
|
2539
|
+
return (IS_LINUX || IS_ANDROID) &&
|
|
2540
|
+
(!!process.env.DISPLAY || !!process.env.WAYLAND_DISPLAY) &&
|
|
2541
|
+
_run('which xdotool').code === 0;
|
|
2542
|
+
},
|
|
2543
|
+
|
|
2544
|
+
// ── Window management ─────────────────────────────────────────────────────
|
|
2545
|
+
|
|
2546
|
+
/**
|
|
2547
|
+
* List all open windows.
|
|
2548
|
+
* @returns {Array<{ id, pid, title, desktop, x, y, w, h }>}
|
|
2549
|
+
*/
|
|
2550
|
+
listWindows() {
|
|
2551
|
+
const wmctrl = _run('which wmctrl').code === 0;
|
|
2552
|
+
const xdotool = _run('which xdotool').code === 0;
|
|
2553
|
+
if (wmctrl) {
|
|
2554
|
+
return _out('wmctrl -l -G -p').split('\n').filter(Boolean).map(l => {
|
|
2555
|
+
const [id, desktop, pid, x, y, w, h, ...title] = l.trim().split(/\s+/);
|
|
2556
|
+
return { id, desktop: +desktop, pid: +pid, x: +x, y: +y, w: +w, h: +h, title: title.join(' ') };
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
if (xdotool) {
|
|
2560
|
+
const ids = _out('xdotool search --onlyvisible --name "" 2>/dev/null').split('\n').filter(Boolean);
|
|
2561
|
+
return ids.map(id => {
|
|
2562
|
+
const title = _out(`xdotool getwindowname ${id} 2>/dev/null`).trim();
|
|
2563
|
+
const pid = _out(`xdotool getwindowpid ${id} 2>/dev/null`).trim();
|
|
2564
|
+
return { id, pid: +pid, title };
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
return [];
|
|
2568
|
+
},
|
|
2569
|
+
|
|
2570
|
+
/**
|
|
2571
|
+
* Get the active (focused) window ID.
|
|
2572
|
+
* @returns {string|null}
|
|
2573
|
+
*/
|
|
2574
|
+
activeWindow() {
|
|
2575
|
+
if (_run('which xdotool').code === 0) return _out('xdotool getactivewindow 2>/dev/null').trim() || null;
|
|
2576
|
+
if (_run('which wmctrl').code === 0) return _out('wmctrl -d').match(/\* (\w+)/)?.[1] ?? null;
|
|
2577
|
+
return null;
|
|
2578
|
+
},
|
|
2579
|
+
|
|
2580
|
+
/**
|
|
2581
|
+
* Focus a window by its ID.
|
|
2582
|
+
* @param {string} windowId
|
|
2583
|
+
*/
|
|
2584
|
+
focusWindow(windowId) {
|
|
2585
|
+
if (_run('which xdotool').code === 0) _spawn('xdotool', ['windowactivate', '--sync', String(windowId)]);
|
|
2586
|
+
else if (_run('which wmctrl').code === 0) _spawn('wmctrl', ['-ia', String(windowId)]);
|
|
2587
|
+
},
|
|
2588
|
+
|
|
2589
|
+
/**
|
|
2590
|
+
* Find windows by title pattern.
|
|
2591
|
+
* @param {string} pattern regex or substring
|
|
2592
|
+
* @returns {string[]} window IDs
|
|
2593
|
+
*/
|
|
2594
|
+
findByTitle(pattern) {
|
|
2595
|
+
if (_run('which xdotool').code === 0) {
|
|
2596
|
+
return _out(`xdotool search --name "${pattern.replace(/"/g, '\\"')}" 2>/dev/null`).split('\n').filter(Boolean);
|
|
2597
|
+
}
|
|
2598
|
+
if (_run('which wmctrl').code === 0) {
|
|
2599
|
+
return _out('wmctrl -l').split('\n').filter(l => l.toLowerCase().includes(pattern.toLowerCase()))
|
|
2600
|
+
.map(l => l.trim().split(/\s+/)[0]);
|
|
2601
|
+
}
|
|
2602
|
+
return [];
|
|
2603
|
+
},
|
|
2604
|
+
|
|
2605
|
+
/**
|
|
2606
|
+
* Find windows by PID.
|
|
2607
|
+
* @param {number} pid
|
|
2608
|
+
* @returns {string[]} window IDs
|
|
2609
|
+
*/
|
|
2610
|
+
findByPID(pid) {
|
|
2611
|
+
if (_run('which xdotool').code === 0) {
|
|
2612
|
+
return _out(`xdotool search --pid ${pid} 2>/dev/null`).split('\n').filter(Boolean);
|
|
2613
|
+
}
|
|
2614
|
+
return [];
|
|
2615
|
+
},
|
|
2616
|
+
|
|
2617
|
+
/**
|
|
2618
|
+
* Close a window gracefully.
|
|
2619
|
+
* @param {string} windowId
|
|
2620
|
+
*/
|
|
2621
|
+
closeWindow(windowId) {
|
|
2622
|
+
if (_run('which wmctrl').code === 0) _spawn('wmctrl', ['-ic', String(windowId)]);
|
|
2623
|
+
else if (_run('which xdotool').code === 0) _spawn('xdotool', ['windowclose', String(windowId)]);
|
|
2624
|
+
},
|
|
2625
|
+
|
|
2626
|
+
/**
|
|
2627
|
+
* Kill a window (force close).
|
|
2628
|
+
* @param {string} windowId
|
|
2629
|
+
*/
|
|
2630
|
+
killWindow(windowId) {
|
|
2631
|
+
if (_run('which xdotool').code === 0) _spawn('xdotool', ['windowkill', String(windowId)]);
|
|
2632
|
+
else _spawn('xkill', []);
|
|
2633
|
+
},
|
|
2634
|
+
|
|
2635
|
+
/**
|
|
2636
|
+
* Move a window to given coordinates.
|
|
2637
|
+
* @param {string} windowId
|
|
2638
|
+
* @param {number} x
|
|
2639
|
+
* @param {number} y
|
|
2640
|
+
*/
|
|
2641
|
+
moveWindow(windowId, x, y) {
|
|
2642
|
+
if (_run('which wmctrl').code === 0) {
|
|
2643
|
+
_spawn('wmctrl', ['-ir', String(windowId), '-e', `0,${x},${y},-1,-1`]);
|
|
2644
|
+
} else if (_run('which xdotool').code === 0) {
|
|
2645
|
+
_spawn('xdotool', ['windowmove', String(windowId), String(x), String(y)]);
|
|
2646
|
+
}
|
|
2647
|
+
},
|
|
2648
|
+
|
|
2649
|
+
/**
|
|
2650
|
+
* Resize a window.
|
|
2651
|
+
* @param {string} windowId
|
|
2652
|
+
* @param {number} w
|
|
2653
|
+
* @param {number} h
|
|
2654
|
+
*/
|
|
2655
|
+
resizeWindow(windowId, w, h) {
|
|
2656
|
+
if (_run('which wmctrl').code === 0) {
|
|
2657
|
+
_spawn('wmctrl', ['-ir', String(windowId), '-e', `0,-1,-1,${w},${h}`]);
|
|
2658
|
+
} else if (_run('which xdotool').code === 0) {
|
|
2659
|
+
_spawn('xdotool', ['windowsize', String(windowId), String(w), String(h)]);
|
|
2660
|
+
}
|
|
2661
|
+
},
|
|
2662
|
+
|
|
2663
|
+
/**
|
|
2664
|
+
* Move and resize in one call.
|
|
2665
|
+
* @param {string} windowId
|
|
2666
|
+
* @param {number} x
|
|
2667
|
+
* @param {number} y
|
|
2668
|
+
* @param {number} w
|
|
2669
|
+
* @param {number} h
|
|
2670
|
+
*/
|
|
2671
|
+
setGeometry(windowId, x, y, w, h) {
|
|
2672
|
+
if (_run('which wmctrl').code === 0) {
|
|
2673
|
+
_spawn('wmctrl', ['-ir', String(windowId), '-e', `0,${x},${y},${w},${h}`]);
|
|
2674
|
+
} else if (_run('which xdotool').code === 0) {
|
|
2675
|
+
_spawn('xdotool', ['windowmove', String(windowId), String(x), String(y)]);
|
|
2676
|
+
_spawn('xdotool', ['windowsize', String(windowId), String(w), String(h)]);
|
|
2677
|
+
}
|
|
2678
|
+
},
|
|
2679
|
+
|
|
2680
|
+
/**
|
|
2681
|
+
* Get window geometry.
|
|
2682
|
+
* @param {string} windowId
|
|
2683
|
+
* @returns {{ x, y, w, h }|null}
|
|
2684
|
+
*/
|
|
2685
|
+
getGeometry(windowId) {
|
|
2686
|
+
if (_run('which xdotool').code === 0) {
|
|
2687
|
+
const geo = _out(`xdotool getwindowgeometry ${windowId} 2>/dev/null`);
|
|
2688
|
+
const pos = geo.match(/Position: (\d+),(\d+)/);
|
|
2689
|
+
const siz = geo.match(/Geometry: (\d+)x(\d+)/);
|
|
2690
|
+
if (pos && siz) return { x: +pos[1], y: +pos[2], w: +siz[1], h: +siz[2] };
|
|
2691
|
+
}
|
|
2692
|
+
if (_run('which wmctrl').code === 0) {
|
|
2693
|
+
const r = _out(`wmctrl -l -G | grep ${windowId}`);
|
|
2694
|
+
const p = r.trim().split(/\s+/);
|
|
2695
|
+
if (p.length >= 8) return { x: +p[3], y: +p[4], w: +p[5], h: +p[6] };
|
|
2696
|
+
}
|
|
2697
|
+
return null;
|
|
2698
|
+
},
|
|
2699
|
+
|
|
2700
|
+
/**
|
|
2701
|
+
* Maximize a window.
|
|
2702
|
+
* @param {string} windowId
|
|
2703
|
+
*/
|
|
2704
|
+
maximize(windowId) {
|
|
2705
|
+
if (_run('which wmctrl').code === 0) {
|
|
2706
|
+
_spawn('wmctrl', ['-ir', String(windowId), '-b', 'add,maximized_vert,maximized_horz']);
|
|
2707
|
+
} else if (_run('which xdotool').code === 0) {
|
|
2708
|
+
_spawn('xdotool', ['windowmove', String(windowId), '0', '0']);
|
|
2709
|
+
const s = Screen.size();
|
|
2710
|
+
_spawn('xdotool', ['windowsize', String(windowId), String(s.width), String(s.height)]);
|
|
2711
|
+
}
|
|
2712
|
+
},
|
|
2713
|
+
|
|
2714
|
+
/**
|
|
2715
|
+
* Minimize a window.
|
|
2716
|
+
* @param {string} windowId
|
|
2717
|
+
*/
|
|
2718
|
+
minimize(windowId) {
|
|
2719
|
+
if (_run('which xdotool').code === 0) _spawn('xdotool', ['windowminimize', String(windowId)]);
|
|
2720
|
+
else if (_run('which wmctrl').code === 0) _spawn('wmctrl', ['-ir', String(windowId), '-b', 'add,hidden']);
|
|
2721
|
+
},
|
|
2722
|
+
|
|
2723
|
+
/**
|
|
2724
|
+
* Restore/unminimize a window.
|
|
2725
|
+
* @param {string} windowId
|
|
2726
|
+
*/
|
|
2727
|
+
restore(windowId) {
|
|
2728
|
+
if (_run('which wmctrl').code === 0) _spawn('wmctrl', ['-ir', String(windowId), '-b', 'remove,maximized_vert,maximized_horz,hidden']);
|
|
2729
|
+
else if (_run('which xdotool').code === 0) _spawn('xdotool', ['windowactivate', String(windowId)]);
|
|
2730
|
+
},
|
|
2731
|
+
|
|
2732
|
+
/**
|
|
2733
|
+
* Always-on-top (raise above all windows).
|
|
2734
|
+
* @param {string} windowId
|
|
2735
|
+
* @param {boolean} [on=true]
|
|
2736
|
+
*/
|
|
2737
|
+
alwaysOnTop(windowId, on = true) {
|
|
2738
|
+
if (_run('which wmctrl').code === 0) {
|
|
2739
|
+
_spawn('wmctrl', ['-ir', String(windowId), '-b', `${on ? 'add' : 'remove'},above`]);
|
|
2740
|
+
}
|
|
2741
|
+
},
|
|
2742
|
+
|
|
2743
|
+
/**
|
|
2744
|
+
* Set window opacity (0.0–1.0). Requires a compositor.
|
|
2745
|
+
* @param {string} windowId
|
|
2746
|
+
* @param {number} opacity
|
|
2747
|
+
*/
|
|
2748
|
+
setOpacity(windowId, opacity) {
|
|
2749
|
+
opacity = Math.max(0, Math.min(1, opacity));
|
|
2750
|
+
const value = Math.round(opacity * 0xFFFFFFFF);
|
|
2751
|
+
if (_run('which xprop').code === 0) {
|
|
2752
|
+
_spawn('xprop', ['-id', String(windowId), '-f', '_NET_WM_WINDOW_OPACITY', '32c', '-set', '_NET_WM_WINDOW_OPACITY', String(value)]);
|
|
2753
|
+
}
|
|
2754
|
+
},
|
|
2755
|
+
|
|
2756
|
+
/**
|
|
2757
|
+
* Get window class / application name.
|
|
2758
|
+
* @param {string} windowId
|
|
2759
|
+
* @returns {{ class: string, name: string }|null}
|
|
2760
|
+
*/
|
|
2761
|
+
getClass(windowId) {
|
|
2762
|
+
if (_run('which xprop').code === 0) {
|
|
2763
|
+
const r = _out(`xprop -id ${windowId} WM_CLASS 2>/dev/null`);
|
|
2764
|
+
const m = r.match(/WM_CLASS.*= "([^"]+)", "([^"]+)"/);
|
|
2765
|
+
return m ? { class: m[1], name: m[2] } : null;
|
|
2766
|
+
}
|
|
2767
|
+
return null;
|
|
2768
|
+
},
|
|
2769
|
+
|
|
2770
|
+
// ── Virtual Desktops / Workspaces ─────────────────────────────────────────
|
|
2771
|
+
|
|
2772
|
+
/**
|
|
2773
|
+
* Get current desktop/workspace number.
|
|
2774
|
+
* @returns {number}
|
|
2775
|
+
*/
|
|
2776
|
+
currentDesktop() {
|
|
2777
|
+
if (_run('which wmctrl').code === 0) {
|
|
2778
|
+
const r = _out('wmctrl -d');
|
|
2779
|
+
const m = r.match(/^(\d+)\s+\*/m);
|
|
2780
|
+
return m ? +m[1] : 0;
|
|
2781
|
+
}
|
|
2782
|
+
if (_run('which xdotool').code === 0) {
|
|
2783
|
+
return +(_out('xdotool get_desktop 2>/dev/null').trim()) || 0;
|
|
2784
|
+
}
|
|
2785
|
+
return 0;
|
|
2786
|
+
},
|
|
2787
|
+
|
|
2788
|
+
/**
|
|
2789
|
+
* Switch to a desktop by number.
|
|
2790
|
+
* @param {number} n 0-indexed
|
|
2791
|
+
*/
|
|
2792
|
+
switchDesktop(n) {
|
|
2793
|
+
if (_run('which wmctrl').code === 0) _spawn('wmctrl', ['-s', String(n)]);
|
|
2794
|
+
else if (_run('which xdotool').code === 0) _spawn('xdotool', ['set_desktop', String(n)]);
|
|
2795
|
+
},
|
|
2796
|
+
|
|
2797
|
+
/**
|
|
2798
|
+
* Move window to a desktop.
|
|
2799
|
+
* @param {string} windowId
|
|
2800
|
+
* @param {number} desktop 0-indexed
|
|
2801
|
+
*/
|
|
2802
|
+
moveToDesktop(windowId, desktop) {
|
|
2803
|
+
if (_run('which wmctrl').code === 0) _spawn('wmctrl', ['-ir', String(windowId), '-t', String(desktop)]);
|
|
2804
|
+
else if (_run('which xdotool').code === 0) _spawn('xdotool', ['set_desktop_for_window', String(windowId), String(desktop)]);
|
|
2805
|
+
},
|
|
2806
|
+
|
|
2807
|
+
/**
|
|
2808
|
+
* List all virtual desktops.
|
|
2809
|
+
* @returns {Array<{ id, name, active }>}
|
|
2810
|
+
*/
|
|
2811
|
+
listDesktops() {
|
|
2812
|
+
if (_run('which wmctrl').code === 0) {
|
|
2813
|
+
return _out('wmctrl -d').split('\n').filter(Boolean).map(l => {
|
|
2814
|
+
const parts = l.trim().split(/\s+/);
|
|
2815
|
+
return { id: +parts[0], active: parts[1] === '*', name: parts.slice(-1)[0] };
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
return [];
|
|
2819
|
+
},
|
|
2820
|
+
|
|
2821
|
+
// ── X11 Properties ────────────────────────────────────────────────────────
|
|
2822
|
+
|
|
2823
|
+
/**
|
|
2824
|
+
* Get an X11 property from a window.
|
|
2825
|
+
* @param {string} windowId
|
|
2826
|
+
* @param {string} property e.g. '_NET_WM_STATE', 'WM_NAME'
|
|
2827
|
+
* @returns {string}
|
|
2828
|
+
*/
|
|
2829
|
+
getProperty(windowId, property) {
|
|
2830
|
+
if (_run('which xprop').code === 0) {
|
|
2831
|
+
return _out(`xprop -id ${windowId} ${property} 2>/dev/null`);
|
|
2832
|
+
}
|
|
2833
|
+
return '';
|
|
2834
|
+
},
|
|
2835
|
+
|
|
2836
|
+
/**
|
|
2837
|
+
* Set an X11 string property on a window.
|
|
2838
|
+
* @param {string} windowId
|
|
2839
|
+
* @param {string} property
|
|
2840
|
+
* @param {string} value
|
|
2841
|
+
*/
|
|
2842
|
+
setProperty(windowId, property, value) {
|
|
2843
|
+
if (_run('which xprop').code === 0) {
|
|
2844
|
+
_spawn('xprop', ['-id', String(windowId), '-f', property, '8s', '-set', property, value]);
|
|
2845
|
+
}
|
|
2846
|
+
},
|
|
2847
|
+
|
|
2848
|
+
/**
|
|
2849
|
+
* Send an X11 ClientMessage event to a window.
|
|
2850
|
+
* @param {string} windowId
|
|
2851
|
+
* @param {string} msgType e.g. '_NET_CLOSE_WINDOW'
|
|
2852
|
+
* @param {number[]} data up to 5 longs
|
|
2853
|
+
*/
|
|
2854
|
+
sendClientMessage(windowId, msgType, data = [0,0,0,0,0]) {
|
|
2855
|
+
if (_run('which xdotool').code === 0) {
|
|
2856
|
+
_spawn('xdotool', ['windowactivate', String(windowId)]);
|
|
2857
|
+
}
|
|
2858
|
+
// Use xdotool or direct Xlib via xdpyinfo — best effort
|
|
2859
|
+
},
|
|
2860
|
+
|
|
2861
|
+
// ── Screen / Display ──────────────────────────────────────────────────────
|
|
2862
|
+
|
|
2863
|
+
/**
|
|
2864
|
+
* Get info about connected X11 outputs (monitors).
|
|
2865
|
+
* @returns {Array<{ name, connected, primary, resolution, position, hz }>}
|
|
2866
|
+
*/
|
|
2867
|
+
outputs() {
|
|
2868
|
+
const r = _out('xrandr 2>/dev/null');
|
|
2869
|
+
const outputs = [];
|
|
2870
|
+
for (const line of r.split('\n')) {
|
|
2871
|
+
const m = line.match(/^(\S+)\s+(connected|disconnected)(?: primary)?\s*(?:(\d+)x(\d+)\+(\d+)\+(\d+))?/);
|
|
2872
|
+
if (m) {
|
|
2873
|
+
outputs.push({
|
|
2874
|
+
name: m[1], connected: m[2] === 'connected', primary: line.includes('primary'),
|
|
2875
|
+
resolution: m[3] ? `${m[3]}x${m[4]}` : null,
|
|
2876
|
+
position: m[5] ? { x: +m[5], y: +m[6] } : null,
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
return outputs;
|
|
2881
|
+
},
|
|
2882
|
+
|
|
2883
|
+
/**
|
|
2884
|
+
* Set display resolution for an output.
|
|
2885
|
+
* @param {string} outputName e.g. 'HDMI-1'
|
|
2886
|
+
* @param {string} resolution e.g. '1920x1080'
|
|
2887
|
+
* @param {string} [rate] e.g. '60'
|
|
2888
|
+
*/
|
|
2889
|
+
setResolution(outputName, resolution, rate) {
|
|
2890
|
+
const args = ['--output', outputName, '--mode', resolution];
|
|
2891
|
+
if (rate) args.push('--rate', rate);
|
|
2892
|
+
_spawn('xrandr', args);
|
|
2893
|
+
},
|
|
2894
|
+
|
|
2895
|
+
/**
|
|
2896
|
+
* Turn an output on or off.
|
|
2897
|
+
* @param {string} outputName
|
|
2898
|
+
* @param {boolean} on
|
|
2899
|
+
*/
|
|
2900
|
+
setOutputEnabled(outputName, on) {
|
|
2901
|
+
_spawn('xrandr', ['--output', outputName, on ? '--auto' : '--off']);
|
|
2902
|
+
},
|
|
2903
|
+
|
|
2904
|
+
/**
|
|
2905
|
+
* Set display rotation.
|
|
2906
|
+
* @param {string} outputName
|
|
2907
|
+
* @param {'normal'|'left'|'right'|'inverted'} rotation
|
|
2908
|
+
*/
|
|
2909
|
+
setRotation(outputName, rotation) {
|
|
2910
|
+
_spawn('xrandr', ['--output', outputName, '--rotate', rotation]);
|
|
2911
|
+
},
|
|
2912
|
+
|
|
2913
|
+
// ── Input / XDG ──────────────────────────────────────────────────────────
|
|
2914
|
+
|
|
2915
|
+
/**
|
|
2916
|
+
* List X11 input devices.
|
|
2917
|
+
* @returns {Array<{ id, name, type }>}
|
|
2918
|
+
*/
|
|
2919
|
+
listInputDevices() {
|
|
2920
|
+
if (_run('which xinput').code === 0) {
|
|
2921
|
+
return _out('xinput list --short').split('\n').filter(Boolean).map(l => {
|
|
2922
|
+
const m = l.match(/^\s+(.+?)\s+id=(\d+)\s+\[(.+?)\]/);
|
|
2923
|
+
return m ? { name: m[1].trim(), id: +m[2], type: m[3].trim() } : null;
|
|
2924
|
+
}).filter(Boolean);
|
|
2925
|
+
}
|
|
2926
|
+
return [];
|
|
2927
|
+
},
|
|
2928
|
+
|
|
2929
|
+
/**
|
|
2930
|
+
* Enable or disable an input device (e.g. touchpad).
|
|
2931
|
+
* @param {number} deviceId
|
|
2932
|
+
* @param {boolean} enabled
|
|
2933
|
+
*/
|
|
2934
|
+
setInputEnabled(deviceId, enabled) {
|
|
2935
|
+
if (_run('which xinput').code === 0) {
|
|
2936
|
+
_spawn('xinput', [enabled ? 'enable' : 'disable', String(deviceId)]);
|
|
2937
|
+
}
|
|
2938
|
+
},
|
|
2939
|
+
|
|
2940
|
+
/**
|
|
2941
|
+
* Set mouse pointer speed/acceleration.
|
|
2942
|
+
* @param {number} speed -1.0 to 1.0 (libinput) or acceleration factor
|
|
2943
|
+
*/
|
|
2944
|
+
setPointerSpeed(speed) {
|
|
2945
|
+
if (_run('which xinput').code === 0) {
|
|
2946
|
+
// Try libinput
|
|
2947
|
+
const devices = X11.listInputDevices().filter(d => d.type.includes('pointer'));
|
|
2948
|
+
for (const d of devices) {
|
|
2949
|
+
_spawn('xinput', ['set-prop', String(d.id), 'libinput Accel Speed', String(speed)]);
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
},
|
|
2953
|
+
|
|
2954
|
+
// ── Clipboard (X11) ───────────────────────────────────────────────────────
|
|
2955
|
+
|
|
2956
|
+
/** Get X11 clipboard content (CLIPBOARD selection). */
|
|
2957
|
+
getClipboard() { return Keyboard.getClipboard(); },
|
|
2958
|
+
|
|
2959
|
+
/** Get X11 primary selection (middle-click paste). */
|
|
2960
|
+
getPrimary() {
|
|
2961
|
+
if (_run('which xsel').code === 0) return _out('xsel --primary --output');
|
|
2962
|
+
if (_run('which xclip').code === 0) return _out('xclip -selection primary -o');
|
|
2963
|
+
return '';
|
|
2964
|
+
},
|
|
2965
|
+
|
|
2966
|
+
/** Set X11 primary selection. */
|
|
2967
|
+
setPrimary(text) {
|
|
2968
|
+
if (_run('which xsel').code === 0) spawnSync('xsel', ['--primary', '--input'], { input: text, encoding: 'utf8' });
|
|
2969
|
+
else if (_run('which xclip').code === 0) spawnSync('xclip', ['-selection', 'primary'], { input: text, encoding: 'utf8' });
|
|
2970
|
+
},
|
|
2971
|
+
|
|
2972
|
+
// ── Compositor / Effects ──────────────────────────────────────────────────
|
|
2973
|
+
|
|
2974
|
+
/**
|
|
2975
|
+
* Is a compositor running?
|
|
2976
|
+
* @returns {boolean}
|
|
2977
|
+
*/
|
|
2978
|
+
hasCompositor() {
|
|
2979
|
+
if (_run('which xprop').code === 0) {
|
|
2980
|
+
return _out('xprop -root _NET_WM_CM_S0 2>/dev/null').trim() !== '';
|
|
2981
|
+
}
|
|
2982
|
+
return false;
|
|
2983
|
+
},
|
|
2984
|
+
|
|
2985
|
+
/**
|
|
2986
|
+
* Get the X11 display name.
|
|
2987
|
+
* @returns {string}
|
|
2988
|
+
*/
|
|
2989
|
+
display() { return process.env.DISPLAY ?? ':0'; },
|
|
2990
|
+
|
|
2991
|
+
/**
|
|
2992
|
+
* Get screen DPI via xdpyinfo.
|
|
2993
|
+
* @returns {{ x: number, y: number }|null}
|
|
2994
|
+
*/
|
|
2995
|
+
dpi() {
|
|
2996
|
+
const r = _out('xdpyinfo 2>/dev/null');
|
|
2997
|
+
const m = r.match(/resolution:\s+(\d+)x(\d+) dots per inch/);
|
|
2998
|
+
return m ? { x: +m[1], y: +m[2] } : null;
|
|
2999
|
+
},
|
|
3000
|
+
|
|
3001
|
+
/**
|
|
3002
|
+
* Bell / ring terminal bell via X11.
|
|
3003
|
+
*/
|
|
3004
|
+
bell() { _run('xbell 2>/dev/null || xdotool key XF86AudioPlay 2>/dev/null'); },
|
|
3005
|
+
|
|
3006
|
+
// ── xdg-open / MIME ───────────────────────────────────────────────────────
|
|
3007
|
+
|
|
3008
|
+
/**
|
|
3009
|
+
* Get default app for a MIME type.
|
|
3010
|
+
* @param {string} mimeType e.g. 'text/html'
|
|
3011
|
+
* @returns {string|null}
|
|
3012
|
+
*/
|
|
3013
|
+
defaultApp(mimeType) {
|
|
3014
|
+
if (_run('which xdg-mime').code === 0) {
|
|
3015
|
+
return _out(`xdg-mime query default ${mimeType}`).trim() || null;
|
|
3016
|
+
}
|
|
3017
|
+
return null;
|
|
3018
|
+
},
|
|
3019
|
+
|
|
3020
|
+
/**
|
|
3021
|
+
* Set default app for a MIME type.
|
|
3022
|
+
* @param {string} mimeType
|
|
3023
|
+
* @param {string} desktopFile e.g. 'firefox.desktop'
|
|
3024
|
+
*/
|
|
3025
|
+
setDefaultApp(mimeType, desktopFile) {
|
|
3026
|
+
if (_run('which xdg-mime').code === 0) {
|
|
3027
|
+
_spawn('xdg-mime', ['default', desktopFile, mimeType]);
|
|
3028
|
+
}
|
|
3029
|
+
},
|
|
3030
|
+
|
|
3031
|
+
/**
|
|
3032
|
+
* Query MIME type of a file.
|
|
3033
|
+
* @param {string} filePath
|
|
3034
|
+
* @returns {string|null}
|
|
3035
|
+
*/
|
|
3036
|
+
mimeType(filePath) {
|
|
3037
|
+
if (_run('which xdg-mime').code === 0) {
|
|
3038
|
+
return _out(`xdg-mime query filetype ${_esc(filePath)}`).trim() || null;
|
|
3039
|
+
}
|
|
3040
|
+
if (_run('which file').code === 0) {
|
|
3041
|
+
return _out(`file --mime-type -b ${_esc(filePath)}`).trim() || null;
|
|
3042
|
+
}
|
|
3043
|
+
return null;
|
|
3044
|
+
},
|
|
3045
|
+
};
|
|
3046
|
+
|
|
3047
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
3048
|
+
// PLATFORM CONSTANT
|
|
3049
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
3050
|
+
|
|
3051
|
+
const KITOS_PLATFORM = PLATFORM;
|
|
3052
|
+
|
|
3053
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
3054
|
+
// EXPORTS
|
|
3055
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
3056
|
+
|
|
3057
|
+
module.exports = {
|
|
3058
|
+
kitdef: {
|
|
3059
|
+
// ── 1. Notifications ─────────────────────────────
|
|
3060
|
+
Notify,
|
|
3061
|
+
|
|
3062
|
+
// ── 2. Text-to-Speech ────────────────────────────
|
|
3063
|
+
TTS,
|
|
3064
|
+
|
|
3065
|
+
// ── 3. Native Dialogs ────────────────────────────
|
|
3066
|
+
Dialog,
|
|
3067
|
+
|
|
3068
|
+
// ── 4. System Menu / Tray ────────────────────────
|
|
3069
|
+
Menu,
|
|
3070
|
+
|
|
3071
|
+
// ── 5. Keyboard Simulation ───────────────────────
|
|
3072
|
+
Keyboard,
|
|
3073
|
+
|
|
3074
|
+
// ── 6. Mouse Control ─────────────────────────────
|
|
3075
|
+
Mouse,
|
|
3076
|
+
|
|
3077
|
+
// ── 7. Screen / Screenshots ──────────────────────
|
|
3078
|
+
Screen,
|
|
3079
|
+
|
|
3080
|
+
// ── 8. Audio Playback ────────────────────────────
|
|
3081
|
+
Audio,
|
|
3082
|
+
|
|
3083
|
+
// ── 9. Camera ────────────────────────────────────
|
|
3084
|
+
Camera,
|
|
3085
|
+
|
|
3086
|
+
// ── 10. Power / Battery ──────────────────────────
|
|
3087
|
+
Power,
|
|
3088
|
+
|
|
3089
|
+
// ── 11. Display Brightness ───────────────────────
|
|
3090
|
+
Brightness,
|
|
3091
|
+
|
|
3092
|
+
// ── 12. WiFi ─────────────────────────────────────
|
|
3093
|
+
Wifi,
|
|
3094
|
+
|
|
3095
|
+
// ── 13. Bluetooth ────────────────────────────────
|
|
3096
|
+
Bluetooth,
|
|
3097
|
+
|
|
3098
|
+
// ── 14. Volume (fine-grained) ────────────────────
|
|
3099
|
+
Volume,
|
|
3100
|
+
|
|
3101
|
+
// ── 15. Trash / Recycle ──────────────────────────
|
|
3102
|
+
Trash,
|
|
3103
|
+
|
|
3104
|
+
// ── 16. Open / Reveal ────────────────────────────
|
|
3105
|
+
Open,
|
|
3106
|
+
|
|
3107
|
+
// ── 17. Autostart ────────────────────────────────
|
|
3108
|
+
Autostart,
|
|
3109
|
+
|
|
3110
|
+
// ── 18. Accessibility ────────────────────────────
|
|
3111
|
+
A11y,
|
|
3112
|
+
|
|
3113
|
+
// ── 19. Idle Time ────────────────────────────────
|
|
3114
|
+
IdleTime,
|
|
3115
|
+
|
|
3116
|
+
// ── 20. Hardware Sensors ─────────────────────────
|
|
3117
|
+
Sensors,
|
|
3118
|
+
|
|
3119
|
+
// ── 21. Location ─────────────────────────────────
|
|
3120
|
+
Location,
|
|
3121
|
+
|
|
3122
|
+
// ── 22. Haptic Vibration ─────────────────────────
|
|
3123
|
+
Vibrate,
|
|
3124
|
+
|
|
3125
|
+
// ── 23. X11 Window Manager ───────────────────────
|
|
3126
|
+
X11,
|
|
3127
|
+
|
|
3128
|
+
// ── Platform info ─────────────────────────────────
|
|
3129
|
+
platform: KITOS_PLATFORM,
|
|
3130
|
+
IS_MACOS,
|
|
3131
|
+
IS_WINDOWS,
|
|
3132
|
+
IS_LINUX,
|
|
3133
|
+
IS_ANDROID,
|
|
3134
|
+
}
|
|
3135
|
+
};
|