@deckbuilderkit/platform 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +19 -4
- package/src/browser.ts +55 -0
- package/src/dialogs.ts +296 -0
- package/src/editors.ts +107 -0
- package/src/errors.ts +15 -0
- package/src/exe.ts +42 -0
- package/src/files.ts +49 -0
- package/src/home.ts +7 -0
- package/src/index.ts +107 -0
- package/src/json-file.ts +73 -0
- package/src/launch.ts +4 -0
- package/src/rename-retry.ts +32 -0
- package/src/run.ts +84 -0
- package/src/secrets.ts +209 -0
- package/src/theme.ts +25 -0
- package/src/trash.ts +90 -0
- package/src/win/autostart.ts +71 -0
- package/src/win/placement.ts +73 -0
- package/src/win/powershell.ts +39 -0
- package/src/win/protocol.ts +79 -0
- package/src/win/toast.ts +168 -0
- package/src/win/tray.ts +223 -0
- package/README.md +0 -3
package/src/index.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// OS-specific code lives here, so apps and the other packages stay portable.
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
export * from './browser.ts';
|
|
4
|
+
export * from './dialogs.ts';
|
|
5
|
+
export * from './editors.ts';
|
|
6
|
+
export * from './errors.ts';
|
|
7
|
+
export * from './exe.ts';
|
|
8
|
+
export * from './files.ts';
|
|
9
|
+
export * from './home.ts';
|
|
10
|
+
export * from './json-file.ts';
|
|
11
|
+
export * from './rename-retry.ts';
|
|
12
|
+
export * from './run.ts';
|
|
13
|
+
export * from './secrets.ts';
|
|
14
|
+
export * from './theme.ts';
|
|
15
|
+
export * from './trash.ts';
|
|
16
|
+
export * from './win/autostart.ts';
|
|
17
|
+
export * from './win/placement.ts';
|
|
18
|
+
export * from './win/powershell.ts';
|
|
19
|
+
export * from './win/protocol.ts';
|
|
20
|
+
export * from './win/toast.ts';
|
|
21
|
+
export * from './win/tray.ts';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reports a fatal error: on stderr, and on Windows also in a message box, because the exe is
|
|
25
|
+
* built without a console. Blocks until the box is closed.
|
|
26
|
+
*/
|
|
27
|
+
export function showError(title: string, text: string): void {
|
|
28
|
+
console.error(`${title}: ${text}`);
|
|
29
|
+
if (process.platform !== 'win32') return;
|
|
30
|
+
try {
|
|
31
|
+
const { dlopen, FFIType } = require('bun:ffi') as typeof import('bun:ffi');
|
|
32
|
+
const user32 = dlopen('user32.dll', {
|
|
33
|
+
MessageBoxW: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
|
34
|
+
});
|
|
35
|
+
const wide = (s: string) => Buffer.from(`${s}\0`, 'utf16le');
|
|
36
|
+
const MB_ICONERROR = 0x10;
|
|
37
|
+
user32.symbols.MessageBoxW(null, wide(text), wide(title), MB_ICONERROR);
|
|
38
|
+
user32.close();
|
|
39
|
+
} catch {
|
|
40
|
+
// stderr above is the fallback
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shows a native window (an HWND) on Windows; elsewhere a no-op. A process started hidden
|
|
46
|
+
* (`bun run` passes SW_HIDE in its STARTUPINFO) gets that value instead of its first ShowWindow
|
|
47
|
+
* call, so the window a library showed once stays hidden; this second call is honoured.
|
|
48
|
+
*/
|
|
49
|
+
export function showWindow(hwnd: import('bun:ffi').Pointer | null): void {
|
|
50
|
+
if (process.platform !== 'win32' || !hwnd) return;
|
|
51
|
+
try {
|
|
52
|
+
const { dlopen, FFIType } = require('bun:ffi') as typeof import('bun:ffi');
|
|
53
|
+
const user32 = dlopen('user32.dll', {
|
|
54
|
+
ShowWindow: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
55
|
+
});
|
|
56
|
+
const SW_SHOW = 5;
|
|
57
|
+
user32.symbols.ShowWindow(hwnd, SW_SHOW);
|
|
58
|
+
user32.close();
|
|
59
|
+
} catch {
|
|
60
|
+
// the window stays as the library left it
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Sets a native window's title-bar and taskbar icon (Windows) to the icon embedded in the running
|
|
66
|
+
* exe. The webview library picks its own icon; this makes the exe's `--windows-icon` the one shown.
|
|
67
|
+
* A no-op elsewhere and when running from source (the exe would be bun's).
|
|
68
|
+
*/
|
|
69
|
+
export function setWindowIconFromExe(hwnd: import('bun:ffi').Pointer | null): void {
|
|
70
|
+
if (process.platform !== 'win32' || !hwnd || /^bun(\.exe)?$/i.test(basename(process.execPath))) return;
|
|
71
|
+
try {
|
|
72
|
+
const { dlopen, FFIType } = require('bun:ffi') as typeof import('bun:ffi');
|
|
73
|
+
const shell32 = dlopen('shell32.dll', {
|
|
74
|
+
ExtractIconExW: { args: [FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.u32 },
|
|
75
|
+
});
|
|
76
|
+
const user32 = dlopen('user32.dll', {
|
|
77
|
+
SendMessageW: { args: [FFIType.ptr, FFIType.u32, FFIType.u64, FFIType.u64], returns: FFIType.u64 },
|
|
78
|
+
});
|
|
79
|
+
const large = new BigUint64Array(1);
|
|
80
|
+
const small = new BigUint64Array(1);
|
|
81
|
+
const path = Buffer.from(`${process.execPath}\0`, 'utf16le');
|
|
82
|
+
shell32.symbols.ExtractIconExW(path, 0, large, small, 1);
|
|
83
|
+
const WM_SETICON = 0x80;
|
|
84
|
+
if (small[0]) user32.symbols.SendMessageW(hwnd, WM_SETICON, 0n, small[0]); // ICON_SMALL
|
|
85
|
+
if (large[0]) user32.symbols.SendMessageW(hwnd, WM_SETICON, 1n, large[0]); // ICON_BIG
|
|
86
|
+
shell32.close();
|
|
87
|
+
user32.close();
|
|
88
|
+
} catch {
|
|
89
|
+
// the window keeps the library's icon
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Opens a URL in the default browser; best-effort, failures are ignored. */
|
|
94
|
+
export function openExternal(url: string): void {
|
|
95
|
+
if (!/^https?:\/\//.test(url)) return;
|
|
96
|
+
const cmd =
|
|
97
|
+
process.platform === 'win32'
|
|
98
|
+
? ['rundll32', 'url.dll,FileProtocolHandler', url]
|
|
99
|
+
: process.platform === 'darwin'
|
|
100
|
+
? ['open', url]
|
|
101
|
+
: ['xdg-open', url];
|
|
102
|
+
try {
|
|
103
|
+
Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true }).unref();
|
|
104
|
+
} catch {
|
|
105
|
+
// no browser launcher available
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/json-file.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { renameWithRetry } from './rename-retry.ts';
|
|
4
|
+
|
|
5
|
+
/** Tail of each file's write queue, keyed by absolute path: writes to one file never overlap. */
|
|
6
|
+
const queues = new Map<string, Promise<unknown>>();
|
|
7
|
+
|
|
8
|
+
function enqueue<T>(file: string, job: () => Promise<T>): Promise<T> {
|
|
9
|
+
const key = resolve(file);
|
|
10
|
+
const run = (queues.get(key) ?? Promise.resolve()).catch(() => {}).then(job);
|
|
11
|
+
const tail = run.catch(() => {});
|
|
12
|
+
queues.set(key, tail);
|
|
13
|
+
void tail.then(() => {
|
|
14
|
+
if (queues.get(key) === tail) queues.delete(key);
|
|
15
|
+
});
|
|
16
|
+
return run;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Writes `data` to a temp file beside `file`, then renames it over the target; a crash never leaves a half-written file. */
|
|
20
|
+
export function writeFileAtomic(file: string, data: string | Uint8Array | Blob | ReadableStream<Uint8Array>): Promise<void> {
|
|
21
|
+
return enqueue(file, async () => {
|
|
22
|
+
await mkdir(dirname(file), { recursive: true });
|
|
23
|
+
const tmp = `${file}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;
|
|
24
|
+
try {
|
|
25
|
+
if (typeof data === 'string' || data instanceof Uint8Array) await writeFile(tmp, data);
|
|
26
|
+
else {
|
|
27
|
+
// Bun.write(path, blob) copies a sliced file whole; the writer streams just the slice.
|
|
28
|
+
const out = Bun.file(tmp).writer();
|
|
29
|
+
try {
|
|
30
|
+
for await (const chunk of data instanceof Blob ? data.stream() : data) out.write(chunk);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
await Promise.resolve(out.end()).catch(() => {});
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
await out.end();
|
|
36
|
+
}
|
|
37
|
+
await renameWithRetry(tmp, file);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
await rm(tmp, { force: true });
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `JSON.stringify(value, null, 2)` plus a newline, written atomically. */
|
|
46
|
+
export function writeJsonAtomic(file: string, value: unknown): Promise<void> {
|
|
47
|
+
return writeFileAtomic(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads and parses a JSON file. A missing file gives undefined; other read errors (EACCES, EISDIR) throw.
|
|
52
|
+
* A file that doesn't parse, or that `accept` rejects, is renamed to `<file>.corrupt-<epoch ms>`, reported
|
|
53
|
+
* on stderr, and gives undefined, so the next write doesn't silently destroy it.
|
|
54
|
+
*/
|
|
55
|
+
export async function readJsonFile(file: string, accept?: (value: unknown) => boolean): Promise<unknown | undefined> {
|
|
56
|
+
let text: string;
|
|
57
|
+
try {
|
|
58
|
+
text = await readFile(file, 'utf8');
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const value: unknown = JSON.parse(text);
|
|
65
|
+
if (!accept || accept(value)) return value;
|
|
66
|
+
} catch {
|
|
67
|
+
// falls through to the quarantine below
|
|
68
|
+
}
|
|
69
|
+
const aside = `${file}.corrupt-${Date.now()}`;
|
|
70
|
+
console.error(`Unreadable JSON file moved aside: ${file} -> ${aside}`);
|
|
71
|
+
await rename(file, aside).catch(() => {});
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
package/src/launch.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Not hidden: windowsHide starts explorer.exe with SW_HIDE and its window never appears.
|
|
2
|
+
export function launch(argv: string[], windowsVerbatimArguments = false): void {
|
|
3
|
+
Bun.spawn(argv, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false, windowsVerbatimArguments }).unref();
|
|
4
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { rename as fsRename, stat } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
const RETRY_CODES = new Set(['EACCES', 'EPERM', 'EBUSY']);
|
|
4
|
+
|
|
5
|
+
export interface RenameRetryOptions {
|
|
6
|
+
/** How long to keep retrying, in ms; default 10 s. */
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
/** Injected in tests. */
|
|
9
|
+
rename?: (from: string, to: string) => Promise<void>;
|
|
10
|
+
platform?: NodeJS.Platform;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `rename`, retried on Windows while EACCES / EPERM / EBUSY last: antivirus and the search indexer
|
|
15
|
+
* hold a just-written file open for a moment (VS Code retries the same way). A folder at `to` is not
|
|
16
|
+
* retried, since EPERM is then the real answer.
|
|
17
|
+
*/
|
|
18
|
+
export async function renameWithRetry(from: string, to: string, opts: RenameRetryOptions = {}): Promise<void> {
|
|
19
|
+
const rename = opts.rename ?? fsRename;
|
|
20
|
+
if ((opts.platform ?? process.platform) !== 'win32') return rename(from, to);
|
|
21
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 10_000);
|
|
22
|
+
for (let attempt = 1; ; attempt++) {
|
|
23
|
+
try {
|
|
24
|
+
return await rename(from, to);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
27
|
+
if (!code || !RETRY_CODES.has(code) || Date.now() >= deadline) throw err;
|
|
28
|
+
if (await stat(to).then((s) => !s.isFile(), () => false)) throw err;
|
|
29
|
+
await Bun.sleep(Math.min(100, attempt * 10));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export type RunResult = { kind: 'done'; code: number; stdout: string; stderr: string } | { kind: 'timeout' };
|
|
4
|
+
|
|
5
|
+
/** Runs a command and collects its output; faked in tests. */
|
|
6
|
+
export type Runner = (cmd: string[], timeoutMs: number) => Promise<RunResult>;
|
|
7
|
+
|
|
8
|
+
export const runWithTimeout: Runner = async (cmd, timeoutMs) => {
|
|
9
|
+
const proc = Bun.spawn(cmd, { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', windowsHide: true });
|
|
10
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
11
|
+
const timedOut = new Promise<'timeout'>((resolve) => {
|
|
12
|
+
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
13
|
+
});
|
|
14
|
+
const done = Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
15
|
+
const result = await Promise.race([done, timedOut]);
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
if (result === 'timeout') {
|
|
18
|
+
killTree(proc.pid);
|
|
19
|
+
return { kind: 'timeout' };
|
|
20
|
+
}
|
|
21
|
+
const [stdout, stderr, code] = result;
|
|
22
|
+
return { kind: 'done', code, stdout, stderr };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Kills a child process and everything it spawned. Shims (scoop, Chocolatey) run the real exe as
|
|
27
|
+
* their own child, so a plain kill would leave that one alive.
|
|
28
|
+
*/
|
|
29
|
+
export function killTree(pid: number): void {
|
|
30
|
+
if (process.platform === 'win32') {
|
|
31
|
+
Bun.spawn(['taskkill', '/PID', String(pid), '/T', '/F'], { stdout: 'ignore', stderr: 'ignore', windowsHide: true });
|
|
32
|
+
} else {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid);
|
|
35
|
+
} catch {
|
|
36
|
+
// already gone
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `killTree` that returns once the tree is gone, e.g. right before `process.exit`. */
|
|
42
|
+
export function killTreeSync(pid: number): void {
|
|
43
|
+
if (process.platform === 'win32') {
|
|
44
|
+
Bun.spawnSync(['taskkill', '/PID', String(pid), '/T', '/F'], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
|
|
45
|
+
} else {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid);
|
|
48
|
+
} catch {
|
|
49
|
+
// already gone
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Polls `url` until it answers with any HTTP status. Resolves true then, false after `timeoutMs`
|
|
56
|
+
* or once `until` returns true (e.g. the server process exited).
|
|
57
|
+
*/
|
|
58
|
+
export async function waitForUrl(url: string, opts: { timeoutMs: number; until?: () => boolean }): Promise<boolean> {
|
|
59
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
60
|
+
while (Date.now() < deadline && !opts.until?.()) {
|
|
61
|
+
try {
|
|
62
|
+
await fetch(url, { signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) });
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
await Bun.sleep(200);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Starts `argv` outside this process's job object, so it outlives this process. Resolves once it
|
|
73
|
+
* started; rejects when it could not (e.g. missing exe).
|
|
74
|
+
*/
|
|
75
|
+
export function startDetached(argv: string[]): Promise<void> {
|
|
76
|
+
const [cmd, ...args] = argv;
|
|
77
|
+
if (!cmd) return Promise.reject(new Error('empty command'));
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
80
|
+
child.once('spawn', () => resolve());
|
|
81
|
+
child.once('error', reject);
|
|
82
|
+
child.unref();
|
|
83
|
+
});
|
|
84
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { PlatformError } from './errors.ts';
|
|
3
|
+
import { appHome } from './home.ts';
|
|
4
|
+
import { readJsonFile, writeJsonAtomic } from './json-file.ts';
|
|
5
|
+
|
|
6
|
+
export interface SecretStore {
|
|
7
|
+
/** False when the OS store can't be used (Linux without secret-tool, macOS without security). */
|
|
8
|
+
available(): boolean;
|
|
9
|
+
/** Throws PlatformError 'secretsUnavailable' when the store isn't available. */
|
|
10
|
+
set(ref: string, value: string): Promise<void>;
|
|
11
|
+
/** null when nothing is stored under `ref` (or the store isn't available). */
|
|
12
|
+
get(ref: string): Promise<string | null>;
|
|
13
|
+
remove(ref: string): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const REF = /^[\w.:-]{1,200}$/;
|
|
17
|
+
const unavailable = (detail?: string) => new PlatformError('secretsUnavailable', 'The secret store is not available.', detail);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Passwords and tokens an app keeps for the current OS user. Windows: DPAPI-encrypted blobs in
|
|
21
|
+
* `<dir>/secrets.json`, readable only by this Windows account. Linux: libsecret via `secret-tool`;
|
|
22
|
+
* macOS: the login keychain via `security` (both best-effort, untested).
|
|
23
|
+
*/
|
|
24
|
+
export function secretStore(appId: string, dir = appHome(appId)): SecretStore {
|
|
25
|
+
if (!/^[\w.-]+$/.test(appId)) throw new Error(`secretStore: invalid app id "${appId}"`);
|
|
26
|
+
const store =
|
|
27
|
+
process.platform === 'win32'
|
|
28
|
+
? dpapiStore(appId, dir)
|
|
29
|
+
: process.platform === 'linux'
|
|
30
|
+
? secretToolStore(appId)
|
|
31
|
+
: process.platform === 'darwin'
|
|
32
|
+
? keychainStore(appId)
|
|
33
|
+
: null;
|
|
34
|
+
const checked = (ref: string) => {
|
|
35
|
+
if (!REF.test(ref)) throw new Error(`secretStore: invalid ref "${ref}"`);
|
|
36
|
+
return ref;
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
available: () => store?.available() ?? false,
|
|
40
|
+
async set(ref, value) {
|
|
41
|
+
checked(ref);
|
|
42
|
+
if (!store?.available()) throw unavailable();
|
|
43
|
+
await store.set(ref, value);
|
|
44
|
+
},
|
|
45
|
+
async get(ref) {
|
|
46
|
+
checked(ref);
|
|
47
|
+
return store?.available() ? store.get(ref) : null;
|
|
48
|
+
},
|
|
49
|
+
async remove(ref) {
|
|
50
|
+
checked(ref);
|
|
51
|
+
if (store?.available()) await store.remove(ref);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Windows: DPAPI --------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
interface SecretsFile {
|
|
59
|
+
version: 1;
|
|
60
|
+
items: Record<string, string>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isSecretsFile = (v: unknown) =>
|
|
64
|
+
typeof v === 'object' &&
|
|
65
|
+
v !== null &&
|
|
66
|
+
(v as SecretsFile).version === 1 &&
|
|
67
|
+
typeof (v as SecretsFile).items === 'object' &&
|
|
68
|
+
(v as SecretsFile).items !== null &&
|
|
69
|
+
Object.values((v as SecretsFile).items).every((s) => typeof s === 'string');
|
|
70
|
+
|
|
71
|
+
function dpapiStore(appId: string, dir: string): SecretStore {
|
|
72
|
+
const file = join(dir, 'secrets.json');
|
|
73
|
+
const entropy = Buffer.from(appId, 'utf8');
|
|
74
|
+
let items: Map<string, string> | null = null;
|
|
75
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
76
|
+
|
|
77
|
+
const locked = <R>(job: () => Promise<R>): Promise<R> => {
|
|
78
|
+
const run = chain.then(job);
|
|
79
|
+
chain = run.catch(() => {});
|
|
80
|
+
return run;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
async function load(): Promise<Map<string, string>> {
|
|
84
|
+
if (!items) items = new Map(Object.entries(((await readJsonFile(file, isSecretsFile)) as SecretsFile | undefined)?.items ?? {}));
|
|
85
|
+
return items;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function commit(next: Map<string, string>): Promise<void> {
|
|
89
|
+
await writeJsonAtomic(file, { version: 1, items: Object.fromEntries(next) } satisfies SecretsFile);
|
|
90
|
+
items = next;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
available: () => true,
|
|
95
|
+
set: (ref, value) =>
|
|
96
|
+
locked(async () => {
|
|
97
|
+
const sealed = dpapi(true, Buffer.from(value, 'utf8'), entropy);
|
|
98
|
+
if (!sealed) throw unavailable('CryptProtectData failed');
|
|
99
|
+
await commit(new Map(await load()).set(ref, sealed.toString('base64')));
|
|
100
|
+
}),
|
|
101
|
+
get: (ref) =>
|
|
102
|
+
locked(async () => {
|
|
103
|
+
const stored = (await load()).get(ref);
|
|
104
|
+
if (stored === undefined) return null;
|
|
105
|
+
const plain = dpapi(false, Buffer.from(stored, 'base64'), entropy);
|
|
106
|
+
if (!plain) throw new PlatformError('secretUnreadable', 'A stored secret could not be decrypted.', ref);
|
|
107
|
+
return plain.toString('utf8');
|
|
108
|
+
}),
|
|
109
|
+
remove: (ref) =>
|
|
110
|
+
locked(async () => {
|
|
111
|
+
const all = await load();
|
|
112
|
+
if (!all.has(ref)) return;
|
|
113
|
+
const next = new Map(all);
|
|
114
|
+
next.delete(ref);
|
|
115
|
+
await commit(next);
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const CRYPTPROTECT_UI_FORBIDDEN = 0x1;
|
|
121
|
+
let dpapiLib: ReturnType<typeof openDpapi> | null = null;
|
|
122
|
+
|
|
123
|
+
function openDpapi() {
|
|
124
|
+
const { dlopen, FFIType } = require('bun:ffi') as typeof import('bun:ffi');
|
|
125
|
+
// (pDataIn, szDataDescr | ppszDataDescr, pOptionalEntropy, pvReserved, pPromptStruct, dwFlags, pDataOut)
|
|
126
|
+
const sig = { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 } as const;
|
|
127
|
+
const crypt32 = dlopen('crypt32.dll', { CryptProtectData: sig, CryptUnprotectData: sig });
|
|
128
|
+
const kernel32 = dlopen('kernel32.dll', { LocalFree: { args: [FFIType.ptr], returns: FFIType.ptr } });
|
|
129
|
+
return { ...crypt32.symbols, ...kernel32.symbols };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A DATA_BLOB (x64: u32 cbData, 4 bytes padding, ptr pbData) over `data`; keep `data` alive during the call. */
|
|
133
|
+
function blobOf(data: Uint8Array, size = data.length): Buffer {
|
|
134
|
+
const { ptr } = require('bun:ffi') as typeof import('bun:ffi');
|
|
135
|
+
const blob = Buffer.alloc(16);
|
|
136
|
+
blob.writeUInt32LE(size, 0);
|
|
137
|
+
blob.writeBigUInt64LE(BigInt(ptr(data)), 8);
|
|
138
|
+
return blob;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** CryptProtectData (`protect`) or CryptUnprotectData over `data`; null when the call fails. */
|
|
142
|
+
function dpapi(protect: boolean, data: Uint8Array, entropy: Uint8Array): Buffer | null {
|
|
143
|
+
const { toArrayBuffer } = require('bun:ffi') as typeof import('bun:ffi');
|
|
144
|
+
const lib = (dpapiLib ??= openDpapi());
|
|
145
|
+
// ptr() rejects empty buffers; cbData stays 0 for them.
|
|
146
|
+
const input = data.length ? data : new Uint8Array(1);
|
|
147
|
+
const inBlob = blobOf(input, data.length);
|
|
148
|
+
const entropyBlob = blobOf(entropy);
|
|
149
|
+
const out = Buffer.alloc(16);
|
|
150
|
+
const call = protect ? lib.CryptProtectData : lib.CryptUnprotectData;
|
|
151
|
+
const ok = call(inBlob, null, entropyBlob, null, null, CRYPTPROTECT_UI_FORBIDDEN, out);
|
|
152
|
+
void [input, entropy];
|
|
153
|
+
if (!ok) return null;
|
|
154
|
+
const size = out.readUInt32LE(0);
|
|
155
|
+
const at = Number(out.readBigUInt64LE(8)) as import('bun:ffi').Pointer;
|
|
156
|
+
try {
|
|
157
|
+
const copy = Buffer.alloc(size);
|
|
158
|
+
if (size) copy.set(new Uint8Array(toArrayBuffer(at, 0, size)));
|
|
159
|
+
return copy;
|
|
160
|
+
} finally {
|
|
161
|
+
lib.LocalFree(at);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- Linux and macOS (best-effort, untested) -------------------------------------------------
|
|
166
|
+
|
|
167
|
+
async function exec(argv: string[], input?: string): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
168
|
+
const proc = Bun.spawn(argv, { stdin: input === undefined ? 'ignore' : Buffer.from(input, 'utf8'), stdout: 'pipe', stderr: 'pipe' });
|
|
169
|
+
const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
170
|
+
return { code, stdout, stderr };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function secretToolStore(appId: string): SecretStore {
|
|
174
|
+
const attrs = (ref: string) => ['app', appId, 'ref', ref];
|
|
175
|
+
return {
|
|
176
|
+
available: () => Bun.which('secret-tool') !== null,
|
|
177
|
+
async set(ref, value) {
|
|
178
|
+
const r = await exec(['secret-tool', 'store', `--label=${appId}`, ...attrs(ref)], value);
|
|
179
|
+
if (r.code !== 0) throw unavailable(r.stderr.trim());
|
|
180
|
+
},
|
|
181
|
+
async get(ref) {
|
|
182
|
+
const r = await exec(['secret-tool', 'lookup', ...attrs(ref)]);
|
|
183
|
+
return r.code === 0 ? r.stdout : null;
|
|
184
|
+
},
|
|
185
|
+
async remove(ref) {
|
|
186
|
+
await exec(['secret-tool', 'clear', ...attrs(ref)]);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The value never goes on argv: `security -i` reads the command from stdin. It is stored as hex
|
|
192
|
+
// text (no quoting needed), so reading always hex-decodes.
|
|
193
|
+
function keychainStore(appId: string): SecretStore {
|
|
194
|
+
return {
|
|
195
|
+
available: () => Bun.which('security') !== null,
|
|
196
|
+
async set(ref, value) {
|
|
197
|
+
const hex = Buffer.from(value, 'utf8').toString('hex');
|
|
198
|
+
const r = await exec(['security', '-i'], `add-generic-password -U -s "${appId}" -a "${ref}" -w ${hex || '""'}\n`);
|
|
199
|
+
if (r.code !== 0) throw unavailable(r.stderr.trim());
|
|
200
|
+
},
|
|
201
|
+
async get(ref) {
|
|
202
|
+
const r = await exec(['security', 'find-generic-password', '-s', appId, '-a', ref, '-w']);
|
|
203
|
+
return r.code === 0 ? Buffer.from(r.stdout.trim(), 'hex').toString('utf8') : null;
|
|
204
|
+
},
|
|
205
|
+
async remove(ref) {
|
|
206
|
+
await exec(['security', 'delete-generic-password', '-s', appId, '-a', ref]);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Runner, runWithTimeout } from './run.ts';
|
|
2
|
+
|
|
3
|
+
const PERSONALIZE = String.raw`HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize`;
|
|
4
|
+
const TIMEOUT_MS = 5_000;
|
|
5
|
+
|
|
6
|
+
/** The light/dark value in `reg.exe query <Personalize> /v AppsUseLightTheme` output. */
|
|
7
|
+
export function parseRegTheme(stdout: string): 'dark' | 'light' | null {
|
|
8
|
+
const m = stdout.match(/AppsUseLightTheme\s+REG_DWORD\s+0x([0-9a-f]+)/i);
|
|
9
|
+
if (!m) return null;
|
|
10
|
+
return parseInt(m[1]!, 16) === 0 ? 'dark' : 'light';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The OS app theme (Windows registry, macOS defaults), or null where there is no such setting. */
|
|
14
|
+
export async function osThemeKind(run: Runner = runWithTimeout): Promise<'dark' | 'light' | null> {
|
|
15
|
+
if (process.platform === 'win32') {
|
|
16
|
+
const r = await run(['reg.exe', 'query', PERSONALIZE, '/v', 'AppsUseLightTheme'], TIMEOUT_MS);
|
|
17
|
+
return r.kind === 'done' && r.code === 0 ? parseRegTheme(r.stdout) : null;
|
|
18
|
+
}
|
|
19
|
+
if (process.platform === 'darwin') {
|
|
20
|
+
// Prints "Dark" in dark mode; fails (no such key) in light mode.
|
|
21
|
+
const r = await run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], TIMEOUT_MS);
|
|
22
|
+
return r.kind === 'done' && r.code === 0 && /dark/i.test(r.stdout) ? 'dark' : 'light';
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
package/src/trash.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { PlatformError } from './errors.ts';
|
|
2
|
+
import { runWithTimeout } from './run.ts';
|
|
3
|
+
|
|
4
|
+
export interface TrashDeps {
|
|
5
|
+
/** Moves one absolute path to the recycle bin. */
|
|
6
|
+
trashOne: (path: string) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const defaultDeps: TrashDeps = { trashOne };
|
|
10
|
+
|
|
11
|
+
const failed = (detail?: string) => new PlatformError('trashFailed', "Couldn't move to the Recycle Bin.", detail);
|
|
12
|
+
const unavailable = (detail?: string) => new PlatformError('trashUnavailable', 'No recycle bin is available on this system.', detail);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Moves the absolute `paths` (files or folders) to the OS recycle bin, one awaited call per path
|
|
16
|
+
* like VS Code. Stops at the first failure; the PlatformError 'trashFailed' (or 'trashUnavailable')
|
|
17
|
+
* lists which paths were and weren't moved. It never falls back to a permanent delete.
|
|
18
|
+
*/
|
|
19
|
+
export async function moveToTrash(paths: string[], deps = defaultDeps): Promise<void> {
|
|
20
|
+
const moved: string[] = [];
|
|
21
|
+
for (const path of paths) {
|
|
22
|
+
try {
|
|
23
|
+
await deps.trashOne(path);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const reason = err instanceof PlatformError ? err : failed((err as Error).message);
|
|
26
|
+
if (paths.length === 1) throw reason;
|
|
27
|
+
const notMoved = paths.slice(moved.length);
|
|
28
|
+
const detail = `${reason.detail ?? reason.message}
|
|
29
|
+
Moved: ${moved.join(', ') || '(none)'}
|
|
30
|
+
Not moved: ${notMoved.join(', ')}`;
|
|
31
|
+
throw reason.code === 'trashUnavailable' ? unavailable(detail) : failed(detail);
|
|
32
|
+
}
|
|
33
|
+
moved.push(path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function trashOne(path: string): Promise<void> {
|
|
38
|
+
if (process.platform === 'win32') return shellTrash(path);
|
|
39
|
+
if (process.platform === 'darwin') {
|
|
40
|
+
const lines = ['on run argv', 'tell application "Finder" to delete (POSIX file (item 1 of argv) as alias)', 'end run'];
|
|
41
|
+
return run(['osascript', ...lines.flatMap((line) => ['-e', line]), path]);
|
|
42
|
+
}
|
|
43
|
+
if (process.platform === 'linux') return run(['gio', 'trash', path]);
|
|
44
|
+
throw unavailable();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The shell call runs in this process: a PowerShell child that compiles C# on the fly (csc.exe,
|
|
48
|
+
// P/Invoke, a delete) is what behavior-based antivirus flagged as ransomware (HEU_AEGIS_CRYPT).
|
|
49
|
+
const FO_DELETE = 3;
|
|
50
|
+
const FOF_SILENT = 0x4;
|
|
51
|
+
const FOF_NOCONFIRMATION = 0x10;
|
|
52
|
+
const FOF_ALLOWUNDO = 0x40;
|
|
53
|
+
const FOF_NOERRORUI = 0x400;
|
|
54
|
+
const FOF_WANTNUKEWARNING = 0x4000;
|
|
55
|
+
|
|
56
|
+
function shellTrash(path: string): Promise<void> {
|
|
57
|
+
const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi');
|
|
58
|
+
const { symbols } = dlopen('shell32.dll', { SHFileOperationW: { args: [FFIType.ptr], returns: FFIType.i32 } });
|
|
59
|
+
const from = Buffer.from(`${path}\0\0`, 'utf16le');
|
|
60
|
+
// SHFILEOPSTRUCTW (x64): hwnd@0, wFunc@8, pFrom@16, pTo@24, fFlags@32, fAnyOperationsAborted@36, hNameMappings@40, lpszProgressTitle@48.
|
|
61
|
+
const op = Buffer.alloc(56);
|
|
62
|
+
op.writeUInt32LE(FO_DELETE, 8);
|
|
63
|
+
op.writeBigUInt64LE(BigInt(ptr(from)), 16);
|
|
64
|
+
op.writeUInt16LE(FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI | FOF_WANTNUKEWARNING, 32);
|
|
65
|
+
const result = symbols.SHFileOperationW(ptr(op));
|
|
66
|
+
if (result !== 0) return Promise.reject(failed(`SHFileOperation failed with code 0x${result.toString(16)}`));
|
|
67
|
+
if (op.readInt32LE(36) !== 0) return Promise.reject(failed('Cancelled.'));
|
|
68
|
+
return Promise.resolve();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function run(argv: string[]): Promise<void> {
|
|
72
|
+
let result: Awaited<ReturnType<typeof runWithTimeout>>;
|
|
73
|
+
try {
|
|
74
|
+
result = await runWithTimeout(argv, 60_000);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw unavailable((err as Error).message);
|
|
77
|
+
}
|
|
78
|
+
if (result.kind === 'timeout') throw failed('Timed out.');
|
|
79
|
+
if (result.code !== 0) throw failed(result.stderr.trim());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// `bun packages/platform/src/trash.ts --selftest`: creates a temp file and moves it to the bin.
|
|
83
|
+
if (import.meta.main && process.argv.includes('--selftest')) {
|
|
84
|
+
const { tmpdir } = await import('node:os');
|
|
85
|
+
const { join } = await import('node:path');
|
|
86
|
+
const file = join(tmpdir(), `deckbuilder-trash-selftest-${Date.now()}.txt`);
|
|
87
|
+
await Bun.write(file, 'DeckBuilder trash self-test\n');
|
|
88
|
+
await moveToTrash([file]);
|
|
89
|
+
console.log(`moved to the recycle bin: ${file} (exists now: ${await Bun.file(file).exists()})`);
|
|
90
|
+
}
|