Files
windmill/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte
T
Guilhem LemouelandClaude Opus 5 3906774eba fix: give a page without the band's chrome what the band was carrying
Second review round, three reachable defects plus one decision:

- A public raw app was 150px tall. The bundle's iframe is `h-full`, and dropping
  its `min-h-screen` left nothing to resolve against on the share, embed and
  custom-path routes — none of which has a layout above them. Measured: 150px in
  a 900px viewport. The floor goes back for those routes and stays off inside the
  workspace, where the layout hands a height down and `min-h-screen` would
  overflow it by the band.
- The band retracted out from under its own open menu on a deployed app. Last
  round taught the sidebar card to hold while the pointer is in a portaled menu
  and left its sibling alone, but the band opens menus too — the workspace disc,
  the fork family, every path level. One helper now, used by both closers.
- `bandPeeked` outlived the page it belonged to. Clicking a control in the band
  navigates away, and the next page may not be one the band floats over, so the
  hover handlers that would clear it are gone — leaving the band already down
  when a full-bleed page came back. `beforeNavigate` clears it.
- An embed has no band to carry a control, so the app's Edit floats over the
  canvas there, where it sat before the band existed. Registering it as the
  band's `afterName` under `navHidden` instead would have cost the app 44px of
  the iframe for a row holding one button.

Reading "is the workspace navigation hidden" now has one definition
(`isMenuHidden`), since a page has to answer it too: the flag is sticky inside an
iframe, so the raw query param is the wrong thing to read.

Also inset the band by the rail only when there is a rail — an embed reserved
its width and crowded its own controls into a compact layout early.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 10:27:23 -07:00

185 lines
6.6 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'
import { isMenuHidden } from '$lib/components/sessions/sessionMode.svelte'
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'
const showEdit = $derived(canWriteApp && !hideEditBtn)
// Decides where Edit goes, not whether it appears: without the workspace navigation there is
// no band to put it in. Same rule the layout hides the sidebar by.
const menuHidden = $derived(isMenuHidden(page.url))
// 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>
<div class="h-full">
<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>
<!-- The band names the app for whoever opened it, write access or not: the route alone registers
no item, and the breadcrumb would fall back to the section name "Apps". -->
<PageHeaderContent
item={{ kind: 'app', path }}
afterName={showEdit && !menuHidden ? editAction : undefined}
fullBleed
/>
<!-- With a band, Edit sits with the app's name rather than at the far end of the bar: on a page
whose header is only there while hovered, the far end is a journey across the window. An
embed has no band — it would cost the app 44px of the iframe to carry one button — so Edit
floats over the canvas there, which is where it sat before the band existed. -->
{#if showEdit && menuHidden}
<div class="absolute bottom-4 right-4 z-50">
{@render editAction()}
</div>
{/if}
{#snippet editAction()}
<Button
unifiedSize="sm"
startIcon={{ icon: Pen }}
variant="subtle"
href={editHref}
id="app-edit-btn">Edit</Button
>
{/snippet}