@workbench-kit/electron-shell 0.0.2-prototype.0.2.6 → 0.0.2-prototype.0.2.8
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 +1 -1
- package/src/{root-confined-asset-protocol.ts → assets/root-confined-asset-protocol.ts} +162 -148
- package/src/index.ts +49 -49
- package/src/{encrypted-secret-vault.ts → secrets/encrypted-secret-vault.ts} +140 -123
- package/src/{open-allowlisted-external-link.ts → security/open-allowlisted-external-link.ts} +66 -66
- package/src/{require-owned-window-for-sender.ts → security/require-owned-window-for-sender.ts} +31 -31
- package/src/{wallpaper-crop.ts → wallpaper/wallpaper-crop.ts} +83 -83
- package/src/{window-controls.ts → window/window-controls.ts} +150 -150
|
@@ -1,123 +1,140 @@
|
|
|
1
|
-
export interface SafeStorageCipher {
|
|
2
|
-
isEncryptionAvailable(): boolean;
|
|
3
|
-
encryptString(plaintext: string): Uint8Array;
|
|
4
|
-
decryptString(payload: Uint8Array): string;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export interface EncryptedSecretVault {
|
|
8
|
-
getSecret(id: string): Promise<string | null>;
|
|
9
|
-
setSecret(id: string, value: string): Promise<void>;
|
|
10
|
-
deleteSecret(id: string): Promise<void>;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface CreateEncryptedSecretVaultOptions {
|
|
14
|
-
readonly cipher: SafeStorageCipher;
|
|
15
|
-
readonly readVault: () => Promise<Uint8Array | null>;
|
|
16
|
-
readonly writeVault: (bytes: Uint8Array) => Promise<void>;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export class EncryptionUnavailableError extends Error {
|
|
20
|
-
readonly code = 'encryption_unavailable' as const;
|
|
21
|
-
|
|
22
|
-
constructor(message = 'OS-backed encryption is unavailable; refusing plaintext vault.') {
|
|
23
|
-
super(message);
|
|
24
|
-
this.name = 'EncryptionUnavailableError';
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface VaultDocument {
|
|
29
|
-
readonly version: 1;
|
|
30
|
-
readonly secrets: Record<string, string>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
*
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
await
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1
|
+
export interface SafeStorageCipher {
|
|
2
|
+
isEncryptionAvailable(): boolean;
|
|
3
|
+
encryptString(plaintext: string): Uint8Array;
|
|
4
|
+
decryptString(payload: Uint8Array): string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface EncryptedSecretVault {
|
|
8
|
+
getSecret(id: string): Promise<string | null>;
|
|
9
|
+
setSecret(id: string, value: string): Promise<void>;
|
|
10
|
+
deleteSecret(id: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CreateEncryptedSecretVaultOptions {
|
|
14
|
+
readonly cipher: SafeStorageCipher;
|
|
15
|
+
readonly readVault: () => Promise<Uint8Array | null>;
|
|
16
|
+
readonly writeVault: (bytes: Uint8Array) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class EncryptionUnavailableError extends Error {
|
|
20
|
+
readonly code = 'encryption_unavailable' as const;
|
|
21
|
+
|
|
22
|
+
constructor(message = 'OS-backed encryption is unavailable; refusing plaintext vault.') {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'EncryptionUnavailableError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface VaultDocument {
|
|
29
|
+
readonly version: 1;
|
|
30
|
+
readonly secrets: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type VaultMutation = (document: VaultDocument) => VaultDocument | null;
|
|
34
|
+
|
|
35
|
+
const textEncoder = new TextEncoder();
|
|
36
|
+
const textDecoder = new TextDecoder();
|
|
37
|
+
|
|
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
|
+
function fromBase64(value: string): Uint8Array {
|
|
47
|
+
const binary = atob(value);
|
|
48
|
+
const bytes = new Uint8Array(binary.length);
|
|
49
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
50
|
+
bytes[index] = binary.charCodeAt(index);
|
|
51
|
+
}
|
|
52
|
+
return bytes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseVault(bytes: Uint8Array | null): VaultDocument {
|
|
56
|
+
if (bytes === null || bytes.byteLength === 0) {
|
|
57
|
+
return { version: 1, secrets: {} };
|
|
58
|
+
}
|
|
59
|
+
const parsed = JSON.parse(textDecoder.decode(bytes)) as Partial<VaultDocument>;
|
|
60
|
+
if (parsed.version !== 1 || typeof parsed.secrets !== 'object' || parsed.secrets === null) {
|
|
61
|
+
throw new Error('Secret vault document is malformed.');
|
|
62
|
+
}
|
|
63
|
+
return { version: 1, secrets: { ...parsed.secrets } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function serializeVault(document: VaultDocument): Uint8Array {
|
|
67
|
+
return textEncoder.encode(`${JSON.stringify(document)}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertEncryptionAvailable(cipher: SafeStorageCipher): void {
|
|
71
|
+
if (!cipher.isEncryptionAvailable()) {
|
|
72
|
+
throw new EncryptionUnavailableError();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
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.
|
|
80
|
+
*/
|
|
81
|
+
export function createEncryptedSecretVault(
|
|
82
|
+
options: CreateEncryptedSecretVaultOptions,
|
|
83
|
+
): EncryptedSecretVault {
|
|
84
|
+
const { cipher, readVault, writeVault } = options;
|
|
85
|
+
let mutationQueue: Promise<void> = Promise.resolve();
|
|
86
|
+
|
|
87
|
+
const load = async (): Promise<VaultDocument> => {
|
|
88
|
+
assertEncryptionAvailable(cipher);
|
|
89
|
+
return parseVault(await readVault());
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const save = async (document: VaultDocument): Promise<void> => {
|
|
93
|
+
assertEncryptionAvailable(cipher);
|
|
94
|
+
await writeVault(serializeVault(document));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const mutateVault = (mutation: VaultMutation): Promise<void> => {
|
|
98
|
+
const result = mutationQueue.then(async () => {
|
|
99
|
+
const document = await load();
|
|
100
|
+
const nextDocument = mutation(document);
|
|
101
|
+
if (nextDocument !== null) {
|
|
102
|
+
await save(nextDocument);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
mutationQueue = result.catch(() => undefined);
|
|
106
|
+
return result;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
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));
|
|
117
|
+
},
|
|
118
|
+
|
|
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
|
+
},
|
|
126
|
+
}));
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
async deleteSecret(id: string): Promise<void> {
|
|
130
|
+
await mutateVault((document) => {
|
|
131
|
+
if (!(id in document.secrets)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const nextSecrets = { ...document.secrets };
|
|
135
|
+
delete nextSecrets[id];
|
|
136
|
+
return { version: 1, secrets: nextSecrets };
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/{open-allowlisted-external-link.ts → security/open-allowlisted-external-link.ts}
RENAMED
|
@@ -1,66 +1,66 @@
|
|
|
1
|
-
export type ExternalLinkAllowlist = Readonly<Record<string, string>>;
|
|
2
|
-
|
|
3
|
-
export class UnknownExternalLinkIdError extends Error {
|
|
4
|
-
readonly code = 'unknown_external_link_id' as const;
|
|
5
|
-
readonly linkId: string;
|
|
6
|
-
|
|
7
|
-
constructor(linkId: string) {
|
|
8
|
-
super('External link id is not in the allowlist.');
|
|
9
|
-
this.name = 'UnknownExternalLinkIdError';
|
|
10
|
-
this.linkId = linkId;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export class InvalidExternalLinkUrlError extends Error {
|
|
15
|
-
readonly code = 'invalid_external_link_url' as const;
|
|
16
|
-
readonly linkId: string;
|
|
17
|
-
|
|
18
|
-
constructor(linkId: string, message = 'Allowlisted external link URL is invalid.') {
|
|
19
|
-
super(message);
|
|
20
|
-
this.name = 'InvalidExternalLinkUrlError';
|
|
21
|
-
this.linkId = linkId;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface OpenAllowlistedExternalLinkInput {
|
|
26
|
-
readonly linkId: string;
|
|
27
|
-
readonly allowlist: ExternalLinkAllowlist;
|
|
28
|
-
readonly openExternal: (url: string) => Promise<void>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function assertHttpsUrl(linkId: string, url: string): void {
|
|
32
|
-
let parsed: URL;
|
|
33
|
-
try {
|
|
34
|
-
parsed = new URL(url);
|
|
35
|
-
} catch {
|
|
36
|
-
throw new InvalidExternalLinkUrlError(linkId, 'Allowlisted external link URL is not absolute.');
|
|
37
|
-
}
|
|
38
|
-
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
39
|
-
throw new InvalidExternalLinkUrlError(
|
|
40
|
-
linkId,
|
|
41
|
-
'Allowlisted external link URL must use http: or https:.',
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Resolve an opaque link id through a host-injected allowlist, then open via `openExternal`.
|
|
48
|
-
* Pair IPC entry with `requireOwnedWindowForSender`. No product URL catalogs in kit.
|
|
49
|
-
*/
|
|
50
|
-
export async function openAllowlistedExternalLink(
|
|
51
|
-
input: OpenAllowlistedExternalLinkInput,
|
|
52
|
-
): Promise<void> {
|
|
53
|
-
const linkId = input.linkId.trim();
|
|
54
|
-
if (linkId.length === 0) {
|
|
55
|
-
throw new UnknownExternalLinkIdError(linkId);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const url = input.allowlist[linkId];
|
|
59
|
-
if (typeof url !== 'string' || url.trim().length === 0) {
|
|
60
|
-
throw new UnknownExternalLinkIdError(linkId);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const resolvedUrl = url.trim();
|
|
64
|
-
assertHttpsUrl(linkId, resolvedUrl);
|
|
65
|
-
await input.openExternal(resolvedUrl);
|
|
66
|
-
}
|
|
1
|
+
export type ExternalLinkAllowlist = Readonly<Record<string, string>>;
|
|
2
|
+
|
|
3
|
+
export class UnknownExternalLinkIdError extends Error {
|
|
4
|
+
readonly code = 'unknown_external_link_id' as const;
|
|
5
|
+
readonly linkId: string;
|
|
6
|
+
|
|
7
|
+
constructor(linkId: string) {
|
|
8
|
+
super('External link id is not in the allowlist.');
|
|
9
|
+
this.name = 'UnknownExternalLinkIdError';
|
|
10
|
+
this.linkId = linkId;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class InvalidExternalLinkUrlError extends Error {
|
|
15
|
+
readonly code = 'invalid_external_link_url' as const;
|
|
16
|
+
readonly linkId: string;
|
|
17
|
+
|
|
18
|
+
constructor(linkId: string, message = 'Allowlisted external link URL is invalid.') {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'InvalidExternalLinkUrlError';
|
|
21
|
+
this.linkId = linkId;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OpenAllowlistedExternalLinkInput {
|
|
26
|
+
readonly linkId: string;
|
|
27
|
+
readonly allowlist: ExternalLinkAllowlist;
|
|
28
|
+
readonly openExternal: (url: string) => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertHttpsUrl(linkId: string, url: string): void {
|
|
32
|
+
let parsed: URL;
|
|
33
|
+
try {
|
|
34
|
+
parsed = new URL(url);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new InvalidExternalLinkUrlError(linkId, 'Allowlisted external link URL is not absolute.');
|
|
37
|
+
}
|
|
38
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
39
|
+
throw new InvalidExternalLinkUrlError(
|
|
40
|
+
linkId,
|
|
41
|
+
'Allowlisted external link URL must use http: or https:.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve an opaque link id through a host-injected allowlist, then open via `openExternal`.
|
|
48
|
+
* Pair IPC entry with `requireOwnedWindowForSender`. No product URL catalogs in kit.
|
|
49
|
+
*/
|
|
50
|
+
export async function openAllowlistedExternalLink(
|
|
51
|
+
input: OpenAllowlistedExternalLinkInput,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const linkId = input.linkId.trim();
|
|
54
|
+
if (linkId.length === 0) {
|
|
55
|
+
throw new UnknownExternalLinkIdError(linkId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const url = input.allowlist[linkId];
|
|
59
|
+
if (typeof url !== 'string' || url.trim().length === 0) {
|
|
60
|
+
throw new UnknownExternalLinkIdError(linkId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const resolvedUrl = url.trim();
|
|
64
|
+
assertHttpsUrl(linkId, resolvedUrl);
|
|
65
|
+
await input.openExternal(resolvedUrl);
|
|
66
|
+
}
|
package/src/{require-owned-window-for-sender.ts → security/require-owned-window-for-sender.ts}
RENAMED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Narrow IPC sender surface. Hosts adapt Electron `WebContents` (or fakes) into this shape.
|
|
3
|
-
* The helper never imports `electron`.
|
|
4
|
-
*/
|
|
5
|
-
export interface IpcSenderLike {
|
|
6
|
-
readonly id?: number;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export class UntrustedIpcSenderError extends Error {
|
|
10
|
-
readonly code = 'untrusted_ipc_sender' as const;
|
|
11
|
-
|
|
12
|
-
constructor(message = 'IPC sender is not bound to an owned window.') {
|
|
13
|
-
super(message);
|
|
14
|
-
this.name = 'UntrustedIpcSenderError';
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Resolve an IPC sender to a host-owned window handle, or throw.
|
|
20
|
-
* Hosts own registry membership; kit owns the gate.
|
|
21
|
-
*/
|
|
22
|
-
export function requireOwnedWindowForSender<TWindow>(
|
|
23
|
-
sender: IpcSenderLike | unknown,
|
|
24
|
-
resolveOwnedWindow: (sender: IpcSenderLike | unknown) => TWindow | null,
|
|
25
|
-
): TWindow {
|
|
26
|
-
const windowHandle = resolveOwnedWindow(sender);
|
|
27
|
-
if (windowHandle === null) {
|
|
28
|
-
throw new UntrustedIpcSenderError();
|
|
29
|
-
}
|
|
30
|
-
return windowHandle;
|
|
31
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Narrow IPC sender surface. Hosts adapt Electron `WebContents` (or fakes) into this shape.
|
|
3
|
+
* The helper never imports `electron`.
|
|
4
|
+
*/
|
|
5
|
+
export interface IpcSenderLike {
|
|
6
|
+
readonly id?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class UntrustedIpcSenderError extends Error {
|
|
10
|
+
readonly code = 'untrusted_ipc_sender' as const;
|
|
11
|
+
|
|
12
|
+
constructor(message = 'IPC sender is not bound to an owned window.') {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'UntrustedIpcSenderError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve an IPC sender to a host-owned window handle, or throw.
|
|
20
|
+
* Hosts own registry membership; kit owns the gate.
|
|
21
|
+
*/
|
|
22
|
+
export function requireOwnedWindowForSender<TWindow>(
|
|
23
|
+
sender: IpcSenderLike | unknown,
|
|
24
|
+
resolveOwnedWindow: (sender: IpcSenderLike | unknown) => TWindow | null,
|
|
25
|
+
): TWindow {
|
|
26
|
+
const windowHandle = resolveOwnedWindow(sender);
|
|
27
|
+
if (windowHandle === null) {
|
|
28
|
+
throw new UntrustedIpcSenderError();
|
|
29
|
+
}
|
|
30
|
+
return windowHandle;
|
|
31
|
+
}
|
|
@@ -1,83 +1,83 @@
|
|
|
1
|
-
export interface SizeLike {
|
|
2
|
-
readonly width: number;
|
|
3
|
-
readonly height: number;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export interface RectLike {
|
|
7
|
-
readonly x: number;
|
|
8
|
-
readonly y: number;
|
|
9
|
-
readonly width: number;
|
|
10
|
-
readonly height: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Compute the source crop rectangle on a wallpaper image for a monitor when the
|
|
15
|
-
* desktop wallpaper is spanned across the virtual desktop.
|
|
16
|
-
*
|
|
17
|
-
* Maps monitor bounds from virtual-desktop coordinates into image pixel space
|
|
18
|
-
* using the image's cover of the full virtual desktop (uniform scale, centered).
|
|
19
|
-
*/
|
|
20
|
-
export function resolveWallpaperCropRect(
|
|
21
|
-
imageSize: SizeLike,
|
|
22
|
-
virtualDesktop: RectLike,
|
|
23
|
-
monitor: RectLike,
|
|
24
|
-
): RectLike {
|
|
25
|
-
if (imageSize.width <= 0 || imageSize.height <= 0) {
|
|
26
|
-
throw new Error('Wallpaper image size must be positive.');
|
|
27
|
-
}
|
|
28
|
-
if (virtualDesktop.width <= 0 || virtualDesktop.height <= 0) {
|
|
29
|
-
throw new Error('Virtual desktop size must be positive.');
|
|
30
|
-
}
|
|
31
|
-
if (monitor.width <= 0 || monitor.height <= 0) {
|
|
32
|
-
throw new Error('Monitor size must be positive.');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const scale = Math.max(
|
|
36
|
-
virtualDesktop.width / imageSize.width,
|
|
37
|
-
virtualDesktop.height / imageSize.height,
|
|
38
|
-
);
|
|
39
|
-
const drawnWidth = imageSize.width * scale;
|
|
40
|
-
const drawnHeight = imageSize.height * scale;
|
|
41
|
-
const offsetX = virtualDesktop.x - (drawnWidth - virtualDesktop.width) / 2;
|
|
42
|
-
const offsetY = virtualDesktop.y - (drawnHeight - virtualDesktop.height) / 2;
|
|
43
|
-
|
|
44
|
-
const cropX = (monitor.x - offsetX) / scale;
|
|
45
|
-
const cropY = (monitor.y - offsetY) / scale;
|
|
46
|
-
const cropWidth = monitor.width / scale;
|
|
47
|
-
const cropHeight = monitor.height / scale;
|
|
48
|
-
|
|
49
|
-
const x = Math.max(0, Math.min(imageSize.width, cropX));
|
|
50
|
-
const y = Math.max(0, Math.min(imageSize.height, cropY));
|
|
51
|
-
const maxWidth = imageSize.width - x;
|
|
52
|
-
const maxHeight = imageSize.height - y;
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
x: Math.round(x),
|
|
56
|
-
y: Math.round(y),
|
|
57
|
-
width: Math.max(0, Math.round(Math.min(cropWidth, maxWidth))),
|
|
58
|
-
height: Math.max(0, Math.round(Math.min(cropHeight, maxHeight))),
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface WallpaperPathResolver {
|
|
63
|
-
resolveWallpaperPath(): Promise<string | null>;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Win32 wallpaper path resolver behind an injected registry reader.
|
|
68
|
-
* Other platforms should inject a resolver that returns null until implemented.
|
|
69
|
-
*/
|
|
70
|
-
export function createWin32WallpaperPathResolver(options: {
|
|
71
|
-
readonly readRegistryString: (keyPath: string, valueName: string) => Promise<string | null>;
|
|
72
|
-
}): WallpaperPathResolver {
|
|
73
|
-
return {
|
|
74
|
-
async resolveWallpaperPath(): Promise<string | null> {
|
|
75
|
-
const value = await options.readRegistryString('HKCU\\Control Panel\\Desktop', 'WallPaper');
|
|
76
|
-
if (value === null) {
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
const trimmed = value.trim();
|
|
80
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
}
|
|
1
|
+
export interface SizeLike {
|
|
2
|
+
readonly width: number;
|
|
3
|
+
readonly height: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface RectLike {
|
|
7
|
+
readonly x: number;
|
|
8
|
+
readonly y: number;
|
|
9
|
+
readonly width: number;
|
|
10
|
+
readonly height: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compute the source crop rectangle on a wallpaper image for a monitor when the
|
|
15
|
+
* desktop wallpaper is spanned across the virtual desktop.
|
|
16
|
+
*
|
|
17
|
+
* Maps monitor bounds from virtual-desktop coordinates into image pixel space
|
|
18
|
+
* using the image's cover of the full virtual desktop (uniform scale, centered).
|
|
19
|
+
*/
|
|
20
|
+
export function resolveWallpaperCropRect(
|
|
21
|
+
imageSize: SizeLike,
|
|
22
|
+
virtualDesktop: RectLike,
|
|
23
|
+
monitor: RectLike,
|
|
24
|
+
): RectLike {
|
|
25
|
+
if (imageSize.width <= 0 || imageSize.height <= 0) {
|
|
26
|
+
throw new Error('Wallpaper image size must be positive.');
|
|
27
|
+
}
|
|
28
|
+
if (virtualDesktop.width <= 0 || virtualDesktop.height <= 0) {
|
|
29
|
+
throw new Error('Virtual desktop size must be positive.');
|
|
30
|
+
}
|
|
31
|
+
if (monitor.width <= 0 || monitor.height <= 0) {
|
|
32
|
+
throw new Error('Monitor size must be positive.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const scale = Math.max(
|
|
36
|
+
virtualDesktop.width / imageSize.width,
|
|
37
|
+
virtualDesktop.height / imageSize.height,
|
|
38
|
+
);
|
|
39
|
+
const drawnWidth = imageSize.width * scale;
|
|
40
|
+
const drawnHeight = imageSize.height * scale;
|
|
41
|
+
const offsetX = virtualDesktop.x - (drawnWidth - virtualDesktop.width) / 2;
|
|
42
|
+
const offsetY = virtualDesktop.y - (drawnHeight - virtualDesktop.height) / 2;
|
|
43
|
+
|
|
44
|
+
const cropX = (monitor.x - offsetX) / scale;
|
|
45
|
+
const cropY = (monitor.y - offsetY) / scale;
|
|
46
|
+
const cropWidth = monitor.width / scale;
|
|
47
|
+
const cropHeight = monitor.height / scale;
|
|
48
|
+
|
|
49
|
+
const x = Math.max(0, Math.min(imageSize.width, cropX));
|
|
50
|
+
const y = Math.max(0, Math.min(imageSize.height, cropY));
|
|
51
|
+
const maxWidth = imageSize.width - x;
|
|
52
|
+
const maxHeight = imageSize.height - y;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
x: Math.round(x),
|
|
56
|
+
y: Math.round(y),
|
|
57
|
+
width: Math.max(0, Math.round(Math.min(cropWidth, maxWidth))),
|
|
58
|
+
height: Math.max(0, Math.round(Math.min(cropHeight, maxHeight))),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WallpaperPathResolver {
|
|
63
|
+
resolveWallpaperPath(): Promise<string | null>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Win32 wallpaper path resolver behind an injected registry reader.
|
|
68
|
+
* Other platforms should inject a resolver that returns null until implemented.
|
|
69
|
+
*/
|
|
70
|
+
export function createWin32WallpaperPathResolver(options: {
|
|
71
|
+
readonly readRegistryString: (keyPath: string, valueName: string) => Promise<string | null>;
|
|
72
|
+
}): WallpaperPathResolver {
|
|
73
|
+
return {
|
|
74
|
+
async resolveWallpaperPath(): Promise<string | null> {
|
|
75
|
+
const value = await options.readRegistryString('HKCU\\Control Panel\\Desktop', 'WallPaper');
|
|
76
|
+
if (value === null) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const trimmed = value.trim();
|
|
80
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|