import { useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { fetchSpaceFiles, fetchSpaceFileContent, getAppShareLink, createAppShareLink, revokeAppShareLink, } from '../../api'; import { deriveAppList, type WorkspaceApp } from './app-bridge'; import { AppRunner } from './AppRunner'; import { createSpaceGateway } from './app-file-gateway'; import { buildAppShareDisplayUrl } from './appShareUrl'; /** * SpaceApps — the workspace "アプリ" tab. * * Lists the runnable workspace apps (subfolders under `apps/` that contain an * `index.html`) and launches a selected one in the AppRunner (sandboxed iframe * + postMessage bridge from Stage 1). Each app may carry an optional * `apps/{name}/app.json` manifest ({title?, description?, entry?}); when present * its title/description are shown, otherwise we fall back to the folder name + * `index.html`. * * NETWORK / SECURITY: the apps themselves run under AppRunner's opaque-origin * sandbox with `connect-src 'none'` — this tab only discovers + launches them. */ export function SpaceApps({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) { const { t } = useTranslation('spaces'); const [appToRun, setAppToRun] = useState(null); const appsQuery = useQuery({ queryKey: ['space-apps', spaceId], queryFn: () => loadWorkspaceApps(spaceId), staleTime: 10_000, }); const apps = appsQuery.data ?? []; return (

{t('apps.heading')}

{appsQuery.isError && (

{t('apps.fetchError')}

)} {!appsQuery.isLoading && !appsQuery.isError && apps.length === 0 && (

{t('apps.empty.title')}

{t('apps.empty.body')}

}} />

)} {apps.length > 0 && (
{apps.map(app => (

{app.title}

{app.description ? (

{app.description}

) : (

apps/{app.name}/index.html

)}
{canManage && }
))}
)} {appToRun && ( setAppToRun(null)} /> )}
); } /** * Discover workspace apps for a space: list `apps/`, then for each subfolder * check for an index.html and read its optional app.json. Folders without an * index.html are skipped (not runnable). A missing `apps/` directory simply * yields an empty list (the listing call 404s → caught → []). */ async function loadWorkspaceApps(spaceId: string): Promise { // List the workspace root first (never 404s on a fresh workspace) and only // descend into apps/ when that directory actually exists — listing a missing // apps/ directly would 404 noisily on every fresh workspace. let hasAppsDir = false; try { const root = await fetchSpaceFiles(spaceId, ''); hasAppsDir = root.entries.some(e => e.kind === 'directory' && e.name === 'apps'); } catch { return []; } if (!hasAppsDir) return []; let appDirs: string[]; try { const appsRoot = await fetchSpaceFiles(spaceId, 'apps'); appDirs = appsRoot.entries.filter(e => e.kind === 'directory').map(e => e.name); } catch { return []; } if (appDirs.length === 0) return []; // Fetch each app folder's listing in parallel to find index.html + app.json. const perApp = await Promise.all( appDirs.map(async name => { try { const dir = await fetchSpaceFiles(spaceId, `apps/${name}`); const names = new Set(dir.entries.filter(e => e.kind !== 'directory').map(e => e.name)); const hasIndex = names.has('index.html'); let manifest: string | null = null; if (hasIndex && names.has('app.json')) { try { manifest = await fetchSpaceFileContent(spaceId, `apps/${name}/app.json`); } catch { manifest = null; // tolerant: a bad/unreadable manifest falls back to defaults } } return { name, hasIndex, manifest }; } catch { return { name, hasIndex: false, manifest: null }; } }), ); const indexMap = new Map(perApp.map(p => [p.name, p])); return deriveAppList( appDirs, name => indexMap.get(name)?.hasIndex ?? false, name => indexMap.get(name)?.manifest ?? null, ); } /** * AppShareControls — 1 アプリの公開共有リンク管理(canManage のみ表示)。 * * 現在のリンク状態を取得し、未発行なら「リンクを作成」、発行済みなら公開 URL の表示・ * コピー・失効を提供する。発行/失効は react-query mutation。公開リンクは read-only・ * ログイン不要・apps/{app}/+output/ に封じ込められる旨を注意書きで明示する。 */ function AppShareControls({ spaceId, appName }: { spaceId: string; appName: string }) { const { t } = useTranslation('spaces'); const qc = useQueryClient(); const [copied, setCopied] = useState(false); const queryKey = ['app-share-link', spaceId, appName]; const linkQuery = useQuery({ queryKey, queryFn: () => getAppShareLink(spaceId, appName), staleTime: 10_000, }); const createMut = useMutation({ mutationFn: () => createAppShareLink(spaceId, appName), onSuccess: (data) => { qc.setQueryData(queryKey, { token: data.token, shareUrl: data.shareUrl, revokedAt: null }); }, }); const revokeMut = useMutation({ mutationFn: () => revokeAppShareLink(spaceId, appName), onSuccess: () => { qc.setQueryData(queryKey, { token: null, revokedAt: new Date().toISOString() }); setCopied(false); }, }); const link = linkQuery.data; const shareUrl = link?.token && link.shareUrl ? link.shareUrl : null; const displayUrl = shareUrl ? buildAppShareDisplayUrl(window.location.origin, shareUrl) : null; const copy = async () => { if (!displayUrl) return; try { await navigator.clipboard.writeText(displayUrl); setCopied(true); window.setTimeout(() => setCopied(false), 1800); } catch { // clipboard 不可(権限なし等)。表示済み URL から手動コピーできるので無視。 } }; const onRevoke = () => { if (window.confirm(t('appShare.revokeConfirm', { appName }))) { revokeMut.mutate(); } }; const busy = createMut.isPending || revokeMut.isPending; return (
{linkQuery.isLoading ? (

{t('appShare.checking')}

) : displayUrl ? (
{t('appShare.issued')}
e.currentTarget.select()} className="w-full truncate rounded border border-hairline bg-canvas px-2 py-1 font-mono text-2xs text-slate-600" />

, strong: }} />

) : (
{(createMut.isError || revokeMut.isError) && (

{t('appShare.opError')}

)}
)}
); }