Files
windmill/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte
T
AlexRV12andClaude Opus 5.5 133613c1ad feat: give app builder operators app tools in AI sessions (#11716)
* feat: give app builder operators app tools in AI sessions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: start builders' session apps from the builder starter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: note the session app editor conforms builder app policies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:23:20 +00:00

315 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { sendUserToast } from '$lib/toast'
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 DropdownV2 from '$lib/components/DropdownV2.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import type { Item } from '$lib/utils'
import { Button } from '$lib/components/common'
import { AppService, OpenAPI } from '$lib/gen'
import { userStore, type UserExt } from '$lib/stores'
import { getUserExt } from '$lib/user'
import { ExternalLink, Pen } from 'lucide-svelte'
import { page } from '$app/state'
import { isMenuHidden } from '$lib/components/sessions/sessionMode.svelte'
import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useEditRights } from '$lib/operatorWriteRights'
let {
workspace,
path,
onEdit,
onLoadState,
syncHashToUrl = true,
ownsPageHeader = false
}: {
workspace: string
path: string
/** True for the route's own page: the app becomes what the page header names. A host that
* renders this inside something else — a session's preview panel, whose band names the
* session — leaves it false, and the app keeps its Edit over its own canvas. */
ownsPageHeader?: boolean
/** Handle Edit in place instead of following `editHref`. An AI session shows
* this viewer inside a preview tab, where a plain link would navigate the whole
* page out of the session rather than flipping the tab to its editor. */
onEdit?: () => void
/** How the load ended, for a host that renders its own state around this viewer.
* A 403 is deliberately neither: the app exists, this member just cannot open it. */
onLoadState?: (state: 'loaded' | 'not_found') => void
/** Whether a raw app's route lives in the page URL's hash; see `RawAppPreview`. */
syncHashToUrl?: boolean
} = $props()
// The app, its permission check and everything this viewer renders belong to `workspace`,
// which on a session preview tab is the session's and not the one the nav points at.
setOperatingWorkspace(() => workspace)
/** This workspace's membership for the viewer, which is what the app's `ctx.username` /
* `ctx.groups` must describe — they sit beside `ctx.workspace` in the same object. */
let appUser: UserExt | undefined = $state(undefined)
let app: any = $state(undefined)
let notExists = $state(false)
let noPermission = $state(false)
/** The app's own permissions, kept apart from the verdict: `loadPerms` resolves before the
* acting user's `whoami` does in a workspace that is not the navigation one, and a verdict
* computed there would stick at "cannot write" with nothing to recompute it. */
let appPerms = $state<{ path: string; extraPerms: Record<string, boolean> } | undefined>(
undefined
)
/** 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)
const editRights = useEditRights()
const canWriteApp = $derived(
!!appPerms &&
editRights.canEditItem(isRawApp ? 'raw_app' : 'app', appPerms.path, appPerms.extraPerms)
)
/** The app's execution mode, from the same lite read as the permissions and for the same
* reason: a sandboxed low-code app loads inside the opaque iframe, so `app` here stays
* undefined and cannot say whether the app answers at a public url. `policy.execution_mode` is
* where the lite payload carries it; the flat field beside it is the apps *list* row's
* spelling (ListableApp, which is what AppRow reads). */
let executionMode = $state<string | undefined>(undefined)
let editHref = $derived(`/${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(`/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() {
// Kept local and handed to PublicApp rather than written to `userStore`: this is the
// membership in `workspace`, and a session preview tab shows a workspace the rest of
// the page is not on — writing it globally would answer every permission check on
// that page (the sidebar, the session bar's fork button) for the wrong workspace.
// The routes that mount this are all under `(logged)`, whose layout has already
// populated `userStore` for the workspace the page *is* on.
appUser = await getUserExt(workspace)
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
onLoadState?.('loaded')
} catch (e: any) {
if (e.status == 401) refresh?.()
else if (e.status == 403) noPermission = true
else {
notExists = true
// Only a 404 is "nothing deployed here"; anything else is a failure to say so.
if (e.status == 404) onLoadState?.('not_found')
else sendUserToast('Could not load app: ' + (e.body ?? e.message ?? e), 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 })
appPerms = { path: lite?.path ?? path, extraPerms: lite?.extra_perms ?? {} }
isRawApp = !!lite?.raw_app
executionMode = lite?.policy?.execution_mode ?? lite?.execution_mode
} catch (_) {
appPerms = undefined
}
}
/** Whether the app answers at a public url at all. Read from the app this viewer has already
* loaded, the way the apps table decides the same thing (AppRow) — the secret itself is a
* request, and asking for one on every app view to decide whether to draw a button is a
* request per view that most views never use. */
const hasPublicUrl = $derived(executionMode === 'anonymous')
/** Resolved when the button is used, not when it is drawn. */
async function openPublicUrl() {
// The tab is opened inside the click itself and filled once the secret arrives. Safari only
// lets a gesture open the tab it opens synchronously, so opening it after the await is
// blocked there — silently, since a blocked popup reports nothing.
const tab = window.open('about:blank', '_blank')
try {
const secret = await AppService.getPublicSecretOfApp({ workspace, path })
if (!secret) {
tab?.close()
sendUserToast('This app has no public url', true)
return
}
// Built from this viewer's workspace rather than the navigation one, like everything
// else here: the two differ inside a session's preview panel.
const url = `${window.location.origin}/public/${workspace}/${secret}`
if (tab) tab.location.href = url
else sendUserToast('Allow pop-ups for this site to open the app’s public url', true)
} catch (e: any) {
tab?.close()
sendUserToast('Could not open the public url: ' + (e?.body ?? e?.message ?? e), true)
}
}
$effect(() => {
if (workspace && path) loadPerms()
})
// Both of this page's buttons carry a label, and a phone's bar has room for neither beside the
// app's path — so below that width they become one menu. Unmeasured (0) counts as wide: the bar
// measures itself on mount, and starting compact would pop them out a frame later.
const compact = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
const compactItems: Item[] = $derived([
...(hasPublicUrl
? [
{
displayName: 'Public url',
icon: ExternalLink,
action: () => openPublicUrl()
}
]
: []),
{
displayName: 'Edit',
icon: Pen,
href: onEdit ? undefined : editHref,
action: onEdit ? () => onEdit() : undefined
}
])
</script>
<div class="h-full">
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
user={appUser ?? $userStore}
{notExists}
{noPermission}
jwtError={false}
inWorkspace
{hideRefreshBar}
{syncHashToUrl}
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". -->
{#if ownsPageHeader}
<PageHeaderContent
item={{ kind: 'app', path }}
actions={showEdit && !menuHidden ? editAction : undefined}
separator="always"
/>
{/if}
<!-- Edit is the bar's action when this viewer owns the bar, and floats over the canvas otherwise:
an embed has no bar — it would cost the app 44px of the iframe to carry one button — and
inside a session's preview panel the bar belongs to the session, so an Edit up there would
sit beside the session's name and act on the panel below it. -->
{#if showEdit && (menuHidden || !ownsPageHeader)}
<div class="absolute bottom-4 right-4 z-50">
{@render editAction()}
</div>
{/if}
{#snippet editAction()}
{#if compact}
<!-- Too little bar to seat both: they fold into one menu rather than losing their labels,
which are what say where each one goes. -->
<DropdownV2 items={compactItems} placement="bottom-end" size="sm" />
{:else}
{#if hasPublicUrl}
<!-- The app on its own, at the url anyone it is shared with uses. A new tab rather than
this one: the viewer here is the same app, so replacing it would look like nothing
happened. The secret is fetched by the click, so the url exists only once asked for. -->
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: ExternalLink }}
onclick={openPublicUrl}
title="Open the app's public url in a new tab">Public url</Button
>
{/if}
<!-- `onEdit` wins over the href: a host that embeds this viewer opens its own editor rather
than navigating the frame to one. -->
<Button
unifiedSize="sm"
startIcon={{ icon: Pen }}
variant="default"
href={onEdit ? undefined : editHref}
on:click={() => onEdit?.()}
title="Edit this app"
id="app-edit-btn">Edit</Button
>
{/if}
{/snippet}