feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
Some checks failed
Test / test (push) Has been cancelled

M-Files-1..6 land the full files-RPC layer + everything 0.3.0 needs to
ship. Apps keep their own UI; this layer ships the typed RPC, the
streams bridge for content I/O, and production hooks (rate limit,
retention, fingerprint gate, metrics).

@shade/files (NEW)
- Standard ops: list/stat/mkdir/delete/move/read/write/getThumbnail with
  Zod-validated wire schemas + clean user-handler types.
- Custom ops: typed via TypeScript declaration merging on CustomOpsMap
  + per-op Zod schemas; client.custom('app.foo', {...}) is fully typed.
- Content I/O: inline (≤ 256 KiB plaintext) base64-in-RPC; streams
  (> 256 KiB) ride @shade/transfer via userMetadata.shadeFilesWriteId
  / shadeFilesReadStreamId correlation. Server-side TransformStream
  bridges accept inbound transfers immediately (engine rejects chunks
  that arrive before accept) and park the readable for the matching
  RPC.
- Directory ops: walk(path, opts) async-iterable depth-first walker;
  uploadDirectory()/downloadDirectory() with bounded concurrency pool
  (default 4, cap 16), aggregated progress, abort.
- Production hooks (callback-based, vendor-neutral): rate-limit (op +
  byte), idempotency cache (LRU + TTL + in-flight de-dupe), path
  policy (traversal + percent-decode hardening), fingerprint gate
  (required/optional/reject), pluggable Ed25519 sig verification with
  ±5 min replay window, onMetric sink (standard names).
- React hooks (subpath @shade/files/react): ShadeFilesProvider,
  useShadeFiles, useFileList, useFileTransfer/Upload/Download.
- Shade.files.serve(handler) + Shade.files.client(peer) high-level
  entrypoint in @shade/sdk; lazy + memoized; one handler per Shade.

Wire format bump
- @shade/proto wire VERSION 0x01 → 0x02. Length prefixes changed from
  u16 to u32. The previous u16 silently truncated payloads above
  64 KiB — a hard correctness ceiling that blocked inline file ops
  up to 256 KiB. Wire-incompatible with 0.2.x peers; new sessions
  only. Cross-platform Kotlin port (android/shade-android) updated to
  match; test-vectors/wire-format.json regenerated.

Concurrency safety
- ShadeSessionManager.encrypt/.decrypt now run under per-peer mutex.
  Concurrent decryptions of the same peer raced ratchet state
  (manifested as sporadic "Failed to decrypt — wrong key or tampered
  data" under load — surfaced once concurrent uploadDirectory pumped
  many writes in flight). Encrypt was already serialized via
  Shade.send's encryptChains; decrypt is now serialized at the
  manager layer too.

@shade/streams extension
- StreamMetadata.userMetadata?: Record<string, string> for
  application-level key/value pairs that round-trip verbatim through
  stream-init plaintext. Used by @shade/files for write/read
  correlation; available to any consumer.

@shade/sdk extension
- Shade.files getter (lazy + memoized).
- BackgroundHooks.onPruneFiles + periodic timer (default 5 min) +
  BackgroundTasks.setHook(name, fn) for runtime hook registration.

Bundles in-flight 0.2.0 work
- packages/shade-streams/, packages/shade-transfer/, related
  shade-sdk streams-bridge + shade-widgets transfer hooks were
  uncommitted prior to this session. Including them keeps the
  workspace consistent at 0.3.0 since @shade/files depends on them.

Tests
- 74 new tests in @shade/files (572 → 646 workspace pass; 0 fail;
  3× stable). Coverage spans unit (inline-threshold + concurrency),
  integration (read-write inline + streams up to 1 MiB, walk +
  upload/download directory, custom-op, metrics, SDK namespace
  end-to-end), and security (tampered-envelope sig verification,
  replay window, fingerprint gate, rate-limit + quota).

Release artifacts
- All packages bumped to 0.3.0 via scripts/bump-version.ts.
- scripts/publish-all.ts PACKAGES updated with shade-files in
  topological order (after shade-transfer, before shade-sdk).
- bun run publish:dry clean (14 packed, 0 failed).
- examples/08-files-browser/ — three-process CLI demo (prekey + Bob
  server + Alice CLI) covering list/stat/mkdir/delete/upload/download.
- docs/files.md — full API + design doc.
- CHANGELOG.md 0.3.0 entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 14:00:01 +02:00
parent 7e0f7320a9
commit fa770d3063
198 changed files with 20412 additions and 256 deletions

View File

