diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 08222998..af612bce 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' -import { AssistantRuntimeProvider, useAssistantApi } from '@assistant-ui/react' +import { AssistantRuntimeProvider, useAssistantApi, useAssistantState } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' import type { AttachmentMetadata, @@ -30,6 +30,9 @@ import { ScratchlistDrawer } from '@/components/AssistantChat/ScratchlistPanel' import { useScratchlist } from '@/lib/use-scratchlist' import { useHappyRuntime } from '@/lib/assistant-runtime' import { createAttachmentAdapter } from '@/lib/attachmentAdapter' +import { consumeSharePendingTransfer } from '@/lib/sharePendingState' +import { deleteShareTransfer, getShareTransfer } from '@/lib/shareTransfer' +import { getDraft } from '@/lib/composer-drafts' import { useTranslation } from '@/lib/use-translation' import { SessionHeader } from '@/components/SessionHeader' import { CursorMigrationBanner } from '@/components/CursorMigrationBanner' @@ -156,6 +159,97 @@ function isUninvokedScheduledMessage(message: DecryptedMessage): boolean { return message.invokedAt == null && message.scheduledAt != null } +/** + * Consumes a pending Web Share Target transfer once the assistant runtime + * is mounted and the session is active enough to accept attachments. + * + * Lifecycle: + * - A mount effect reads the transfer id out of sessionStorage *once* + * via consumeSharePendingTransfer() (not during render — StrictMode + * would consume on the discarded pass). The id is stashed in a ref. + * - The actual seed (composer.setText + composer.addAttachment per file) + * runs once `props.sessionActive` is true. Inactive sessions disable + * the attachmentAdapter, so writing attachments while inactive would + * no-op and leak Blobs in IDB. The seed waits in a re-renderable + * effect for the active flip. + * - `consumedRef` gates the effect to a single seed per component + * instance — refs survive a StrictMode mount/cleanup/remount pair, so + * the second invoke early-returns and the first invoke's async chain + * completes naturally (we deliberately don't cancel on cleanup; the + * upload is idempotent and the only side effects on the composer are + * no-ops once the runtime is unmounted). + * - The IDB row is deleted after the seed completes so a back-button + * refresh of /sessions/:id doesn't re-attach the same payload. + */ +function ShareSeedConsumer(props: { sessionId: string; sessionActive: boolean }) { + const assistantApi = useAssistantApi() + const composerText = useAssistantState(({ composer }) => composer.text) + const composerTextRef = useRef(composerText) + const initRef = useRef(false) + const transferIdRef = useRef(null) + const consumedRef = useRef(false) + const [transferReady, setTransferReady] = useState(false) + + useEffect(() => { + composerTextRef.current = composerText + }, [composerText]) + + // Consume in an effect, not during render — React.StrictMode double- + // invokes render functions in dev; a render-time consume deletes the + // sessionStorage key on the discarded pass and the committed render + // then sees no transfer. + useEffect(() => { + if (initRef.current) return + initRef.current = true + transferIdRef.current = consumeSharePendingTransfer() + setTransferReady(true) + }, []) + + useEffect(() => { + if (!transferReady) return + if (consumedRef.current) return + const transferId = transferIdRef.current + if (!transferId) return + if (!props.sessionActive) return + consumedRef.current = true + + void (async () => { + try { + const payload = await getShareTransfer(transferId) + if (!payload) return + const seedText = [payload.title, payload.text, payload.url] + .filter((part) => typeof part === 'string' && part.length > 0) + .join('\n') + .trim() + if (seedText.length > 0) { + const existingText = composerTextRef.current.trim().length > 0 + ? composerTextRef.current + : getDraft(props.sessionId) + const nextText = [existingText.trim(), seedText] + .filter((part) => part.length > 0) + .join('\n\n') + if (nextText.length > 0) { + assistantApi.composer().setText(nextText) + } + } + for (const file of payload.files) { + const reconstructed = new File([file.blob], file.name, { type: file.type }) + try { + await assistantApi.composer().addAttachment(reconstructed) + } catch (err) { + console.error('share-seed addAttachment failed', err) + } + } + await deleteShareTransfer(transferId).catch(() => {}) + } catch (err) { + console.error('share-seed pull failed', err) + } + })() + }, [transferReady, props.sessionActive, props.sessionId, assistantApi]) + + return null +} + /** * Mounts the per-session scratchlist DRAWER (composer-controlled). * @@ -1025,6 +1119,7 @@ function SessionChatInner(props: SessionChatProps) { ) : null} +
{ + it('returns /share for root base', () => { + expect(shareTargetPathnameFromBase('/')).toBe('/share') + }) + + it('returns /repo/share for subpath base', () => { + expect(shareTargetPathnameFromBase('/repo/')).toBe('/repo/share') + }) + + it('handles base without trailing slash', () => { + expect(shareTargetPathnameFromBase('/repo')).toBe('/repo/share') + }) +}) diff --git a/web/src/lib/sharePath.ts b/web/src/lib/sharePath.ts new file mode 100644 index 00000000..8e8f62a0 --- /dev/null +++ b/web/src/lib/sharePath.ts @@ -0,0 +1,18 @@ +/** + * Web Share Target paths must respect Vite `base` so subpath deployments + * (e.g. GitHub Pages at `//`) keep the POST action inside the PWA + * scope and under the service worker's control. + */ + +const RESOLVE_ORIGIN = 'https://hapi.local/' + +/** Build the share-target pathname from an explicit Vite base (build-time). */ +export function shareTargetPathnameFromBase(baseUrl: string): string { + const normalized = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/` + return new URL('share', new URL(normalized, RESOLVE_ORIGIN)).pathname +} + +/** Share-target pathname for the current bundle (`import.meta.env.BASE_URL`). */ +export function shareTargetPathname(): string { + return shareTargetPathnameFromBase(import.meta.env.BASE_URL) +} diff --git a/web/src/lib/sharePendingState.test.ts b/web/src/lib/sharePendingState.test.ts new file mode 100644 index 00000000..0dd4420d --- /dev/null +++ b/web/src/lib/sharePendingState.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + SHARE_PENDING_TRANSFER_KEY, + consumeSharePendingTransfer, + setSharePendingTransfer, +} from './sharePendingState' + +afterEach(() => { + try { window.sessionStorage.clear() } catch { /* noop */ } +}) + +describe('sharePendingState', () => { + it('round-trips a transfer id and clears the slot on consume', () => { + setSharePendingTransfer('xfer-1') + expect(window.sessionStorage.getItem(SHARE_PENDING_TRANSFER_KEY)).toBe('xfer-1') + + const first = consumeSharePendingTransfer() + expect(first).toBe('xfer-1') + + const second = consumeSharePendingTransfer() + expect(second).toBeNull() + }) + + it('returns null when no transfer is pending', () => { + expect(consumeSharePendingTransfer()).toBeNull() + }) + + it('overwrites a stale id rather than appending', () => { + setSharePendingTransfer('a') + setSharePendingTransfer('b') + expect(consumeSharePendingTransfer()).toBe('b') + expect(consumeSharePendingTransfer()).toBeNull() + }) +}) diff --git a/web/src/lib/sharePendingState.ts b/web/src/lib/sharePendingState.ts new file mode 100644 index 00000000..3673b54d --- /dev/null +++ b/web/src/lib/sharePendingState.ts @@ -0,0 +1,41 @@ +/** + * sessionStorage hand-off between the share picker (`/share`) and the + * session mount (`SessionChat`). + * + * The picker stores the IDB transfer id under this key, navigates to + * `/sessions/:id` (or `/sessions/new`), and the session mounter reads + clears + * the key on first render. sessionStorage rather than router state because: + * + * - it survives the `/sessions/new` -> `/sessions/:id` navigation that + * `NewSessionPage` performs internally with `replace: true`, which + * would drop router history state. + * - it scopes to the PWA window/tab — Android Chrome opens the share + * target in the installed PWA's own window, so collisions with other + * tabs are not a concern. + * + * The key is read **once** per mount; consume() returns the id and clears + * the slot atomically so a refresh of /sessions/:id doesn't replay the + * upload. + */ + +export const SHARE_PENDING_TRANSFER_KEY = 'hapi.share.pendingTransferId' + +export function setSharePendingTransfer(transferId: string): void { + try { + window.sessionStorage.setItem(SHARE_PENDING_TRANSFER_KEY, transferId) + } catch { + // Quota errors / disabled storage — caller proceeds without seed. + } +} + +export function consumeSharePendingTransfer(): string | null { + try { + const value = window.sessionStorage.getItem(SHARE_PENDING_TRANSFER_KEY) + if (value) { + window.sessionStorage.removeItem(SHARE_PENDING_TRANSFER_KEY) + } + return value + } catch { + return null + } +} diff --git a/web/src/lib/shareTransfer.test.ts b/web/src/lib/shareTransfer.test.ts new file mode 100644 index 00000000..f9244a34 --- /dev/null +++ b/web/src/lib/shareTransfer.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildSharePayloadFromFormData, + ingestShareRequest, + type ShareTransferPayload, +} from './shareTransfer' + +describe('buildSharePayloadFromFormData', () => { + it('extracts text-only share with empty file list', async () => { + const fd = new FormData() + fd.set('title', 'My note') + fd.set('text', 'Hello world') + fd.set('url', 'https://example.com/page') + + const payload = await buildSharePayloadFromFormData(fd, 1700000000000) + + expect(payload).toEqual({ + title: 'My note', + text: 'Hello world', + url: 'https://example.com/page', + files: [], + createdAt: 1700000000000, + }) + }) + + it('falls back to empty strings when fields are missing', async () => { + const fd = new FormData() + const payload = await buildSharePayloadFromFormData(fd, 42) + + expect(payload.title).toBe('') + expect(payload.text).toBe('') + expect(payload.url).toBe('') + expect(payload.files).toEqual([]) + expect(payload.createdAt).toBe(42) + }) + + it('extracts a single image file with type', async () => { + const fd = new FormData() + const file = new File([new Uint8Array([1, 2, 3])], 'photo.png', { type: 'image/png' }) + fd.append('files', file) + + const payload = await buildSharePayloadFromFormData(fd) + + expect(payload.files).toHaveLength(1) + expect(payload.files[0]).toMatchObject({ + name: 'photo.png', + type: 'image/png', + }) + expect(payload.files[0].blob).toBeInstanceOf(Blob) + }) + + it('handles multi-file shares preserving order', async () => { + const fd = new FormData() + const a = new File([new Uint8Array([1])], 'a.txt', { type: 'text/plain' }) + const b = new File([new Uint8Array([2])], 'b.pdf', { type: 'application/pdf' }) + const c = new File([new Uint8Array([3])], 'c.bin', { type: '' }) + fd.append('files', a) + fd.append('files', b) + fd.append('files', c) + + const payload = await buildSharePayloadFromFormData(fd) + + expect(payload.files.map((f) => f.name)).toEqual(['a.txt', 'b.pdf', 'c.bin']) + // Empty mime should fall back to application/octet-stream so the + // downstream uploader doesn't choke on Content-Type: ''. + expect(payload.files[2].type).toBe('application/octet-stream') + }) + + it('ignores non-File entries under the "files" key', async () => { + const fd = new FormData() + fd.append('files', 'stringy not a file') + const file = new File([new Uint8Array([0])], 'real.txt', { type: 'text/plain' }) + fd.append('files', file) + + const payload = await buildSharePayloadFromFormData(fd) + + expect(payload.files).toHaveLength(1) + expect(payload.files[0].name).toBe('real.txt') + }) +}) + +describe('ingestShareRequest', () => { + // jsdom/undici loses File objects when serializing FormData through + // `new Request({ body })` and re-parsing via `request.formData()`. The + // production SW only invokes Request#formData() once on the inbound + // multipart frame; tests substitute a stub that returns the FormData + // directly so the path under test (form -> payload -> put -> redirect) + // is exercised without depending on multipart roundtrip fidelity. + function makeRequest(formData: FormData): Request { + return { + formData: () => Promise.resolve(formData), + } as unknown as Request + } + + it('persists payload via the put dep and returns a /share?id=… redirect', async () => { + const fd = new FormData() + fd.set('title', 'shared') + fd.append('files', new File([new Uint8Array([7])], 'a.bin', { type: '' })) + + const put = vi.fn<(payload: ShareTransferPayload) => Promise>() + .mockResolvedValue('xfer-abc') + + const result = await ingestShareRequest(makeRequest(fd), { + put, + now: () => 9999, + }) + + expect(put).toHaveBeenCalledTimes(1) + const arg = put.mock.calls[0][0] + expect(arg.title).toBe('shared') + expect(arg.files).toHaveLength(1) + expect(arg.createdAt).toBe(9999) + expect(result.redirectTo).toBe('/share?id=xfer-abc') + }) + + it('encodes the transfer id so it survives querystring placement', async () => { + const put = vi.fn<(payload: ShareTransferPayload) => Promise>() + .mockResolvedValue('contains spaces & ampersands') + + const result = await ingestShareRequest(makeRequest(new FormData()), { put }) + + expect(result.redirectTo).toBe('/share?id=contains%20spaces%20%26%20ampersands') + }) + + it('propagates put rejections so the SW can fall back to error redirect', async () => { + const put = vi.fn<(payload: ShareTransferPayload) => Promise>() + .mockRejectedValue(new Error('quota exceeded')) + + await expect( + ingestShareRequest(makeRequest(new FormData()), { put }) + ).rejects.toThrow('quota exceeded') + }) +}) diff --git a/web/src/lib/shareTransfer.ts b/web/src/lib/shareTransfer.ts new file mode 100644 index 00000000..427b95ae --- /dev/null +++ b/web/src/lib/shareTransfer.ts @@ -0,0 +1,185 @@ +import { shareTargetPathname } from './sharePath' + +/** + * Share-target transfer storage. + * + * Android Chrome's Web Share Target API delivers a multipart POST to + * /share. The service worker can't hand the resulting Blob objects to the + * SPA via window state (the form POST is processed before any window + * exists), so we stash the payload in IndexedDB under a transfer id and + * 303-redirect to /share?id=. The SPA route then pulls the + * payload out. + * + * Two concerns live in this module: + * + * 1. Persistence — wraps an IDB object store (`transfers`) with a typed + * put/get/delete and an opportunistic TTL sweep. IDB is used because it + * survives the SW->document hop and accepts Blobs directly; localStorage + * is string-only and would force an expensive base64 round-trip. + * + * 2. Form parsing — `buildSharePayloadFromFormData` and `ingestShareRequest` + * are pure functions that the service worker calls. Keeping them out of + * the SW lifecycle code lets unit tests cover the multipart shape + * without spinning up a real ServiceWorkerGlobalScope. + */ + +const DB_NAME = 'hapi-share-transfers' +const DB_VERSION = 1 +const STORE = 'transfers' +export const SHARE_TRANSFER_TTL_MS = 60 * 60 * 1000 + +export type ShareTransferFile = { + name: string + type: string + blob: Blob +} + +export type ShareTransferPayload = { + title: string + text: string + url: string + files: ShareTransferFile[] + createdAt: number +} + +type StoredRecord = ShareTransferPayload & { id: string } + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE, { keyPath: 'id' }) + } + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error('Failed to open share-transfer DB')) + }) +} + +function tx(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest | null): Promise { + return new Promise((resolve, reject) => { + openDb().then((db) => { + const transaction = db.transaction(STORE, mode) + const store = transaction.objectStore(STORE) + const request = run(store) + transaction.oncomplete = () => { + db.close() + resolve(request ? request.result : null) + } + transaction.onerror = () => { + db.close() + reject(transaction.error ?? new Error('share-transfer tx failed')) + } + transaction.onabort = () => { + db.close() + reject(transaction.error ?? new Error('share-transfer tx aborted')) + } + }, reject) + }) +} + +export async function putShareTransfer(payload: ShareTransferPayload): Promise { + const id = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + const record: StoredRecord = { id, ...payload } + await tx('readwrite', (store) => store.put(record)) + return id +} + +export async function getShareTransfer(id: string): Promise { + const record = await tx('readonly', (store) => store.get(id)) + if (!record) return null + const { id: _id, ...payload } = record + return payload +} + +export async function deleteShareTransfer(id: string): Promise { + await tx('readwrite', (store) => store.delete(id)) +} + +export async function cleanupExpiredShareTransfers(now: number = Date.now()): Promise { + return new Promise((resolve, reject) => { + openDb().then((db) => { + const transaction = db.transaction(STORE, 'readwrite') + const store = transaction.objectStore(STORE) + const cursorReq = store.openCursor() + let removed = 0 + cursorReq.onsuccess = () => { + const cursor = cursorReq.result + if (!cursor) return + const value = cursor.value as StoredRecord + if (now - value.createdAt > SHARE_TRANSFER_TTL_MS) { + cursor.delete() + removed++ + } + cursor.continue() + } + transaction.oncomplete = () => { + db.close() + resolve(removed) + } + transaction.onerror = () => { + db.close() + reject(transaction.error ?? new Error('share-transfer cleanup failed')) + } + }, reject) + }) +} + +/** + * Pure form-data -> payload conversion. Exposed for unit tests. + * + * The Web Share Target manifest declares `title`, `text`, `url`, and a + * `files` part. Android Chrome sometimes omits parts the source app didn't + * supply, so each text field falls back to '' and `files` filters out + * non-File entries (string parts named 'files' have been observed when an + * app shares text-only). + */ +export async function buildSharePayloadFromFormData( + formData: FormData, + now: number = Date.now() +): Promise { + const title = stringField(formData, 'title') + const text = stringField(formData, 'text') + const url = stringField(formData, 'url') + const fileEntries = formData.getAll('files').filter((entry): entry is File => entry instanceof File) + const files: ShareTransferFile[] = fileEntries.map((file) => ({ + name: file.name, + type: file.type || 'application/octet-stream', + blob: file + })) + return { title, text, url, files, createdAt: now } +} + +function stringField(formData: FormData, name: string): string { + const value = formData.get(name) + return typeof value === 'string' ? value : '' +} + +export type ShareIngestDeps = { + put: (payload: ShareTransferPayload) => Promise + now?: () => number +} + +export type ShareIngestResult = { redirectTo: string } + +/** + * Service-worker entry point. Reads the multipart form, persists it via the + * injected `put` (defaulting to IndexedDB in production), and returns the + * relative URL to redirect to. The 303 status that converts the POST into + * a GET is set by the SW caller. + */ +export async function ingestShareRequest( + request: Request, + deps: ShareIngestDeps +): Promise { + const now = deps.now ? deps.now() : Date.now() + const formData = await request.formData() + const payload = await buildSharePayloadFromFormData(formData, now) + const id = await deps.put(payload) + const sharePath = shareTargetPathname() + return { redirectTo: `${sharePath}?id=${encodeURIComponent(id)}` } +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 3b13d7c2..40847b86 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -46,6 +46,9 @@ import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' import SettingsPage from '@/routes/settings' +import SharePage from '@/routes/share' +import { setSharePendingTransfer } from '@/lib/sharePendingState' +import { deleteShareTransfer } from '@/lib/shareTransfer' function BackIcon(props: { className?: string }) { return ( @@ -947,13 +950,19 @@ function NewSessionPage() { const queryClient = useQueryClient() const { machines, isLoading: machinesLoading, error: machinesError } = useMachines(api, true) const { t } = useTranslation() - const { directory: initialDirectory, machineId: initialMachineId } = newSessionRoute.useSearch() + const { directory: initialDirectory, machineId: initialMachineId, shareTransferId } = newSessionRoute.useSearch() const handleCancel = useCallback(() => { + if (shareTransferId) { + void deleteShareTransfer(shareTransferId) + } navigate({ to: '/sessions' }) - }, [navigate]) + }, [navigate, shareTransferId]) const handleSuccess = useCallback((sessionId: string) => { + if (shareTransferId) { + setSharePendingTransfer(shareTransferId) + } void queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) // Replace current page with /sessions to clear spawn flow from history navigate({ to: '/sessions', replace: true }) @@ -964,18 +973,19 @@ function NewSessionPage() { params: { sessionId }, }) }) - }, [navigate, queryClient]) + }, [navigate, queryClient, shareTransferId]) const handleChooseFolder = useCallback((args: { machineId: string | null; directory: string }) => { // Forward the currently-selected machine so /browse opens scoped to // it rather than falling back to `hapi:lastMachineId`, which can // disagree if the user changed machines without yet creating a - // session. - navigate({ - to: '/browse', - search: args.machineId ? { machineId: args.machineId } : {} - }) - }, [navigate]) + // session. Preserve shareTransferId so a share-target spawn that + // detours through /browse still seeds the composer after success. + const search: { machineId?: string; shareTransferId?: string } = {} + if (args.machineId) search.machineId = args.machineId + if (shareTransferId) search.shareTransferId = shareTransferId + navigate({ to: '/browse', search }) + }, [navigate, shareTransferId]) return (
@@ -1023,14 +1033,16 @@ function BrowsePage() { const goBack = useAppGoBack() const { machines, isLoading: machinesLoading } = useMachines(api, true) const { t } = useTranslation() - const { machineId: initialMachineId } = browseRoute.useSearch() + const { machineId: initialMachineId, shareTransferId } = browseRoute.useSearch() const handleStartSession = useCallback((machineId: string, directory: string) => { navigate({ to: '/sessions/new', - search: { directory, machineId } + search: shareTransferId + ? { directory, machineId, shareTransferId } + : { directory, machineId } }) - }, [navigate]) + }, [navigate, shareTransferId]) return (
@@ -1149,6 +1161,7 @@ const sessionFileRoute = createRoute({ type NewSessionSearch = { directory?: string machineId?: string + shareTransferId?: string } const newSessionRoute = createRoute({ @@ -1162,6 +1175,9 @@ const newSessionRoute = createRoute({ if (typeof search.machineId === 'string' && search.machineId) { result.machineId = search.machineId } + if (typeof search.shareTransferId === 'string' && search.shareTransferId) { + result.shareTransferId = search.shareTransferId + } return result }, component: NewSessionPage, @@ -1170,11 +1186,15 @@ const newSessionRoute = createRoute({ const browseRoute = createRoute({ getParentRoute: () => rootRoute, path: '/browse', - validateSearch: (search: Record): { machineId?: string } => { + validateSearch: (search: Record): { machineId?: string; shareTransferId?: string } => { + const result: { machineId?: string; shareTransferId?: string } = {} if (typeof search.machineId === 'string' && search.machineId) { - return { machineId: search.machineId } + result.machineId = search.machineId } - return {} + if (typeof search.shareTransferId === 'string' && search.shareTransferId) { + result.shareTransferId = search.shareTransferId + } + return result }, component: BrowsePage, }) @@ -1185,6 +1205,25 @@ const settingsRoute = createRoute({ component: SettingsPage, }) +// Web Share Target landing route. Service worker (`web/src/sw.ts`) +// intercepts the manifest's `POST /share` and 303-redirects here with an +// IDB transfer id. `error=ingest` is set when the SW failed to write IDB. +const shareRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/share', + validateSearch: (search: Record): { id?: string; error?: string } => { + const result: { id?: string; error?: string } = {} + if (typeof search.id === 'string' && search.id) { + result.id = search.id + } + if (typeof search.error === 'string' && search.error) { + result.error = search.error + } + return result + }, + component: SharePage, +}) + export const routeTree = rootRoute.addChildren([ indexRoute, sessionsRoute.addChildren([ @@ -1198,6 +1237,7 @@ export const routeTree = rootRoute.addChildren([ ]), browseRoute, settingsRoute, + shareRoute, ]) type RouterHistory = Parameters[0]['history'] diff --git a/web/src/routes/share/index.tsx b/web/src/routes/share/index.tsx new file mode 100644 index 00000000..4dd859a0 --- /dev/null +++ b/web/src/routes/share/index.tsx @@ -0,0 +1,293 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate, useSearch } from '@tanstack/react-router' +import { useAppContext } from '@/lib/app-context' +import { useSessions } from '@/hooks/queries/useSessions' +import { useTranslation } from '@/lib/use-translation' +import { LoadingState } from '@/components/LoadingState' +import { + deleteShareTransfer, + getShareTransfer, + type ShareTransferPayload, +} from '@/lib/shareTransfer' +import { setSharePendingTransfer } from '@/lib/sharePendingState' +import type { SessionSummary } from '@/types/api' + +type LoadState = + | { state: 'loading' } + | { state: 'missing'; reason: 'not-found' | 'ingest-error' | 'no-id' } + | { state: 'ready'; payload: ShareTransferPayload } + +function shortenText(text: string, max = 200): string { + const trimmed = text.trim() + if (trimmed.length <= max) return trimmed + return trimmed.slice(0, max).trimEnd() + '…' +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` + return `${(n / (1024 * 1024)).toFixed(1)} MB` +} + +function getSessionTitle(session: SessionSummary): string { + return session.metadata?.summary?.text + ?? session.metadata?.name + ?? session.metadata?.path + ?? session.id.slice(0, 8) +} + +function SharePreview(props: { payload: ShareTransferPayload }) { + const { payload } = props + const { t } = useTranslation() + const firstImage = payload.files.find((f) => f.type.startsWith('image/')) + const previewUrl = useMemo(() => { + if (!firstImage) return null + return URL.createObjectURL(firstImage.blob) + }, [firstImage]) + useEffect(() => { + return () => { + if (previewUrl) URL.revokeObjectURL(previewUrl) + } + }, [previewUrl]) + + if (payload.files.length === 0) { + const fallback = payload.text || payload.url || payload.title + return ( +
+
+ {t('share.preview.text')} +
+
+ {fallback ? shortenText(fallback) : t('share.preview.empty')} +
+
+ ) + } + + if (payload.files.length === 1) { + const file = payload.files[0] + return ( +
+ {previewUrl ? ( + {file.name} + ) : ( +
+ {file.type.split('/')[1]?.slice(0, 4) ?? 'file'} +
+ )} +
+
+ {file.name} +
+
+ {formatBytes(file.blob.size)} · {file.type || 'application/octet-stream'} +
+
+
+ ) + } + + const summary = payload.files.slice(0, 3).map((f) => f.name).join(', ') + + (payload.files.length > 3 ? '…' : '') + return ( +
+
+ {t('share.preview.files', { n: payload.files.length })} +
+
{summary}
+
+ ) +} + +export default function SharePage() { + const { api } = useAppContext() + const { t } = useTranslation() + const navigate = useNavigate() + const [load, setLoad] = useState({ state: 'loading' }) + const { sessions, isLoading: sessionsLoading } = useSessions(api) + + // Pulled via the typed validateSearch in router.tsx; reading + // `window.location.search` directly would diverge from the rest of the + // codebase and miss future schema tightening. + const search = useSearch({ from: '/share' }) as { id?: string; error?: string } + const transferId = search.id ?? null + const ingestError = search.error === 'ingest' + + useEffect(() => { + let cancelled = false + if (ingestError) { + setLoad({ state: 'missing', reason: 'ingest-error' }) + return + } + if (!transferId) { + setLoad({ state: 'missing', reason: 'no-id' }) + return + } + getShareTransfer(transferId).then((payload) => { + if (cancelled) return + if (!payload) { + setLoad({ state: 'missing', reason: 'not-found' }) + return + } + setLoad({ state: 'ready', payload }) + }).catch(() => { + if (cancelled) return + setLoad({ state: 'missing', reason: 'not-found' }) + }) + return () => { cancelled = true } + }, [transferId, ingestError]) + + // Snapshot the active session list once when sessions finish loading so + // the picker doesn't re-shuffle under the operator's finger as SSE + // updates roll in (activeAt heartbeats nudge the order every few + // seconds; even updatedAt-keyed sorts visually flicker on every + // metadata patch). The picker is a one-shot interaction — closing the + // share sheet and re-sharing produces a fresh snapshot. Sorted by + // updatedAt desc to match SessionList's canonical "most recent + // interaction first" order. + const [pickerSessions, setPickerSessions] = useState(null) + useEffect(() => { + if (pickerSessions !== null) return + if (sessionsLoading) return + setPickerSessions( + [...sessions] + .filter((s) => s.active) + .sort((a, b) => b.updatedAt - a.updatedAt) + ) + }, [pickerSessions, sessions, sessionsLoading]) + + const handlePickSession = useCallback((sessionId: string) => { + if (!transferId) return + // Don't await deleteShareTransfer here — SessionChat consumes the + // payload then deletes the IDB row (it owns the lifecycle once we + // hand off). If we delete here, SessionChat won't find it. + setSharePendingTransfer(transferId) + navigate({ to: '/sessions/$sessionId', params: { sessionId } }) + }, [navigate, transferId]) + + const handleNewSession = useCallback(() => { + if (!transferId) return + // Pass the transfer id via route search — do NOT arm sessionStorage here. + // Arming before the session exists leaves a stale id that the next + // unrelated SessionChat mount would consume (cancel/spawn-fail path). + navigate({ to: '/sessions/new', search: { shareTransferId: transferId } }) + }, [navigate, transferId]) + + const handleDiscard = useCallback(() => { + if (transferId) { + void deleteShareTransfer(transferId) + } + navigate({ to: '/sessions', replace: true }) + }, [navigate, transferId]) + + if (load.state === 'loading') { + return ( +
+ +
+ ) + } + + if (load.state === 'missing') { + const reasonKey = load.reason === 'ingest-error' + ? 'share.error.ingest' + : load.reason === 'no-id' + ? 'share.error.noId' + : 'share.notFound.body' + return ( +
+
+ {t('share.notFound.title')} +
+
+ {t(reasonKey)} +
+ +
+ ) + } + + const { payload } = load + + return ( +
+
+
+
+
{t('share.title')}
+ +
+
+ {t('share.subtitle')} +
+
+
+ +
+
+ + +
+
+ {t('share.recentSessions')} +
+ {pickerSessions === null ? ( + + ) : pickerSessions.length === 0 ? ( +
+ {t('share.noActiveSessions')} +
+ ) : ( +
    + {pickerSessions.map((session) => ( +
  • + +
  • + ))} +
+ )} +
+ + +
+
+
+ ) +} diff --git a/web/src/sw.ts b/web/src/sw.ts index 021d4a70..4ed48479 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -3,6 +3,14 @@ import { precacheAndRoute } from 'workbox-precaching' import { registerRoute } from 'workbox-routing' import { CacheFirst, NetworkFirst } from 'workbox-strategies' import { ExpirationPlugin } from 'workbox-expiration' +import { + cleanupExpiredShareTransfers, + ingestShareRequest, + putShareTransfer, +} from './lib/shareTransfer' +import { shareTargetPathname } from './lib/sharePath' + +const sharePath = shareTargetPathname() declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array @@ -131,3 +139,41 @@ self.addEventListener('notificationclick', (event) => { const url = data?.url ?? '/' event.waitUntil(self.clients.openWindow(url)) }) + +// Web Share Target — manifest declares POST /share, Android Chrome posts a +// multipart form with title/text/url/files. Stash in IDB so the SPA route +// can read it after the 303 redirect (which converts POST -> GET). +self.addEventListener('fetch', (event) => { + const request = event.request + if (request.method !== 'POST') return + const url = new URL(request.url) + if (url.pathname !== sharePath) return + + event.respondWith(handleShareTarget(request)) +}) + +async function handleShareTarget(request: Request): Promise { + // Resolve to absolute URLs because Response.redirect throws on relative + // input per the Fetch spec; Chrome currently tolerates relative paths + // but the SW spec is explicit and the cost of resolving is one line. + const origin = self.location.origin + try { + const { redirectTo } = await ingestShareRequest(request, { put: putShareTransfer }) + return Response.redirect(new URL(redirectTo, origin).toString(), 303) + } catch (error) { + // Surface a minimal page if IDB write fails — don't 5xx silently or + // the user gets a Chrome error sheet instead of useful UI. + console.error('share-target ingest failed', error) + return Response.redirect(new URL(`${sharePath}?error=ingest`, origin).toString(), 303) + } +} + +// Best-effort GC for stale share transfers (TTL-only — never blocks +// anything else). 1h TTL is set in shareTransfer.ts. +self.addEventListener('activate', (event) => { + event.waitUntil( + cleanupExpiredShareTransfers().catch((error) => { + console.warn('share-transfer cleanup failed', error) + }) + ) +}) diff --git a/web/vite.config.ts b/web/vite.config.ts index 04a254fb..2f2e29b6 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,8 +3,10 @@ import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { shareTargetPathnameFromBase } from './src/lib/sharePath' const base = process.env.VITE_BASE_URL || '/' +const shareAction = shareTargetPathnameFromBase(base) const hubTarget = process.env.VITE_HUB_PROXY || 'http://127.0.0.1:3006' const appVersion = readAppVersion() @@ -100,7 +102,38 @@ export default defineConfig({ type: 'image/png', purpose: 'any' } - ] + ], + // Web Share Target — Android Chrome routes POSTs to /share + // when the user picks HAPI in the system share sheet. The + // service worker (`web/src/sw.ts`) intercepts POST /share, + // stashes the multipart payload in IndexedDB, and 303- + // redirects to /share?id= for the SPA picker. + // `*/*` is the broad fallback; explicit MIME prefixes stay + // first because some Chrome versions only honor declared + // prefixes when surfacing in the share sheet. + share_target: { + action: shareAction, + method: 'POST', + enctype: 'multipart/form-data', + params: { + title: 'title', + text: 'text', + url: 'url', + files: [ + { + name: 'files', + accept: [ + 'image/*', + 'application/pdf', + 'text/*', + 'application/json', + 'application/zip', + '*/*' + ] + } + ] + } + } }, injectManifest: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}']