Files
windmill/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte
T
Guilhem LemouelandClaude Opus 5 0f0aaac876 fix: the header carries what it promised, and nothing it does not
The registry's item described pickers the breadcrumb no longer has: nine fields — the
editability flags, the change callbacks, the navigation hook — that nothing read, while
the flow editor passed eight of them and quietly lost its inline rename. The type now
says what the bar does: a path, and either a summary or the page's own control for one.
The flow editor renames through that control, as the detail page already did.

`barPlacement` went the same way: no page registered 'inline', so the layout's guard
never fired.

The sessions page registered an inset of 0 whenever its preview pane never mounted —
the "no session selected" and opted-out arms — and 0 reads as "a page owns the right
edge", floating the band over the page's first line. It registers an inset only once
the pane has a width.

The band's sidebar handle docks through `setDetached` like the card's own toggle, so
docking still suppresses the rail's entry animation and closes the card; a summary
without its breadcrumb no longer leads with a stray separator in an embed; a page's
hint sits with its name whichever way the page named itself; and `Menu.closeInstantly`
and `EditorHeader`'s `inline`/`hideSummary` go, their callers having been replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 09:26:31 +02:00

171 lines
6.2 KiB
Svelte

<script lang="ts">
/*
* WIN-2006: shared in-workspace app viewer (low-code AND raw). Renders the app
* through the same PublicAppFrame -> PublicApp machinery the public viewer uses,
* so the sandbox behavior is identical on every page. PublicAppFrame picks the
* rendering: an opaque /app_embed iframe for sandboxed low-code, or inline (with
* the bundle isolated in RawAppPreview's own opaque iframe) for raw and for
* unsandboxed apps. When the publisher opts an app into sandbox isolation, its
* untrusted markup/JS must not run with the member's full session — hence the
* scoped embed token / opaque isolation.
*/
import { base } from '$lib/base'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { Button } from '$lib/components/common'
import { AppService, OpenAPI } from '$lib/gen'
import { userStore } from '$lib/stores'
import { canWrite } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { Pen } from 'lucide-svelte'
import { page } from '$app/state'
let {
workspace,
path
}: {
workspace: string
path: string
} = $props()
let app: any = $state(undefined)
let notExists = $state(false)
let noPermission = $state(false)
let canWriteApp = $state(false)
/** Raw vs low-code, read from the app itself rather than from the route:
* both kinds render here and either route serves either kind (links to a raw
* app point at /apps/get all over the app), so only the app can say which
* editor the Edit button must open. */
let isRawApp = $state(false)
let editHref = $derived(`${base}/${isRawApp ? 'apps_raw' : 'apps'}/edit/${path}?nodraft=true`)
let refresh: (() => void) | undefined
// The opaque iframe loads the dedicated cookieless, chrome-less viewer route.
// The page's query/hash are forwarded so the app sees the same `ctx.query` /
// `ctx.hash` as the pre-sandbox viewer did. Captured ONCE (not reactively):
// the embedder later mirrors the app's own hash/query changes back onto this
// page's URL (wm_embed_hash relay), and re-deriving the iframe src from them
// would reload the app on its every navigation.
const initialSearchHash = page.url.search + page.url.hash
let viewerUrl = $derived(`${base}/app_embed/${workspace}/${path}${initialSearchHash}`)
const hideEditBtn = page.url.searchParams.get('hideEditBtn') === 'true'
const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true'
// Embedder side: mint a scoped embed token (by path) from the member's session.
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
const headers: Record<string, string> = {}
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const consent = opts?.sdkConsent ? '?sdk_consent=true' : ''
const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}${consent}`, {
headers
})
if (!res.ok) {
const err: any = new Error('Failed to fetch embed token')
err.status = res.status
throw err
}
return await res.json()
}
// Viewer side — used for the inline renderings (raw, and unsandboxed low-code);
// the sandboxed low-code case loads inside the opaque /app_embed iframe instead.
// getAppByPath returns bundle_secret + runnables for raw apps, which
// PublicApp -> RawAppPreview needs.
async function loadApp() {
try {
userStore.set(await getUserExt(workspace))
} catch (e) {
console.warn('Anonymous user')
}
try {
const loaded: any = await AppService.getAppByPath({ workspace, path })
// Raw apps need the bundle secret to load their bundle. getAppByPath
// doesn't compute it (unlike the public handlers), so fetch it here — the
// same call the previous raw viewer used — and hand it to PublicApp ->
// RawAppPreview via bundle_secret.
if (loaded?.raw_app && !loaded.bundle_secret) {
try {
loaded.bundle_secret = await AppService.getPublicSecretOfLatestVersionOfApp({
workspace,
path
})
} catch (e) {
console.error('Failed to load raw app bundle secret', e)
}
}
app = loaded
noPermission = false
notExists = false
} catch (e: any) {
if (e.status == 401) refresh?.()
else if (e.status == 403) noPermission = true
else notExists = true
}
}
// Edit button: determine write access and which editor to open on this
// real-origin page (cookie). The sandboxed low-code app never loads on this
// page (it loads inside the opaque iframe), so `app` can't be the source.
async function loadPerms() {
try {
const lite: any = await AppService.getAppLiteByPath({ workspace, path })
canWriteApp = canWrite(lite?.path, lite?.extra_perms ?? {}, $userStore)
isRawApp = !!lite?.raw_app
} catch (_) {
canWriteApp = false
}
}
$effect(() => {
if (workspace && path) loadPerms()
})
</script>
<!-- The band draws no edge of its own, so the separation from it is this page's first line. It
goes on the app's wrapper rather than on the frame, which the header-less public page shares —
and as an inset shadow rather than a border, which would take a pixel off an app sized to fill
the page exactly and leave it scrolling. -->
<div class="h-full shadow-[inset_0_1px_0_0_rgb(var(--color-border-light))]">
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
{notExists}
{noPermission}
jwtError={false}
inWorkspace
{hideRefreshBar}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
</div>
{#if canWriteApp && !hideEditBtn}
<!-- The page header carries it, with the app's name: a floating button over the app's own
canvas lands on whatever the app draws there. -->
<PageHeaderContent item={{ kind: 'app', path }} actions={editAction} />
{/if}
{#snippet editAction()}
<Button
unifiedSize="sm"
startIcon={{ icon: Pen }}
variant="subtle"
href={editHref}
id="app-edit-btn">Edit</Button
>
{/snippet}