@workbench-kit/electron-shell 0.0.2-prototype.0.2.32 → 0.0.2-prototype.0.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,11 @@ import { openAllowlistedExternalLink } from '@workbench-kit/electron-shell/exter
34
34
  import { createApplicationQuitGuard } from '@workbench-kit/electron-shell/application-quit-guard';
35
35
  import { registerPrivilegedAssetProtocolScheme } from '@workbench-kit/electron-shell/asset-protocol';
36
36
  import { requireOwnedWindowForSender } from '@workbench-kit/electron-shell/sender-security';
37
+ import { createEncryptedSecretVault } from '@workbench-kit/electron-shell/secret-vault';
38
+ import {
39
+ createWin32RegistryStringReader,
40
+ resolveWallpaperCropRect,
41
+ } from '@workbench-kit/electron-shell/wallpaper';
37
42
  import {
38
43
  createWindowControlsBridge,
39
44
  registerWindowControlIpc,
@@ -48,6 +53,19 @@ maximized state returned by the main handler.
48
53
  asset privileges. Call it before app readiness; the host retains its scheme,
49
54
  URL parsing, cache policy, responses, and post-ready `protocol.handle` wiring.
50
55
 
56
+ `createEncryptedSecretVault` encrypts the whole document so persisted bytes do
57
+ not reveal secret ids. Bulk reads/writes and single-key operations share one FIFO
58
+ queue. `documentCodec` lets the host retain its existing plaintext envelope, and
59
+ `writeVault` receives sorted secret-id metadata for an opaque reference or empty-
60
+ vault deletion policy. Hosts retain atomic storage, namespace/path selection,
61
+ references, and backup/restore policy. Legacy version 1 entry-encrypted documents
62
+ are migrated on the next mutation.
63
+
64
+ `resolveWallpaperCropRect` uses one aspect-ratio-preserving cover rule for a
65
+ spanned virtual desktop. The same focused entry provides a bounded, shell-free
66
+ Win32 registry reader and a path resolver with host-injected existence checks and
67
+ fallback path.
68
+
51
69
  ## Application quit guard (`./application-quit-guard`)
52
70
 
53
71
  Electron's `before-quit` event must be vetoed synchronously, even when checking
@@ -71,16 +89,22 @@ electronApp.on('before-quit', (event) => {
71
89
  ```
72
90
 
73
91
  Repeated quit events are coalesced into the active request. After save or discard,
74
- the guard rechecks dirty state and resumes only when clean. Errors, timeout, and a
75
- still-dirty recheck fail closed. `cancelPending()` aborts an obsolete request and
76
- invalidates any late guard completion; call it when the owning lifecycle is
77
- replaced. A callback that has already started must honor its `AbortSignal` to stop
78
- its own side effects.
92
+ the guard rechecks dirty state and resumes only when clean. It invokes `resumeQuit`
93
+ on the next event-loop task so Electron can finish dispatching the original
94
+ `before-quit` event first. Errors, timeout, and a still-dirty recheck fail closed.
95
+ `cancelPending()` aborts an obsolete request and invalidates any late guard
96
+ completion; call it when the owning lifecycle is replaced. A callback that has
97
+ already started must honor its `AbortSignal` to stop its own side effects.
79
98
  The injected `resumeQuit` port must re-enter the registered guard synchronously;
80
99
  an asynchronous wrapper is treated as a fresh request. OS shutdown paths that do
81
100
  not emit `before-quit`, and updater flows that emit it after windows close, remain
82
101
  host-owned lifecycle concerns outside this guard.
83
102
 
103
+ The repository also runs `pnpm test:electron-quit-guard` against Electron 41.5
104
+ with a live `BrowserWindow`. It verifies the deferred save path, a coalesced quit
105
+ attempt before the scheduled resume, and the normal window-close / application-quit
106
+ lifecycle after the permitted re-entry.
107
+
84
108
  For external links, keep the product allowlist outside the Kit and pass only an
85
109
  opaque link id into the generic helper:
86
110
 
@@ -38,6 +38,11 @@ function raceWithAbort(operation, signal, readAbortKind) {
38
38
  });
39
39
  });
40
40
  }
41
+ function waitForNextTask() {
42
+ return new Promise((resolve) => {
43
+ setTimeout(resolve, 0);
44
+ });
45
+ }
41
46
  /**
42
47
  * Coordinate an Electron-style before-quit boundary without importing Electron.
43
48
  * The host owns event registration, dirty state, prompts, persistence, and quit invocation.
@@ -76,7 +81,10 @@ function createApplicationQuitGuard(options) {
76
81
  }
77
82
  return resume(reason);
78
83
  };
79
- const resume = (reason) => {
84
+ const resume = async (reason) => {
85
+ // A Promise continuation can still run before Electron finishes dispatching the original
86
+ // before-quit event. Cross a task boundary before re-entering app.quit().
87
+ await run(waitForNextTask);
80
88
  // The decision is now irreversible. `cancelPending` must not report a cancellable request
81
89
  // while the host is synchronously re-entering before-quit.
82
90
  if (isCurrent()) {
@@ -99,7 +107,7 @@ function createApplicationQuitGuard(options) {
99
107
  throw new TypeError('Application quit guard isDirty must return a boolean.');
100
108
  }
101
109
  if (!dirty) {
102
- return resume('clean');
110
+ return await resume('clean');
103
111
  }
104
112
  const decision = await run(() => options.requestDecision(controller.signal));
105
113
  if (decision === 'cancel') {
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EncryptionUnavailableError = void 0;
4
+ exports.createEncryptedSecretVault = createEncryptedSecretVault;
5
+ class EncryptionUnavailableError extends Error {
6
+ constructor(message = 'OS-backed encryption is unavailable; refusing plaintext vault.') {
7
+ super(message);
8
+ this.code = 'encryption_unavailable';
9
+ this.name = 'EncryptionUnavailableError';
10
+ }
11
+ }
12
+ exports.EncryptionUnavailableError = EncryptionUnavailableError;
13
+ const textDecoder = new TextDecoder();
14
+ function fromBase64(value) {
15
+ const binary = atob(value);
16
+ const bytes = new Uint8Array(binary.length);
17
+ for (let index = 0; index < binary.length; index += 1) {
18
+ bytes[index] = binary.charCodeAt(index);
19
+ }
20
+ return bytes;
21
+ }
22
+ function parseSecretRecord(value) {
23
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
24
+ throw new Error('Secret vault document is malformed.');
25
+ }
26
+ const secrets = createSecretRecord();
27
+ for (const [id, secret] of Object.entries(value)) {
28
+ if (typeof secret !== 'string') {
29
+ throw new Error('Secret vault document is malformed.');
30
+ }
31
+ secrets[id] = secret;
32
+ }
33
+ return secrets;
34
+ }
35
+ function createSecretRecord(entries = []) {
36
+ const secrets = Object.create(null);
37
+ for (const [id, secret] of entries) {
38
+ secrets[id] = secret;
39
+ }
40
+ return secrets;
41
+ }
42
+ function hasOwnSecret(secrets, id) {
43
+ return Object.prototype.hasOwnProperty.call(secrets, id);
44
+ }
45
+ function parseDefaultVaultPlaintext(plaintext) {
46
+ const parsed = JSON.parse(plaintext);
47
+ if (parsed.version !== 2) {
48
+ throw new Error('Secret vault document is malformed.');
49
+ }
50
+ return parseSecretRecord(parsed.secrets);
51
+ }
52
+ const defaultDocumentCodec = {
53
+ parse: parseDefaultVaultPlaintext,
54
+ serialize: (secrets) => JSON.stringify({ version: 2, secrets }),
55
+ };
56
+ function parseLegacyVault(bytes) {
57
+ const parsed = JSON.parse(textDecoder.decode(bytes));
58
+ if (parsed.version !== 1) {
59
+ throw new Error('Secret vault document is malformed.');
60
+ }
61
+ return { version: 1, secrets: parseSecretRecord(parsed.secrets) };
62
+ }
63
+ function assertEncryptionAvailable(cipher) {
64
+ if (!cipher.isEncryptionAvailable()) {
65
+ throw new EncryptionUnavailableError();
66
+ }
67
+ }
68
+ function decryptVault(cipher, bytes, documentCodec) {
69
+ try {
70
+ return {
71
+ version: 2,
72
+ secrets: parseSecretRecord(documentCodec.parse(cipher.decryptString(bytes))),
73
+ };
74
+ }
75
+ catch (encryptedDocumentError) {
76
+ try {
77
+ const legacy = parseLegacyVault(bytes);
78
+ const secrets = createSecretRecord();
79
+ for (const [id, encoded] of Object.entries(legacy.secrets)) {
80
+ secrets[id] = cipher.decryptString(fromBase64(encoded));
81
+ }
82
+ return { version: 2, secrets };
83
+ }
84
+ catch {
85
+ throw encryptedDocumentError;
86
+ }
87
+ }
88
+ }
89
+ /**
90
+ * Opaque whole-document secret vault using an injected OS-backed cipher.
91
+ *
92
+ * The encrypted payload hides secret identifiers as well as values. Operations are
93
+ * processed in invocation order so reads observe earlier pending writes. Version 1
94
+ * entry-encrypted documents remain readable and are rewritten by the next mutation.
95
+ * Hosts own the plaintext envelope through an optional codec, atomic persistence,
96
+ * references derived from commit metadata, and multi-instance coordination.
97
+ */
98
+ function createEncryptedSecretVault(options) {
99
+ const { cipher, readVault, writeVault } = options;
100
+ const documentCodec = options.documentCodec ?? defaultDocumentCodec;
101
+ let operationQueue = Promise.resolve();
102
+ const load = async () => {
103
+ assertEncryptionAvailable(cipher);
104
+ const bytes = await readVault();
105
+ if (bytes === null || bytes.byteLength === 0) {
106
+ return { version: 2, secrets: createSecretRecord() };
107
+ }
108
+ return decryptVault(cipher, bytes, documentCodec);
109
+ };
110
+ const save = async (document) => {
111
+ assertEncryptionAvailable(cipher);
112
+ const secretIds = Object.keys(document.secrets).sort((left, right) => left.localeCompare(right));
113
+ await writeVault(cipher.encryptString(documentCodec.serialize(document.secrets)), {
114
+ secretIds,
115
+ });
116
+ };
117
+ const runOperation = (operation) => {
118
+ const result = operationQueue.then(operation);
119
+ operationQueue = result.then(() => undefined, () => undefined);
120
+ return result;
121
+ };
122
+ const mutateVault = (mutation) => runOperation(async () => {
123
+ const document = await load();
124
+ const nextDocument = mutation(document);
125
+ if (nextDocument !== null) {
126
+ await save(nextDocument);
127
+ }
128
+ });
129
+ return {
130
+ getSecret(id) {
131
+ return runOperation(async () => {
132
+ const document = await load();
133
+ return hasOwnSecret(document.secrets, id) ? document.secrets[id] : null;
134
+ });
135
+ },
136
+ getSecrets(ids) {
137
+ const requestedIds = [...ids];
138
+ return runOperation(async () => {
139
+ const document = await load();
140
+ const result = new Map();
141
+ for (const id of requestedIds) {
142
+ if (hasOwnSecret(document.secrets, id)) {
143
+ result.set(id, document.secrets[id]);
144
+ }
145
+ }
146
+ return result;
147
+ });
148
+ },
149
+ hasSecret(id) {
150
+ return runOperation(async () => {
151
+ const document = await load();
152
+ return hasOwnSecret(document.secrets, id);
153
+ });
154
+ },
155
+ setSecret(id, value) {
156
+ return mutateVault((document) => {
157
+ const secrets = createSecretRecord(Object.entries(document.secrets));
158
+ secrets[id] = value;
159
+ return { version: 2, secrets };
160
+ });
161
+ },
162
+ setSecrets(values) {
163
+ const snapshot = [...values];
164
+ return mutateVault((document) => ({
165
+ version: 2,
166
+ secrets: createSecretRecord([...Object.entries(document.secrets), ...snapshot]),
167
+ }));
168
+ },
169
+ deleteSecret(id) {
170
+ return mutateVault((document) => {
171
+ if (!hasOwnSecret(document.secrets, id)) {
172
+ return null;
173
+ }
174
+ const nextSecrets = createSecretRecord(Object.entries(document.secrets));
175
+ delete nextSecrets[id];
176
+ return { version: 2, secrets: nextSecrets };
177
+ });
178
+ },
179
+ };
180
+ }
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveWallpaperCropRect = resolveWallpaperCropRect;
4
+ exports.createWin32RegistryStringReader = createWin32RegistryStringReader;
5
+ exports.createWin32WallpaperPathResolver = createWin32WallpaperPathResolver;
6
+ function isFinitePositive(value) {
7
+ return Number.isFinite(value) && value > 0;
8
+ }
9
+ function hasFiniteOrigin(rect) {
10
+ return Number.isFinite(rect.x) && Number.isFinite(rect.y);
11
+ }
12
+ /**
13
+ * Compute the source crop rectangle on a wallpaper image for a monitor when the
14
+ * desktop wallpaper is spanned across the virtual desktop.
15
+ *
16
+ * Maps monitor bounds from virtual-desktop coordinates into image pixel space
17
+ * using the image's cover of the full virtual desktop (uniform scale, centered).
18
+ */
19
+ function resolveWallpaperCropRect(imageSize, virtualDesktop, monitor) {
20
+ if (!isFinitePositive(imageSize.width) || !isFinitePositive(imageSize.height)) {
21
+ throw new Error('Wallpaper image size must be finite and positive.');
22
+ }
23
+ if (!hasFiniteOrigin(virtualDesktop) ||
24
+ !isFinitePositive(virtualDesktop.width) ||
25
+ !isFinitePositive(virtualDesktop.height)) {
26
+ throw new Error('Virtual desktop bounds must be finite with a positive size.');
27
+ }
28
+ if (!hasFiniteOrigin(monitor) ||
29
+ !isFinitePositive(monitor.width) ||
30
+ !isFinitePositive(monitor.height)) {
31
+ throw new Error('Monitor bounds must be finite with a positive size.');
32
+ }
33
+ const scale = Math.max(virtualDesktop.width / imageSize.width, virtualDesktop.height / imageSize.height);
34
+ const drawnWidth = imageSize.width * scale;
35
+ const drawnHeight = imageSize.height * scale;
36
+ const offsetX = virtualDesktop.x - (drawnWidth - virtualDesktop.width) / 2;
37
+ const offsetY = virtualDesktop.y - (drawnHeight - virtualDesktop.height) / 2;
38
+ const cropX = (monitor.x - offsetX) / scale;
39
+ const cropY = (monitor.y - offsetY) / scale;
40
+ const cropWidth = monitor.width / scale;
41
+ const cropHeight = monitor.height / scale;
42
+ const x = Math.max(0, Math.min(imageSize.width, cropX));
43
+ const y = Math.max(0, Math.min(imageSize.height, cropY));
44
+ const maxWidth = imageSize.width - x;
45
+ const maxHeight = imageSize.height - y;
46
+ return {
47
+ x: Math.round(x),
48
+ y: Math.round(y),
49
+ width: Math.max(0, Math.round(Math.min(cropWidth, maxWidth))),
50
+ height: Math.max(0, Math.round(Math.min(cropHeight, maxHeight))),
51
+ };
52
+ }
53
+ const WIN32_REGISTRY_QUERY_DEFAULT_MAX_BUFFER_BYTES = 64 * 1024;
54
+ const WIN32_REGISTRY_QUERY_DEFAULT_TIMEOUT_MS = 2000;
55
+ function escapeRegularExpression(value) {
56
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
57
+ }
58
+ /** Create a bounded, shell-free `reg query` string reader. */
59
+ function createWin32RegistryStringReader(options) {
60
+ const maxBuffer = options.maxBufferBytes ?? WIN32_REGISTRY_QUERY_DEFAULT_MAX_BUFFER_BYTES;
61
+ const timeout = options.timeoutMs ?? WIN32_REGISTRY_QUERY_DEFAULT_TIMEOUT_MS;
62
+ if (!Number.isFinite(maxBuffer) || maxBuffer <= 0) {
63
+ throw new Error('maxBufferBytes must be a finite positive number.');
64
+ }
65
+ if (!Number.isFinite(timeout) || timeout <= 0) {
66
+ throw new Error('timeoutMs must be a finite positive number.');
67
+ }
68
+ return (keyPath, valueName) => new Promise((resolve) => {
69
+ try {
70
+ options.execFile('reg', ['query', keyPath, '/v', valueName], { encoding: 'utf8', maxBuffer, shell: false, timeout, windowsHide: true }, (error, stdout) => {
71
+ if (error) {
72
+ resolve(null);
73
+ return;
74
+ }
75
+ const pattern = new RegExp(`^\\s*${escapeRegularExpression(valueName)}\\s+REG_\\w+\\s+(.+)$`, 'imu');
76
+ const value = pattern.exec(stdout)?.[1]?.trim();
77
+ resolve(value && value.length > 0 ? value : null);
78
+ });
79
+ }
80
+ catch {
81
+ resolve(null);
82
+ }
83
+ });
84
+ }
85
+ /**
86
+ * Win32 wallpaper path resolver behind an injected registry reader.
87
+ * Other platforms should inject a resolver that returns null until implemented.
88
+ */
89
+ function createWin32WallpaperPathResolver(options) {
90
+ return {
91
+ async resolveWallpaperPath() {
92
+ const value = await options.readRegistryString('HKCU\\Control Panel\\Desktop', 'WallPaper');
93
+ const registryPath = value?.trim() || null;
94
+ if (registryPath !== null &&
95
+ (!options.pathExists || (await options.pathExists(registryPath)))) {
96
+ return registryPath;
97
+ }
98
+ const fallbackPath = options.fallbackPath?.trim() || null;
99
+ if (fallbackPath !== null &&
100
+ (!options.pathExists || (await options.pathExists(fallbackPath)))) {
101
+ return fallbackPath;
102
+ }
103
+ return null;
104
+ },
105
+ };
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workbench-kit/electron-shell",
3
- "version": "0.0.2-prototype.0.2.32",
3
+ "version": "0.0.2-prototype.0.2.34",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "types": "./src/index.ts",
@@ -18,9 +18,15 @@
18
18
  "preload": [
19
19
  "src/preload/index.ts"
20
20
  ],
21
+ "secret-vault": [
22
+ "src/secrets/encrypted-secret-vault.ts"
23
+ ],
21
24
  "sender-security": [
22
25
  "src/security/require-owned-window-for-sender.ts"
23
26
  ],
27
+ "wallpaper": [
28
+ "src/wallpaper/wallpaper-crop.ts"
29
+ ],
24
30
  "window-controls": [
25
31
  "src/window/window-controls.ts"
26
32
  ]
@@ -47,12 +53,24 @@
47
53
  "default": "./src/security/open-allowlisted-external-link.ts"
48
54
  },
49
55
  "./preload": "./src/preload/index.ts",
56
+ "./secret-vault": {
57
+ "types": "./src/secrets/encrypted-secret-vault.ts",
58
+ "require": "./dist/secrets/encrypted-secret-vault.js",
59
+ "import": "./src/secrets/encrypted-secret-vault.ts",
60
+ "default": "./src/secrets/encrypted-secret-vault.ts"
61
+ },
50
62
  "./sender-security": {
51
63
  "types": "./src/security/require-owned-window-for-sender.ts",
52
64
  "require": "./dist/security/require-owned-window-for-sender.js",
53
65
  "import": "./src/security/require-owned-window-for-sender.ts",
54
66
  "default": "./src/security/require-owned-window-for-sender.ts"
55
67
  },
68
+ "./wallpaper": {
69
+ "types": "./src/wallpaper/wallpaper-crop.ts",
70
+ "require": "./dist/wallpaper/wallpaper-crop.js",
71
+ "import": "./src/wallpaper/wallpaper-crop.ts",
72
+ "default": "./src/wallpaper/wallpaper-crop.ts"
73
+ },
56
74
  "./window-controls": {
57
75
  "types": "./src/window/window-controls.ts",
58
76
  "require": "./dist/window/window-controls.js",
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export {
13
13
  type CreateEncryptedSecretVaultOptions,
14
14
  type EncryptedSecretVault,
15
15
  type SafeStorageCipher,
16
+ type SecretVaultCommitMetadata,
17
+ type SecretVaultDocumentCodec,
16
18
  } from './secrets/encrypted-secret-vault.js';
17
19
  export {
18
20
  InvalidExternalLinkUrlError,
@@ -33,11 +35,15 @@ export {
33
35
  type RegisterPrivilegedAssetProtocolSchemeOptions,
34
36
  } from './assets/privileged-asset-protocol.js';
35
37
  export {
38
+ createWin32RegistryStringReader,
36
39
  createWin32WallpaperPathResolver,
37
40
  resolveWallpaperCropRect,
41
+ type CreateWin32RegistryStringReaderOptions,
38
42
  type RectLike,
39
43
  type SizeLike,
40
44
  type WallpaperPathResolver,
45
+ type Win32RegistryExecFile,
46
+ type Win32RegistryExecFileOptions,
41
47
  } from './wallpaper/wallpaper-crop.js';
42
48
  export {
43
49
  createWindowControlsBridge,
@@ -109,6 +109,12 @@ function raceWithAbort<Value>(
109
109
  });
110
110
  }
111
111
 
112
+ function waitForNextTask(): Promise<void> {
113
+ return new Promise((resolve) => {
114
+ setTimeout(resolve, 0);
115
+ });
116
+ }
117
+
112
118
  /**
113
119
  * Coordinate an Electron-style before-quit boundary without importing Electron.
114
120
  * The host owns event registration, dirty state, prompts, persistence, and quit invocation.
@@ -158,7 +164,13 @@ export function createApplicationQuitGuard(
158
164
  return resume(reason);
159
165
  };
160
166
 
161
- const resume = (reason: ApplicationQuitProceedReason): ApplicationQuitGuardResult => {
167
+ const resume = async (
168
+ reason: ApplicationQuitProceedReason,
169
+ ): Promise<ApplicationQuitGuardResult> => {
170
+ // A Promise continuation can still run before Electron finishes dispatching the original
171
+ // before-quit event. Cross a task boundary before re-entering app.quit().
172
+ await run(waitForNextTask);
173
+
162
174
  // The decision is now irreversible. `cancelPending` must not report a cancellable request
163
175
  // while the host is synchronously re-entering before-quit.
164
176
  if (isCurrent()) {
@@ -181,7 +193,7 @@ export function createApplicationQuitGuard(
181
193
  throw new TypeError('Application quit guard isDirty must return a boolean.');
182
194
  }
183
195
  if (!dirty) {
184
- return resume('clean');
196
+ return await resume('clean');
185
197
  }
186
198
 
187
199
  const decision = await run(() => options.requestDecision(controller.signal));
@@ -6,14 +6,27 @@ export interface SafeStorageCipher {
6
6
 
7
7
  export interface EncryptedSecretVault {
8
8
  getSecret(id: string): Promise<string | null>;
9
+ getSecrets(ids: readonly string[]): Promise<ReadonlyMap<string, string>>;
10
+ hasSecret(id: string): Promise<boolean>;
9
11
  setSecret(id: string, value: string): Promise<void>;
12
+ setSecrets(values: ReadonlyMap<string, string>): Promise<void>;
10
13
  deleteSecret(id: string): Promise<void>;
11
14
  }
12
15
 
13
16
  export interface CreateEncryptedSecretVaultOptions {
14
17
  readonly cipher: SafeStorageCipher;
18
+ readonly documentCodec?: SecretVaultDocumentCodec;
15
19
  readonly readVault: () => Promise<Uint8Array | null>;
16
- readonly writeVault: (bytes: Uint8Array) => Promise<void>;
20
+ readonly writeVault: (bytes: Uint8Array, metadata: SecretVaultCommitMetadata) => Promise<void>;
21
+ }
22
+
23
+ export interface SecretVaultDocumentCodec {
24
+ parse(plaintext: string): Readonly<Record<string, string>>;
25
+ serialize(secrets: Readonly<Record<string, string>>): string;
26
+ }
27
+
28
+ export interface SecretVaultCommitMetadata {
29
+ readonly secretIds: readonly string[];
17
30
  }
18
31
 
19
32
  export class EncryptionUnavailableError extends Error {
@@ -26,23 +39,19 @@ export class EncryptionUnavailableError extends Error {
26
39
  }
27
40
 
28
41
  interface VaultDocument {
42
+ readonly version: 2;
43
+ readonly secrets: Record<string, string>;
44
+ }
45
+
46
+ interface LegacyVaultDocument {
29
47
  readonly version: 1;
30
48
  readonly secrets: Record<string, string>;
31
49
  }
32
50
 
33
51
  type VaultMutation = (document: VaultDocument) => VaultDocument | null;
34
52
 
35
- const textEncoder = new TextEncoder();
36
53
  const textDecoder = new TextDecoder();
37
54
 
38
- function toBase64(bytes: Uint8Array): string {
39
- let binary = '';
40
- for (const byte of bytes) {
41
- binary += String.fromCharCode(byte);
42
- }
43
- return btoa(binary);
44
- }
45
-
46
55
  function fromBase64(value: string): Uint8Array {
47
56
  const binary = atob(value);
48
57
  const bytes = new Uint8Array(binary.length);
@@ -52,19 +61,59 @@ function fromBase64(value: string): Uint8Array {
52
61
  return bytes;
53
62
  }
54
63
 
55
- function parseVault(bytes: Uint8Array | null): VaultDocument {
56
- if (bytes === null || bytes.byteLength === 0) {
57
- return { version: 1, secrets: {} };
64
+ function parseSecretRecord(value: unknown): Record<string, string> {
65
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
66
+ throw new Error('Secret vault document is malformed.');
67
+ }
68
+ const secrets = createSecretRecord();
69
+ for (const [id, secret] of Object.entries(value)) {
70
+ if (typeof secret !== 'string') {
71
+ throw new Error('Secret vault document is malformed.');
72
+ }
73
+ secrets[id] = secret;
74
+ }
75
+ return secrets;
76
+ }
77
+
78
+ function createSecretRecord(
79
+ entries: Iterable<readonly [string, string]> = [],
80
+ ): Record<string, string> {
81
+ const secrets = Object.create(null) as Record<string, string>;
82
+ for (const [id, secret] of entries) {
83
+ secrets[id] = secret;
58
84
  }
59
- const parsed = JSON.parse(textDecoder.decode(bytes)) as Partial<VaultDocument>;
60
- if (parsed.version !== 1 || typeof parsed.secrets !== 'object' || parsed.secrets === null) {
85
+ return secrets;
86
+ }
87
+
88
+ function hasOwnSecret(secrets: Readonly<Record<string, string>>, id: string): boolean {
89
+ return Object.prototype.hasOwnProperty.call(secrets, id);
90
+ }
91
+
92
+ function parseDefaultVaultPlaintext(plaintext: string): Record<string, string> {
93
+ const parsed = JSON.parse(plaintext) as {
94
+ readonly version?: unknown;
95
+ readonly secrets?: unknown;
96
+ };
97
+ if (parsed.version !== 2) {
61
98
  throw new Error('Secret vault document is malformed.');
62
99
  }
63
- return { version: 1, secrets: { ...parsed.secrets } };
100
+ return parseSecretRecord(parsed.secrets);
64
101
  }
65
102
 
66
- function serializeVault(document: VaultDocument): Uint8Array {
67
- return textEncoder.encode(`${JSON.stringify(document)}\n`);
103
+ const defaultDocumentCodec: SecretVaultDocumentCodec = {
104
+ parse: parseDefaultVaultPlaintext,
105
+ serialize: (secrets) => JSON.stringify({ version: 2, secrets }),
106
+ };
107
+
108
+ function parseLegacyVault(bytes: Uint8Array): LegacyVaultDocument {
109
+ const parsed = JSON.parse(textDecoder.decode(bytes)) as {
110
+ readonly version?: unknown;
111
+ readonly secrets?: unknown;
112
+ };
113
+ if (parsed.version !== 1) {
114
+ throw new Error('Secret vault document is malformed.');
115
+ }
116
+ return { version: 1, secrets: parseSecretRecord(parsed.secrets) };
68
117
  }
69
118
 
70
119
  function assertEncryptionAvailable(cipher: SafeStorageCipher): void {
@@ -73,67 +122,136 @@ function assertEncryptionAvailable(cipher: SafeStorageCipher): void {
73
122
  }
74
123
  }
75
124
 
125
+ function decryptVault(
126
+ cipher: SafeStorageCipher,
127
+ bytes: Uint8Array,
128
+ documentCodec: SecretVaultDocumentCodec,
129
+ ): VaultDocument {
130
+ try {
131
+ return {
132
+ version: 2,
133
+ secrets: parseSecretRecord(documentCodec.parse(cipher.decryptString(bytes))),
134
+ };
135
+ } catch (encryptedDocumentError) {
136
+ try {
137
+ const legacy = parseLegacyVault(bytes);
138
+ const secrets = createSecretRecord();
139
+ for (const [id, encoded] of Object.entries(legacy.secrets)) {
140
+ secrets[id] = cipher.decryptString(fromBase64(encoded));
141
+ }
142
+ return { version: 2, secrets };
143
+ } catch {
144
+ throw encryptedDocumentError;
145
+ }
146
+ }
147
+ }
148
+
76
149
  /**
77
- * Opaque secret vault using an injected OS-backed cipher.
78
- * Fails closed when encryption is unavailable; serializes mutations per vault instance.
79
- * Hosts own persistence (and multi-instance coordination) via readVault/writeVault.
150
+ * Opaque whole-document secret vault using an injected OS-backed cipher.
151
+ *
152
+ * The encrypted payload hides secret identifiers as well as values. Operations are
153
+ * processed in invocation order so reads observe earlier pending writes. Version 1
154
+ * entry-encrypted documents remain readable and are rewritten by the next mutation.
155
+ * Hosts own the plaintext envelope through an optional codec, atomic persistence,
156
+ * references derived from commit metadata, and multi-instance coordination.
80
157
  */
81
158
  export function createEncryptedSecretVault(
82
159
  options: CreateEncryptedSecretVaultOptions,
83
160
  ): EncryptedSecretVault {
84
161
  const { cipher, readVault, writeVault } = options;
85
- let mutationQueue: Promise<void> = Promise.resolve();
162
+ const documentCodec = options.documentCodec ?? defaultDocumentCodec;
163
+ let operationQueue: Promise<void> = Promise.resolve();
86
164
 
87
165
  const load = async (): Promise<VaultDocument> => {
88
166
  assertEncryptionAvailable(cipher);
89
- return parseVault(await readVault());
167
+ const bytes = await readVault();
168
+ if (bytes === null || bytes.byteLength === 0) {
169
+ return { version: 2, secrets: createSecretRecord() };
170
+ }
171
+ return decryptVault(cipher, bytes, documentCodec);
90
172
  };
91
173
 
92
174
  const save = async (document: VaultDocument): Promise<void> => {
93
175
  assertEncryptionAvailable(cipher);
94
- await writeVault(serializeVault(document));
176
+ const secretIds = Object.keys(document.secrets).sort((left, right) =>
177
+ left.localeCompare(right),
178
+ );
179
+ await writeVault(cipher.encryptString(documentCodec.serialize(document.secrets)), {
180
+ secretIds,
181
+ });
182
+ };
183
+
184
+ const runOperation = <TResult>(operation: () => Promise<TResult>): Promise<TResult> => {
185
+ const result = operationQueue.then(operation);
186
+ operationQueue = result.then(
187
+ () => undefined,
188
+ () => undefined,
189
+ );
190
+ return result;
95
191
  };
96
192
 
97
- const mutateVault = (mutation: VaultMutation): Promise<void> => {
98
- const result = mutationQueue.then(async () => {
193
+ const mutateVault = (mutation: VaultMutation): Promise<void> =>
194
+ runOperation(async () => {
99
195
  const document = await load();
100
196
  const nextDocument = mutation(document);
101
197
  if (nextDocument !== null) {
102
198
  await save(nextDocument);
103
199
  }
104
200
  });
105
- mutationQueue = result.catch(() => undefined);
106
- return result;
107
- };
108
201
 
109
202
  return {
110
- async getSecret(id: string): Promise<string | null> {
111
- const document = await load();
112
- const encoded = document.secrets[id];
113
- if (typeof encoded !== 'string') {
114
- return null;
115
- }
116
- return cipher.decryptString(fromBase64(encoded));
203
+ getSecret(id: string): Promise<string | null> {
204
+ return runOperation(async () => {
205
+ const document = await load();
206
+ return hasOwnSecret(document.secrets, id) ? document.secrets[id]! : null;
207
+ });
208
+ },
209
+
210
+ getSecrets(ids: readonly string[]): Promise<ReadonlyMap<string, string>> {
211
+ const requestedIds = [...ids];
212
+ return runOperation(async () => {
213
+ const document = await load();
214
+ const result = new Map<string, string>();
215
+ for (const id of requestedIds) {
216
+ if (hasOwnSecret(document.secrets, id)) {
217
+ result.set(id, document.secrets[id]!);
218
+ }
219
+ }
220
+ return result;
221
+ });
222
+ },
223
+
224
+ hasSecret(id: string): Promise<boolean> {
225
+ return runOperation(async () => {
226
+ const document = await load();
227
+ return hasOwnSecret(document.secrets, id);
228
+ });
229
+ },
230
+
231
+ setSecret(id: string, value: string): Promise<void> {
232
+ return mutateVault((document) => {
233
+ const secrets = createSecretRecord(Object.entries(document.secrets));
234
+ secrets[id] = value;
235
+ return { version: 2, secrets };
236
+ });
117
237
  },
118
238
 
119
- async setSecret(id: string, value: string): Promise<void> {
120
- await mutateVault((document) => ({
121
- version: 1,
122
- secrets: {
123
- ...document.secrets,
124
- [id]: toBase64(cipher.encryptString(value)),
125
- },
239
+ setSecrets(values: ReadonlyMap<string, string>): Promise<void> {
240
+ const snapshot = [...values];
241
+ return mutateVault((document) => ({
242
+ version: 2,
243
+ secrets: createSecretRecord([...Object.entries(document.secrets), ...snapshot]),
126
244
  }));
127
245
  },
128
246
 
129
- async deleteSecret(id: string): Promise<void> {
130
- await mutateVault((document) => {
131
- if (!(id in document.secrets)) {
247
+ deleteSecret(id: string): Promise<void> {
248
+ return mutateVault((document) => {
249
+ if (!hasOwnSecret(document.secrets, id)) {
132
250
  return null;
133
251
  }
134
- const nextSecrets = { ...document.secrets };
252
+ const nextSecrets = createSecretRecord(Object.entries(document.secrets));
135
253
  delete nextSecrets[id];
136
- return { version: 1, secrets: nextSecrets };
254
+ return { version: 2, secrets: nextSecrets };
137
255
  });
138
256
  },
139
257
  };
@@ -10,6 +10,14 @@ export interface RectLike {
10
10
  readonly height: number;
11
11
  }
12
12
 
13
+ function isFinitePositive(value: number): boolean {
14
+ return Number.isFinite(value) && value > 0;
15
+ }
16
+
17
+ function hasFiniteOrigin(rect: Pick<RectLike, 'x' | 'y'>): boolean {
18
+ return Number.isFinite(rect.x) && Number.isFinite(rect.y);
19
+ }
20
+
13
21
  /**
14
22
  * Compute the source crop rectangle on a wallpaper image for a monitor when the
15
23
  * desktop wallpaper is spanned across the virtual desktop.
@@ -22,14 +30,22 @@ export function resolveWallpaperCropRect(
22
30
  virtualDesktop: RectLike,
23
31
  monitor: RectLike,
24
32
  ): RectLike {
25
- if (imageSize.width <= 0 || imageSize.height <= 0) {
26
- throw new Error('Wallpaper image size must be positive.');
33
+ if (!isFinitePositive(imageSize.width) || !isFinitePositive(imageSize.height)) {
34
+ throw new Error('Wallpaper image size must be finite and positive.');
27
35
  }
28
- if (virtualDesktop.width <= 0 || virtualDesktop.height <= 0) {
29
- throw new Error('Virtual desktop size must be positive.');
36
+ if (
37
+ !hasFiniteOrigin(virtualDesktop) ||
38
+ !isFinitePositive(virtualDesktop.width) ||
39
+ !isFinitePositive(virtualDesktop.height)
40
+ ) {
41
+ throw new Error('Virtual desktop bounds must be finite with a positive size.');
30
42
  }
31
- if (monitor.width <= 0 || monitor.height <= 0) {
32
- throw new Error('Monitor size must be positive.');
43
+ if (
44
+ !hasFiniteOrigin(monitor) ||
45
+ !isFinitePositive(monitor.width) ||
46
+ !isFinitePositive(monitor.height)
47
+ ) {
48
+ throw new Error('Monitor bounds must be finite with a positive size.');
33
49
  }
34
50
 
35
51
  const scale = Math.max(
@@ -63,21 +79,101 @@ export interface WallpaperPathResolver {
63
79
  resolveWallpaperPath(): Promise<string | null>;
64
80
  }
65
81
 
82
+ export interface Win32RegistryExecFileOptions {
83
+ readonly encoding: 'utf8';
84
+ readonly maxBuffer: number;
85
+ readonly shell: false;
86
+ readonly timeout: number;
87
+ readonly windowsHide: true;
88
+ }
89
+
90
+ export type Win32RegistryExecFile = (
91
+ file: string,
92
+ args: readonly string[],
93
+ options: Win32RegistryExecFileOptions,
94
+ callback: (error: Error | null, stdout: string) => void,
95
+ ) => void;
96
+
97
+ export interface CreateWin32RegistryStringReaderOptions {
98
+ readonly execFile: Win32RegistryExecFile;
99
+ readonly maxBufferBytes?: number;
100
+ readonly timeoutMs?: number;
101
+ }
102
+
103
+ const WIN32_REGISTRY_QUERY_DEFAULT_MAX_BUFFER_BYTES = 64 * 1024;
104
+ const WIN32_REGISTRY_QUERY_DEFAULT_TIMEOUT_MS = 2_000;
105
+
106
+ function escapeRegularExpression(value: string): string {
107
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
108
+ }
109
+
110
+ /** Create a bounded, shell-free `reg query` string reader. */
111
+ export function createWin32RegistryStringReader(
112
+ options: CreateWin32RegistryStringReaderOptions,
113
+ ): (keyPath: string, valueName: string) => Promise<string | null> {
114
+ const maxBuffer = options.maxBufferBytes ?? WIN32_REGISTRY_QUERY_DEFAULT_MAX_BUFFER_BYTES;
115
+ const timeout = options.timeoutMs ?? WIN32_REGISTRY_QUERY_DEFAULT_TIMEOUT_MS;
116
+ if (!Number.isFinite(maxBuffer) || maxBuffer <= 0) {
117
+ throw new Error('maxBufferBytes must be a finite positive number.');
118
+ }
119
+ if (!Number.isFinite(timeout) || timeout <= 0) {
120
+ throw new Error('timeoutMs must be a finite positive number.');
121
+ }
122
+
123
+ return (keyPath, valueName) =>
124
+ new Promise((resolve) => {
125
+ try {
126
+ options.execFile(
127
+ 'reg',
128
+ ['query', keyPath, '/v', valueName],
129
+ { encoding: 'utf8', maxBuffer, shell: false, timeout, windowsHide: true },
130
+ (error, stdout) => {
131
+ if (error) {
132
+ resolve(null);
133
+ return;
134
+ }
135
+ const pattern = new RegExp(
136
+ `^\\s*${escapeRegularExpression(valueName)}\\s+REG_\\w+\\s+(.+)$`,
137
+ 'imu',
138
+ );
139
+ const value = pattern.exec(stdout)?.[1]?.trim();
140
+ resolve(value && value.length > 0 ? value : null);
141
+ },
142
+ );
143
+ } catch {
144
+ resolve(null);
145
+ }
146
+ });
147
+ }
148
+
66
149
  /**
67
150
  * Win32 wallpaper path resolver behind an injected registry reader.
68
151
  * Other platforms should inject a resolver that returns null until implemented.
69
152
  */
70
153
  export function createWin32WallpaperPathResolver(options: {
71
154
  readonly readRegistryString: (keyPath: string, valueName: string) => Promise<string | null>;
155
+ readonly fallbackPath?: string | null;
156
+ readonly pathExists?: (filePath: string) => Promise<boolean>;
72
157
  }): WallpaperPathResolver {
73
158
  return {
74
159
  async resolveWallpaperPath(): Promise<string | null> {
75
160
  const value = await options.readRegistryString('HKCU\\Control Panel\\Desktop', 'WallPaper');
76
- if (value === null) {
77
- return null;
161
+ const registryPath = value?.trim() || null;
162
+ if (
163
+ registryPath !== null &&
164
+ (!options.pathExists || (await options.pathExists(registryPath)))
165
+ ) {
166
+ return registryPath;
167
+ }
168
+
169
+ const fallbackPath = options.fallbackPath?.trim() || null;
170
+ if (
171
+ fallbackPath !== null &&
172
+ (!options.pathExists || (await options.pathExists(fallbackPath)))
173
+ ) {
174
+ return fallbackPath;
78
175
  }
79
- const trimmed = value.trim();
80
- return trimmed.length > 0 ? trimmed : null;
176
+ return null;
81
177
  },
82
178
  };
83
179
  }