@@ -0,0 +1,65 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { Shade } from '@shade/sdk';
import { resolveTheme, type ShadeTheme, type ThemeMode } from './theme.js';
export interface ShadeRuntimeContextValue {
runtime: Shade;
theme: ShadeTheme;
}
const ShadeRuntimeContext = createContext<ShadeRuntimeContextValue | null>(null);
export interface ShadeRuntimeProviderProps {
/** Initialized `Shade` instance (after `await shade.initialize()`). */
runtime: Shade;
/** Theme mode: `'dark'` (default), `'light'`, or `'auto'`. */
themeMode?: ThemeMode;
/** Optional theme overrides applied on top of the resolved base theme. */
themeOverrides?: Partial<ShadeTheme>;
children: React.ReactNode;
}
/**
* `ShadeRuntimeProvider` — root for the upload/download widget tree.
*
* Distinct from `ShadeProvider` (which targets the observer dashboard) —
* this one wraps an actual `Shade` runtime so transfer hooks/components
* can call `shade.upload(...)`, `shade.onIncomingTransfer(...)`, etc.
*
* Wrap your application root once; multiple uploaders/downloaders share
* the same runtime instance.
*/
export function ShadeRuntimeProvider({
runtime,
themeMode = 'dark',
themeOverrides,
children,
}: ShadeRuntimeProviderProps): React.ReactElement {
const value = useMemo<ShadeRuntimeContextValue>(
() => ({ runtime, theme: { ...resolveTheme(themeMode), ...themeOverrides } }),
[runtime, themeMode, themeOverrides],
);
return (
<ShadeRuntimeContext.Provider value={value}>{children}</ShadeRuntimeContext.Provider>
);
}
export function useShadeRuntime(): Shade {
const ctx = useContext(ShadeRuntimeContext);
if (ctx === null) {
throw new Error(
'useShadeRuntime must be used inside <ShadeRuntimeProvider runtime={shade}>',
);
}
return ctx.runtime;
}
export function useShadeRuntimeTheme(): ShadeTheme {
const ctx = useContext(ShadeRuntimeContext);
if (ctx === null) {
throw new Error(
'useShadeRuntimeTheme must be used inside <ShadeRuntimeProvider runtime={shade}>',
);
}
return ctx.theme;
}

View File

