mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
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>
185 lines
6.6 KiB
Svelte
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}
|