mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
feat(web): Web Share Target -> Android system share sheet integration (#933)
* feat(web): Web Share Target -> composer attachment preload
PWA manifest now declares a `share_target` so Android Chrome surfaces
HAPI in the system share sheet for any app (Photos, Files, browser).
Pipeline on share:
1. Service worker intercepts POST /share, parses the multipart payload
(title/text/url + N files), persists it in IndexedDB under a
transfer id, and 303-redirects to /share?id=<id>. The 303 forces
Chrome to convert the POST into a GET so the SPA route mounts.
2. New /share route loads the transfer, previews the content, and
lets the user pick a recent active session (top 5 by activeAt) or
a "+ New session". Tapping a session stashes the transfer id in
sessionStorage and navigates to /sessions/:id.
3. SessionChat mounts a ShareSeedConsumer once the AssistantRuntime
is up; it consumes the pending transfer once per mount, seeds
composer text + per-file attachments via the existing
attachmentAdapter, then deletes the IDB row so a refresh of the
session page does not replay the upload.
The whole feature reuses the existing /sessions/:id/upload endpoint;
no hub or shared changes.
Limitations (also disclosed in the PR body):
- PWA must be installed; Android Chrome only registers share_target
on install. iOS Safari ignores the manifest field entirely.
- File MIME accept list is broad (`*/*` fallback); some Chrome
versions still filter despite this.
Tests:
- shareTransfer.test.ts (8) covers payload parse, multi-file order,
type fallback, ingest redirect shape and error propagation.
- sharePendingState.test.ts (3) covers atomic consume + overwrite.
Closes: pending upstream issue (filed before PR per intake doc).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): freeze picker session list at mount, sort by updatedAt, drop top-5 cap
The /share picker was visually re-shuffling under the operator's finger
as SSE events rolled in: every session metadata patch refreshed the
React Query cache, the useMemo recomputed, and items reordered (often
within a second of opening the share sheet). Sort key was activeAt,
which heartbeats every few seconds while a session is connected,
making the noise floor even higher.
Three changes:
- Snapshot the active-session list once when sessions finish loading
via useState + a deferred useEffect. The picker is a one-shot
interaction; closing the share sheet and re-sharing produces a
fresh snapshot, so freezing for the duration of the picker view is
the right trade.
- Sort by updatedAt desc to match SessionList's canonical "most
recent interaction first" order. updatedAt only moves on
user-meaningful events, not heartbeats.
- Drop the TOP_SESSIONS=5 cap. The picker is already inside an
app-scroll-y container, so showing all active sessions and letting
the operator scroll matches the operator's mental model better
than an arbitrary truncation.
Per operator dogfood report: "list of recent sessions is constantly
updating; should be just a scrollable list, from most recent
interaction to not."
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): base-aware share_target paths for subpath PWA deploys
Manifest share_target.action, SW POST matching, and ingest 303 redirects
were hard-coded to /share. Standalone builds with --base /<repo>/ put
scope/start_url under the subpath but left the share action at origin
root, so Chrome posted outside the SW scope and the handler never ran.
Extract shareTargetPathnameFromBase() (used at build time in
vite.config.ts and at runtime via import.meta.env.BASE_URL in sw.ts and
shareTransfer.ts). Normalizes base to a trailing slash before URL
resolution so /repo and /repo/ both resolve to /repo/share.
Addresses upstream PR #933 review (Major).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): defer sessionStorage arm until new session spawn succeeds
The "+ New session" picker path called setSharePendingTransfer before a
session existed. Cancel, spawn failure, or backing out left a stale id in
sessionStorage that the next unrelated SessionChat mount would consume.
Pass shareTransferId via /sessions/new search params instead; arm the
consumer only in handleSuccess after spawn, and delete the IDB row on
cancel.
Addresses upstream PR #933 review (Major).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): append share text to existing composer draft
ShareSeedConsumer called setText(seedText) unconditionally, clobbering
per-session drafts restored by useComposerDraft from sessionStorage.
Merge share title/text/url after any in-composer text or saved draft,
joined with a blank line. Pass sessionId into ShareSeedConsumer so
getDraft() can be consulted when the composer is still empty.
Addresses upstream PR #933 review (Major).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): preserve shareTransferId through /browse detour
New-session spawn from the share picker could lose shareTransferId when
the operator opened /browse to pick a folder: handleChooseFolder and
BrowsePage handleStartSession dropped the search param, so handleSuccess
never armed the composer consumer.
Thread shareTransferId through browseRoute search validation and both
navigation hops.
Addresses upstream PR #933 review (Major).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): consume pending transfer in effect for StrictMode
ShareSeedConsumer called consumeSharePendingTransfer during render.
React.StrictMode double-invokes render in dev; the discarded pass
deleted the sessionStorage key before the committed render seeded.
Move consume into a mount-only useEffect and gate the seed effect on
transferReady.
Addresses upstream PR #933 review (Minor).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string | null>(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}
|
||||
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<ShareSeedConsumer sessionId={props.session.id} sessionActive={props.session.active} />
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<HappyThread
|
||||
// Key with prefix: different components under the same session
|
||||
|
||||
@@ -673,4 +673,21 @@ export default {
|
||||
'misc.permissionRequired': 'permission required',
|
||||
'misc.percentLeft': '{percent}% left',
|
||||
'misc.online': 'online',
|
||||
|
||||
// Web Share Target picker
|
||||
'share.title': 'Share to HAPI',
|
||||
'share.subtitle': 'Pick a session to attach this to.',
|
||||
'share.recentSessions': 'Recent active sessions',
|
||||
'share.newSession': 'New session',
|
||||
'share.discard': 'Discard',
|
||||
'share.loading': 'Loading shared content…',
|
||||
'share.notFound.title': 'Shared content not found',
|
||||
'share.notFound.body': 'This share link expired or was opened directly without a transfer.',
|
||||
'share.error.ingest': "We couldn't read the shared content. Try again from the source app.",
|
||||
'share.error.noId': 'No share id was provided. Open this page from the system share sheet.',
|
||||
'share.backToSessions': 'Back to sessions',
|
||||
'share.preview.text': 'Shared text',
|
||||
'share.preview.empty': '(empty share)',
|
||||
'share.preview.files': '{n} files',
|
||||
'share.noActiveSessions': 'No active sessions. Pick "New session" below.',
|
||||
} as const
|
||||
|
||||
@@ -677,4 +677,21 @@ export default {
|
||||
'misc.permissionRequired': '需要权限',
|
||||
'misc.percentLeft': '剩余 {percent}%',
|
||||
'misc.online': '在线',
|
||||
|
||||
// Web Share Target 分享面板
|
||||
'share.title': '分享到 HAPI',
|
||||
'share.subtitle': '选择要附加到的会话。',
|
||||
'share.recentSessions': '最近的活跃会话',
|
||||
'share.newSession': '新建会话',
|
||||
'share.discard': '放弃',
|
||||
'share.loading': '正在加载分享内容…',
|
||||
'share.notFound.title': '未找到分享内容',
|
||||
'share.notFound.body': '此分享链接已过期或被直接打开而没有传输。',
|
||||
'share.error.ingest': '无法读取分享内容,请从源应用重试。',
|
||||
'share.error.noId': '未提供分享 ID,请从系统分享面板打开此页面。',
|
||||
'share.backToSessions': '返回会话列表',
|
||||
'share.preview.text': '分享文本',
|
||||
'share.preview.empty': '(空分享)',
|
||||
'share.preview.files': '{n} 个文件',
|
||||
'share.noActiveSessions': '没有活跃会话。请在下方选择"新建会话"。',
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shareTargetPathnameFromBase } from './sharePath'
|
||||
|
||||
describe('shareTargetPathnameFromBase', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Web Share Target paths must respect Vite `base` so subpath deployments
|
||||
* (e.g. GitHub Pages at `/<repo>/`) 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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<string>>()
|
||||
.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<string>>()
|
||||
.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<string>>()
|
||||
.mockRejectedValue(new Error('quota exceeded'))
|
||||
|
||||
await expect(
|
||||
ingestShareRequest(makeRequest(new FormData()), { put })
|
||||
).rejects.toThrow('quota exceeded')
|
||||
})
|
||||
})
|
||||
@@ -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=<transferId>. 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<IDBDatabase> {
|
||||
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<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T> | null): Promise<T | null> {
|
||||
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<string> {
|
||||
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<IDBValidKey>('readwrite', (store) => store.put(record))
|
||||
return id
|
||||
}
|
||||
|
||||
export async function getShareTransfer(id: string): Promise<ShareTransferPayload | null> {
|
||||
const record = await tx<StoredRecord | undefined>('readonly', (store) => store.get(id))
|
||||
if (!record) return null
|
||||
const { id: _id, ...payload } = record
|
||||
return payload
|
||||
}
|
||||
|
||||
export async function deleteShareTransfer(id: string): Promise<void> {
|
||||
await tx<undefined>('readwrite', (store) => store.delete(id))
|
||||
}
|
||||
|
||||
export async function cleanupExpiredShareTransfers(now: number = Date.now()): Promise<number> {
|
||||
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<ShareTransferPayload> {
|
||||
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<string>
|
||||
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<ShareIngestResult> {
|
||||
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)}` }
|
||||
}
|
||||
+55
-15
@@ -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 (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -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 (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -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<string, unknown>): { machineId?: string } => {
|
||||
validateSearch: (search: Record<string, unknown>): { 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<string, unknown>): { 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<typeof createRouter>[0]['history']
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-md bg-[var(--app-secondary-bg)] p-3 text-sm text-[var(--app-fg)]">
|
||||
<div className="text-xs font-semibold text-[var(--app-hint)]">
|
||||
{t('share.preview.text')}
|
||||
</div>
|
||||
<div className="mt-1 break-words">
|
||||
{fallback ? shortenText(fallback) : t('share.preview.empty')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (payload.files.length === 1) {
|
||||
const file = payload.files[0]
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-md bg-[var(--app-secondary-bg)] p-3">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
className="h-16 w-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded bg-[var(--app-subtle-bg)] text-xs uppercase text-[var(--app-hint)]">
|
||||
{file.type.split('/')[1]?.slice(0, 4) ?? 'file'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-[var(--app-fg)]">
|
||||
{file.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--app-hint)]">
|
||||
{formatBytes(file.blob.size)} · {file.type || 'application/octet-stream'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const summary = payload.files.slice(0, 3).map((f) => f.name).join(', ')
|
||||
+ (payload.files.length > 3 ? '…' : '')
|
||||
return (
|
||||
<div className="rounded-md bg-[var(--app-secondary-bg)] p-3 text-sm text-[var(--app-fg)]">
|
||||
<div className="text-xs font-semibold text-[var(--app-hint)]">
|
||||
{t('share.preview.files', { n: payload.files.length })}
|
||||
</div>
|
||||
<div className="mt-1 break-words">{summary}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SharePage() {
|
||||
const { api } = useAppContext()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [load, setLoad] = useState<LoadState>({ 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<SessionSummary[] | null>(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 (
|
||||
<div className="flex h-full flex-col items-center justify-center p-4">
|
||||
<LoadingState label={t('share.loading')} className="text-sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (load.state === 'missing') {
|
||||
const reasonKey = load.reason === 'ingest-error'
|
||||
? 'share.error.ingest'
|
||||
: load.reason === 'no-id'
|
||||
? 'share.error.noId'
|
||||
: 'share.notFound.body'
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-4 text-center">
|
||||
<div className="text-sm font-medium text-[var(--app-fg)]">
|
||||
{t('share.notFound.title')}
|
||||
</div>
|
||||
<div className="max-w-md text-xs text-[var(--app-hint)]">
|
||||
{t(reasonKey)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: '/sessions', replace: true })}
|
||||
className="rounded-md bg-[var(--app-link)] px-3 py-1.5 text-sm text-white"
|
||||
>
|
||||
{t('share.backToSessions')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { payload } = load
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-[var(--app-bg)]">
|
||||
<div className="border-b border-[var(--app-border)] bg-[var(--app-bg)] p-3 pt-[calc(0.75rem+env(safe-area-inset-top))]">
|
||||
<div className="mx-auto w-full max-w-content">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-semibold">{t('share.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDiscard}
|
||||
className="rounded-md px-2 py-1 text-xs text-[var(--app-hint)] hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
||||
>
|
||||
{t('share.discard')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--app-hint)]">
|
||||
{t('share.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-scroll-y flex-1 min-h-0">
|
||||
<div className="mx-auto w-full max-w-content space-y-4 p-3">
|
||||
<SharePreview payload={payload} />
|
||||
|
||||
<div>
|
||||
<div className="px-1 pb-1 text-xs font-semibold uppercase tracking-wide text-[var(--app-hint)]">
|
||||
{t('share.recentSessions')}
|
||||
</div>
|
||||
{pickerSessions === null ? (
|
||||
<LoadingState label={t('share.loading')} className="text-sm py-4" />
|
||||
) : pickerSessions.length === 0 ? (
|
||||
<div className="rounded-md bg-[var(--app-secondary-bg)] p-3 text-xs text-[var(--app-hint)]">
|
||||
{t('share.noActiveSessions')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="overflow-hidden rounded-md bg-[var(--app-secondary-bg)]">
|
||||
{pickerSessions.map((session) => (
|
||||
<li key={session.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickSession(session.id)}
|
||||
className="flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-[var(--app-fg)]">
|
||||
{getSessionTitle(session)}
|
||||
</div>
|
||||
{session.metadata?.path ? (
|
||||
<div className="truncate text-xs text-[var(--app-hint)]">
|
||||
{session.metadata.path}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewSession}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed border-[var(--app-border)] px-3 py-3 text-sm font-medium text-[var(--app-link)] hover:bg-[var(--app-secondary-bg)]"
|
||||
>
|
||||
+ {t('share.newSession')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string | { url: string; revision?: string }>
|
||||
@@ -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<Response> {
|
||||
// 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)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user