mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance backup progress handling and improve user status toggling
This commit is contained in:
+12
-11
@@ -53,6 +53,17 @@ import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
|||||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||||
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||||
|
|
||||||
|
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const detail = value as Record<string, unknown>;
|
||||||
|
const operation = detail.operation;
|
||||||
|
return (
|
||||||
|
(operation === 'backup-restore' || operation === 'backup-export' || operation === 'backup-remote-run')
|
||||||
|
&& typeof detail.step === 'string'
|
||||||
|
&& typeof detail.fileName === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const IMPORT_ROUTE = '/backup/import-export';
|
const IMPORT_ROUTE = '/backup/import-export';
|
||||||
const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||||
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||||
@@ -927,17 +938,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
||||||
const payload = frame.arguments?.[0]?.Payload;
|
const payload = frame.arguments?.[0]?.Payload;
|
||||||
if (
|
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
|
||||||
payload
|
|
||||||
&& typeof payload === 'object'
|
|
||||||
&& (
|
|
||||||
payload.operation === 'backup-restore'
|
|
||||||
|| payload.operation === 'backup-export'
|
|
||||||
|| payload.operation === 'backup-remote-run'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
dispatchBackupProgress(payload as BackupProgressDetail);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
return status || '-';
|
return status || '-';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeToggleableStatus = (status: string): 'active' | 'banned' | null => {
|
||||||
|
const normalized = String(status || '').toLowerCase();
|
||||||
|
if (normalized === 'active' || normalized === 'banned') return normalized;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
@@ -55,7 +61,9 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{props.users.map((user) => (
|
{props.users.map((user) => {
|
||||||
|
const toggleableStatus = normalizeToggleableStatus(user.status);
|
||||||
|
return (
|
||||||
<tr key={user.id}>
|
<tr key={user.id}>
|
||||||
<td data-label={t('txt_email')}>{user.email}</td>
|
<td data-label={t('txt_email')}>{user.email}</td>
|
||||||
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
||||||
@@ -66,8 +74,11 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={user.id === props.currentUserId}
|
disabled={user.id === props.currentUserId || !toggleableStatus}
|
||||||
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
|
onClick={() => {
|
||||||
|
if (!toggleableStatus) return;
|
||||||
|
void props.onToggleUserStatus(user.id, toggleableStatus);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
||||||
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
||||||
@@ -81,7 +92,8 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -625,7 +625,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setSettings(result.settings);
|
setSettings(result.settings);
|
||||||
setSelectedDestinationId(selectedDestination.id);
|
setSelectedDestinationId(selectedDestination.id);
|
||||||
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||||
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.fileName }));
|
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { Download, Eye, Lock } from 'lucide-preact';
|
import { Download, Eye, Lock } from 'lucide-preact';
|
||||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||||
|
import { toBufferSource } from '@/lib/crypto';
|
||||||
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -61,13 +62,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
if (props.keyPart) {
|
if (props.keyPart) {
|
||||||
try {
|
try {
|
||||||
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
||||||
blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(decryptedBytes)], { type: 'application/octet-stream' });
|
||||||
} catch {
|
} catch {
|
||||||
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
||||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||||
}
|
}
|
||||||
downloadBytesAsFile(
|
downloadBytesAsFile(
|
||||||
new Uint8Array(await blob.arrayBuffer()),
|
new Uint8Array(await blob.arrayBuffer()),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { JSX } from 'preact';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
||||||
import {
|
import {
|
||||||
@@ -96,6 +97,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
|||||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: props.cipher.id,
|
id: props.cipher.id,
|
||||||
});
|
});
|
||||||
|
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -113,7 +115,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
|||||||
className="btn btn-secondary small totp-drag-btn"
|
className="btn btn-secondary small totp-drag-btn"
|
||||||
title={t('txt_drag_to_reorder')}
|
title={t('txt_drag_to_reorder')}
|
||||||
aria-label={t('txt_drag_to_reorder')}
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
{...attributes}
|
{...dragButtonAttributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
>
|
>
|
||||||
<GripVertical size={14} className="btn-icon" />
|
<GripVertical size={14} className="btn-icon" />
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
...COMMON_TIME_ZONES,
|
...COMMON_TIME_ZONES,
|
||||||
...props.availableTimeZones,
|
...props.availableTimeZones,
|
||||||
]));
|
]));
|
||||||
|
const selectedIntervalHours = props.selectedDestination?.schedule.intervalHours ?? 24;
|
||||||
|
|
||||||
if (props.selectedRecommendedProvider) {
|
if (props.selectedRecommendedProvider) {
|
||||||
return (
|
return (
|
||||||
@@ -216,7 +217,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
value={String(props.selectedDestination.schedule.intervalHours || 24)}
|
value={String(selectedIntervalHours)}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
||||||
@@ -234,7 +235,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
||||||
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
||||||
const active = preset === props.selectedDestination.schedule.intervalHours;
|
const active = preset === selectedIntervalHours;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={preset}
|
key={preset}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RefObject } from 'preact';
|
import type { JSX, RefObject } from 'preact';
|
||||||
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +71,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
|||||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: props.id,
|
id: props.id,
|
||||||
});
|
});
|
||||||
|
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -89,7 +90,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
|||||||
className="btn btn-secondary small website-drag-btn"
|
className="btn btn-secondary small website-drag-btn"
|
||||||
title={t('txt_drag_to_reorder')}
|
title={t('txt_drag_to_reorder')}
|
||||||
aria-label={t('txt_drag_to_reorder')}
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
{...attributes}
|
{...dragButtonAttributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
>
|
>
|
||||||
<GripVertical size={14} className="btn-icon" />
|
<GripVertical size={14} className="btn-icon" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { base64ToBytes, decryptBw } from './crypto';
|
import { base64ToBytes, decryptBw, toBufferSource } from './crypto';
|
||||||
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
||||||
import type { Profile, SessionState } from './types';
|
import type { Profile, SessionState } from './types';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ const AES_GCM_ALGORITHM = 'AES-GCM';
|
|||||||
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey(
|
return crypto.subtle.importKey(
|
||||||
'pkcs8',
|
'pkcs8',
|
||||||
pkcs8,
|
toBufferSource(pkcs8),
|
||||||
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||||
false,
|
false,
|
||||||
['decrypt']
|
['decrypt']
|
||||||
@@ -17,7 +17,7 @@ async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey('raw', keyBytes, { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
return crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptPortableBackupSettings(
|
export async function decryptPortableBackupSettings(
|
||||||
@@ -50,15 +50,15 @@ export async function decryptPortableBackupSettings(
|
|||||||
await crypto.subtle.decrypt(
|
await crypto.subtle.decrypt(
|
||||||
{ name: PORTABLE_ALGORITHM },
|
{ name: PORTABLE_ALGORITHM },
|
||||||
privateKey,
|
privateKey,
|
||||||
base64ToBytes(wrap.wrappedKey)
|
toBufferSource(base64ToBytes(wrap.wrappedKey))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const aesKey = await importPortableAesKey(portableDek);
|
const aesKey = await importPortableAesKey(portableDek);
|
||||||
const plaintext = new Uint8Array(
|
const plaintext = new Uint8Array(
|
||||||
await crypto.subtle.decrypt(
|
await crypto.subtle.decrypt(
|
||||||
{ name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) },
|
{ name: AES_GCM_ALGORITHM, iv: toBufferSource(base64ToBytes(portable.iv)) },
|
||||||
aesKey,
|
aesKey,
|
||||||
base64ToBytes(portable.ciphertext)
|
toBufferSource(base64ToBytes(portable.ciphertext))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
|
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type AuthedFetch,
|
type AuthedFetch,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { readResponseBytesWithProgress } from '../download';
|
import { readResponseBytesWithProgress } from '../download';
|
||||||
|
import { toBufferSource } from '../crypto';
|
||||||
import { unzipSync, zipSync } from 'fflate';
|
import { unzipSync, zipSync } from 'fflate';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -367,7 +368,7 @@ export function extractBackupFileChecksumPrefix(fileName: string): string | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
const digest = await crypto.subtle.digest('SHA-256', toBufferSource(bytes));
|
||||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,10 +152,12 @@ export async function createSend(
|
|||||||
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
||||||
const uploadUrl = uploadInfo?.url;
|
const uploadUrl = uploadInfo?.url;
|
||||||
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
||||||
|
const payload = new ArrayBuffer(encryptedFileBytes.byteLength);
|
||||||
|
new Uint8Array(payload).set(encryptedFileBytes);
|
||||||
const uploadResp = await uploadDirectEncryptedPayload({
|
const uploadResp = await uploadDirectEncryptedPayload({
|
||||||
accessToken: session.accessToken,
|
accessToken: session.accessToken,
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
payload: encryptedFileBytes,
|
payload,
|
||||||
fileUploadType: uploadInfo?.fileUploadType,
|
fileUploadType: uploadInfo?.fileUploadType,
|
||||||
unsupportedMessage: 'Unsupported send upload type',
|
unsupportedMessage: 'Unsupported send upload type',
|
||||||
onProgress,
|
onProgress,
|
||||||
|
|||||||
@@ -63,14 +63,14 @@ interface UploadWithProgressOptions {
|
|||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
headers?: HeadersInit;
|
headers?: HeadersInit;
|
||||||
body?: Document | XMLHttpRequestBodyInit | null;
|
body?: XMLHttpRequestBodyInit | null;
|
||||||
onProgress?: (percent: number | null) => void;
|
onProgress?: (percent: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DirectEncryptedUploadOptions {
|
interface DirectEncryptedUploadOptions {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
payload: ArrayBuffer | Uint8Array;
|
payload: XMLHttpRequestBodyInit;
|
||||||
fileUploadType: number | null | undefined;
|
fileUploadType: number | null | undefined;
|
||||||
unsupportedMessage: string;
|
unsupportedMessage: string;
|
||||||
onProgress?: (percent: number | null) => void;
|
onProgress?: (percent: number | null) => void;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||||
return new Uint8Array(bytes).buffer;
|
return new Uint8Array(bytes).buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,8 @@
|
|||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "preact",
|
"jsxImportSource": "preact",
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"],
|
"@/*": ["./src/*"],
|
||||||
"@shared/*": ["../shared/*"]
|
"@shared/*": ["../shared/*"]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user