@@ -31,7 +31,7 @@ export function ServerStatus(): React.ReactElement {
label="Rate limited"
value={stats.totalRateLimited}
theme={theme}
color={stats.totalRateLimited > 0 ? theme.danger : undefined}
{...(stats.totalRateLimited > 0 ? { color: theme.danger } : {})}
/>
</div>
)}

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { useShadeContext } from '../ShadeProvider.js';
import type { ShadeTheme } from '../theme.js';
/**
* Common widget shell — provides consistent border, padding, header.

View File

@@ -0,0 +1,115 @@
import React, { useCallback, useState } from 'react';
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
export interface DropZoneProps {
/** Called when files are dropped or selected via the click-to-pick action. */
onFiles: (files: File[]) => void;
/** Allow multiple files (default: true). */
multiple?: boolean;
/** `<input type="file">` accept filter (e.g. `'.zip,application/zip'`). */
accept?: string;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
}
/**
* Drag-and-drop file zone built on native HTML5 DnD APIs. Falls back to a
* native file picker when the user clicks or focuses-and-presses-Enter.
*/
export function DropZone({
onFiles,
multiple = true,
accept,
className,
style,
children,
}: DropZoneProps): React.ReactElement {
const theme = useShadeRuntimeTheme();
const [isDragging, setIsDragging] = useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setIsDragging(true);
}, []);
const onDragLeave = useCallback(() => {
setIsDragging(false);
}, []);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) onFiles(files);
},
[onFiles],
);
const onChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files === null ? [] : Array.from(e.target.files);
if (files.length > 0) onFiles(files);
e.target.value = ''; // allow re-picking the same file
},
[onFiles],
);
const onClick = useCallback(() => {
inputRef.current?.click();
}, []);
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
inputRef.current?.click();
}
},
[],
);
const borderActive = theme.dropZoneBorderActive ?? theme.accent;
return (
<div
className={className}
role="button"
tabIndex={0}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={onClick}
onKeyDown={onKeyDown}
style={{
border: `2px dashed ${isDragging ? borderActive : theme.border}`,
borderRadius: theme.radius,
padding: 32,
textAlign: 'center',
background: isDragging ? theme.accentMuted : theme.bg,
color: theme.textMuted,
cursor: 'pointer',
transition: 'border-color 0.15s, background 0.15s',
fontFamily: theme.font,
...style,
}}
>
<input
ref={inputRef}
type="file"
multiple={multiple}
{...(accept !== undefined ? { accept } : {})}
onChange={onChange}
style={{ display: 'none' }}
/>
{children ?? (
<span>
{isDragging ? 'Slipp filer for å laste opp' : 'Dra og slipp filer her, eller klikk for å velge'}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
export interface ETAReadoutProps {
/** ETA in seconds. Undefined → unknown. */
seconds: number | undefined;
className?: string;
style?: React.CSSProperties;
}
export function ETAReadout({
seconds,
className,
style,
}: ETAReadoutProps): React.ReactElement {
const theme = useShadeRuntimeTheme();
return (
<span
className={className}
style={{
fontFamily: theme.fontMono,
fontSize: 12,
color: theme.textMuted,
...style,
}}
>
{formatEta(seconds)}
</span>
);
}
export function formatEta(seconds: number | undefined): string {
if (seconds === undefined || !Number.isFinite(seconds)) return 'ETA —';
if (seconds < 1) return 'ETA <1s';
if (seconds < 60) return `ETA ${Math.round(seconds)}s`;
const minutes = Math.floor(seconds / 60);
const remSeconds = Math.round(seconds % 60);
if (minutes < 60) return `ETA ${minutes}m ${remSeconds}s`;
const hours = Math.floor(minutes / 60);
const remMinutes = minutes % 60;
return `ETA ${hours}h ${remMinutes}m`;
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
import type { LaneProgress } from '@shade/sdk';
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
export interface LaneIndicatorProps {
lanes: LaneProgress[];
className?: string;
style?: React.CSSProperties;
}
export function LaneIndicator({
lanes,
className,
style,
}: LaneIndicatorProps): React.ReactElement {
const theme = useShadeRuntimeTheme();
const active = theme.laneActive ?? theme.success;
const idle = theme.laneIdle ?? theme.textDim;
const error = theme.laneError ?? theme.danger;
return (
<div
className={className}
role="img"
aria-label={`${lanes.length} lanes`}
style={{ display: 'inline-flex', gap: 4, alignItems: 'center', ...style }}
>
{lanes.map((l) => {
let color: string;
if (l.state === 'error') color = error;
else if (l.state === 'sending' || l.state === 'done') color = active;
else color = idle;
return (
<span
key={l.laneId}
title={`lane ${l.laneId}: ${l.state}`}
style={{
width: 6,
height: 6,
borderRadius: 3,
background: color,
opacity: l.state === 'idle' ? 0.4 : 1,
transition: 'background 0.2s',
}}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
export interface ProgressBarProps {
/** Progress in [0, 1]. Undefined → indeterminate. */
percent?: number;
height?: number;
className?: string;
style?: React.CSSProperties;
}
export function ProgressBar({
percent,
height = 6,
className,
style,
}: ProgressBarProps): React.ReactElement {
const theme = useShadeRuntimeTheme();
const track = theme.progressTrack ?? theme.border;
const fill = theme.progressFill ?? theme.accent;
const indeterminate = theme.progressFillIndeterminate ?? theme.accentMuted;
const isIndeterminate = percent === undefined;
const widthPct = isIndeterminate ? 100 : Math.max(0, Math.min(1, percent)) * 100;
return (
<div
className={className}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
{...(percent !== undefined ? { 'aria-valuenow': Math.round(percent * 100) } : {})}
style={{
width: '100%',
height,
background: track,
borderRadius: height / 2,
overflow: 'hidden',
position: 'relative',
...style,
}}
>
<div
style={{
width: `${widthPct}%`,
height: '100%',
background: isIndeterminate ? indeterminate : fill,
transition: isIndeterminate ? undefined : 'width 0.18s linear',
}}
/>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import React from 'react';
import type {
IncomingTransfer,
TransferHandle,
TransferOutput,
TransferProgress,
} from '@shade/sdk';
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
import {
useShadeDownload,
type DownloadEntry,
type UseShadeDownloadOptions,
} from '../../useShadeDownload.js';
import { TransferRow } from './TransferRow.js';
export interface ShadeDownloaderProps {
/** Auto-accept incoming transfers via this callback (returns the output sink). */
autoAccept?: UseShadeDownloadOptions['autoAccept'];
/** Render the prompt for a pending (not-yet-accepted) transfer. */
renderPending?: (args: {
incoming: IncomingTransfer;
accept: (output: TransferOutput) => Promise<TransferHandle>;
decline: (reason?: string) => Promise<void>;
}) => React.ReactNode;
/** Render-prop for active transfer rows. */
renderRow?: (args: {
handle: TransferHandle;
progress: TransferProgress | null;
name: string;
done: boolean;
error: unknown;
onDismiss: () => void;
}) => React.ReactNode;
/** Render-prop for the empty state (no pending or active transfers). */
renderEmpty?: () => React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
/**
* Composite downloader: lists pending (awaiting accept) and active
* (in-progress) transfers, with bundled defaults for both. Customize via
* `renderPending` / `renderRow`.
*/
export function ShadeDownloader({
autoAccept,
renderPending,
renderRow,
renderEmpty,
className,
style,
}: ShadeDownloaderProps): React.ReactElement {
const theme = useShadeRuntimeTheme();
const { pending, active, accept, decline, dismiss } = useShadeDownload(
autoAccept !== undefined ? { autoAccept } : {},
);
return (
<div
className={className}
style={{
display: 'flex',
flexDirection: 'column',
gap: 12,
fontFamily: theme.font,
color: theme.text,
...style,
}}
>
{pending.length === 0 && active.length === 0 ? (
renderEmpty?.() ?? <span style={{ color: theme.textDim }}>Ingen overføringer</span>
) : null}
{pending.map((entry) => {
const acceptForOutput = (output: TransferOutput): Promise<TransferHandle> =>
accept(entry.incoming.streamId, output);
const declineWith = (reason?: string): Promise<void> =>
decline(entry.incoming.streamId, reason);
if (renderPending !== undefined) {
return (
<React.Fragment key={entry.incoming.streamId}>
{renderPending({ incoming: entry.incoming, accept: acceptForOutput, decline: declineWith })}
</React.Fragment>
);
}
return (
<DefaultPendingRow
key={entry.incoming.streamId}
incoming={entry.incoming}
onAccept={acceptForOutput}
onDecline={() => void declineWith('user-decline')}
/>
);
})}
{active.map((entry: DownloadEntry) => {
if (entry.handle === null) return null;
const name = entry.incoming.metadata.name ?? entry.incoming.streamId.slice(0, 8);
const dismissEntry = (): void => dismiss(entry.incoming.streamId);
const handle = entry.handle;
if (renderRow !== undefined) {
return (
<React.Fragment key={entry.incoming.streamId}>
{renderRow({
handle,
progress: entry.progress,
name,
done: entry.done,
error: entry.error,
onDismiss: dismissEntry,
})}
</React.Fragment>
);
}
return (
<TransferRow
key={entry.incoming.streamId}
handle={handle}
progress={entry.progress}
name={name}
done={entry.done}
error={entry.error}
{...(entry.incoming.metadata.sizeBytes !== undefined
? { bytesTotal: entry.incoming.metadata.sizeBytes }
: {})}
onDismiss={dismissEntry}
/>
);
})}
</div>
);
}
function DefaultPendingRow({
incoming,
onAccept,
onDecline,
}: {
incoming: IncomingTransfer;
onAccept: (output: TransferOutput) => Promise<TransferHandle>;
onDecline: () => void;
}): React.ReactElement {
const theme = useShadeRuntimeTheme();
return (
<div
style={{
padding: 12,
background: theme.bgElevated,
border: `1px solid ${theme.border}`,
borderRadius: theme.radius,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ fontSize: 13 }}>
Innkommende overføring fra <strong>{incoming.from}</strong>
</div>
<div style={{ fontSize: 12, color: theme.textMuted }}>
{incoming.metadata.name ?? incoming.streamId}
{incoming.metadata.sizeBytes !== undefined
? ` · ${formatBytes(incoming.metadata.sizeBytes)}`
: ''}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={() => void onAccept({ kind: 'buffer' })}
style={{
padding: '4px 10px',
background: theme.accent,
color: theme.bg,
border: 'none',
borderRadius: 4,
fontSize: 12,
cursor: 'pointer',
}}
>
Godta (i minne)
</button>
<button
type="button"
onClick={onDecline}
style={{
padding: '4px 10px',
background: theme.bg,
color: theme.textMuted,
border: `1px solid ${theme.border}`,
borderRadius: 4,
fontSize: 12,
cursor: 'pointer',
}}
>
Avslå
</button>
</div>
</div>
);
}
function formatBytes(b: number): string {
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`;
return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}

View File

@@ -0,0 +1,171 @@
import React from 'react';
import type {
TransferHandle,
TransferOptions,
TransferProgress,
TransferResult,
} from '@shade/sdk';
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
import { useShadeUpload, type UploadEntry } from '../../useShadeUpload.js';
import { DropZone } from './DropZone.js';
import { TransferRow } from './TransferRow.js';
export interface ShadeUploaderProps {
/** Peer address to upload to. */
to: string;
multiple?: boolean;
accept?: string;
/** Override the default lane count (default 4 from `@shade/transfer`). */
lanes?: number;
/** Override per-chunk plaintext size. */
chunkSize?: number;
/** Extra options forwarded to `Shade.upload`. */
uploadOptions?: Partial<TransferOptions>;
/**
* Render-prop for full UI replacement of the per-row entry. Returns whatever
* you want to render in place of the default `TransferRow`.
*/
renderRow?: (args: {
handle: TransferHandle;
progress: TransferProgress | null;
name: string;
done: boolean;
error: unknown;
onDismiss: () => void;
}) => React.ReactNode;
/** Render-prop for the empty state (no uploads in progress). */
renderEmpty?: () => React.ReactNode;
/** Render-prop for the drop zone. */
renderDropZone?: (props: { onFiles: (files: File[]) => void }) => React.ReactNode;
onComplete?: (result: TransferResult) => void;
onError?: (error: unknown) => void;
className?: string;
style?: React.CSSProperties;
}
/**
* Composite uploader: a drop zone + a list of in-flight transfers.
*
* Drop a file (or click to pick), and Shade handles encryption + chunking +
* lanes + retry + integrity verification. Customize via the `renderRow`
* render-prop or use the headless `useShadeUpload` hook directly.
*/
export function ShadeUploader({
to,
multiple = true,
accept,
lanes,
chunkSize,
uploadOptions,
renderRow,
renderEmpty,
renderDropZone,
onComplete,
onError,
className,
style,
}: ShadeUploaderProps): React.ReactElement {
const theme = useShadeRuntimeTheme();
const { upload, uploads, dismiss } = useShadeUpload();
const handleFiles = React.useCallback(
async (files: File[]) => {
for (const file of files) {
try {
const handle = await upload({
to,
input: file,
...(lanes !== undefined ? { lanes } : {}),
...(chunkSize !== undefined ? { chunkSize } : {}),
...uploadOptions,
metadata: {
name: file.name,
...(file.type !== '' ? { contentType: file.type } : {}),
...uploadOptions?.metadata,
},
});
if (onComplete !== undefined) {
void handle
.done()
.then(onComplete)
.catch(() => {
/* error surfaced via onError */
});
}
if (onError !== undefined) {
void handle.done().catch(onError);
}
} catch (err) {
onError?.(err);
}
}
},
[upload, to, lanes, chunkSize, uploadOptions, onComplete, onError],
);
return (
<div
className={className}
style={{
display: 'flex',
flexDirection: 'column',
gap: 12,
fontFamily: theme.font,
color: theme.text,
...style,
}}
>
{renderDropZone !== undefined ? (
renderDropZone({ onFiles: handleFiles })
) : (
<DropZone onFiles={handleFiles} multiple={multiple} {...(accept !== undefined ? { accept } : {})} />
)}
{uploads.length === 0 ? (
renderEmpty?.() ?? null
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{uploads.map((entry: UploadEntry) => {
const name = describeEntry(entry);
const dismissEntry = (): void => dismiss(entry.handle.streamId);
const cancelEntry = (): void => {
void entry.handle.abort('user-cancel');
};
if (renderRow !== undefined) {
return (
<React.Fragment key={entry.handle.streamId}>
{renderRow({
handle: entry.handle,
progress: entry.progress,
name,
done: entry.done,
error: entry.error,
onDismiss: dismissEntry,
})}
</React.Fragment>
);
}
return (
<TransferRow
key={entry.handle.streamId}
handle={entry.handle}
progress={entry.progress}
name={name}
done={entry.done}
error={entry.error}
{...(entry.progress?.bytesTotal !== undefined ? { bytesTotal: entry.progress.bytesTotal } : {})}
onCancel={cancelEntry}
onDismiss={dismissEntry}
/>
);
})}
</div>
)}
</div>
);
}
function describeEntry(entry: UploadEntry): string {
// The progress event includes bytesTotal but not the file name; we keep
// a hint by reading the underlying stream metadata when available.
return entry.handle.streamId.slice(0, 8);
}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
export interface SpeedReadoutProps {
bytesPerSecond: number;
className?: string;
style?: React.CSSProperties;
}
export function SpeedReadout({
bytesPerSecond,
className,
style,
}: SpeedReadoutProps): React.ReactElement {
const theme = useShadeRuntimeTheme();
return (
<span
className={className}
style={{
fontFamily: theme.fontMono,
fontSize: 12,
color: theme.textMuted,
...style,
}}
>
{formatBytesPerSecond(bytesPerSecond)}
</span>
);
}
export function formatBytesPerSecond(bps: number): string {
if (!Number.isFinite(bps) || bps <= 0) return '— B/s';
if (bps < 1024) return `${bps.toFixed(0)} B/s`;
if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
if (bps < 1024 * 1024 * 1024) return `${(bps / (1024 * 1024)).toFixed(2)} MB/s`;
return `${(bps / (1024 * 1024 * 1024)).toFixed(2)} GB/s`;
}

View File

@@ -0,0 +1,116 @@
import React from 'react';
import type { TransferHandle, TransferProgress } from '@shade/sdk';
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
import { ProgressBar } from './ProgressBar.js';
import { SpeedReadout } from './SpeedReadout.js';
import { ETAReadout } from './ETAReadout.js';
import { LaneIndicator } from './LaneIndicator.js';
export interface TransferRowProps {
handle: TransferHandle;
progress: TransferProgress | null;
/** Optional file name to display. */
name?: string;
/** Total bytes for this transfer (used to render percent when progress lacks it). */
bytesTotal?: number;
done?: boolean;
error?: unknown;
onCancel?: () => void;
onDismiss?: () => void;
className?: string;
style?: React.CSSProperties;
}
/** Default UI for a single transfer. Replace via render-prop on the parent. */
export function TransferRow({
handle,
progress,
name,
bytesTotal,
done,
error,
onCancel,
onDismiss,
className,
style,
}: TransferRowProps): React.ReactElement {
const theme = useShadeRuntimeTheme();
const percent = progress?.percent ?? (bytesTotal !== undefined && progress !== null ? progress.bytesSent / bytesTotal : undefined);
const errorMessage =
error instanceof Error ? error.message : error !== undefined ? String(error) : null;
return (
<div
className={className}
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
padding: 12,
background: theme.bgElevated,
border: `1px solid ${theme.border}`,
borderRadius: theme.radius,
fontFamily: theme.font,
color: theme.text,
...style,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span
style={{
flex: 1,
fontSize: 13,
color: errorMessage !== null ? theme.danger : theme.text,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={name ?? handle.streamId}
>
{name ?? handle.streamId}
</span>
{progress !== null ? (
<LaneIndicator lanes={progress.lanes} />
) : null}
{!done && onCancel !== undefined ? (
<button
type="button"
onClick={onCancel}
style={smallButton(theme.danger, theme.bg)}
>
Avbryt
</button>
) : null}
{done && onDismiss !== undefined ? (
<button
type="button"
onClick={onDismiss}
style={smallButton(theme.textMuted, theme.bg)}
>
Lukk
</button>
) : null}
</div>
<ProgressBar {...(percent !== undefined ? { percent } : {})} />
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<SpeedReadout bytesPerSecond={progress?.bytesPerSecond ?? 0} />
<ETAReadout {...(progress?.etaSeconds !== undefined ? { seconds: progress.etaSeconds } : { seconds: undefined })} />
</div>
{errorMessage !== null ? (
<div style={{ fontSize: 12, color: theme.danger }}>{errorMessage}</div>
) : null}
</div>
);
}
function smallButton(fg: string, bg: string): React.CSSProperties {
return {
padding: '2px 8px',
background: bg,
color: fg,
border: `1px solid ${fg}`,
borderRadius: 4,
fontSize: 11,
cursor: 'pointer',
};
}

View File

@@ -16,3 +16,38 @@ export type { ShadeState, UseShadeStateResult } from './useShadeState.js';
export type { ShadeEventEnvelope, UseShadeEventsResult } from './useShadeEvents.js';
export type { ShadeTheme, ThemeMode } from './theme.js';
export type { WidgetCatalogProps, WidgetKey } from './components/WidgetCatalog.js';
// ─── Stream-transfer widgets (v0.2.0) ────────────────────
export {
ShadeRuntimeProvider,
useShadeRuntime,
useShadeRuntimeTheme,
} from './ShadeRuntimeProvider.js';
export type {
ShadeRuntimeProviderProps,
ShadeRuntimeContextValue,
} from './ShadeRuntimeProvider.js';
export { useShadeUpload } from './useShadeUpload.js';
export type { UploadEntry, UseShadeUploadResult } from './useShadeUpload.js';
export { useShadeDownload } from './useShadeDownload.js';
export type {
DownloadEntry,
UseShadeDownloadOptions,
UseShadeDownloadResult,
} from './useShadeDownload.js';
export { ShadeUploader } from './components/transfer/ShadeUploader.js';
export type { ShadeUploaderProps } from './components/transfer/ShadeUploader.js';
export { ShadeDownloader } from './components/transfer/ShadeDownloader.js';
export type { ShadeDownloaderProps } from './components/transfer/ShadeDownloader.js';
export { DropZone } from './components/transfer/DropZone.js';
export type { DropZoneProps } from './components/transfer/DropZone.js';
export { TransferRow } from './components/transfer/TransferRow.js';
export type { TransferRowProps } from './components/transfer/TransferRow.js';
export { ProgressBar } from './components/transfer/ProgressBar.js';
export type { ProgressBarProps } from './components/transfer/ProgressBar.js';
export { SpeedReadout, formatBytesPerSecond } from './components/transfer/SpeedReadout.js';
export type { SpeedReadoutProps } from './components/transfer/SpeedReadout.js';
export { ETAReadout, formatEta } from './components/transfer/ETAReadout.js';
export type { ETAReadoutProps } from './components/transfer/ETAReadout.js';
export { LaneIndicator } from './components/transfer/LaneIndicator.js';
export type { LaneIndicatorProps } from './components/transfer/LaneIndicator.js';

View File

@@ -21,6 +21,22 @@ export interface ShadeTheme {
fontMono: string;
radius: string;
shadow: string;
// ─── Stream-transfer tokens (v0.2.0) ─────────────────────
/** Background of progress-bar track. Default: `border`. */
progressTrack?: string;
/** Filled portion of the progress bar. Default: `accent`. */
progressFill?: string;
/** Animated fill for indeterminate progress. Default: `accentMuted`. */
progressFillIndeterminate?: string;
/** Border of an active drag-and-drop zone. Default: `accent`. */
dropZoneBorderActive?: string;
/** Lane indicator color when sending. Default: `success`. */
laneActive?: string;
/** Lane indicator color when idle. Default: `textDim`. */
laneIdle?: string;
/** Lane indicator color on error. Default: `danger`. */
laneError?: string;
}
export const darkTheme: ShadeTheme = {

View File

@@ -0,0 +1,161 @@
import { useCallback, useEffect, useState } from 'react';
import type {
IncomingTransfer,
TransferHandle,
TransferOutput,
TransferProgress,
} from '@shade/sdk';
import { useShadeRuntime } from './ShadeRuntimeProvider.js';
export interface DownloadEntry {
incoming: IncomingTransfer;
handle: TransferHandle | null;
progress: TransferProgress | null;
error: unknown;
done: boolean;
}
export interface UseShadeDownloadOptions {
/**
* If provided, accept transfers automatically with the supplied output
* (called once per incoming). Useful for "auto-save to /uploads" servers.
*/
autoAccept?: (incoming: IncomingTransfer) => Promise<TransferOutput | null>;
}
export interface UseShadeDownloadResult {
/** Pending transfers awaiting accept/decline. */
pending: DownloadEntry[];
/** In-flight transfers being received. */
active: DownloadEntry[];
/** Manually accept a pending transfer with a chosen output. */
accept: (streamId: string, output: TransferOutput) => Promise<TransferHandle>;
/** Manually decline a pending transfer. */
decline: (streamId: string, reason?: string) => Promise<void>;
dismiss: (streamId: string) => void;
}
/**
* Headless hook for download tracking. Subscribes to incoming transfers
* and exposes a list of pending and active downloads.
*/
export function useShadeDownload(
options: UseShadeDownloadOptions = {},
): UseShadeDownloadResult {
const shade = useShadeRuntime();
const [pending, setPending] = useState<DownloadEntry[]>([]);
const [active, setActive] = useState<DownloadEntry[]>([]);
const trackHandle = useCallback(
(incoming: IncomingTransfer, handle: TransferHandle) => {
const entry: DownloadEntry = {
incoming,
handle,
progress: null,
error: null,
done: false,
};
setPending((prev) => prev.filter((p) => p.incoming.streamId !== incoming.streamId));
setActive((prev) => [entry, ...prev]);
void (async () => {
try {
for await (const ev of handle.events) {
if (ev.type === 'progress') {
setActive((prev) =>
prev.map((a) =>
a.incoming.streamId === incoming.streamId ? { ...a, progress: ev.progress } : a,
),
);
} else if (ev.type === 'complete') {
setActive((prev) =>
prev.map((a) =>
a.incoming.streamId === incoming.streamId ? { ...a, done: true } : a,
),
);
} else if (ev.type === 'error') {
setActive((prev) =>
prev.map((a) =>
a.incoming.streamId === incoming.streamId
? { ...a, error: ev.error, done: true }
: a,
),
);
}
}
} catch (err) {
setActive((prev) =>
prev.map((a) =>
a.incoming.streamId === incoming.streamId
? { ...a, error: err, done: true }
: a,
),
);
}
})();
},
[],
);
useEffect(() => {
let unsubscribe: (() => void) | null = null;
let cancelled = false;
void (async () => {
const cleanup = await shade.onIncomingTransfer(async (incoming) => {
if (options.autoAccept !== undefined) {
const output = await options.autoAccept(incoming);
if (output !== null) {
const handle = await incoming.accept({ output });
trackHandle(incoming, handle);
return;
}
}
const entry: DownloadEntry = {
incoming,
handle: null,
progress: null,
error: null,
done: false,
};
setPending((prev) => [entry, ...prev]);
});
if (cancelled) cleanup();
else unsubscribe = cleanup;
})();
return () => {
cancelled = true;
unsubscribe?.();
};
}, [shade, options.autoAccept, trackHandle]);
const accept = useCallback(
async (streamId: string, output: TransferOutput): Promise<TransferHandle> => {
const entry = pending.find((p) => p.incoming.streamId === streamId);
if (entry === undefined) {
throw new Error(`No pending transfer with streamId=${streamId}`);
}
const handle = await entry.incoming.accept({ output });
trackHandle(entry.incoming, handle);
return handle;
},
[pending, trackHandle],
);
const decline = useCallback(
async (streamId: string, reason?: string) => {
const entry = pending.find((p) => p.incoming.streamId === streamId);
if (entry !== undefined) {
await entry.incoming.decline(reason);
setPending((prev) => prev.filter((p) => p.incoming.streamId !== streamId));
}
},
[pending],
);
const dismiss = useCallback((streamId: string) => {
setPending((prev) => prev.filter((p) => p.incoming.streamId !== streamId));
setActive((prev) => prev.filter((a) => a.incoming.streamId !== streamId));
}, []);
return { pending, active, accept, decline, dismiss };
}

View File

@@ -0,0 +1,90 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { TransferHandle, TransferOptions, TransferProgress } from '@shade/sdk';
import { useShadeRuntime } from './ShadeRuntimeProvider.js';
export interface UploadEntry {
handle: TransferHandle;
progress: TransferProgress | null;
error: unknown;
done: boolean;
}
export interface UseShadeUploadResult {
/** Start a new upload. Resolves once the handle is available. */
upload: (opts: TransferOptions) => Promise<TransferHandle>;
/** Currently-tracked uploads (most recent first). */
uploads: UploadEntry[];
/** Drop a finished/failed entry from the list. */
dismiss: (streamId: string) => void;
}
/**
* Headless hook for upload tracking. Subscribes to progress events on each
* started transfer and surfaces them as React state. Drop a custom UI on
* top, or use the bundled `<ShadeUploader />` component.
*/
export function useShadeUpload(): UseShadeUploadResult {
const shade = useShadeRuntime();
const [uploads, setUploads] = useState<UploadEntry[]>([]);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const upload = useCallback(
async (opts: TransferOptions) => {
const handle = await shade.upload(opts);
const entry: UploadEntry = { handle, progress: null, error: null, done: false };
setUploads((prev) => [entry, ...prev]);
void (async () => {
try {
for await (const ev of handle.events) {
if (!mountedRef.current) return;
if (ev.type === 'progress') {
setUploads((prev) =>
prev.map((u) =>
u.handle.streamId === handle.streamId ? { ...u, progress: ev.progress } : u,
),
);
} else if (ev.type === 'complete') {
setUploads((prev) =>
prev.map((u) =>
u.handle.streamId === handle.streamId ? { ...u, done: true } : u,
),
);
} else if (ev.type === 'error') {
setUploads((prev) =>
prev.map((u) =>
u.handle.streamId === handle.streamId
? { ...u, error: ev.error, done: true }
: u,
),
);
}
}
} catch (err) {
if (!mountedRef.current) return;
setUploads((prev) =>
prev.map((u) =>
u.handle.streamId === handle.streamId ? { ...u, error: err, done: true } : u,
),
);
}
})();
return handle;
},
[shade],
);
const dismiss = useCallback((streamId: string) => {
setUploads((prev) => prev.filter((u) => u.handle.streamId !== streamId));
}, []);
return { upload, uploads, dismiss };
}