feat(web): avatar thumbnails with original preview, registration avatar, pure-JS crypto fallback for http [CI]
This commit is contained in:
1 parent
283196938e
commit
d66c93fe98
36 files changed
+735
-60
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { callWorker } from './workerClient';
|
||||
import type { AccountDeviceBody, InvitationBody } from './generated/api';
|
||||
import type { PersonalProfile } from './protocol/account';
|
||||
import type { AvatarChange, PersonalProfile } from './protocol/account';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
|
||||
@@ -18,7 +18,9 @@ export const accountApi = {
|
||||
sync: () => callWorker<AccountStatus>('syncAccount', { apiBase: API_BASE }),
|
||||
invitations: () => callWorker<AccountInvitation[]>('accountInvitations', { apiBase: API_BASE }),
|
||||
acceptInvitation: (id: string) => callWorker<void>('acceptInvitation', { id, apiBase: API_BASE }),
|
||||
updatePersonal: (displayName: string, avatar?: string) => callWorker<AccountStatus>('updatePersonal', { displayName, avatar, apiBase: API_BASE }),
|
||||
/** 默认是有损的缩略图;full 为 true 取原图(预览时) */
|
||||
avatar: (username: string, version: number, full = false) => callWorker<string | undefined>('getAvatar', { username, version, full, apiBase: API_BASE }),
|
||||
updatePersonal: (displayName: string, avatar?: AvatarChange) => callWorker<AccountStatus>('updatePersonal', { displayName, avatar, apiBase: API_BASE }),
|
||||
devices: () => callWorker<AccountDevice[]>('accountDevices', { apiBase: API_BASE }),
|
||||
renameDevice: (id: string, name: string) => callWorker<void>('renameAccountDevice', { id, name, apiBase: API_BASE }),
|
||||
revokeDevice: (id: string) => callWorker<AccountStatus>('revokeAccountDevice', { id, apiBase: API_BASE }),
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface WorkspaceApi {
|
||||
createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>;
|
||||
/** 使用现有空间 settings 接口更新空间设置。 */
|
||||
updateChannelSettings(patch: ChannelSettingsPatch): Promise<void>;
|
||||
/** 空间头像原图(信息里只有缩略图),预览时才下载 */
|
||||
spaceAvatar(): Promise<string | undefined>;
|
||||
/** 通过链接加入:先 GET 空间信息预览,再 POST join */
|
||||
joinChannel(link: ParsedChannelLink, password?: string): Promise<ChannelInfo>;
|
||||
inviteUser(username: string): Promise<void>;
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { AccountProfile } from '../generated/api';
|
||||
export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
|
||||
export interface SigningKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
|
||||
/** Account vault v2: credentials and cached metadata, encrypted before transport. */
|
||||
export type PersonalProfile = Pick<AccountProfile, 'displayName' | 'modifiedAt'> & { avatar?: string };
|
||||
/** avatar 只在加密线程里有(发消息要带头像快照);界面这边通过 hasAvatar 判断,头像另取 */
|
||||
export type PersonalProfile = Pick<AccountProfile, 'displayName' | 'modifiedAt'> & { avatar?: string; hasAvatar?: boolean };
|
||||
export interface ResourceDelete { id: string; modifiedAt: number }
|
||||
export interface VaultResource {
|
||||
domainKeys?: Record<string, string>;
|
||||
@@ -25,3 +26,6 @@ export interface VaultResource {
|
||||
state?: 'draft' | 'sealed';
|
||||
}
|
||||
export interface VaultProfile { v: 3; keys: InvitationKeyPair & { signing: SigningKeyPair }; resources: VaultResource[]; deletes: ResourceDelete[] }
|
||||
|
||||
/** 资料里头像的修改:换成新的(原图 + 缩略图)、保持不变、或移除(不传) */
|
||||
export type AvatarChange = { avatar: string; thumb: string } | 'keep' | undefined;
|
||||
@@ -17,6 +17,7 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) =
|
||||
export const realApi: WorkspaceApi = {
|
||||
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }),
|
||||
updateChannelSettings: (patch: ChannelSettingsPatch) => callWorker<void>('updateChannelSettings', { id: currentChannelId(), patch, apiBase: API_BASE }),
|
||||
spaceAvatar: () => callWorker<string | undefined>('getSpaceAvatar', { id: currentChannelId(), apiBase: API_BASE }),
|
||||
joinChannel: (link: ParsedChannelLink, password?: string) => callWorker<ChannelInfo>('joinChannel', { link, password, apiBase: API_BASE }),
|
||||
inviteUser: (username) => callWorker<void>('inviteChannelUser', { id: currentChannelId(), username, apiBase: API_BASE }),
|
||||
listInvitations: () => callWorker<AccountInvitation[]>('listChannelInvitations', { id: currentChannelId(), apiBase: API_BASE }),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fallbackSubtle as soft } from './subtleFallback';
|
||||
|
||||
const native = globalThis.crypto.subtle;
|
||||
const text = (value: string) => new TextEncoder().encode(value);
|
||||
const hex = (data: ArrayBuffer | Uint8Array) => Array.from(data instanceof Uint8Array ? data : new Uint8Array(data), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
describe('crypto.subtle 的纯 JS 替代实现和原生逐字节一致', () => {
|
||||
it('SHA-256、HMAC', async () => {
|
||||
expect(hex(await soft.digest('SHA-256', text('hello')))).toBe(hex(await native.digest('SHA-256', text('hello'))));
|
||||
const a = await soft.importKey('raw', text('k'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const b = await native.importKey('raw', text('k'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
expect(hex(await soft.sign('HMAC', a, text('m')))).toBe(hex(await native.sign('HMAC', b, text('m'))));
|
||||
});
|
||||
|
||||
it('PBKDF2 和 HKDF', async () => {
|
||||
const salt = text('salty');
|
||||
const pa = await soft.importKey('raw', text('password'), 'PBKDF2', false, ['deriveBits']);
|
||||
const pb = await native.importKey('raw', text('password'), 'PBKDF2', false, ['deriveBits']);
|
||||
expect(hex(await soft.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 5000 }, pa, 256)))
|
||||
.toBe(hex(await native.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 5000 }, pb, 256)));
|
||||
const ha = await soft.importKey('raw', text('root'), 'HKDF', false, ['deriveBits']);
|
||||
const hb = await native.importKey('raw', text('root'), 'HKDF', false, ['deriveBits']);
|
||||
expect(hex(await soft.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: text('i') }, ha, 256)))
|
||||
.toBe(hex(await native.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: text('i') }, hb, 256)));
|
||||
});
|
||||
|
||||
it('AES-GCM 互相能解开,带附加数据;篡改会失败', async () => {
|
||||
const raw = crypto.getRandomValues(new Uint8Array(32)); const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const a = await soft.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
const b = await native.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
const params = { name: 'AES-GCM', iv, additionalData: text('aad') };
|
||||
const sealed = await soft.encrypt(params, a, text('秘密内容'));
|
||||
expect(hex(sealed)).toBe(hex(await native.encrypt(params, b, text('秘密内容'))));
|
||||
expect(new TextDecoder().decode(await native.decrypt(params, b, sealed))).toBe('秘密内容');
|
||||
const tampered = new Uint8Array(sealed); tampered[0] = tampered[0]! ^ 1;
|
||||
await expect(soft.decrypt(params, a, tampered)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('ECDSA:原生签的软件能验,软件签的原生能验', async () => {
|
||||
const pair = await soft.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) as CryptoKeyPair;
|
||||
const jwkPrivate = await soft.exportKey('jwk', pair.privateKey) as JsonWebKey;
|
||||
const jwkPublic = await soft.exportKey('jwk', pair.publicKey) as JsonWebKey;
|
||||
const nativePrivate = await native.importKey('jwk', jwkPrivate, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
|
||||
const nativePublic = await native.importKey('jwk', jwkPublic, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
||||
const algorithm = { name: 'ECDSA', hash: 'SHA-256' };
|
||||
const fromSoft = await soft.sign(algorithm, pair.privateKey, text('消息'));
|
||||
expect(fromSoft.byteLength).toBe(64);
|
||||
expect(await native.verify(algorithm, nativePublic, fromSoft, text('消息'))).toBe(true);
|
||||
const fromNative = await native.sign(algorithm, nativePrivate, text('消息'));
|
||||
expect(await soft.verify(algorithm, pair.publicKey, fromNative, text('消息'))).toBe(true);
|
||||
expect(await soft.verify(algorithm, pair.publicKey, fromNative, text('别的消息'))).toBe(false);
|
||||
});
|
||||
|
||||
it('ECDH:两边算出同一个共享密钥,JWK 互通', async () => {
|
||||
const ours = await soft.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']) as CryptoKeyPair;
|
||||
const theirs = await native.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
||||
const theirPublic = await native.exportKey('jwk', theirs.publicKey);
|
||||
const ourPublic = await soft.exportKey('jwk', ours.publicKey) as JsonWebKey;
|
||||
const soft1 = await soft.deriveBits({ name: 'ECDH', public: await soft.importKey('jwk', theirPublic, { name: 'ECDH', namedCurve: 'P-256' }, false, []) } as never, ours.privateKey, 256);
|
||||
const nativePublic = await native.importKey('jwk', ourPublic, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
||||
const native1 = await native.deriveBits({ name: 'ECDH', public: nativePublic }, theirs.privateKey, 256);
|
||||
expect(hex(soft1)).toBe(hex(native1));
|
||||
});
|
||||
|
||||
it('不可导出的密钥导出会失败', async () => {
|
||||
const key = await soft.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt']) as CryptoKey;
|
||||
await expect(soft.exportKey('raw', key)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { gcm } from '@noble/ciphers/aes.js';
|
||||
import { p256 } from '@noble/curves/nist.js';
|
||||
import { hkdf } from '@noble/hashes/hkdf.js';
|
||||
import { hmac } from '@noble/hashes/hmac.js';
|
||||
import { pbkdf2 } from '@noble/hashes/pbkdf2.js';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
|
||||
/**
|
||||
* 浏览器只在 https 或 localhost 下提供 crypto.subtle。通过局域网 IP 用 http 访问时它不存在,
|
||||
* 注册、登录(密码派生、保险库加解密、签名)全都会失败。这里用纯 JS 实现这个项目用到的那一小部分接口,
|
||||
* 只在 crypto.subtle 不存在时才安装;输出和原生实现逐字节一致(见 subtleFallback.test.ts),所以同一个账号在 https 和 http 下可以互通。
|
||||
*
|
||||
* 只覆盖用到的算法:SHA-256、HMAC-SHA256、HKDF-SHA256、PBKDF2-SHA256、AES-256-GCM、ECDH P-256、ECDSA P-256(SHA-256)。
|
||||
* 纯 JS 比原生慢(PBKDF2 的 60 万次迭代要一两秒),而且 http 页面本身可以被网络上的人替换,所以这只是开发和局域网试用的退路。
|
||||
*/
|
||||
type Bytes = Uint8Array;
|
||||
interface FallbackKey {
|
||||
readonly __fallback: true;
|
||||
type: 'secret' | 'public' | 'private';
|
||||
extractable: boolean;
|
||||
algorithm: { name: string; [key: string]: unknown };
|
||||
usages: string[];
|
||||
raw?: Bytes;
|
||||
/** EC:私钥 d(32 字节)和公钥(65 字节,未压缩) */
|
||||
d?: Bytes;
|
||||
point?: Bytes;
|
||||
}
|
||||
|
||||
const toBytes = (data: BufferSource): Bytes => (data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||
const fresh = (data: Bytes): ArrayBuffer => data.slice().buffer as ArrayBuffer;
|
||||
const name = (algorithm: string | { name: string }): string => (typeof algorithm === 'string' ? algorithm : algorithm.name).toUpperCase();
|
||||
const b64u = (data: Bytes): string => btoa(String.fromCharCode(...data)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
||||
const unb64u = (text: string): Bytes => Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), (char) => char.charCodeAt(0));
|
||||
const fail = (message: string, kind = 'OperationError'): never => { throw new DOMException(message, kind); };
|
||||
const asKey = (key: CryptoKey): FallbackKey => ((key as unknown as FallbackKey).__fallback ? (key as unknown as FallbackKey) : fail('密钥不是由这个环境创建的', 'InvalidAccessError'));
|
||||
const wrap = (key: Omit<FallbackKey, '__fallback'>): CryptoKey => ({ __fallback: true, ...key }) as unknown as CryptoKey;
|
||||
|
||||
function ecKey(algorithmName: string, type: 'public' | 'private', point: Bytes, d: Bytes | undefined, extractable: boolean, usages: string[]): CryptoKey {
|
||||
return wrap({ type, extractable, usages, algorithm: { name: algorithmName, namedCurve: 'P-256' }, point, ...(d ? { d } : {}) });
|
||||
}
|
||||
|
||||
function ecPair(algorithmName: string, extractable: boolean, usages: string[]): CryptoKeyPair {
|
||||
const d = p256.utils.randomSecretKey();
|
||||
const point = p256.getPublicKey(d, false);
|
||||
const publicUsages = algorithmName === 'ECDSA' ? usages.filter((usage) => usage === 'verify') : [];
|
||||
const privateUsages = usages.filter((usage) => usage !== 'verify');
|
||||
return { publicKey: ecKey(algorithmName, 'public', point, undefined, true, publicUsages), privateKey: ecKey(algorithmName, 'private', point, d, extractable, privateUsages) };
|
||||
}
|
||||
|
||||
function importJwk(jwk: JsonWebKey, algorithm: { name: string; namedCurve?: string }, extractable: boolean, usages: string[]): CryptoKey {
|
||||
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || !jwk.x || !jwk.y) fail('只支持 P-256 的 EC 密钥', 'DataError');
|
||||
const point = new Uint8Array(65);
|
||||
point[0] = 4; point.set(unb64u(jwk.x!), 1); point.set(unb64u(jwk.y!), 33);
|
||||
const d = jwk.d ? unb64u(jwk.d) : undefined;
|
||||
return ecKey(algorithm.name, d ? 'private' : 'public', point, d, extractable, usages);
|
||||
}
|
||||
|
||||
function exportJwk(key: FallbackKey): JsonWebKey {
|
||||
if (!key.extractable) fail('这个密钥不允许导出', 'InvalidAccessError');
|
||||
if (!key.point) fail('只能导出 EC 密钥', 'NotSupportedError');
|
||||
const ops = key.type === 'private' ? key.usages : key.usages;
|
||||
return { kty: 'EC', crv: 'P-256', ...(key.d ? { d: b64u(key.d) } : {}), x: b64u(key.point!.subarray(1, 33)), y: b64u(key.point!.subarray(33, 65)), ext: true, key_ops: ops };
|
||||
}
|
||||
|
||||
const subtle = {
|
||||
async digest(algorithm: string | { name: string }, data: BufferSource): Promise<ArrayBuffer> {
|
||||
if (name(algorithm) !== 'SHA-256') fail('只支持 SHA-256', 'NotSupportedError');
|
||||
return fresh(sha256(toBytes(data)));
|
||||
},
|
||||
|
||||
async importKey(format: string, keyData: BufferSource | JsonWebKey, algorithm: string | { name: string; namedCurve?: string; hash?: string }, extractable: boolean, usages: string[]): Promise<CryptoKey> {
|
||||
const algo = name(algorithm);
|
||||
if (format === 'jwk') return importJwk(keyData as JsonWebKey, typeof algorithm === 'string' ? { name: algorithm } : algorithm, extractable, usages);
|
||||
if (format !== 'raw') fail('只支持 raw 和 jwk', 'NotSupportedError');
|
||||
const raw = toBytes(keyData as BufferSource).slice();
|
||||
if (algo === 'AES-GCM' && ![16, 24, 32].includes(raw.length)) fail('AES 密钥长度不对', 'DataError');
|
||||
return wrap({ type: 'secret', extractable, usages, algorithm: typeof algorithm === 'string' ? { name: algorithm } : { ...algorithm }, raw });
|
||||
},
|
||||
|
||||
async exportKey(format: string, key: CryptoKey): Promise<JsonWebKey | ArrayBuffer> {
|
||||
const own = asKey(key);
|
||||
if (format === 'jwk') return exportJwk(own);
|
||||
if (!own.extractable) fail('这个密钥不允许导出', 'InvalidAccessError');
|
||||
return fresh(own.raw ?? own.point ?? new Uint8Array());
|
||||
},
|
||||
|
||||
async generateKey(algorithm: { name: string; length?: number; namedCurve?: string }, extractable: boolean, usages: string[]): Promise<CryptoKey | CryptoKeyPair> {
|
||||
const algo = name(algorithm);
|
||||
if (algo === 'AES-GCM') return wrap({ type: 'secret', extractable, usages, algorithm: { name: 'AES-GCM', length: algorithm.length ?? 256 }, raw: crypto.getRandomValues(new Uint8Array((algorithm.length ?? 256) / 8)) });
|
||||
if ((algo === 'ECDH' || algo === 'ECDSA') && algorithm.namedCurve === 'P-256') return ecPair(algo, extractable, usages);
|
||||
return fail('不支持的算法', 'NotSupportedError');
|
||||
},
|
||||
|
||||
async sign(algorithm: string | { name: string }, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer> {
|
||||
const own = asKey(key);
|
||||
const algo = name(algorithm);
|
||||
if (algo === 'HMAC') return fresh(hmac(sha256, own.raw!, toBytes(data)));
|
||||
if (algo === 'ECDSA' && own.d) return fresh(p256.sign(toBytes(data), own.d, { prehash: true, format: 'compact' }));
|
||||
return fail('不支持的签名', 'NotSupportedError');
|
||||
},
|
||||
|
||||
async verify(algorithm: string | { name: string }, key: CryptoKey, signature: BufferSource, data: BufferSource): Promise<boolean> {
|
||||
const own = asKey(key);
|
||||
const algo = name(algorithm);
|
||||
if (algo === 'HMAC') {
|
||||
const expected = hmac(sha256, own.raw!, toBytes(data)); const got = toBytes(signature);
|
||||
return expected.length === got.length && expected.every((byte, index) => byte === got[index]);
|
||||
}
|
||||
if (algo === 'ECDSA' && own.point) {
|
||||
try { return p256.verify(toBytes(signature), toBytes(data), own.point, { prehash: true, format: 'compact', lowS: false }); } catch { return false; }
|
||||
}
|
||||
return fail('不支持的验证', 'NotSupportedError');
|
||||
},
|
||||
|
||||
async encrypt(algorithm: { name: string; iv: BufferSource; additionalData?: BufferSource; tagLength?: number }, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer> {
|
||||
if (name(algorithm) !== 'AES-GCM' || (algorithm.tagLength ?? 128) !== 128) fail('只支持 AES-GCM(128 位标签)', 'NotSupportedError');
|
||||
return fresh(gcm(asKey(key).raw!, toBytes(algorithm.iv), algorithm.additionalData ? toBytes(algorithm.additionalData) : undefined).encrypt(toBytes(data)));
|
||||
},
|
||||
|
||||
async decrypt(algorithm: { name: string; iv: BufferSource; additionalData?: BufferSource; tagLength?: number }, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer> {
|
||||
if (name(algorithm) !== 'AES-GCM' || (algorithm.tagLength ?? 128) !== 128) fail('只支持 AES-GCM(128 位标签)', 'NotSupportedError');
|
||||
try { return fresh(gcm(asKey(key).raw!, toBytes(algorithm.iv), algorithm.additionalData ? toBytes(algorithm.additionalData) : undefined).decrypt(toBytes(data))); }
|
||||
catch { return fail('解密失败', 'OperationError'); }
|
||||
},
|
||||
|
||||
async deriveBits(algorithm: { name: string; hash?: string; salt?: BufferSource; info?: BufferSource; iterations?: number; public?: CryptoKey }, key: CryptoKey, length: number): Promise<ArrayBuffer> {
|
||||
const own = asKey(key);
|
||||
const algo = name(algorithm);
|
||||
if (algo === 'PBKDF2') return fresh(pbkdf2(sha256, own.raw!, toBytes(algorithm.salt!), { c: algorithm.iterations!, dkLen: length / 8 }));
|
||||
if (algo === 'HKDF') return fresh(hkdf(sha256, own.raw!, toBytes(algorithm.salt!), toBytes(algorithm.info!), length / 8));
|
||||
if (algo === 'ECDH' && own.d) return fresh(p256.getSharedSecret(own.d, asKey(algorithm.public!).point!, true).subarray(1, 33).slice(0, length / 8));
|
||||
return fail('不支持的派生', 'NotSupportedError');
|
||||
},
|
||||
|
||||
async deriveKey(algorithm: { name: string; salt?: BufferSource; info?: BufferSource }, key: CryptoKey, derived: { name: string; length?: number }, extractable: boolean, usages: string[]): Promise<CryptoKey> {
|
||||
const length = derived.length ?? 256;
|
||||
const raw = new Uint8Array(await subtle.deriveBits(algorithm as never, key, length));
|
||||
return wrap({ type: 'secret', extractable, usages, algorithm: { name: derived.name, length }, raw });
|
||||
},
|
||||
};
|
||||
|
||||
/** randomUUID 同样只在安全上下文里有 */
|
||||
function randomUuid(): `${string}-${string}-${string}-${string}-${string}` {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40; bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
/** crypto.subtle、crypto.randomUUID 不存在时(http 的局域网访问)装上替代实现。已经有原生的就什么也不做 */
|
||||
/** force:即使有原生实现也用纯 JS 的(排查用,localStorage 里设 webdrop.softcrypto=1) */
|
||||
export function installSubtleFallback(force = false): boolean {
|
||||
if (typeof crypto === 'undefined') return false;
|
||||
if (force) { Object.defineProperty(crypto, 'subtle', { value: subtle, configurable: true }); return true; }
|
||||
if (!crypto.randomUUID) Object.defineProperty(crypto, 'randomUUID', { value: randomUuid, configurable: true });
|
||||
if (crypto.subtle) return false;
|
||||
Object.defineProperty(crypto, 'subtle', { value: subtle, configurable: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
export { subtle as fallbackSubtle };
|
||||
@@ -55,6 +55,8 @@ export interface ChannelSettingsPatch {
|
||||
name?: string;
|
||||
/** 空字符串表示移除头像 */
|
||||
avatar?: string;
|
||||
/** 有损的小图,随头像一起上传 */
|
||||
avatarThumb?: string;
|
||||
maxMembers?: number;
|
||||
membersCanUpload?: boolean;
|
||||
ttlSeconds?: number;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { installSubtleFallback } from './subtleFallback';
|
||||
import { setApiBase, getServerInfo } from './worker/http';
|
||||
import type { WorkspaceRoleRequest } from './generated/api';
|
||||
import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys';
|
||||
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
|
||||
import { createChannel, forgetChannel, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
|
||||
import { createChannel, forgetChannel, getSpaceAvatar, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
|
||||
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
|
||||
import { cancelFileUpload } from './worker/uploadQueue';
|
||||
import { stopChannel } from './worker/channelTransport';
|
||||
@@ -15,6 +16,8 @@ import { serial } from './worker/serial';
|
||||
import { errorMessage } from './errors';
|
||||
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
|
||||
import { createFolder, createThread, createGroup, renameGroup, deleteGroup, createRole, updateRole, deleteRole, setRoleMembers, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads';
|
||||
import { fetchAvatar } from './worker/avatars';
|
||||
import { servePlaybackRange } from './worker/playbackStream';
|
||||
import { refreshLoadedWorkspaces } from './worker/workspaceStructure';
|
||||
import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account';
|
||||
|
||||
@@ -45,7 +48,9 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
case 'beginPasskeyLogin': return beginPasskeyLogin(String(payload.username ?? ''));
|
||||
case 'finishPasskeyLogin': return finishPasskeyLogin(String(payload.username ?? ''), id, String(payload.credential ?? ''), payload.prf as ArrayBuffer | undefined, payload.remember !== false);
|
||||
case 'unlockPasskeyLogin': return unlockPasskeyLogin(String(payload.password ?? ''), payload.remember !== false);
|
||||
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); void refreshLoadedWorkspaces(); return accountStatus();
|
||||
case 'getAvatar': return fetchAvatar(String(payload.username ?? ''), Number(payload.version ?? 0), payload.full === true);
|
||||
case 'getSpaceAvatar': return getSpaceAvatar(id);
|
||||
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), payload.avatar as Parameters<typeof updatePersonal>[1]); void refreshLoadedWorkspaces(); return accountStatus();
|
||||
case 'accountDevices': return listAccountDevices();
|
||||
case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true;
|
||||
case 'revokeAccountDevice': await revokeAccountDevice(id); return accountStatus();
|
||||
@@ -127,6 +132,12 @@ function laneOf(operation: string, payload: Record<string, unknown>): string | u
|
||||
|
||||
scope.onmessage = (event) => {
|
||||
const request = event.data;
|
||||
// 边下边播的取数请求:数据通过随消息转移来的端口直接回给 Service Worker,不走请求/回复
|
||||
if (request.operation === 'streamRange') {
|
||||
const port = event.ports[0];
|
||||
if (port) void servePlaybackRange(port, request.payload as Parameters<typeof servePlaybackRange>[1]);
|
||||
return;
|
||||
}
|
||||
const lane = laneOf(request.operation, request.payload as Record<string, unknown>);
|
||||
const run = () => dispatch(request.operation, request.payload);
|
||||
const task = lane ? serial(lane, run) : run();
|
||||
@@ -138,3 +149,6 @@ scope.onmessage = (event) => {
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// http 的局域网访问没有 crypto.subtle:装上纯 JS 的替代实现(原生存在时什么也不做)
|
||||
installSubtleFallback(self.name.includes('softcrypto'));
|
||||
@@ -1,5 +1,5 @@
|
||||
import { importResources, importResourceDeletes, listAllResources, listResourceDeletes, setResourceChangeListener, setResourceScope, type SavedResource } from '../database';
|
||||
import { apiFetch } from './http';
|
||||
import { apiFetch, jsonBody } from './http';
|
||||
import { clearResourceSessions, getSession } from './sessions';
|
||||
import { setAccountIdentity, requireAccount } from './accountSession';
|
||||
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto';
|
||||
@@ -11,6 +11,8 @@ import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount,
|
||||
import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api';
|
||||
|
||||
import { loadPersonal, personalProfile } from './accountProfile';
|
||||
import { primeAvatar } from './avatars';
|
||||
import type { AvatarChange } from '../protocol/account';
|
||||
import type { AccountProfile, AccountProfileRequest } from '../generated/api';
|
||||
|
||||
import type { PersonalProfile } from '../protocol/account';
|
||||
@@ -206,7 +208,8 @@ export async function unlockAuthenticatedAccount(identity: AccountSessionBody, k
|
||||
export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }> {
|
||||
return { username: account?.username ?? null, revision: account?.revision ?? 0,
|
||||
remembered: account?.remember ?? false, unlockUsername: passkeyUnlockUsername(),
|
||||
...(account?.personal ? { personal: account.personal } : {}),
|
||||
// 界面每隔几秒就读一次状态:不能每次都带着几 MB 的头像过 postMessage,头像由界面另外取
|
||||
...(account?.personal ? { personal: { displayName: account.personal.displayName, modifiedAt: account.personal.modifiedAt, hasAvatar: account.personal.hasAvatar ?? Boolean(account.personal.avatar) } } : {}),
|
||||
...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) };
|
||||
}
|
||||
export async function logoutAccount(): Promise<void> {
|
||||
@@ -230,13 +233,20 @@ export async function clearSavedLogin(): Promise<void> {
|
||||
}
|
||||
await forgetAccount(); await forgetLoginFields();
|
||||
}
|
||||
export async function updatePersonal(displayName: string, avatar?: string): Promise<void> {
|
||||
export async function updatePersonal(displayName: string, change?: AvatarChange): Promise<void> {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
const session = account;
|
||||
const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName, avatar } satisfies AccountProfileRequest) }).then(r => r.json() as Promise<AccountProfile>);
|
||||
const request: AccountProfileRequest = change === 'keep' ? { displayName, keepAvatar: true }
|
||||
: change ? { displayName, keepAvatar: false, avatar: change.avatar, avatarThumb: change.thumb } : { displayName, keepAvatar: false };
|
||||
const { body, headers: encoding } = await jsonBody(request);
|
||||
const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), ...encoding }, body }).then(r => r.json() as Promise<AccountProfile>);
|
||||
stillActive(session);
|
||||
const previous = session.personal?.avatar;
|
||||
session.personal = personalProfile(value);
|
||||
// 本机刚设置的头像直接用,不用再下载;只改名字时沿用现有的
|
||||
const shown = change && change !== 'keep' ? change.thumb : change === 'keep' ? previous : undefined;
|
||||
if (shown) { session.personal.avatar = shown; session.personal.hasAvatar = true; }
|
||||
primeAvatar(value.username, value.modifiedAt, shown);
|
||||
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
|
||||
await persistSession(session, { vault: await sealJson(session.profile, session.key), revision: session.revision });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { apiFetch } from './http';
|
||||
import { fetchAvatar } from './avatars';
|
||||
import type { AccountProfile } from '../generated/api';
|
||||
import type { PersonalProfile } from '../protocol/account';
|
||||
|
||||
export function personalProfile(value: AccountProfile): PersonalProfile {
|
||||
return { displayName: value.displayName, avatar: value.avatar ?? undefined, modifiedAt: value.modifiedAt };
|
||||
return { displayName: value.displayName, hasAvatar: value.hasAvatar || Boolean(value.avatar), ...(value.avatar ? { avatar: value.avatar } : {}), modifiedAt: value.modifiedAt };
|
||||
}
|
||||
export async function loadPersonal(token: string): Promise<PersonalProfile> {
|
||||
const headers = { 'X-WD-Account': token };
|
||||
const profile = await apiFetch('/accounts/profile', { headers }).then(r => r.json() as Promise<AccountProfile>);
|
||||
return personalProfile(profile);
|
||||
const personal = personalProfile(profile);
|
||||
// 头像后台取,不挡住登录;取到后补进来,之后发消息的头像快照就有了
|
||||
if (personal.hasAvatar && !personal.avatar) void fetchAvatar(profile.username, profile.modifiedAt).then((avatar) => { if (avatar) personal.avatar = avatar; });
|
||||
return personal;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { apiFetch } from './http';
|
||||
import { accountToken } from './accountSession';
|
||||
|
||||
/**
|
||||
* 头像不再随成员资料、空间结构一起下发(动图头像有几 MB,每次刷新结构都带上会把打开空间拖得很慢)。
|
||||
* 资料里只有「有没有头像」和修改时间,头像另取一次并缓存;修改时间变了才重新取,
|
||||
* 服务端对头像带 ETag,浏览器重新打开时只回 304。
|
||||
*/
|
||||
interface Entry { version: number; value?: string; loading?: Promise<string | undefined> }
|
||||
const cache = new Map<string, Entry>();
|
||||
const waiting: (() => void)[] = [];
|
||||
let running = 0;
|
||||
const PARALLEL = 4;
|
||||
|
||||
/** 已经取到的头像(没取到或版本不对返回 undefined) */
|
||||
export function cachedAvatar(username: string, version: number): string | undefined {
|
||||
const entry = cache.get(username);
|
||||
return entry && entry.version === version ? entry.value : undefined;
|
||||
}
|
||||
|
||||
/** 本机刚设置的头像,直接放进缓存,不用再下载一遍 */
|
||||
export function primeAvatar(username: string, version: number, value: string | undefined): void {
|
||||
cache.set(username, { version, ...(value ? { value } : {}) });
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function slot<T>(task: () => Promise<T>): Promise<T> {
|
||||
if (running >= PARALLEL) await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
running += 1;
|
||||
try { return await task(); }
|
||||
finally { running -= 1; waiting.shift()?.(); }
|
||||
}
|
||||
|
||||
async function download(username: string, token: string, full: boolean): Promise<string> {
|
||||
// no-cache:浏览器每次都带 ETag 去验证,没变化就是 304,不用重新下载
|
||||
const response = await apiFetch(`/accounts/users/${encodeURIComponent(username)}/avatar${full ? '?size=full' : ''}`, { headers: { 'X-WD-Account': token }, cache: 'no-cache' });
|
||||
return blobToDataUrl(await response.blob());
|
||||
}
|
||||
|
||||
/** 默认取有损的缩略图(列表、聊天用,小,有内存缓存);full 取原图(预览时才用,不放进内存缓存) */
|
||||
export function fetchAvatar(username: string, version: number, full = false): Promise<string | undefined> {
|
||||
if (full) {
|
||||
const token = accountToken();
|
||||
return token ? download(username, token, true).catch(() => undefined) : Promise.resolve(undefined);
|
||||
}
|
||||
const entry = cache.get(username);
|
||||
if (entry && entry.version === version) return entry.loading ?? Promise.resolve(entry.value);
|
||||
const token = accountToken();
|
||||
if (!token) return Promise.resolve(undefined);
|
||||
const next: Entry = { version };
|
||||
next.loading = slot(async () => {
|
||||
try {
|
||||
const value = await download(username, token, false);
|
||||
next.value = value;
|
||||
return value;
|
||||
} catch { cache.delete(username); return undefined; }
|
||||
});
|
||||
cache.set(username, next);
|
||||
return next.loading;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { canDecryptRecord, objectAuthorAccount, objectEventProjection, readDataK
|
||||
import { getResource, cacheResource, type SavedResource } from '../database';
|
||||
import { channelCache } from './resourceMetadata';
|
||||
import { cachedPerson, loadWorkspace } from './workspaceStructure';
|
||||
import { cachedAvatar } from './avatars';
|
||||
import { displayAvatar } from './senderSnapshot';
|
||||
import type { FileItem, Item, SenderInfo, Snapshot } from '../types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
@@ -37,7 +38,8 @@ async function authorOf(resourceId: string, itemId: string): Promise<SenderInfo
|
||||
const username = objectAuthorAccount(resourceId, itemId);
|
||||
if (!username) return undefined;
|
||||
const person = cachedPerson(resourceId, username);
|
||||
const avatar = person?.avatar ? await displayAvatar(person.avatar) : undefined;
|
||||
const full = person?.hasAvatar ? cachedAvatar(person.username, person.modifiedAt) : undefined;
|
||||
const avatar = full ? await displayAvatar(full) : undefined;
|
||||
return { username, displayName: person?.displayName || username, ...(avatar ? { avatar } : {}) };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cacheResource, getResource, listResources, removeResource, saveResource
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
|
||||
import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '../types';
|
||||
import { apiFetch, readJson, getServerInfo } from './http';
|
||||
import { jsonBody, apiFetch, readJson, getServerInfo } from './http';
|
||||
import { channelCache, isOfflineFailure } from './resourceMetadata';
|
||||
import { accountToken } from './accountSession';
|
||||
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
|
||||
@@ -153,15 +153,17 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
||||
const body: SettingsRequest = {};
|
||||
if (patch.name !== undefined) body.name = patch.name.trim();
|
||||
if (patch.avatar !== undefined) body.avatar = patch.avatar;
|
||||
if (patch.avatarThumb) body.avatar_thumb = patch.avatarThumb;
|
||||
if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
|
||||
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
|
||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
|
||||
if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy;
|
||||
if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword;
|
||||
const { body: payload, headers: encoding } = await jsonBody(body, Boolean(patch.avatar));
|
||||
const info = await apiFetch(`/channels/${resourceId}/settings`, {
|
||||
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
method: 'PATCH', headers: { ...session.authHeaders('owner'), ...encoding },
|
||||
body: payload,
|
||||
}).then((response) => response.json() as Promise<ChannelWire>);
|
||||
session.verifyChannelInfo(info);
|
||||
const updated = await cacheResource(resource, channelCache(info));
|
||||
@@ -175,3 +177,13 @@ export async function forgetChannel(id: string): Promise<void> {
|
||||
await removeResource(id);
|
||||
}
|
||||
|
||||
|
||||
/** 空间头像原图:信息里只带缩略图,预览时才下载原图 */
|
||||
export async function getSpaceAvatar(resourceId: string): Promise<string | undefined> {
|
||||
const { session } = await getSession(resourceId);
|
||||
try {
|
||||
const response = await apiFetch(`/channels/${resourceId}/avatar`, { headers: session.authHeaders('read'), cache: 'no-cache' });
|
||||
const blob = await response.blob();
|
||||
return await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); });
|
||||
} catch { return undefined; }
|
||||
}
|
||||
@@ -47,7 +47,8 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise<Re
|
||||
const response = await fetch(apiPath(path), {
|
||||
...init,
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
// 头像这类带 ETag 的资源可以让浏览器重新验证(304),其余一律不缓存
|
||||
cache: init.cache ?? 'no-store',
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
@@ -136,3 +137,11 @@ export async function readJson<T>(path: string, headers: Record<string, string>)
|
||||
return apiFetch(path, { headers }).then((response) => response.json() as Promise<T>);
|
||||
}
|
||||
|
||||
|
||||
/** JSON 请求体,浏览器支持时用 gzip 压缩(头像是 base64,压缩后能省下约四分之一的上传量) */
|
||||
export async function jsonBody(value: unknown, compress = true): Promise<{ body: BodyInit; headers: Record<string, string> }> {
|
||||
const text = JSON.stringify(value);
|
||||
if (!compress || typeof CompressionStream === 'undefined') return { body: text, headers: { 'Content-Type': 'application/json' } };
|
||||
const zipped = await new Response(new Blob([text]).stream().pipeThrough(new CompressionStream('gzip'))).blob();
|
||||
return { body: zipped, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getResource, cacheResource } from '../database';
|
||||
import { getSession } from './sessions';
|
||||
import { apiFetch } from './http';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { cachedAvatar, fetchAvatar } from './avatars';
|
||||
import { displayAvatar } from './senderSnapshot';
|
||||
|
||||
const current = new Map<string, WorkspaceBody>();
|
||||
@@ -19,11 +20,31 @@ async function publish(id: string, body: WorkspaceBody): Promise<WorkspaceBody>
|
||||
current.set(id, body);
|
||||
const resource = await getResource(id);
|
||||
if (resource) await cacheResource(resource, { workspaceMetadata: body });
|
||||
const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await displayAvatar(person.avatar) : undefined })));
|
||||
await emit(id, body);
|
||||
if (current.get(id) !== body) return current.get(id) ?? body;
|
||||
publishWorkspace(id, { t: 'structure', state: body.structure, people });
|
||||
void loadAvatars(id, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 先把成员列表发出去(没有头像时结构很小,打开空间立刻可用),头像稍后逐个补上 */
|
||||
async function emit(id: string, body: WorkspaceBody): Promise<void> {
|
||||
const people = await Promise.all(body.people.map(async person => {
|
||||
const full = person.hasAvatar ? cachedAvatar(person.username, person.modifiedAt) : undefined;
|
||||
return { ...person, avatar: full ? await displayAvatar(full) : undefined };
|
||||
}));
|
||||
if (current.get(id) !== body) return;
|
||||
publishWorkspace(id, { t: 'structure', state: body.structure, people });
|
||||
}
|
||||
const republishing = new Set<string>();
|
||||
async function loadAvatars(id: string, body: WorkspaceBody): Promise<void> {
|
||||
const missing = body.people.filter(person => person.hasAvatar && cachedAvatar(person.username, person.modifiedAt) === undefined);
|
||||
if (!missing.length) return;
|
||||
await Promise.all(missing.map(person => fetchAvatar(person.username, person.modifiedAt)));
|
||||
const latest = current.get(id);
|
||||
if (!latest || republishing.has(id)) return;
|
||||
republishing.add(id);
|
||||
try { await emit(id, latest); } finally { republishing.delete(id); }
|
||||
}
|
||||
/** 结构由流里的 settings 通知保持最新;发消息等热路径直接用缓存,不再每次多等一个往返 */
|
||||
export async function cachedWorkspace(id: string): Promise<WorkspaceBody> {
|
||||
return current.get(id) ?? loadWorkspace(id);
|
||||
|
||||
@@ -8,8 +8,13 @@ let nextId = 1;
|
||||
const pending = new Map<number, Pending>();
|
||||
const listeners = new Map<string, Set<(event: BridgeEvent, blob?: Blob) => void>>();
|
||||
|
||||
/** 排查用:localStorage 里设 webdrop.softcrypto=1,强制用纯 JS 的加密实现(模拟 http 局域网访问) */
|
||||
export function forcedSoftCrypto(): boolean {
|
||||
try { return localStorage.getItem('webdrop.softcrypto') === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
function getWorker(): Worker {
|
||||
worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: 'webdrop-crypto' });
|
||||
worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: forcedSoftCrypto() ? 'webdrop-crypto softcrypto' : 'webdrop-crypto' });
|
||||
worker.onmessage = (message: MessageEvent<WorkerReply>) => {
|
||||
const reply = message.data;
|
||||
if ('requestId' in reply) {
|
||||
@@ -70,3 +75,8 @@ export function subscribeWorker<T extends BridgeEvent>(resourceId: string, liste
|
||||
}
|
||||
|
||||
export type { BridgeEvent };
|
||||
|
||||
/** 直接给加密线程发消息(不等回复),可以带 MessagePort 之类要转移的对象 */
|
||||
export function postToWorker(message: { operation: string; payload: unknown }, transfer: Transferable[]): void {
|
||||
getWorker().postMessage(message, transfer);
|
||||
}
|
||||
Reference in new issue
Block a user