diff --git a/.gitignore b/.gitignore index 3164478251..106a06615a 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ src/web-ui/src/generated/api/ # Tauri generated files apps/desktop/gen/ src/apps/desktop/gen/ +src/apps/miniapp-shell/gen/ src-tauri/gen/ # Updater signing private key (never commit; pubkey is in tauri.conf.json) diff --git a/Cargo.lock b/Cargo.lock index 73c0bb2251..0e26bef7ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6363,6 +6363,7 @@ dependencies = [ "openbitfun-core", "openbitfun-core-types", "openbitfun-events", + "openbitfun-miniapp-ipc", "openbitfun-product-domains", "openbitfun-relay-service", "openbitfun-runtime-ports", @@ -6498,6 +6499,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "openbitfun-miniapp-ipc" +version = "1.0.6" +dependencies = [ + "openbitfun-product-domains", + "serde", + "serde_json", + "tempfile", + "tokio", + "uuid", +] + [[package]] name = "openbitfun-miniapp-market-server" version = "1.0.6" @@ -6543,6 +6556,28 @@ dependencies = [ "zip 4.6.1", ] +[[package]] +name = "openbitfun-miniapp-shell" +version = "1.0.6" +dependencies = [ + "image 0.25.10", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation", + "openbitfun-miniapp-ipc", + "openbitfun-services-core", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tokio", + "uuid", + "windows 0.61.3", +] + [[package]] name = "openbitfun-opencode-adapter" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index 26f9ee293f..21439d63f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "src/apps/cli", "src/apps/sdk-host", "src/apps/desktop", + "src/apps/miniapp-shell", "src/apps/data-tools", "src/apps/server", "src/apps/relay-server", @@ -14,6 +15,7 @@ members = [ "src/crates/interfaces/app-server-protocol", "src/crates/interfaces/sdk-host", "src/crates/adapters/agent-runtime-ipc", + "src/crates/adapters/miniapp-ipc", "src/crates/assembly/agent-content", "src/crates/assembly/core", "src/crates/assembly/external-sources", @@ -347,3 +349,12 @@ lto = false codegen-units = 16 strip = false incremental = true + +# Build the window-only sidecar independently, including during Desktop dev. +# Never inherit Desktop's large debug artifact or its unified dependency features. +[profile.miniapp-shell] +inherits = "release" +opt-level = "z" +lto = "fat" +debug = false +strip = true diff --git a/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts b/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts index 176a610dde..39944db129 100644 --- a/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts +++ b/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts @@ -52,6 +52,12 @@ export type SharedI18nTerms = { export const SHARED_TERMS_BY_APP_LANGUAGE = { "en-US": { + "window": { + "minimize": "Minimize app window", + "maximize": "Maximize app window", + "restore": "Restore app window", + "close": "Close app window" + }, "product": { "name": "OpenBitFun", "remote": "OpenBitFun Remote" @@ -108,6 +114,12 @@ export const SHARED_TERMS_BY_APP_LANGUAGE = { } }, "zh-CN": { + "window": { + "minimize": "最小化应用窗口", + "maximize": "最大化应用窗口", + "restore": "还原应用窗口", + "close": "关闭应用窗口" + }, "product": { "name": "OpenBitFun", "remote": "OpenBitFun Remote" @@ -164,6 +176,12 @@ export const SHARED_TERMS_BY_APP_LANGUAGE = { } }, "zh-TW": { + "window": { + "minimize": "最小化應用視窗", + "maximize": "最大化應用視窗", + "restore": "還原應用視窗", + "close": "關閉應用視窗" + }, "product": { "name": "OpenBitFun", "remote": "OpenBitFun Remote" diff --git a/design-system/apps/design-lab/src/design-layout.css b/design-system/apps/design-lab/src/design-layout.css index 77c3710be9..b4792dbb3f 100644 --- a/design-system/apps/design-lab/src/design-layout.css +++ b/design-system/apps/design-lab/src/design-layout.css @@ -1,17 +1,17 @@ /* IBM Plex Mono, latin subset, as served by openbitfun.com. The product site sets both body and display to it, so the homepage follows; CJK still falls back to the platform sans below. */ @font-face { - font-family: "IBM Plex Mono"; + font-family: "IBM Plex Mono"; /* typography-audit: allow -- @font-face needs the literal bundled family name. */ font-style: normal; - font-weight: 400; + font-weight: 400; /* typography-audit: allow -- @font-face selects this bundled font file by its literal weight. */ font-display: swap; src: url("./assets/fonts/ibm-plex-mono-latin-400.woff2") format("woff2"); } @font-face { - font-family: "IBM Plex Mono"; + font-family: "IBM Plex Mono"; /* typography-audit: allow -- @font-face needs the literal bundled family name. */ font-style: normal; - font-weight: 500; + font-weight: 500; /* typography-audit: allow -- @font-face selects this bundled font file by its literal weight. */ font-display: swap; src: url("./assets/fonts/ibm-plex-mono-latin-500.woff2") format("woff2"); } @@ -20,9 +20,9 @@ homepage headline, so 600 (SemiBold, the same OFL 1.1 family) is self-hosted here rather than letting the browser synthesise a fake bold from 400. */ @font-face { - font-family: "IBM Plex Mono"; + font-family: "IBM Plex Mono"; /* typography-audit: allow -- @font-face needs the literal bundled family name. */ font-style: normal; - font-weight: 600; + font-weight: 600; /* typography-audit: allow -- @font-face selects this bundled font file by its literal weight. */ font-display: swap; src: url("./assets/fonts/ibm-plex-mono-latin-600.woff2") format("woff2"); } @@ -194,7 +194,7 @@ background: transparent; font-family: var(--_lab-label-font); font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-font-weight-regular); + font-weight: var(--openbitfun-type-label-md-font-weight); line-height: var(--openbitfun-type-modifier-leading-ui-line-height); letter-spacing: var(--openbitfun-type-modifier-tracking-subtle-letter-spacing); text-decoration: none; @@ -235,7 +235,7 @@ .design-primary-navigation a[aria-current], .design-secondary-navigation a[aria-current] { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-selection-surface); - font-weight: var(--openbitfun-font-weight-semibold); + font-weight: var(--openbitfun-type-label-selected-font-weight); } /* The group parent is the open branch, so it does not take a second filled row: the fill marks the chapter being read, and two stacked fills would read as one heavy block. Declared after the @@ -290,12 +290,12 @@ color: var(--openbitfun-color-content-caption); font-family: var(--_lab-label-font); font-size: var(--openbitfun-type-label-xs-font-size); - font-weight: var(--openbitfun-font-weight-regular); + font-weight: var(--openbitfun-type-label-md-font-weight); line-height: var(--openbitfun-type-modifier-leading-ui-line-height); letter-spacing: var(--openbitfun-type-modifier-tracking-wide-letter-spacing); text-transform: none; } -.page-kicker-index { color: var(--openbitfun-color-content-primary); font-weight: var(--openbitfun-font-weight-semibold); font-variant-numeric: tabular-nums; } +.page-kicker-index { color: var(--openbitfun-color-content-primary); font-weight: var(--openbitfun-type-label-selected-font-weight); font-variant-numeric: tabular-nums; } .lab-shell :is(.page-heading, .component-detail-heading, .colors-heading-copy, .guide-hero) p { margin: 0; max-width: 46rem; color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-md-font-size); line-height: var(--openbitfun-type-body-md-line-height); } .design-brand-page p, .design-foundation-section p, .design-component-usage p, .design-component-tokens p { color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-md-font-size); line-height: var(--openbitfun-type-body-md-line-height); } .design-component-card :is(h2, h3) a { display: flex; gap: var(--openbitfun-space-3); align-items: center; justify-content: space-between; color: inherit; text-decoration: none; } @@ -306,7 +306,7 @@ .design-catalog-filters { display: flex; gap: var(--openbitfun-space-2); flex-wrap: wrap; padding-block: var(--openbitfun-space-3); margin-bottom: var(--openbitfun-space-8); } .design-catalog-filter { position: relative; padding: var(--openbitfun-space-2) var(--openbitfun-space-3); border: 0; border-radius: var(--_lab-radius-control); background: transparent; color: var(--openbitfun-color-content-muted); font: inherit; font-size: var(--openbitfun-type-body-sm-font-size); cursor: pointer; } .design-catalog-filter:hover { background: var(--openbitfun-component-button-fill-background); color: var(--openbitfun-color-content-secondary); } -.design-catalog-filter[aria-pressed="true"] { background: var(--openbitfun-color-selection-surface); color: var(--openbitfun-color-content-primary); font-weight: var(--openbitfun-font-weight-semibold); } +.design-catalog-filter[aria-pressed="true"] { background: var(--openbitfun-color-selection-surface); color: var(--openbitfun-color-content-primary); font-weight: var(--openbitfun-type-label-selected-font-weight); } .lab-page.design-component-library { display: block; padding-block: 0; min-height: calc(100dvh - var(--_lab-topbar-height)); } /* The directory keeps its own scroll region inside the rail: a route branch, then the long list, then the sibling routes, so neither the preview nor the rest of the section moves with it. */ @@ -317,13 +317,13 @@ /* Categories are separated by space, not by a rule: the same reason the page index dropped its hairlines, and the caption weight already carries the grouping. */ .design-library-group + .design-library-group { margin-top: var(--openbitfun-space-5); } -.design-library-group h3 { margin: 0; padding: var(--openbitfun-space-2) var(--openbitfun-space-2); color: var(--openbitfun-color-content-caption); font-family: var(--_lab-label-font); font-size: var(--openbitfun-type-label-xs-font-size); font-weight: var(--openbitfun-font-weight-regular); letter-spacing: var(--openbitfun-type-modifier-tracking-wide-letter-spacing); } +.design-library-group h3 { margin: 0; padding: var(--openbitfun-space-2) var(--openbitfun-space-2); color: var(--openbitfun-color-content-caption); font-family: var(--_lab-label-font); font-size: var(--openbitfun-type-label-xs-font-size); font-weight: var(--openbitfun-type-label-md-font-weight); letter-spacing: var(--openbitfun-type-modifier-tracking-wide-letter-spacing); } .design-library-group ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 0; } .design-library-link { position: relative; display: flex; align-items: center; gap: var(--openbitfun-space-3); min-height: var(--openbitfun-control-height-md); padding: var(--openbitfun-space-2); border-radius: var(--_lab-radius-control); color: var(--openbitfun-color-content-secondary); font-family: var(--_lab-label-font); font-size: var(--openbitfun-type-label-sm-font-size); line-height: var(--openbitfun-type-modifier-leading-ui-line-height); letter-spacing: var(--openbitfun-type-modifier-tracking-subtle-letter-spacing); text-decoration: none; } .design-library-link > * { min-width: 0; } .design-library-link::before { counter-increment: lab-component; content: counter(lab-component, decimal-leading-zero); flex: none; color: var(--openbitfun-color-content-caption); font-variant-numeric: tabular-nums; } .design-library-link:hover { color: var(--openbitfun-color-content-primary); background: transparent; } -.design-library-link[aria-current="page"] { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-selection-surface); font-weight: var(--openbitfun-font-weight-semibold); } +.design-library-link[aria-current="page"] { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-selection-surface); font-weight: var(--openbitfun-type-label-selected-font-weight); } .design-library-link[aria-current="page"]::before { color: var(--openbitfun-color-content-primary); } .design-library-empty { padding: var(--openbitfun-space-3); color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); } .design-library-content { min-width: 0; padding-block: var(--openbitfun-space-6) var(--openbitfun-space-12); } diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index 07b56130cf..cea8fe175a 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -209,7 +209,7 @@ function ReferenceDisclosure({ label, children }: { label: string; children: Rea ); } -const buttonVariants = ["outline", "fill", "secondary", "primary", "text"] as const; +const buttonVariants = ["outline", "fill", "secondary", "primary", "quiet", "text"] as const; const iconButtonVariants = ["annotation", "quiet", "glass", "outline", "fill", "primary"] as const; const iconButtonSizes = ["xs", "standard", "sm", "md", "lg"] as const; const buttonInspectorStates = ["default", "hover", "active"] as const; diff --git a/design-system/apps/design-lab/src/pages/OverviewPage.css b/design-system/apps/design-lab/src/pages/OverviewPage.css index 1e1566154b..c3914c1e7e 100644 --- a/design-system/apps/design-lab/src/pages/OverviewPage.css +++ b/design-system/apps/design-lab/src/pages/OverviewPage.css @@ -56,7 +56,7 @@ max-width: 60rem; margin: 0; font-size: var(--openbitfun-type-display-fluid-title-font-size); - font-weight: var(--openbitfun-font-weight-semibold); + font-weight: var(--openbitfun-type-display-sm-font-weight); line-height: var(--openbitfun-type-display-fluid-title-line-height); letter-spacing: var(--openbitfun-type-display-fluid-title-letter-spacing); text-wrap: balance; @@ -239,13 +239,13 @@ /* The display PageHeader is sized in the page's own scale; inside the frame it follows the frame instead, so the window stays a legible picture at every width the page allows. */ .design-home-preview-welcome-header [data-openbitfun-part="heading"] { - font-size: clamp(var(--openbitfun-font-size-2xl), 4.6cqi, var(--openbitfun-type-heading-welcome-font-size)); + font-size: clamp(var(--openbitfun-type-heading-panel-font-size), 4.6cqi, var(--openbitfun-type-heading-welcome-font-size)); /* typography-audit: allow -- This scaled specimen keeps fluid container/viewport geometry between the established type scale bounds. */ text-wrap: balance; } .design-home-preview-welcome-header [data-openbitfun-part="description"] { color: var(--openbitfun-color-content-secondary); - font-size: clamp(var(--openbitfun-font-size-sm), 2.2cqi, var(--openbitfun-type-page-intro-font-size)); + font-size: clamp(var(--openbitfun-type-body-sm-font-size), 2.2cqi, var(--openbitfun-type-page-intro-font-size)); /* typography-audit: allow -- This scaled specimen keeps fluid container/viewport geometry between the established type scale bounds. */ } .design-home-preview-composer { @@ -282,8 +282,8 @@ margin: 0; /* One step under the hero's sentence and two over the steps: the band is a closing note, not a second headline. Fluid so it grows with the window like the rest of the page's display type. */ - font-size: clamp(var(--openbitfun-font-size-3xl), 2.4vw, var(--openbitfun-type-display-sm-font-size)); - font-weight: var(--openbitfun-font-weight-semibold); + font-size: clamp(var(--openbitfun-font-size-3xl), 2.4vw, var(--openbitfun-type-display-sm-font-size)); /* typography-audit: allow -- This scaled specimen keeps fluid container/viewport geometry between the established type scale bounds. */ + font-weight: var(--openbitfun-type-display-sm-font-weight); line-height: var(--openbitfun-type-display-sm-line-height); letter-spacing: var(--openbitfun-type-display-sm-letter-spacing); text-wrap: balance; @@ -420,7 +420,7 @@ font-size: var(--openbitfun-type-heading-section-font-size); font-weight: var(--openbitfun-type-heading-section-font-weight); line-height: var(--openbitfun-type-heading-section-line-height); - letter-spacing: var(--openbitfun-letter-spacing-tight); + letter-spacing: var(--openbitfun-type-display-sm-letter-spacing); } .design-home-footer-path h2 a { diff --git a/design-system/apps/design-lab/src/styles.css b/design-system/apps/design-lab/src/styles.css index e38337f20e..616321a0bb 100644 --- a/design-system/apps/design-lab/src/styles.css +++ b/design-system/apps/design-lab/src/styles.css @@ -3028,7 +3028,7 @@ body, color: var(--openbitfun-color-content-caption); font-family: var(--openbitfun-type-label-xs-font-family); font-size: var(--openbitfun-type-label-xs-font-size); - font-weight: var(--openbitfun-font-weight-regular); + font-weight: var(--openbitfun-type-support-font-weight); letter-spacing: var(--openbitfun-type-label-xs-letter-spacing); } diff --git a/design-system/apps/design-lab/vite/component-detail-contract.test.mjs b/design-system/apps/design-lab/vite/component-detail-contract.test.mjs index c32677a087..f45f13b471 100644 --- a/design-system/apps/design-lab/vite/component-detail-contract.test.mjs +++ b/design-system/apps/design-lab/vite/component-detail-contract.test.mjs @@ -141,7 +141,7 @@ test("Button preview exposes the public presentation variants", async () => { assert.ok(declaration); assert.deepEqual( [...declaration[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]), - ["outline", "fill", "secondary", "primary", "text"], + ["outline", "fill", "secondary", "primary", "quiet", "text"], ); }); diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 4c8aa058a8..5f5ded1c35 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -128,6 +128,15 @@ Keep `outline` for ordinary toolbar utilities and secondary choices. A neutral `tone="danger"` for destructive actions. Disabled and loading states belong to the same variant; do not switch a primary action to outline when it is disabled. +Use `quiet` for compact toolbar text actions such as refresh, submit, and open +in browser. It uses neutral secondary text, a transparent surface and border at +rest, then primary text and a subtle border on hover or press, without an +underline. It shares native focus, disabled, and loading behavior with other +variants, uses the base corner radius and `space.3` inline padding, and keeps +the selected size's height and typography. Use `text` for link-like emphasis. +Consumers should select this variant instead of recreating its colors and +hover border in product CSS. + Button outline and text variants have transparent resting surfaces. Fill and primary state colors come from the theme's `component.button.*` contract, with variant-specific disabled content. The secondary variant keeps its opaque @@ -750,6 +759,8 @@ Keep `Dialog` and `Sheet` mounted and set `open={false}` to close them. They ret PageHeader `md` uses the settings title with a primary 15px description; `display` uses the welcome heading and medium 17px introduction with a 12px gap. ActionCard uses 12px padding, section-heading typography (15px semibold), and a primary 13px single-line action description. Its inset outline does not inflate the 62px medium minimum height; longer content keeps the independent sibling actions and OverflowText behavior. +`ActionCard actionPlacement="floating"` overlays its independent action buttons without reserving text width. It shares the circular glass material, pointer-hover and keyboard-focus reveal, touch visibility, and reduced-transparency fallback used by tab-close actions and Capsules. Cards use the larger standard icon-button size; tabs and Capsules retain the compact xs size. The default `inline` placement keeps actions in the layout. Each action can provide `loading`, `disabled`, `tone`, and an optional `tooltip` while the main card remains a separate native button. + KeyHint uses 10px text on a 10px line and 2px block padding. StatusPill uses a 14px line with 2px block padding; opt into `emphasis` for short mode labels such as Ask, while ordinary status descriptions retain readable content colors. LauncherButton owns a 72px minimum width, 40px height, 10px side padding, 4px gap, 12px icon and 11px monospace text. Product shells may retain a deliberate compact greeting that expands into this geometry. Composer and ChatComposer share the 16px surface radius, 8px padding, 12px content gap, composer border, context tint, and composer shadow. Compact ChatComposer uses a 24px action track plus 8px padding on each side and two 1px borders (42px border-box height); expanded height follows editor content. The generic context shell has no duplicate outer border or surface inset. Queue and editor state remain owned by their existing slots and product adapters. diff --git a/design-system/packages/ui/src/components/ActionCard/ActionCard.meta.ts b/design-system/packages/ui/src/components/ActionCard/ActionCard.meta.ts index 7efab89812..92309be51b 100644 --- a/design-system/packages/ui/src/components/ActionCard/ActionCard.meta.ts +++ b/design-system/packages/ui/src/components/ActionCard/ActionCard.meta.ts @@ -2,7 +2,7 @@ import type { ComponentMeta } from "../../registry.types"; export const actionCardMeta = { category: "action", - description: "An actionable card with independent leading, title, description, selection, and sibling action areas.", + description: "An actionable card with independent content and inline or floating glass sibling actions.", maturity: "stable", name: "ActionCard", props: [ @@ -12,6 +12,7 @@ export const actionCardMeta = { { name: "description", type: "ReactNode" }, { name: "leading", type: "ReactNode" }, { defaultValue: "[]", name: "actions", type: "readonly ActionCardAction[]" }, + { defaultValue: "inline", name: "actionPlacement", type: "inline | floating" }, { defaultValue: "sm", name: "size", type: "sm | md" }, { defaultValue: "false", name: "selected", type: "boolean" }, { defaultValue: "false", name: "disabled", type: "boolean" }, @@ -26,6 +27,9 @@ export const actionCardMeta = { "color.content.primary", "color.content.secondary", "color.border.default", + "color.border.subtle", + "color.surface.raised", + "effect.blur.subtle", "color.focus.ring", "control.actionCard.smMinBlockSize", "control.actionCard.mdMinBlockSize", @@ -38,6 +42,8 @@ export const actionCardMeta = { "control.actionCard.actionsGap", "control.actionCard.actionsPaddingInlineEnd", "control.actionCard.radius", + "control.iconButton.standardSize", + "control.iconButton.standardIconSize", "type.heading.section.fontSize", "type.body.sm.fontSize", "type.actionRow.lineHeight", diff --git a/design-system/packages/ui/src/components/ActionCard/ActionCard.module.css b/design-system/packages/ui/src/components/ActionCard/ActionCard.module.css index 016fd0ea9c..e0fb62217f 100644 --- a/design-system/packages/ui/src/components/ActionCard/ActionCard.module.css +++ b/design-system/packages/ui/src/components/ActionCard/ActionCard.module.css @@ -142,6 +142,12 @@ padding-inline-end: var(--openbitfun-control-action-card-actions-padding-inline-end); } + .root > .floatingActions { + --_floating-action-size: var(--openbitfun-control-icon-button-standard-size); + inset-inline-end: var(--openbitfun-control-action-card-actions-padding-inline-end); + gap: var(--openbitfun-control-action-card-actions-gap); + } + @media (prefers-reduced-motion: reduce) { .root { transition: none; diff --git a/design-system/packages/ui/src/components/ActionCard/ActionCard.tsx b/design-system/packages/ui/src/components/ActionCard/ActionCard.tsx index ffdc1d6296..deaba07a30 100644 --- a/design-system/packages/ui/src/components/ActionCard/ActionCard.tsx +++ b/design-system/packages/ui/src/components/ActionCard/ActionCard.tsx @@ -5,17 +5,23 @@ import { type ReactNode, } from "react"; import { IconButton, type IconButtonProps } from "../IconButton"; +import { Tooltip } from "../Tooltip"; import { classNames } from "../../internal/classNames"; +import { FloatingAction } from "../../internal/FloatingAction"; +import actionStyles from "../../internal/FloatingAction.module.css"; import { OverflowText } from "../../primitives/OverflowText"; import styles from "./ActionCard.module.css"; -export interface ActionCardAction { +export interface ActionCardAction + extends Pick { disabled?: boolean; + loading?: boolean; icon: ReactNode; id: string; label: string; onClick?: MouseEventHandler; tone?: IconButtonProps["tone"]; + tooltip?: ReactNode; } export type ActionCardSize = "sm" | "md"; @@ -23,6 +29,8 @@ export type ActionCardSize = "sm" | "md"; export interface ActionCardProps extends Omit, "children" | "className"> { actions?: readonly ActionCardAction[]; + /** Floating actions share tab-close glass styling without reducing the text width. */ + actionPlacement?: "inline" | "floating"; children?: ReactNode; /** Rich card body (preview, multiline copy, selection marker) instead of title/description. */ body?: ReactNode; @@ -37,6 +45,7 @@ export interface ActionCardProps export const ActionCard = forwardRef( function ActionCard({ actions = [], + actionPlacement = "inline", children, body, triggerClassName, @@ -49,10 +58,32 @@ export const ActionCard = forwardRef( type = "button", ...props }, ref) { + const actionButtons = actions.map((action) => { + const button = ( + + ); + return action.tooltip != null + ? {button} + : button; + }); return ( 0 ? "true" : "false"} data-selected={selected ? "true" : "false"} @@ -80,22 +111,15 @@ export const ActionCard = forwardRef( )} } - {actions.length > 0 && ( + {actions.length > 0 && (actionPlacement === "floating" ? ( + + {actionButtons} + + ) : ( - {actions.map((action) => ( - - ))} + {actionButtons} - )} + ))} ); }, diff --git a/design-system/packages/ui/src/components/Button/Button.meta.ts b/design-system/packages/ui/src/components/Button/Button.meta.ts index cf5b62719a..a14b172270 100644 --- a/design-system/packages/ui/src/components/Button/Button.meta.ts +++ b/design-system/packages/ui/src/components/Button/Button.meta.ts @@ -2,11 +2,11 @@ import type { ComponentMeta } from "../../registry.types"; export const buttonMeta = { category: "action", - description: "An action with transparent outline and text variants, floating glass, neutral fill, secondary, and primary presentation.", + description: "An action with outline, quiet neutral text, link text, floating glass, neutral fill, secondary, and primary presentation.", maturity: "stable", name: "Button", props: [ - { defaultValue: "outline", name: "variant", type: "outline | glass | fill | secondary | primary | text" }, + { defaultValue: "outline", name: "variant", type: "outline | glass | fill | secondary | primary | quiet | text" }, { defaultValue: "md", name: "size", type: "xs | sm | md | lg" }, { defaultValue: "neutral", name: "tone", type: "neutral | danger" }, { defaultValue: "false", name: "loading", type: "boolean" }, @@ -38,6 +38,8 @@ export const buttonMeta = { "color.action.primary.content", "color.surface.tertiary", "color.border.default", + "color.content.primary", + "color.content.secondary", "color.border.strong", "color.focus.ring", "color.status.danger.border", @@ -56,5 +58,7 @@ export const buttonMeta = { "type.label.lg.fontSize", "type.label.selected.fontWeight", "radius.pill", + "radius.base", + "space.3", ], } as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/Button/Button.module.css b/design-system/packages/ui/src/components/Button/Button.module.css index c3a6a71a5b..a68f0e5c89 100644 --- a/design-system/packages/ui/src/components/Button/Button.module.css +++ b/design-system/packages/ui/src/components/Button/Button.module.css @@ -144,6 +144,23 @@ border-radius: 0; } + .button[data-openbitfun-variant="quiet"] { + --_button-background: transparent; + --_button-background-hover: transparent; + --_button-background-active: transparent; + --_button-border: transparent; + --_button-border-hover: var(--openbitfun-color-border-default); + --_button-border-active: var(--openbitfun-color-border-default); + --_button-content: var(--openbitfun-color-content-secondary); + + padding-inline: var(--openbitfun-space-3); + border-radius: var(--openbitfun-radius-base); + } + + .button[data-openbitfun-tone="neutral"][data-openbitfun-variant="quiet"]:is(:hover, :active, [data-openbitfun-preview-state="hover"], [data-openbitfun-preview-state="active"]):not(:disabled) { + --_button-content: var(--openbitfun-color-content-primary); + } + .button[data-openbitfun-variant="text"]:is(:hover, [data-openbitfun-preview-state="hover"]):not(:disabled) { --_button-content: var(--openbitfun-component-button-text-content-hover); @@ -196,6 +213,12 @@ --_button-content: var(--openbitfun-color-status-danger-content); } + .button[data-openbitfun-tone="danger"][data-openbitfun-variant="quiet"] { + --_button-border: transparent; + --_button-border-hover: var(--openbitfun-color-status-danger-border); + --_button-border-active: var(--openbitfun-color-status-danger-border); + } + .button[data-openbitfun-tone="danger"][data-openbitfun-variant="fill"]:disabled { --_button-background: var(--openbitfun-color-status-danger-surface); } diff --git a/design-system/packages/ui/src/components/Button/Button.tsx b/design-system/packages/ui/src/components/Button/Button.tsx index a0b544a09e..1d843a1eed 100644 --- a/design-system/packages/ui/src/components/Button/Button.tsx +++ b/design-system/packages/ui/src/components/Button/Button.tsx @@ -17,7 +17,7 @@ export interface ButtonProps extends ButtonHTMLAttributes { size?: "xs" | "sm" | "md" | "lg"; tone?: "danger" | "neutral"; trailingIcon?: ReactNode; - variant?: "fill" | "glass" | "outline" | "primary" | "secondary" | "text"; + variant?: "fill" | "glass" | "outline" | "primary" | "quiet" | "secondary" | "text"; } export const Button = forwardRef(function Button({ diff --git a/design-system/packages/ui/src/components/IconButton/IconButton.tsx b/design-system/packages/ui/src/components/IconButton/IconButton.tsx index c41a124ffa..450eeaf081 100644 --- a/design-system/packages/ui/src/components/IconButton/IconButton.tsx +++ b/design-system/packages/ui/src/components/IconButton/IconButton.tsx @@ -47,7 +47,7 @@ export const IconButton = forwardRef(functio data-openbitfun-tone={tone} data-openbitfun-variant={resolvedVariant} data-loading={loading ? "true" : "false"} - data-size={floating ? "xs" : size} + data-size={floating ?? size} disabled={disabled || loading} ref={ref} type={type} diff --git a/design-system/packages/ui/src/internal/FloatingAction.module.css b/design-system/packages/ui/src/internal/FloatingAction.module.css index 5721d52166..8985bc8b90 100644 --- a/design-system/packages/ui/src/internal/FloatingAction.module.css +++ b/design-system/packages/ui/src/internal/FloatingAction.module.css @@ -24,6 +24,15 @@ transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } + .action[data-floating-action-group] { + inline-size: max-content; + } + + .action[data-floating-action-group] > button { + flex: 0 0 var(--_floating-action-size); + inline-size: var(--_floating-action-size); + } + @media (hover: hover) and (pointer: fine) { .action { pointer-events: none; diff --git a/design-system/packages/ui/src/internal/FloatingAction.tsx b/design-system/packages/ui/src/internal/FloatingAction.tsx index 5525c51f29..4d8e0a5d90 100644 --- a/design-system/packages/ui/src/internal/FloatingAction.tsx +++ b/design-system/packages/ui/src/internal/FloatingAction.tsx @@ -4,10 +4,15 @@ import { FloatingActionContext } from "./floatingActionContext"; import glassStyles from "./GlassAction.module.css"; import styles from "./FloatingAction.module.css"; -export function FloatingAction({ children, className, ...props }: HTMLAttributes) { +interface FloatingActionProps extends HTMLAttributes { + group?: boolean; + size?: "xs" | "standard"; +} + +export function FloatingAction({ children, className, group = false, size = "xs", ...props }: FloatingActionProps) { return ( - - + + {Children.map(children, (child: ReactNode) => ( isValidElement>(child) && child.type === "button" ? cloneElement(child, { className: classNames(child.props.className, glassStyles.surface) }) diff --git a/design-system/packages/ui/src/internal/floatingActionContext.ts b/design-system/packages/ui/src/internal/floatingActionContext.ts index ec2f7864c0..2b092433a0 100644 --- a/design-system/packages/ui/src/internal/floatingActionContext.ts +++ b/design-system/packages/ui/src/internal/floatingActionContext.ts @@ -1,4 +1,4 @@ import { createContext } from "react"; -/** Capsule and tab action slots own their floating presentation and geometry. */ -export const FloatingActionContext = createContext(false); +/** Capsule, tab, and card action slots own their floating presentation and geometry. */ +export const FloatingActionContext = createContext<"xs" | "standard" | null>(null); diff --git a/design-system/packages/ui/src/mobile/MobileAvatar/MobileAvatar.module.css b/design-system/packages/ui/src/mobile/MobileAvatar/MobileAvatar.module.css index ad8ca60cc8..29e3cfa6a4 100644 --- a/design-system/packages/ui/src/mobile/MobileAvatar/MobileAvatar.module.css +++ b/design-system/packages/ui/src/mobile/MobileAvatar/MobileAvatar.module.css @@ -9,7 +9,7 @@ .image, .content { display: block; inline-size: 100%; block-size: 100%; overflow: hidden; border-radius: inherit; object-fit: cover; } .content { display: grid; place-items: center; } .content > :where(svg, img) { inline-size: 100%; block-size: 100%; } - .initials { display: grid; inline-size: 100%; block-size: 100%; place-items: center; letter-spacing: var(--openbitfun-letter-spacing-subtle); } + .initials { display: grid; inline-size: 100%; block-size: 100%; place-items: center; letter-spacing: var(--openbitfun-type-modifier-tracking-subtle-letter-spacing); } /* The dot keeps a ring of the surrounding surface so it separates from the artwork it overlaps. */ .status { position: absolute; inset-block-end: 0; inset-inline-end: 0; inline-size: var(--_avatar-dot); block-size: var(--_avatar-dot); border-radius: var(--openbitfun-radius-pill); background: var(--openbitfun-color-content-muted); box-shadow: 0 0 0 2px var(--openbitfun-color-surface-canvas); } diff --git a/design-system/packages/ui/src/mobile/MobileTag/MobileTag.module.css b/design-system/packages/ui/src/mobile/MobileTag/MobileTag.module.css index a0dcc476c2..884950ae51 100644 --- a/design-system/packages/ui/src/mobile/MobileTag/MobileTag.module.css +++ b/design-system/packages/ui/src/mobile/MobileTag/MobileTag.module.css @@ -3,7 +3,7 @@ .root[data-size="sm"] { min-block-size: 36px; padding-inline: var(--openbitfun-space-3); font-size: var(--openbitfun-type-label-sm-font-size); } .root[data-appearance="accent"] { color: var(--openbitfun-color-accent-default); background: var(--openbitfun-color-accent-surface); } /* Selection is a filled block, the same signal the rest of the mobile set uses for state. */ - .root[data-selected="true"] { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-selection-surface); font-weight: var(--openbitfun-font-weight-semibold); } + .root[data-selected="true"] { color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-selection-surface); font-weight: var(--openbitfun-type-label-selected-font-weight); } .root[data-selected="true"][data-appearance="accent"] { color: var(--openbitfun-color-action-primary-content); background: var(--openbitfun-color-accent-default); } @media (hover: hover) and (pointer: fine) { diff --git a/design-system/packages/ui/tests/action-card.test.mjs b/design-system/packages/ui/tests/action-card.test.mjs index 5f89bc65aa..519ddf7f02 100644 --- a/design-system/packages/ui/tests/action-card.test.mjs +++ b/design-system/packages/ui/tests/action-card.test.mjs @@ -10,7 +10,7 @@ test("ActionCard keeps its native trigger and sibling actions independent", () = createElement(ActionCard, { actions: [ { icon: createElement("svg"), id: "pin", label: "Pin" }, - { icon: createElement("svg"), id: "more", label: "More" }, + { icon: createElement("svg"), id: "more", label: "More", "aria-haspopup": "menu", "aria-expanded": true, "aria-controls": "card-menu" }, ], description: "Start a conversation", leading: createElement("svg", { "data-icon": "message" }), @@ -30,6 +30,30 @@ test("ActionCard keeps its native trigger and sibling actions independent", () = assert.match(markup, /data-openbitfun-part="description"[^>]*data-overflow-behavior="marquee"[^>]*>]*>Start a conversation<\/span><\/span>/); assert.equal((markup.match(/ + + + + + diff --git a/src/apps/miniapp-shell/src/shell.rs b/src/apps/miniapp-shell/src/shell.rs new file mode 100644 index 0000000000..796e7cf7da --- /dev/null +++ b/src/apps/miniapp-shell/src/shell.rs @@ -0,0 +1,533 @@ +use crate::string; +use openbitfun_miniapp_ipc::{self as ipc, launcher}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::PathBuf; +use tauri::Manager; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_opener::OpenerExt; + +pub(crate) struct ShellState { + record: launcher::LaunchRecord, + nonce: String, + connection: tokio::sync::Mutex>, +} +#[derive(Clone)] +struct Connection { + endpoint: ipc::Endpoint, + instance_id: String, + view_id: String, +} + +async fn endpoint(record: &launcher::LaunchRecord) -> Result { + let path = ipc::endpoint_path(&record.data_dir); + async fn read(path: &std::path::Path) -> Result { + let bytes = tokio::fs::read(path).await.map_err(|e| e.to_string())?; + let endpoint: ipc::Endpoint = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + tokio::time::timeout( + std::time::Duration::from_secs(2), + ipc::call(&endpoint, "ping", Value::Null), + ) + .await + .map_err(|_| "MiniApp host did not respond")??; + Ok(endpoint) + } + if let Ok(endpoint) = read(&path).await { + return Ok(endpoint); + } + let mut child = + openbitfun_services_core::process_manager::create_command(&record.host_executable); + child + .arg("--miniapp-background") + .env("OPENBITFUN_USER_ROOT", &record.user_root) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + let mut process = child.spawn().map_err(|e| { + format!( + "Could not start OpenBitFun at {}: {e}", + record.host_executable.display() + ) + })?; + // A detached native reaper does not keep the shell's Tokio runtime alive. + std::thread::spawn(move || { + let _ = process.wait(); + }); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(60); + loop { + if let Ok(endpoint) = read(&path).await { + return Ok(endpoint); + } + if tokio::time::Instant::now() >= deadline { + return Err( + "OpenBitFun did not become ready. Open the host application and retry.".into(), + ); + } + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ShellRequest { + operation: String, + #[serde(default)] + params: Value, +} + +async fn host_call(state: &ShellState, method: &str, mut params: Value) -> Result { + let connection = state + .connection + .lock() + .await + .clone() + .ok_or("MiniApp host is not connected")?; + if !params.is_object() { + params = json!({}); + } + params["instanceId"] = json!(connection.instance_id); + params["viewId"] = json!(connection.view_id); + params["nonce"] = json!(state.nonce); + ipc::call(&connection.endpoint, method, params).await +} + +#[tauri::command] +pub(crate) async fn miniapp_shell_call( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + state: tauri::State<'_, ShellState>, + request: ShellRequest, +) -> Result { + if window.label() != "miniapp-shell" { + return Err("MiniApp Shell command is unavailable on this surface".into()); + } + let params = request.params; + match request.operation.as_str() { + "bootstrap" => { + let endpoint = endpoint(&state.record).await?; + let value = ipc::call( + &endpoint, + "open", + json!({"launchId":state.record.launch_id,"nonce":state.nonce,"shellCapabilities":[ipc::MINIAPP_CUSTOM_CHROME_CAPABILITY,ipc::MINIAPP_CHAT_SNAPSHOT_CAPABILITY]}), + ) + .await?; + if value["reused"] == true { + app.exit(0); + return Ok(value); + } + let instance_id = string(&value["instance"], "instanceId")?; + let view_id = string(&value["view"], "viewId")?; + *state.connection.lock().await = Some(Connection { + endpoint, + instance_id, + view_id, + }); + let title = value["app"]["chrome"]["titlebar"]["title"] + .as_str() + .or_else(|| { + value["app"]["i18n"]["locales"][value["locale"].as_str().unwrap_or("en-US")] + ["name"] + .as_str() + }) + .or_else(|| value["app"]["name"].as_str()) + .unwrap_or(&state.record.name); + window.set_title(title).map_err(|e| e.to_string())?; + window.show().map_err(|e| e.to_string())?; + Ok(value) + } + "ready" | "tab" | "poll" | "permissions" | "chatSnapshot" => { + host_call(&state, &request.operation, params).await + } + "close" => { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + host_call(&state, "close", json!({})), + ) + .await; + app.exit(0); + Ok(Value::Null) + } + "windowState" => window_state(&window), + #[cfg(target_os = "macos")] + "titlebarDoubleClick" => { + crate::window_chrome::double_click(&window).await?; + window_state(&window) + } + "drag" => { + window.start_dragging().map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "resize" => { + let direction = serde_json::from_value(params["direction"].clone()) + .map_err(|error| format!("Unsupported MiniApp resize direction: {error}"))?; + window + .as_ref() + .window() + .start_resize_dragging(direction) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "minimize" => { + window.minimize().map_err(|e| e.to_string())?; + window_state(&window) + } + "maximize" => { + if window.is_fullscreen().map_err(|e| e.to_string())? { + window.set_fullscreen(false).map_err(|e| e.to_string())?; + } else { + if window.is_maximized().map_err(|e| e.to_string())? { + window.unmaximize().map_err(|e| e.to_string())?; + } else { + window.maximize().map_err(|e| e.to_string())?; + } + } + window_state(&window) + } + "fullscreen" => { + window + .set_fullscreen(!window.is_fullscreen().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + window_state(&window) + } + "focus" => { + window.show().map_err(|e| e.to_string())?; + window.unminimize().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "rpc" => { + let method = string(¶ms, "method")?; + // Private host adapters cannot be requested by MiniApp content. + if matches!(method.as_str(), "grantSelectedPath" | "authorizeLocal") { + return Err("Private MiniApp host operation".into()); + } + host_call(&state, "rpc", params).await + } + "local" => { + let method = string(¶ms, "method")?; + let args = params.get("params").cloned().unwrap_or(json!({})); + host_call( + &state, + "rpc", + json!({"method":"authorizeLocal","params":{"action":method,"sessionId":args.get("sessionId")}}), + ) + .await?; + match method.as_str() { + "dialog.open" | "dialog.save" => { + let mut builder = app.dialog().file(); + if let Some(title) = args["title"].as_str() { + builder = builder.set_title(title); + } + if let Some(path) = args["defaultPath"].as_str() { + let path = std::path::Path::new(path); + if path.is_dir() { + builder = builder.set_directory(path); + } else { + if let Some(parent) = path.parent() { + builder = builder.set_directory(parent); + } + if let Some(name) = path.file_name() { + builder = builder.set_file_name(name.to_string_lossy()); + } + } + } + if let Some(filters) = args["filters"].as_array() { + for filter in filters { + let extensions: Vec<_> = filter["extensions"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + builder = builder.add_filter( + filter["name"].as_str().unwrap_or("Files"), + &extensions, + ); + } + } + let (tx, rx) = tokio::sync::oneshot::channel(); + let multiple = args["multiple"] == true; + let directory = args["directory"] == true; + if method == "dialog.save" { + builder.save_file(move |path| { + let _ = tx.send(path.map(|path| vec![path])); + }); + } else if multiple && directory { + builder.pick_folders(move |paths| { + let _ = tx.send(paths); + }); + } else if multiple { + builder.pick_files(move |paths| { + let _ = tx.send(paths); + }); + } else if directory { + builder.pick_folder(move |path| { + let _ = tx.send(path.map(|path| vec![path])); + }); + } else { + builder.pick_file(move |path| { + let _ = tx.send(path.map(|path| vec![path])); + }); + } + let Some(paths) = rx.await.map_err(|e| e.to_string())? else { + return Ok(Value::Null); + }; + let paths: Vec<_> = paths + .into_iter() + .map(|path| { + path.into_path() + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|e| e.to_string()) + }) + .collect::>()?; + for path in &paths { + host_call( + &state, + "rpc", + json!({"method":"grantSelectedPath","params":{"path":path}}), + ) + .await?; + } + if multiple { + Ok(json!(paths)) + } else { + Ok(json!(paths.first())) + } + } + "dialog.message" => { + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .message(args["message"].as_str().unwrap_or("")) + .title(args["title"].as_str().unwrap_or(&state.record.name)) + .show(move |answer| { + let _ = tx.send(answer); + }); + Ok(json!(rx.await.map_err(|e| e.to_string())?)) + } + "system.openExternal" => { + let url = string(&args, "url")?; + let parsed = tauri::Url::parse(&url).map_err(|e| e.to_string())?; + if !matches!(parsed.scheme(), "https" | "http" | "mailto") { + return Err("Unsupported external URL scheme".into()); + } + app.opener() + .open_url(url, None::) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "system.revealInFolder" => { + app.opener() + .reveal_item_in_dir(string(&args, "path")?) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + "notifications.system" => { + use tauri_plugin_notification::NotificationExt; + app.notification() + .builder() + .title(string(&args, "title")?) + .body(args["body"].as_str().unwrap_or("")) + .show() + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + // The browser owns clipboard/composer presentation after native permission validation. + "clipboard.readText" + | "clipboard.writeText" + | "chat.claimComposer" + | "chat.focusSession" => Ok(Value::Null), + _ => Err("Unsupported MiniApp local adapter".into()), + } + } + _ => Err("Unsupported MiniApp Shell operation".into()), + } +} + +fn window_state(window: &tauri::WebviewWindow) -> Result { + Ok(json!({ + "maximized": window.is_maximized().map_err(|e| e.to_string())?, + "fullscreen": window.is_fullscreen().map_err(|e| e.to_string())?, + })) +} + +pub(crate) async fn run(path: PathBuf) { + let record = match launcher::read_record(&path) { + Ok(record) => record, + Err(error) => { + crate::show_fatal_startup_error(&format!("MiniApp launcher failed: {error}")); + return; + } + }; + // The private record is refreshed by host upgrades and app title changes; + // the bundle's embedded record remains a stable bootstrap pointer. + let central_path = launcher::launch_path(&record); + let record = if central_path != path && central_path.is_file() { + match launcher::read_record(¢ral_path) { + Ok(current) if current.launch_id == record.launch_id => current, + _ => { + crate::show_fatal_startup_error( + "MiniApp launch record is incompatible. Recreate the system app in OpenBitFun.", + ); + return; + } + } + } else { + record + }; + let mut context = tauri::generate_context!(); + context.config_mut().identifier = format!("com.openbitfun.miniapp.{}", record.launch_id); + context.config_mut().product_name = Some(record.name.clone()); + context.package_info_mut().name = record.name.clone(); + #[cfg(target_os = "linux")] + { + context.config_mut().app.enable_gtk_app_id = true; + } + #[cfg(target_os = "windows")] + // SAFETY: set the process identity before creating any native windows. + unsafe { + if let Err(error) = windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID( + &windows::core::HSTRING::from(context.config().identifier.as_str()), + ) { + eprintln!("MiniApp application identity failed: {error}"); + return; + } + } + let title = record.name.clone(); + // Window/taskbar icons use the app's own resource. macOS Dock identity is + // owned by the signed bundle's ICNS, with no Tauri development override. + let icon = image::open(central_path.with_file_name("icon-256.png")) + .or_else(|_| image::open(path.with_file_name("icon-256.png"))) + .map(|image| image.to_rgba8()); + context.set_default_window_icon(icon.ok().map(|icon| { + let (width, height) = icon.dimensions(); + tauri::image::Image::new_owned(icon.into_raw(), width, height) + })); + let result = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_notification::init()) + .manage(ShellState { + record, + nonce: uuid::Uuid::new_v4().to_string(), + connection: tokio::sync::Mutex::new(None), + }) + .register_uri_scheme_protocol("openbitfun-miniapp", |_, request| { + let (mime, body): (&str, Vec) = match request.uri().path() { + "/titlebar.js" => ( + "text/javascript", + include_bytes!("../../../shared/miniapp-chrome/titlebar.js").to_vec(), + ), + "/miniapp-chat.js" => ( + "text/javascript", + include_bytes!("miniapp-chat.js").to_vec(), + ), + "/generated/chat-style-classes.js" => ( + "text/javascript", + include_bytes!("generated/chat-style-classes.js").to_vec(), + ), + "/chat-styles.css" => ( + "text/css", + include_bytes!("generated/chat-styles.css").to_vec(), + ), + "/chat-labels.json" => ( + "application/json", + include_bytes!("chat-labels.json").to_vec(), + ), + "/titlebar.css" => ( + "text/css", + include_bytes!("../../../shared/miniapp-chrome/titlebar.css").to_vec(), + ), + "/window-controls.css" => ( + "text/css", + include_bytes!("../../../shared/miniapp-chrome/window-controls.css").to_vec(), + ), + "/labels.json" => ( + "application/json", + include_bytes!("../../../shared/miniapp-chrome/labels.generated.json").to_vec(), + ), + "/icons.json" => ( + "application/json", + include_bytes!("../../../shared/miniapp-chrome/icons.generated.json").to_vec(), + ), + "/appearance.css" => ( + "text/css", + include_bytes!("../../../shared/miniapp-chrome/generated/theme.css").to_vec(), + ), + _ => ( + "text/html; charset=utf-8", + include_bytes!("shell.html").to_vec(), + ), + }; + tauri::http::Response::builder() + .header("Content-Type", mime) + .body(body) + .unwrap() + }) + .invoke_handler(tauri::generate_handler![miniapp_shell_call]) + .setup(move |app| { + let config = crate::window_chrome::config(); + tauri::WebviewWindowBuilder::from_config(app, &config)? + .title(&title) + .initialization_script(format!( + "Object.defineProperty(window, '__MINIAPP_PLATFORM__', {{ value: '{}' }});", + std::env::consts::OS, + )) + .resizable(true) + .visible(false) + .inner_size(1050.0, 760.0) + .min_inner_size(480.0, 360.0) + .disable_drag_drop_handler() + .build()?; + // Native heartbeats survive hidden/minimized WebView timer suspension. + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + let state = handle.state::(); + let _ = tokio::time::timeout( + std::time::Duration::from_secs(3), + host_call(&state, "heartbeat", json!({})), + ) + .await; + } + }); + Ok(()) + }) + .build(context); + match result { + Ok(app) => app.run(|handle, event| { + let close_requested = match event { + tauri::RunEvent::WindowEvent { + event: tauri::WindowEvent::CloseRequested { api, .. }, + .. + } => { + api.prevent_close(); + true + } + tauri::RunEvent::ExitRequested { + code: None, api, .. + } => { + // Dock/menu Quit has the same lifetime effect as closing + // the window. Our final app.exit(0) must pass through. + api.prevent_exit(); + true + } + _ => false, + }; + if close_requested { + let handle = handle.clone(); + // Do not block the native event loop or nest Tokio block_on. + tauri::async_runtime::spawn(async move { + let state = handle.state::(); + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + host_call(&state, "close", json!({})), + ) + .await; + handle.exit(0); + }); + } + }), + Err(error) => eprintln!("MiniApp window failed: {error}"), + } +} diff --git a/src/apps/miniapp-shell/src/window_chrome.rs b/src/apps/miniapp-shell/src/window_chrome.rs new file mode 100644 index 0000000000..6ae08e6110 --- /dev/null +++ b/src/apps/miniapp-shell/src/window_chrome.rs @@ -0,0 +1,126 @@ +//! Native window policy. MiniApps customize the row, never system controls. + +pub(crate) fn config() -> tauri::utils::config::WindowConfig { + #[allow(unused_mut)] + let mut config = tauri::utils::config::WindowConfig { + label: "miniapp-shell".into(), + url: tauri::WebviewUrl::External( + "openbitfun-miniapp://localhost/index.html".parse().unwrap(), + ), + decorations: cfg!(target_os = "macos"), + ..Default::default() + }; + #[cfg(target_os = "macos")] + { + // Configure both the native Window and WebView, as the main app does. + // Tao then restores AppKit's own buttons on resize/fullscreen changes. + config.title_bar_style = tauri::TitleBarStyle::Overlay; + config.hidden_title = true; + config.traffic_light_position = macos::traffic_light_position(); + } + config +} + +#[cfg(target_os = "macos")] +pub(crate) async fn double_click(window: &tauri::WebviewWindow) -> Result<(), String> { + let (send, receive) = tokio::sync::oneshot::channel(); + let native_window = window.clone(); + window + .run_on_main_thread(move || { + let result = native_window + .ns_window() + .map_err(|error| error.to_string()) + .map(|pointer| { + // SAFETY: Tauri owns the live NSWindow, and this closure + // runs on AppKit's main thread for the retained window. + let window = unsafe { &*pointer.cast::() }; + macos::double_click(window); + }); + let _ = send.send(result); + }) + .map_err(|error| error.to_string())?; + receive.await.map_err(|error| error.to_string())? +} + +#[cfg(target_os = "macos")] +mod macos { + use objc2::MainThreadMarker; + use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowStyleMask}; + use objc2_foundation::{NSRect, NSString, NSUserDefaults}; + + fn toolbar_height() -> f64 { + let tokens: serde_json::Value = serde_json::from_str(include_str!( + "../../../../design-system/packages/design-tokens/src/system.tokens.json" + )) + .expect("Bundled design system tokens must be valid"); + tokens + .pointer("/layout/toolbar/mdHeight/$value") + .and_then(serde_json::Value::as_str) + .and_then(|value| value.strip_suffix("px")) + .and_then(|value| value.parse().ok()) + .expect("The native toolbar requires the design system height in logical pixels") + } + + fn traffic_light_inset(row: f64, native_titlebar: f64, button: f64) -> f64 { + // Tao measures this from the container bottom, not the button top. + row / 2.0 + native_titlebar / 2.0 - button + } + + pub(super) fn traffic_light_position() -> Option { + let mtm = MainThreadMarker::new()?; + let style = NSWindowStyleMask::Titled + | NSWindowStyleMask::Closable + | NSWindowStyleMask::Miniaturizable + | NSWindowStyleMask::Resizable; + let native_height = NSWindow::frameRectForContentRect_styleMask(NSRect::ZERO, style, mtm) + .size + .height; + let button = + NSWindow::standardWindowButton_forStyleMask(NSWindowButton::CloseButton, style, mtm)?; + Some(tauri::utils::config::LogicalPosition { + x: 12.0, + y: traffic_light_inset(toolbar_height(), native_height, button.frame().size.height), + }) + } + + pub(super) fn double_click(window: &NSWindow) { + let action = NSUserDefaults::standardUserDefaults() + .stringForKey(&NSString::from_str("AppleActionOnDoubleClick")) + .map(|value| value.to_string()); + match action.as_deref() { + Some("Minimize") => window.performMiniaturize(None), + Some("None") => {} + _ => window.performZoom(None), + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn native_button_center_matches_the_shared_toolbar() { + for (native_titlebar, button) in [(22.0, 14.0), (28.0, 16.0)] { + let inset = traffic_light_inset(toolbar_height(), native_titlebar, button); + assert_eq!( + inset + button - native_titlebar / 2.0, + toolbar_height() / 2.0 + ); + } + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn native_controls_follow_the_host_platform() { + let config = super::config(); + assert_eq!(config.decorations, cfg!(target_os = "macos")); + #[cfg(target_os = "macos")] + { + assert_eq!(config.title_bar_style, tauri::TitleBarStyle::Overlay); + assert!(config.hidden_title); + } + } +} diff --git a/src/apps/miniapp-shell/styles/chat-layout.scss b/src/apps/miniapp-shell/styles/chat-layout.scss new file mode 100644 index 0000000000..bedbed0222 --- /dev/null +++ b/src/apps/miniapp-shell/styles/chat-layout.scss @@ -0,0 +1,106 @@ +@use '../../../web-ui/src/flow_chat/transcript-layout' as transcriptLayout; + +// Only native-Shell anatomy adapters live here. Hi and FlowChat own their visuals. +.miniapp-chat.openbitfun-fmc { + // Native resize edges and the existing permission mailbox stay above chat. + z-index: 10; +} + +.miniapp-chat__panel.openbitfun-fmc__panel { + max-height: calc(100dvh - var(--openbitfun-layout-toolbar-md-height) - var(--openbitfun-space-8)); +} + +.miniapp-chat__body { + position: relative; +} + +.miniapp-chat__messages { + @include transcriptLayout.viewport; + box-sizing: border-box; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: auto; + overflow-wrap: anywhere; + overscroll-behavior: contain; + scrollbar-gutter: stable both-edges; + padding-block-end: calc(var(--openbitfun-control-chat-composer-compact-height) + var(--openbitfun-space-6) + var(--openbitfun-space-6)); +} + +.miniapp-chat__transcript { + @include transcriptLayout.reading-column; + flex-shrink: 0; + padding-block: var(--openbitfun-space-4); +} + +.miniapp-chat__transcript:has(> .miniapp-chat__welcome:only-child) { + flex: 1; + width: 100%; + max-width: none; + padding: 0; +} + +.miniapp-chat__welcome .openbitfun-fmc__miniapp-welcome-icon svg { + width: calc(var(--openbitfun-space-6) + var(--openbitfun-space-1)); + height: calc(var(--openbitfun-space-6) + var(--openbitfun-space-1)); +} + +.miniapp-chat__suggestions .miniapp-chat__glyph { display: inline-flex; } +.miniapp-chat__suggestions svg { + width: var(--openbitfun-control-icon-size-xs); + height: var(--openbitfun-control-icon-size-xs); +} + +.miniapp-chat__message p { margin: 0; } + +.miniapp-chat__message[data-role='assistant'] { + margin-block-end: var(--openbitfun-control-flow-chat-paragraph-gap); +} + +.miniapp-chat__input { + box-sizing: border-box; + width: 100%; + min-width: 0; + max-height: 160px; + padding: 0; + margin: 0; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; + font-family: var(--openbitfun-type-flow-control-font-family); + font-size: var(--openbitfun-type-flow-control-font-size); + font-weight: var(--openbitfun-type-flow-control-font-weight); + line-height: var(--openbitfun-type-flow-body-line-height); + resize: none; + outline: none; +} + +.miniapp-chat__input::placeholder { color: var(--openbitfun-color-content-muted); } +.miniapp-chat__title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } + +.miniapp-chat__note { + flex-shrink: 0; + padding: var(--openbitfun-space-2) var(--openbitfun-space-3); + color: var(--openbitfun-color-content-secondary); + font-family: var(--openbitfun-type-flow-support-font-family); + font-size: var(--openbitfun-type-flow-support-font-size); + font-weight: var(--openbitfun-type-flow-support-font-weight); + line-height: var(--openbitfun-type-flow-support-line-height); + overflow-wrap: anywhere; +} + +.miniapp-chat__note[data-error='true'] { color: var(--openbitfun-color-status-danger-content); } +.miniapp-chat__launcher[data-attention='true']::after { + content: ''; + position: absolute; + inset-block-start: var(--openbitfun-space-1); + inset-inline-end: var(--openbitfun-space-1); + inline-size: var(--openbitfun-space-1); + block-size: var(--openbitfun-space-1); + border-radius: var(--openbitfun-radius-pill); + background: var(--openbitfun-color-accent-default); +} + +.miniapp-chat [hidden] { display: none !important; } diff --git a/src/apps/miniapp-shell/styles/chat.js b/src/apps/miniapp-shell/styles/chat.js new file mode 100644 index 0000000000..c292774f48 --- /dev/null +++ b/src/apps/miniapp-shell/styles/chat.js @@ -0,0 +1,12 @@ +// Build-time style projection only: no React components or product runtime. +import launcher from '../../../../design-system/packages/ui/src/components/LauncherButton/LauncherButton.module.css'; +import iconButton from '../../../../design-system/packages/ui/src/components/IconButton/IconButton.module.css'; +import composer from '../../../../design-system/packages/ui/src/flow-chat/composer/ChatComposer.module.css'; +import '../../../web-ui/src/app/layout/FloatingMiniChat.scss'; +import '../../../web-ui/src/flow_chat/components/ChatInput.scss'; +import '../../../web-ui/src/flow_chat/components/FlowTextBlock.scss'; +import '../../../web-ui/src/flow_chat/components/modern/UserMessageItem.scss'; +import '../../../web-ui/src/flow_chat/components/modern/ModelRoundItem.scss'; +import './chat-layout.scss'; + +export { launcher, iconButton, composer }; diff --git a/src/apps/miniapp-shell/tauri.conf.json b/src/apps/miniapp-shell/tauri.conf.json new file mode 100644 index 0000000000..33be838a45 --- /dev/null +++ b/src/apps/miniapp-shell/tauri.conf.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "OpenBitFun MiniApp", + "identifier": "com.openbitfun.miniapp-shell", + "build": { "frontendDist": "bootstrap" }, + "bundle": { "active": false, "icon": ["../desktop/icons/openbitfun-app-icon.png", "../desktop/icons/openbitfun-app-icon.ico"] }, + "app": { + "windows": [], + "security": { "csp": null, "capabilities": [] }, + "withGlobalTauri": true + } +} diff --git a/src/crates/adapters/AGENTS.md b/src/crates/adapters/AGENTS.md index df4b715f08..df2d70768a 100644 --- a/src/crates/adapters/AGENTS.md +++ b/src/crates/adapters/AGENTS.md @@ -12,6 +12,7 @@ services. | Crate | Responsibility | Local doc | |---|---|---| | `agent-runtime-ipc` | Non-published private local IPC adapter for the opt-in first-party Shared TUI Runtime; closed interactive operations only | [AGENTS.md](agent-runtime-ipc/AGENTS.md) | +| `miniapp-ipc` | Private local launch records, bounded framing and discovery shared by Desktop and the lightweight MiniApp Shell; no execution owners | [AGENTS.md](miniapp-ipc/AGENTS.md) | | `ai-adapters` | AI provider request/response adapters and stream protocol glue | [AGENTS.md](ai-adapters/AGENTS.md) | | `opencode-adapter` | OpenCode source semantics for user Instructions plus the live Command, standalone Tool, Subagent, MCP, and static Hook providers; managed-package static preview | [AGENTS.md](opencode-adapter/AGENTS.md) | | `dsh-adapter` | DeepSeek Harness (`dsh`) bundle/profile projection, static Hook bridge discovery and explicit MCP declarations | [AGENTS.md](dsh-adapter/AGENTS.md) | diff --git a/src/crates/adapters/miniapp-ipc/AGENTS.md b/src/crates/adapters/miniapp-ipc/AGENTS.md new file mode 100644 index 0000000000..386c63be49 --- /dev/null +++ b/src/crates/adapters/miniapp-ipc/AGENTS.md @@ -0,0 +1,19 @@ +# MiniApp private IPC + +This non-published adapter is shared only by Desktop and the lightweight MiniApp +Shell. It owns the existing v1 launch-record format, discovery record, credentials, +bounded JSON framing and local UDS/Named Pipe client. It has no Tauri, Agent, +execution, storage, task, permission, or instance ownership. + +Keep wire and persisted records compatible with existing system apps. Preserve +unknown launch-record fields and reject unsupported versions without rewriting +data. Remote workspace, Peer, mobile and detached execution remain explicitly +unsupported by this local presentation adapter. Do not reuse the separate Shared +TUI Runtime IPC protocol or add TCP/browser access. + +Focused verification: + +```bash +cargo test --locked -p openbitfun-miniapp-ipc +pnpm run check:core-boundaries +``` diff --git a/src/crates/adapters/miniapp-ipc/Cargo.toml b/src/crates/adapters/miniapp-ipc/Cargo.toml new file mode 100644 index 0000000000..392e3ef0bb --- /dev/null +++ b/src/crates/adapters/miniapp-ipc/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openbitfun-miniapp-ipc" +version.workspace = true +authors.workspace = true +edition.workspace = true +publish = false +description = "Private local protocol shared by MiniApp windows and the Desktop host" + +[dependencies] +openbitfun-product-domains = { path = "../../contracts/product-domains", features = ["miniapp"] } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["io-util", "net"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/src/crates/adapters/miniapp-ipc/src/launcher.rs b/src/crates/adapters/miniapp-ipc/src/launcher.rs new file mode 100644 index 0000000000..301ffce0e2 --- /dev/null +++ b/src/crates/adapters/miniapp-ipc/src/launcher.rs @@ -0,0 +1,109 @@ +//! Persisted bootstrap records shared by the Desktop host and lightweight shell. +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchRecord { + #[serde(default = "protocol_version")] + pub protocol_version: u32, + pub launch_id: String, + pub app_id: String, + #[serde(default)] + pub workspace_path: Option, + pub data_dir: PathBuf, + pub user_root: PathBuf, + pub host_executable: PathBuf, + pub name: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} +fn protocol_version() -> u32 { + 1 +} + +pub fn launch_path(record: &LaunchRecord) -> PathBuf { + record + .data_dir + .join("miniapp-desktop/launchers") + .join(&record.launch_id) + .join("launch.json") +} + +pub fn read_record(path: &Path) -> Result { + let record: LaunchRecord = + serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + if record.protocol_version != 1 || uuid::Uuid::parse_str(&record.launch_id).is_err() { + return Err("Unsupported MiniApp launcher; recreate it in OpenBitFun".into()); + } + Ok(record) +} + +pub fn write_record(record: &LaunchRecord) -> Result { + let path = launch_path(record); + crate::private_json(&path, record)?; + Ok(path) +} + +pub fn launch_manifest() -> Result, String> { + let args: Vec<_> = std::env::args_os().collect(); + if let Some(index) = args.iter().position(|arg| arg == "--miniapp-launch") { + return args + .get(index + 1) + .map(|path| Some(PathBuf::from(path))) + .ok_or_else(|| "--miniapp-launch requires a launch record path".into()); + } + #[cfg(target_os = "macos")] + if let Ok(exe) = std::env::current_exe() { + if let Some(contents) = exe.parent().and_then(|parent| parent.parent()) { + let path = contents.join("Resources/openbitfun-miniapp.json"); + if path.is_file() { + return Ok(Some(path)); + } + } + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate as ipc; + fn fixture(root: &Path) -> LaunchRecord { + LaunchRecord { + protocol_version: 1, + launch_id: uuid::Uuid::new_v4().to_string(), + app_id: "notes".into(), + workspace_path: None, + data_dir: root.join("data"), + user_root: root.to_path_buf(), + host_executable: root.join("OpenBitFun"), + name: "Notes".into(), + extra: Default::default(), + } + } + #[test] + fn launch_records_accept_legacy_optional_fields_and_reject_future_protocol_without_rewriting() { + let root = tempfile::tempdir().unwrap(); + let record = fixture(root.path()); + let path = write_record(&record).unwrap(); + let mut value = serde_json::to_value(&record).unwrap(); + value.as_object_mut().unwrap().remove("protocolVersion"); + value.as_object_mut().unwrap().remove("workspacePath"); + value["futureField"] = serde_json::json!({"keep":"untouched"}); + ipc::private_json(&path, &value).unwrap(); + let legacy = read_record(&path).unwrap(); + assert_eq!(legacy.launch_id, record.launch_id); + assert_eq!(legacy.workspace_path, None); + write_record(&legacy).unwrap(); + let rewritten: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(rewritten["futureField"], value["futureField"]); + value["protocolVersion"] = serde_json::json!(99); + ipc::private_json(&path, &value).unwrap(); + let before = std::fs::read(&path).unwrap(); + assert!(read_record(&path).is_err()); + assert_eq!(std::fs::read(path).unwrap(), before); + } +} diff --git a/src/crates/adapters/miniapp-ipc/src/lib.rs b/src/crates/adapters/miniapp-ipc/src/lib.rs new file mode 100644 index 0000000000..e048e066f9 --- /dev/null +++ b/src/crates/adapters/miniapp-ipc/src/lib.rs @@ -0,0 +1,194 @@ +//! Desktop shell transport. Private per-user local IPC, bounded frames, no TCP listener. +pub mod launcher; +pub use openbitfun_product_domains::miniapp::instances::MINIAPP_PRESENTATION_PROTOCOL; + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +pub const MINIAPP_CUSTOM_CHROME_CAPABILITY: &str = "miniapp_custom_chrome_v1"; +pub const MINIAPP_CHAT_SNAPSHOT_CAPABILITY: &str = "miniapp_chat_snapshot_v1"; + +const MAX_FRAME: usize = 16 * 1024 * 1024; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Endpoint { + pub protocol_version: u32, + pub address: String, + pub token: String, + pub host_epoch: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub token: String, + pub protocol_version: u32, + pub method: String, + #[serde(default)] + pub params: Value, +} + +impl Request { + pub fn authorized(&self, endpoint: &Endpoint) -> bool { + self.token == endpoint.token + && self.protocol_version == endpoint.protocol_version + && self.protocol_version == MINIAPP_PRESENTATION_PROTOCOL + } +} + +pub async fn read(stream: &mut S) -> Result { + let size = stream.read_u32().await.map_err(|e| e.to_string())? as usize; + if size > MAX_FRAME { + return Err("MiniApp IPC frame is too large".into()); + } + let mut bytes = vec![0; size]; + stream + .read_exact(&mut bytes) + .await + .map_err(|e| e.to_string())?; + serde_json::from_slice(&bytes).map_err(|e| e.to_string()) +} + +pub async fn write( + stream: &mut S, + value: &T, +) -> Result<(), String> { + let bytes = serde_json::to_vec(value).map_err(|e| e.to_string())?; + if bytes.len() > MAX_FRAME { + return Err("MiniApp IPC frame is too large".into()); + } + stream + .write_u32(bytes.len() as u32) + .await + .map_err(|e| e.to_string())?; + stream.write_all(&bytes).await.map_err(|e| e.to_string())?; + stream.flush().await.map_err(|e| e.to_string()) +} + +pub fn endpoint_path(data_dir: &Path) -> PathBuf { + data_dir.join("miniapp-desktop/host.json") +} + +pub async fn call(endpoint: &Endpoint, method: &str, params: Value) -> Result { + if endpoint.protocol_version != MINIAPP_PRESENTATION_PROTOCOL { + return Err( + "MiniApp launcher protocol is incompatible; recreate the system shortcut in OpenBitFun" + .into(), + ); + } + #[cfg(unix)] + let mut stream = tokio::net::UnixStream::connect(&endpoint.address) + .await + .map_err(|e| e.to_string())?; + #[cfg(windows)] + let mut stream = tokio::net::windows::named_pipe::ClientOptions::new() + .open(&endpoint.address) + .map_err(|e| e.to_string())?; + write( + &mut stream, + &Request { + token: endpoint.token.clone(), + protocol_version: MINIAPP_PRESENTATION_PROTOCOL, + method: method.into(), + params, + }, + ) + .await?; + let response: Result = read(&mut stream).await?; + response +} + +pub fn private_json(path: &Path, value: &impl Serialize) -> Result<(), String> { + let parent = path.parent().ok_or("Missing MiniApp data directory")?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + } + let mut file = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + serde_json::to_writer(&mut file, value).map_err(|e| e.to_string())?; + file.as_file().sync_all().map_err(|e| e.to_string())?; + file.persist(path).map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + #[tokio::test] + async fn existing_v1_client_connects_and_receives_host_response() { + // A short socket directory avoids macOS sockaddr_un path limits. + let directory = tempfile::tempdir_in("/tmp").unwrap(); + let address = directory.path().join("host.sock"); + let listener = tokio::net::UnixListener::bind(&address).unwrap(); + let endpoint = Endpoint { + protocol_version: 1, + address: address.to_string_lossy().into_owned(), + token: "private".into(), + host_epoch: "epoch".into(), + }; + let expected = endpoint.clone(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let request: Request = read(&mut socket).await.unwrap(); + assert!(request.authorized(&expected)); + assert_eq!(request.method, "ping"); + write( + &mut socket, + &Ok::<_, String>(serde_json::json!({"hostEpoch":"epoch"})), + ) + .await + .unwrap(); + }); + let response = call(&endpoint, "ping", Value::Null).await.unwrap(); + assert_eq!(response["hostEpoch"], "epoch"); + server.await.unwrap(); + let future = Endpoint { + protocol_version: 99, + ..endpoint + }; + assert!(call(&future, "ping", Value::Null) + .await + .unwrap_err() + .contains("incompatible")); + } + #[tokio::test] + async fn framed_transport_requires_both_credentials_and_protocol() { + let endpoint = Endpoint { + address: "unused".into(), + token: "private-token".into(), + host_epoch: "host".into(), + protocol_version: 1, + }; + let (mut client, mut server) = tokio::io::duplex(1024); + let request = Request { + token: endpoint.token.clone(), + protocol_version: 1, + method: "ping".into(), + params: Value::Null, + }; + write(&mut client, &request).await.unwrap(); + let mut decoded: Request = read(&mut server).await.unwrap(); + assert!(decoded.authorized(&endpoint)); + decoded.protocol_version = 2; + assert!(!decoded.authorized(&endpoint)); + decoded.protocol_version = 1; + decoded.token = "different-user".into(); + assert!(!decoded.authorized(&endpoint)); + } + #[tokio::test] + async fn rejects_oversized_frames_before_allocating_payload() { + let (mut writer, mut reader) = tokio::io::duplex(32); + writer.write_u32((MAX_FRAME + 1) as u32).await.unwrap(); + assert!(read::(&mut reader) + .await + .unwrap_err() + .contains("too large")); + } +} diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md index 876efd0bfa..1dd35eacc1 100644 --- a/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md @@ -162,6 +162,14 @@ Node.js 或本地目录。更新前先读取应用,使用它的 `appId` 和 `e - `ui.js` 负责状态、渲染、事件、i18n - `worker.js` 只承载真正需要后台执行的逻辑 +窗口控制栏也属于 MiniApp 定制点:在 `meta.json` 的 `chrome.titlebar` +中声明外观、布局和应用动作;在 `source/ui.js` 通过 `app.chrome?.onAction` +处理动作。草稿预览包含独立窗口栏;应用内动作由主顶部栏承载。 +系统控件遵循宿主平台:macOS 保留原生红绿灯,Windows 复用主应用右侧窗口按钮。 +只定制标题、应用按钮和语义配色,不移动或重绘系统控件。 +配置字段与图标列表见 `api-reference.md` 的 MiniApp window chrome。 +不要在应用内容中再复制原生窗口按钮,也不要直接调用 Tauri。 + ### 6. 只使用真实存在的宿主能力 MiniApp 里可用的是 `window.app`。 diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/api-reference.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/api-reference.md index 45671c9fe3..61e8be39c4 100644 --- a/src/crates/assembly/core/builtin_skills/miniapp-dev/api-reference.md +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/api-reference.md @@ -474,3 +474,65 @@ const savePath = await app.dialog.save({ ``` 依赖 URL 的域名必须在 `permissions.net.allow` 中声明。 + +## MiniApp window chrome + +`meta.json` may contain an optional `chrome.titlebar`. The host renders a +customizable row while preserving platform window conventions: actual native +traffic lights on macOS, and the main app's right-hand caption controls on Windows. +The same declared app actions appear at the right of OpenBitFun's top bar when +embedded. The Shell keeps its native lifecycle controls and return-to-app icon. + +```json +{ + "chrome": { + "titlebar": { + "showTitle": true, + "showIcon": false, + "titleAlignment": "start", + "density": "compact", + "buttonShape": "rounded", + "surface": "surface-scene", + "foreground": "content-primary", + "border": "border-subtle", + "actions": [{ + "id": "export", "icon": "download", "label": "Export", + "labels": {"zh-CN": "导出", "zh-TW": "匯出"}, "enabled": true + }] + } + } +} +``` + +The app logo is hidden by default; set `showIcon: true` to display it. +An optional `title` overrides the localized app name. `titleAlignment` is `start` +or `center`; `density` is `compact` or `comfortable`; `buttonShape` is `square` +or `rounded` for app/return buttons. System controls cannot be moved or restyled +by the app. Legacy `controlsPosition` values are retained for compatibility and +ignored by the renderer. macOS keeps the main app's toolbar height even with +`compact` density, reserving space for its native traffic lights. Semantic `surface` choices +are `surface-scene`, `surface-canvas`, `surface-raised`, `surface-subtle`; +`foreground` is `content-primary`, `content-secondary`, `content-muted`; +`border` is `border-subtle`, `border-default`, `border-strong`. + +Action IDs must be unique and labels nonempty. Supported action icon keys: +`download`, `save`, `search`, `settings`, `refresh`, `plus`, `play`, `stop`, +`check`, `share`, `help`. Handle actions in `source/ui.js`: + +```js +const handleAction = ({ actionId }) => { + if (actionId === 'export') exportDocument(); +}; +app.chrome?.onAction(handleAction); +// app.chrome?.offAction(handleAction) removes this subscription. +``` + +Actions are delivered only to the owning content view, with its existing +permissions. They never invoke arbitrary native commands. In a draft, the +window controls are inert and the titlebar's appearance can be previewed; macOS +previews reserve native control space without drawing imitation traffic lights. +Editing `chrome` does not grant host permissions. Use `{ "chrome": {} }` to +restore defaults. `OpenBitFunControl` create/update operations also accept +`chrome`, so remote controllers can edit the configuration without accessing a +local path. Native presentation remains unavailable for remote workspaces, +Peer Device Mode, mobile and Detached Dispatch. diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs index ed491a9e01..e52f077eaa 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs @@ -612,6 +612,7 @@ mod tests { use openbitfun_product_domains::miniapp::types::{MiniAppI18n, MiniAppLocaleStrings}; use std::collections::HashMap; MiniAppMeta { + chrome: None, id: id.to_string(), name: name.to_string(), description: String::new(), diff --git a/src/crates/assembly/core/src/miniapp/manager.rs b/src/crates/assembly/core/src/miniapp/manager.rs index ce3c47615b..44170191bc 100644 --- a/src/crates/assembly/core/src/miniapp/manager.rs +++ b/src/crates/assembly/core/src/miniapp/manager.rs @@ -169,29 +169,40 @@ impl MiniAppManager { permissions: MiniAppPermissions, ai_context: Option, workspace_root: Option<&Path>, + ) -> OpenBitFunResult { + self.create_with_input( + MiniAppCreateInput { + name, + description, + icon, + category, + tags, + source, + permissions, + ai_context, + chrome: None, + }, + workspace_root, + ) + .await + } + + pub async fn create_with_input( + &self, + input: MiniAppCreateInput, + workspace_root: Option<&Path>, ) -> OpenBitFunResult { let id = Uuid::new_v4().to_string(); let now = Utc::now().timestamp_millis(); - - let compiled_html = - self.compile_source(&id, &source, &permissions, "dark", workspace_root)?; - + let compiled_html = self.compile_source( + &id, + &input.source, + &input.permissions, + "dark", + workspace_root, + )?; self.runtime_facade() - .create_app( - id, - MiniAppCreateInput { - name, - description, - icon, - category, - tags, - source, - permissions, - ai_context, - }, - compiled_html, - now, - ) + .create_app(id, input, compiled_html, now) .await .map_err(map_miniapp_port_error) } @@ -210,18 +221,32 @@ impl MiniAppManager { permissions: Option, ai_context: Option, workspace_root: Option<&Path>, + ) -> OpenBitFunResult { + self.update_with_patch( + app_id, + MiniAppUpdatePatch { + name, + description, + icon, + category, + tags, + source, + permissions, + ai_context, + chrome: None, + }, + workspace_root, + ) + .await + } + + pub async fn update_with_patch( + &self, + app_id: &str, + patch: MiniAppUpdatePatch, + workspace_root: Option<&Path>, ) -> OpenBitFunResult { let previous_app = self.storage.load(app_id).await?; - let patch = MiniAppUpdatePatch { - name, - description, - icon, - category, - tags, - source, - permissions, - ai_context, - }; let now = Utc::now().timestamp_millis(); let compiled_html = if self.uses_market_strict_runtime(app_id).await { self.compile_market_source( @@ -933,6 +958,7 @@ mod tests { let source_dir = root.join(SOURCE_DIR); tokio::fs::create_dir_all(&source_dir).await.unwrap(); let meta = MiniAppMeta { + chrome: None, id: "template-id".to_string(), name: "Imported".to_string(), description: "Imported app".to_string(), diff --git a/src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs b/src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs index a260d6f8e2..581ddee779 100644 --- a/src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs +++ b/src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs @@ -257,6 +257,26 @@ pub const GENERATED_SHARED_TERMS: &[GeneratedSharedTermEntry] = &[ key: "tools.write", value: "写入", }, + GeneratedSharedTermEntry { + locale: LocaleId::ZhCN, + key: "window.close", + value: "关闭应用窗口", + }, + GeneratedSharedTermEntry { + locale: LocaleId::ZhCN, + key: "window.maximize", + value: "最大化应用窗口", + }, + GeneratedSharedTermEntry { + locale: LocaleId::ZhCN, + key: "window.minimize", + value: "最小化应用窗口", + }, + GeneratedSharedTermEntry { + locale: LocaleId::ZhCN, + key: "window.restore", + value: "还原应用窗口", + }, GeneratedSharedTermEntry { locale: LocaleId::ZhTW, key: "agents.Claw", @@ -447,6 +467,26 @@ pub const GENERATED_SHARED_TERMS: &[GeneratedSharedTermEntry] = &[ key: "tools.write", value: "寫入", }, + GeneratedSharedTermEntry { + locale: LocaleId::ZhTW, + key: "window.close", + value: "關閉應用視窗", + }, + GeneratedSharedTermEntry { + locale: LocaleId::ZhTW, + key: "window.maximize", + value: "最大化應用視窗", + }, + GeneratedSharedTermEntry { + locale: LocaleId::ZhTW, + key: "window.minimize", + value: "最小化應用視窗", + }, + GeneratedSharedTermEntry { + locale: LocaleId::ZhTW, + key: "window.restore", + value: "還原應用視窗", + }, GeneratedSharedTermEntry { locale: LocaleId::EnUS, key: "agents.Claw", @@ -637,6 +677,26 @@ pub const GENERATED_SHARED_TERMS: &[GeneratedSharedTermEntry] = &[ key: "tools.write", value: "Write", }, + GeneratedSharedTermEntry { + locale: LocaleId::EnUS, + key: "window.close", + value: "Close app window", + }, + GeneratedSharedTermEntry { + locale: LocaleId::EnUS, + key: "window.maximize", + value: "Maximize app window", + }, + GeneratedSharedTermEntry { + locale: LocaleId::EnUS, + key: "window.minimize", + value: "Minimize app window", + }, + GeneratedSharedTermEntry { + locale: LocaleId::EnUS, + key: "window.restore", + value: "Restore app window", + }, ]; pub fn generated_locale_entry(id: LocaleId) -> &'static GeneratedLocaleContractEntry { diff --git a/src/crates/contracts/product-domains/AGENTS.md b/src/crates/contracts/product-domains/AGENTS.md index a79196e94e..9037389d84 100644 --- a/src/crates/contracts/product-domains/AGENTS.md +++ b/src/crates/contracts/product-domains/AGENTS.md @@ -68,6 +68,10 @@ cargo check -p openbitfun-core --features product-full For documentation-only changes, run `git diff --check`. +For MiniApp titlebar metadata and legacy/future-field preservation, run +`cargo test --locked -p openbitfun-product-domains --no-default-features --features miniapp --lib miniapp::chrome::tests` +and the MiniApp contracts target below. + For external session catalog and persisted candidate upgrade compatibility: ```bash diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 385f02f7c3..03ca0a83b0 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "1cd850650aa8b8df165b6fe69c90f060c240169f4becf163ecaba3fbe037000b", - "ownerDigest": "b96bf9292a92548facbd835b8b89c4865bfdd984b356c9001995f552c6a34f51", + "digest": "d5b8d80b7ff09050f81fab7d2dcc5125db12a0232af1ef8bf94ce571b08efd0f", + "ownerDigest": "0d90452aea46f54ca82cfe25b6d96f1f374e83b1962e2066d378c14c1cab35db", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -5944,6 +5944,10 @@ "category": { "type": "string" }, + "chrome": { + "description": "Per-app titlebar configuration; see MiniApp chrome.titlebar contract. Use {} to restore defaults.", + "type": "object" + }, "css": { "type": "string" }, @@ -6076,6 +6080,10 @@ "category": { "type": "string" }, + "chrome": { + "description": "Per-app titlebar configuration; see MiniApp chrome.titlebar contract. Use {} to restore defaults.", + "type": "object" + }, "css": { "type": "string" }, @@ -22672,6 +22680,10 @@ "category": { "type": "string" }, + "chrome": { + "description": "Per-app titlebar configuration; see MiniApp chrome.titlebar contract. Use {} to restore defaults.", + "type": "object" + }, "css": { "type": "string" }, @@ -22772,6 +22784,10 @@ "category": { "type": "string" }, + "chrome": { + "description": "Per-app titlebar configuration; see MiniApp chrome.titlebar contract. Use {} to restore defaults.", + "type": "object" + }, "css": { "type": "string" }, diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 8aa720ef80..190f8f8997 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:b89f0fab51cf3cff", + "digest": "fnv1a64:1d617d6e336f2a60", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -4892,6 +4892,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "miniapp_presentation", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "miniapp_recompile", "surface": "tauri_command", @@ -4940,6 +4952,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "miniapp_shell_call", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "miniapp_sync_draft_from_fs", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs index 1d53002689..9e9b0fe9a1 100644 --- a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs +++ b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs @@ -206,6 +206,12 @@ pub fn build_bridge_script( offUserMessage: (fn) => app.off('chat:userMessage', fn), }}, + // Per-app titlebar actions are delivered only to this content view. + chrome: {{ + onAction: (fn) => app.on('chrome:action', fn), + offAction: (fn) => app.off('chrome:action', fn), + }}, + // Clipboard namespace — proxies to host navigator.clipboard (bypasses sandbox restriction). clipboard: {{ writeText: (text) => _rpc('clipboard.writeText', {{ text }}), diff --git a/src/crates/contracts/product-domains/src/miniapp/chrome.rs b/src/crates/contracts/product-domains/src/miniapp/chrome.rs new file mode 100644 index 0000000000..cf0c846e59 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/chrome.rs @@ -0,0 +1,183 @@ +//! Per-MiniApp presentation data. Native window authority stays with the host. + +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashSet}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MiniAppChrome { + pub titlebar: MiniAppTitlebar, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MiniAppTitlebar { + /// Missing title follows the localized application name. + pub title: Option, + pub show_title: bool, + pub show_icon: bool, + pub title_alignment: String, + pub density: String, + pub button_shape: String, + /// Legacy metadata, preserved on disk. Native platforms own control placement. + pub controls_position: String, + /// Semantic token suffixes, never arbitrary CSS or executable markup. + pub surface: String, + pub foreground: String, + pub border: String, + pub actions: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl Default for MiniAppTitlebar { + fn default() -> Self { + Self { + title: None, + show_title: true, + show_icon: false, + title_alignment: "start".into(), + density: "comfortable".into(), + button_shape: "rounded".into(), + controls_position: "end".into(), + surface: "surface-scene".into(), + foreground: "content-primary".into(), + border: "border-subtle".into(), + actions: Vec::new(), + extra: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppChromeAction { + pub id: String, + pub label: String, + /// Keys follow the shared MiniApp chrome icon catalog. + pub icon: String, + #[serde(default)] + pub labels: BTreeMap, + #[serde(default = "enabled")] + pub enabled: bool, + #[serde(flatten)] + pub extra: BTreeMap, +} + +fn enabled() -> bool { + true +} + +impl MiniAppChrome { + pub fn validate(&self) -> Result<(), String> { + let bar = &self.titlebar; + for (field, value, choices) in [ + ( + "titleAlignment", + bar.title_alignment.as_str(), + &["start", "center"][..], + ), + ( + "density", + bar.density.as_str(), + &["compact", "comfortable"][..], + ), + ( + "buttonShape", + bar.button_shape.as_str(), + &["square", "rounded"][..], + ), + ( + "controlsPosition", + bar.controls_position.as_str(), + &["start", "end"][..], + ), + ( + "surface", + bar.surface.as_str(), + &[ + "surface-scene", + "surface-canvas", + "surface-raised", + "surface-subtle", + ][..], + ), + ( + "foreground", + bar.foreground.as_str(), + &["content-primary", "content-secondary", "content-muted"][..], + ), + ( + "border", + bar.border.as_str(), + &["border-subtle", "border-default", "border-strong"][..], + ), + ] { + if !choices.contains(&value) { + return Err(format!("Unsupported MiniApp chrome {field}: {value}")); + } + } + let mut ids = HashSet::new(); + for action in &bar.actions { + if action.id.trim().is_empty() || !ids.insert(&action.id) { + return Err("MiniApp chrome action IDs must be non-empty and unique".into()); + } + if action.label.trim().is_empty() { + return Err("MiniApp chrome actions require an accessible label".into()); + } + if ![ + "download", "save", "search", "settings", "refresh", "plus", "play", "stop", + "check", "share", "help", + ] + .contains(&action.icon.as_str()) + { + return Err(format!( + "Unsupported MiniApp chrome action icon: {}", + action.icon + )); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn partial_chrome_uses_defaults_and_roundtrips() { + for (old, show_icon) in [ + (serde_json::json!({"titlebar":{"showTitle":false}}), false), + ( + serde_json::json!({"titlebar":{"showTitle":false,"showIcon":true}}), + true, + ), + ] { + let chrome: MiniAppChrome = serde_json::from_value(old).unwrap(); + assert!(!chrome.titlebar.show_title); + assert_eq!(chrome.titlebar.show_icon, show_icon); + chrome.validate().unwrap(); + assert_eq!( + chrome, + serde_json::from_value(serde_json::to_value(&chrome).unwrap()).unwrap() + ); + } + } + + #[test] + fn rejects_css_injection_and_duplicate_actions() { + let mut chrome = MiniAppChrome::default(); + chrome.titlebar.surface = "url(https://example.com)".into(); + assert!(chrome.validate().is_err()); + chrome.titlebar.surface = "surface-scene".into(); + let action: MiniAppChromeAction = serde_json::from_value( + serde_json::json!({"id":"export","label":"Export","icon":"download"}), + ) + .unwrap(); + chrome.titlebar.actions = vec![action.clone(), action]; + assert!(chrome.validate().is_err()); + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/instances.rs b/src/crates/contracts/product-domains/src/miniapp/instances.rs new file mode 100644 index 0000000000..7273986504 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/instances.rs @@ -0,0 +1,176 @@ +//! MiniApp presentation contracts. Transfers retain the instance until all views close. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const MINIAPP_PRESENTATION_PROTOCOL: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MiniAppViewKind { + Window, + Tab, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppView { + pub view_id: String, + pub kind: MiniAppViewKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppInstance { + pub instance_id: String, + pub app_id: String, + pub workspace_path: Option, + pub primary_view: Option, + pub pending_view: Option, + /// Content control is deliberately not advertised by the presentation host. + #[serde(default)] + pub content_capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppInstanceSnapshot { + pub protocol_version: u32, + pub host_epoch: String, + pub revision: u64, + pub instances: Vec, +} + +/// Future semantic controls use declared actions, never arbitrary JS evaluation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppContentActionRequest { + pub instance_id: String, + pub action: String, + pub arguments: Value, + pub request_id: String, +} + +impl MiniAppInstance { + pub fn has_views(&self) -> bool { + self.primary_view.is_some() || self.pending_view.is_some() + } + + pub fn accepts_view(&self, view_id: &str) -> bool { + self.primary_view + .iter() + .chain(self.pending_view.iter()) + .any(|view| view.view_id == view_id) + } + + /// The old view remains authoritative until the prepared target reports ready. + pub fn prepare(&mut self, view: MiniAppView) -> Result { + if let Some(current) = &self.primary_view { + if current.kind == view.kind { + return Ok(current.clone()); + } + } + if let Some(pending) = &self.pending_view { + if pending.kind == view.kind { + return Ok(pending.clone()); + } + return Err("A MiniApp view transfer is already in progress".into()); + } + self.pending_view = Some(view.clone()); + Ok(view) + } + + pub fn ready(&mut self, view_id: &str) -> Result, String> { + if self + .primary_view + .as_ref() + .is_some_and(|view| view.view_id == view_id) + { + return Ok(None); + } + if !self + .pending_view + .as_ref() + .is_some_and(|view| view.view_id == view_id) + { + return Err("MiniApp view is stale or was not prepared".into()); + } + Ok(std::mem::replace( + &mut self.primary_view, + self.pending_view.take(), + )) + } + + pub fn detach(&mut self, view_id: &str) { + if self + .primary_view + .as_ref() + .is_some_and(|view| view.view_id == view_id) + { + self.primary_view = None; + } + if self + .pending_view + .as_ref() + .is_some_and(|view| view.view_id == view_id) + { + self.pending_view = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn instance() -> MiniAppInstance { + MiniAppInstance { + instance_id: "instance".into(), + app_id: "app".into(), + workspace_path: None, + primary_view: Some(MiniAppView { + view_id: "old".into(), + kind: MiniAppViewKind::Window, + }), + pending_view: None, + content_capabilities: vec![], + } + } + #[test] + fn failed_transfer_keeps_source_and_stale_ready_cannot_close_it() { + let mut value = instance(); + value + .prepare(MiniAppView { + view_id: "new".into(), + kind: MiniAppViewKind::Tab, + }) + .unwrap(); + assert_eq!(value.primary_view.as_ref().unwrap().view_id, "old"); + value.detach("new"); + assert!(value.ready("new").is_err()); + assert_eq!(value.primary_view.as_ref().unwrap().view_id, "old"); + } + #[test] + fn commit_is_idempotent_and_late_close_does_not_detach_target() { + let mut value = instance(); + value + .prepare(MiniAppView { + view_id: "new".into(), + kind: MiniAppViewKind::Tab, + }) + .unwrap(); + assert_eq!(value.ready("new").unwrap().unwrap().view_id, "old"); + assert!(value.ready("new").unwrap().is_none()); + value.detach("old"); + assert_eq!(value.primary_view.as_ref().unwrap().view_id, "new"); + } + #[test] + fn older_instance_payload_has_no_content_control_capability() { + let old = serde_json::json!({"instanceId":"i","appId":"a","workspacePath":null,"primaryView":null,"pendingView":null}); + let value: MiniAppInstance = serde_json::from_value(old.clone()).unwrap(); + assert!(value.content_capabilities.is_empty()); + let roundtrip = serde_json::to_value(value).unwrap(); + for (key, expected) in old.as_object().unwrap() { + assert_eq!(&roundtrip[key], expected); + } + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs b/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs index 434b9b2a2d..7f2ad902b8 100644 --- a/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs +++ b/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs @@ -18,6 +18,7 @@ pub struct MiniAppCreateInput { pub source: MiniAppSource, pub permissions: MiniAppPermissions, pub ai_context: Option, + pub chrome: Option, } #[derive(Debug, Clone, Default)] @@ -30,6 +31,7 @@ pub struct MiniAppUpdatePatch { pub source: Option, pub permissions: Option, pub ai_context: Option, + pub chrome: Option, } impl MiniAppUpdatePatch { @@ -65,7 +67,7 @@ fn canonical_json(value: Value) -> Value { /// Hash the user-controlled MiniApp content while excluding lifecycle counters, /// compiled output, and runtime state. pub fn miniapp_content_hash(app: &MiniApp) -> String { - let payload = canonical_json(json!({ + let mut payload = json!({ "name": &app.name, "description": &app.description, "icon": &app.icon, @@ -76,7 +78,12 @@ pub fn miniapp_content_hash(app: &MiniApp) -> String { "aiContext": &app.ai_context, "runtimeProfile": app.runtime_profile, "i18n": &app.i18n, - })); + }); + // Keep hashes of legacy apps stable when the new optional field is absent. + if let Some(chrome) = &app.chrome { + payload["chrome"] = json!(chrome); + } + let payload = canonical_json(payload); let encoded = serde_json::to_vec(&payload) .expect("MiniApp content consists only of serializable domain fields"); format!("sha256:{}", hex::encode(Sha256::digest(encoded))) @@ -129,6 +136,7 @@ pub fn build_created_app( ); let mut app = MiniApp { + chrome: input.chrome, id, name: input.name, description: input.description, @@ -181,6 +189,9 @@ pub fn apply_update_patch( if let Some(permissions) = patch.permissions { app.permissions = permissions; } + if let Some(chrome) = patch.chrome { + app.chrome = Some(chrome); + } if let Some(ai_context) = patch.ai_context { app.ai_context = Some(ai_context); } @@ -277,6 +288,7 @@ pub fn apply_draft_to_active( app.permissions = draft.permissions; app.ai_context = draft.ai_context; app.i18n = draft.i18n; + app.chrome = draft.chrome; app.version = current.version + 1; app.updated_at = now; app.compiled_html = compiled_html; @@ -465,6 +477,7 @@ mod tests { #[test] fn prepare_imported_meta_rehomes_identity_and_timestamps_without_changing_product_fields() { let mut meta = crate::miniapp::types::MiniAppMeta { + chrome: None, id: "template-id".to_string(), name: "Imported".to_string(), description: "Imported app".to_string(), @@ -496,6 +509,7 @@ mod tests { let app = build_created_app( "app-1".to_string(), MiniAppCreateInput { + chrome: None, name: "App".to_string(), description: "Desc".to_string(), icon: "box".to_string(), diff --git a/src/crates/contracts/product-domains/src/miniapp/market.rs b/src/crates/contracts/product-domains/src/miniapp/market.rs index 5a9cc05ad2..623ec78f6c 100644 --- a/src/crates/contracts/product-domains/src/miniapp/market.rs +++ b/src/crates/contracts/product-domains/src/miniapp/market.rs @@ -194,6 +194,8 @@ pub struct MarketSubmissionDraftRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MarketPackageMeta { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chrome: Option, pub name: String, pub description: String, pub icon: String, diff --git a/src/crates/contracts/product-domains/src/miniapp/mod.rs b/src/crates/contracts/product-domains/src/miniapp/mod.rs index bd079c5007..19e4294811 100644 --- a/src/crates/contracts/product-domains/src/miniapp/mod.rs +++ b/src/crates/contracts/product-domains/src/miniapp/mod.rs @@ -4,11 +4,13 @@ pub mod agent_bridge; pub mod ai_bridge; pub mod bridge_builder; pub mod builtin; +pub mod chrome; pub mod compiler; pub mod customization; pub mod draft; pub mod exporter; pub mod host_routing; +pub mod instances; pub mod lifecycle; pub mod market; pub mod permission_policy; diff --git a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs index 5a9d296410..8201e9c1fa 100644 --- a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs +++ b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs @@ -31,6 +31,17 @@ use crate::miniapp::storage::{ }; use crate::miniapp::types::{MiniApp, MiniAppMeta, MiniAppRuntimeProfile, MiniAppSource}; +fn validate_chrome( + chrome: Option<&crate::miniapp::chrome::MiniAppChrome>, +) -> MiniAppPortResult<()> { + if let Some(chrome) = chrome { + chrome + .validate() + .map_err(|error| MiniAppPortError::new(MiniAppPortErrorKind::InvalidInput, error))?; + } + Ok(()) +} + /// Storage-backed facade for MiniApp runtime-state lifecycle transitions. pub struct MiniAppRuntimeFacade<'a> { storage: &'a dyn MiniAppStoragePort, @@ -71,6 +82,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { compiled_html: String, now: i64, ) -> MiniAppPortResult { + validate_chrome(input.chrome.as_ref())?; let app = build_created_app(id, input, compiled_html, now); self.storage.save(app.clone()).await?; Ok(app) @@ -84,6 +96,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { compiled_html: String, now: i64, ) -> MiniAppPortResult { + validate_chrome(patch.chrome.as_ref())?; let app = apply_update_patch(&previous_app, patch, compiled_html, now); self.storage .save_version(app_id, previous_app.version, previous_app) @@ -172,6 +185,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { } let MarketPackageMeta { + chrome, name, description, icon, @@ -184,6 +198,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { let mut next = apply_update_patch( &previous, MiniAppUpdatePatch { + chrome: chrome.clone(), name: Some(name), description: Some(description), icon: Some(icon), @@ -197,6 +212,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { now, ); next.i18n = i18n; + next.chrome = chrome; next.runtime_profile = MiniAppRuntimeProfile::MarketStrict; next.runtime.content_hash = miniapp_content_hash(&next); let metadata = @@ -219,6 +235,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { now: i64, ) -> MiniAppPortResult { let MarketPackageMeta { + chrome, name, description, icon, @@ -231,6 +248,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { let mut app = build_created_app( id, MiniAppCreateInput { + chrome, name, description, icon, @@ -296,6 +314,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { let app_id = draft.app_id.clone(); let draft_root = draft.draft_root.clone(); let mut manifest = manifest_from_draft(&draft); + validate_chrome(draft.app.chrome.as_ref())?; let app = apply_draft_source_sync_result(draft.app, compiled_html, now); manifest.updated_at = app.updated_at; self.save_draft_with_manifest(app_id, draft_root, app, manifest) @@ -359,6 +378,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { baseline: MiniAppCustomizationBaseline, now: i64, ) -> MiniAppPortResult { + validate_chrome(draft_app.chrome.as_ref())?; let app_id = current.id.clone(); let app = apply_draft_to_active(¤t, draft_app, compiled_html, now); if app.version != current.version { @@ -851,6 +871,7 @@ mod tests { fn imported_app() -> MiniApp { MiniApp { + chrome: None, id: "imported".to_string(), name: "Imported".to_string(), description: "Imported app".to_string(), @@ -1023,6 +1044,7 @@ mod tests { fn market_package_meta(version: u32) -> MarketPackageMeta { MarketPackageMeta { + chrome: None, name: "Market App".to_string(), description: "A reviewed app".to_string(), icon: "box".to_string(), diff --git a/src/crates/contracts/product-domains/src/miniapp/types.rs b/src/crates/contracts/product-domains/src/miniapp/types.rs index db0d378e18..35052e3625 100644 --- a/src/crates/contracts/product-domains/src/miniapp/types.rs +++ b/src/crates/contracts/product-domains/src/miniapp/types.rs @@ -255,6 +255,8 @@ pub struct MiniApp { /// Optional per-locale overrides for `name` / `description` / `tags`. #[serde(default, skip_serializing_if = "Option::is_none")] pub i18n: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chrome: Option, } /// MiniApp metadata only (for list views; no source/compiled_html). @@ -281,6 +283,8 @@ pub struct MiniAppMeta { /// Optional per-locale overrides for `name` / `description` / `tags`. #[serde(default, skip_serializing_if = "Option::is_none")] pub i18n: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chrome: Option, } impl From<&MiniApp> for MiniAppMeta { @@ -300,6 +304,7 @@ impl From<&MiniApp> for MiniAppMeta { runtime: app.runtime.clone(), runtime_profile: app.runtime_profile, i18n: app.i18n.clone(), + chrome: app.chrome.clone(), } } } diff --git a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs index 332f3752ac..61d0dd0c8d 100644 --- a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs +++ b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs @@ -213,6 +213,7 @@ fn miniapp_input(action: &str) -> Value { json!({ "type": "array", "items": { "type": "string" } }), ); properties.insert("permissions".into(), json!({ "type": "object" })); + properties.insert("chrome".into(), json!({ "type": "object", "description": "Per-app titlebar configuration; see MiniApp chrome.titlebar contract. Use {} to restore defaults." })); properties.insert("esmDependencies".into(), json!({ "type": "array", "items": { "type": "object", "required": ["name"], "additionalProperties": false, "properties": { "name": { "type": "string" }, "version": { "type": "string" }, "url": { "type": "string" } } diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 0173c65631..b618e39094 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -496,10 +496,12 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("miniapp_market_submit_installed", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_market_withdraw_submission", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_permission_diff_for_draft", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("miniapp_presentation", LocalOnly, ControllerLocal, REFUSED), op("miniapp_recompile", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_render_slide_page", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_runtime_status", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_set_draft_permissions", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("miniapp_shell_call", LocalOnly, ControllerLocal, REFUSED), op("miniapp_sync_draft_from_fs", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_sync_from_fs", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_worker_call", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs index 278a0cb378..b09e578c7a 100644 --- a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs @@ -1297,6 +1297,7 @@ fn miniapp_lifecycle_create_and_update_helpers_preserve_manager_contract() { let created = build_created_app( "app-1".to_string(), MiniAppCreateInput { + chrome: None, name: "Demo".to_string(), description: "Demo app".to_string(), icon: "sparkles".to_string(), @@ -1340,6 +1341,7 @@ fn miniapp_lifecycle_create_and_update_helpers_preserve_manager_contract() { ..MiniAppPermissions::default() }; let patch = MiniAppUpdatePatch { + chrome: None, name: Some("Updated".to_string()), source: Some(updated_source.clone()), permissions: Some(updated_permissions.clone()), @@ -1382,6 +1384,7 @@ fn miniapp_lifecycle_create_and_update_helpers_preserve_manager_contract() { let metadata_only = apply_update_patch( &updated, MiniAppUpdatePatch { + chrome: None, tags: Some(vec!["metadata".to_string()]), ..MiniAppUpdatePatch::default() }, @@ -1898,6 +1901,7 @@ fn miniapp_runtime_facade_owns_manager_create_update_draft_and_apply_workflows() let created = block_on(facade.create_app( "created".to_string(), MiniAppCreateInput { + chrome: None, name: "Created".to_string(), description: "Created app".to_string(), icon: "box".to_string(), @@ -1922,6 +1926,7 @@ fn miniapp_runtime_facade_owns_manager_create_update_draft_and_apply_workflows() "created".to_string(), created.clone(), MiniAppUpdatePatch { + chrome: None, source: Some(MiniAppSource { css: "body { color: red; }".to_string(), ..MiniAppSource::default() @@ -2300,6 +2305,7 @@ fn miniapp_customization_decline_policy_updates_existing_and_trims_old_records() fn sample_miniapp_for_lifecycle(source: MiniAppSource) -> MiniApp { MiniApp { + chrome: None, id: "demo".to_string(), name: "Demo".to_string(), description: "Demo app".to_string(), @@ -2318,3 +2324,42 @@ fn sample_miniapp_for_lifecycle(source: MiniAppSource) -> MiniApp { i18n: None, } } + +#[test] +fn miniapp_chrome_legacy_roundtrip_and_draft_rollback_keep_app_identity() { + use openbitfun_product_domains::miniapp::chrome::MiniAppChrome; + let app = sample_miniapp_for_lifecycle(MiniAppSource::default()); + let mut old = serde_json::to_value(&app).unwrap(); + old.as_object_mut().unwrap().remove("chrome"); + let legacy: MiniApp = serde_json::from_value(old.clone()).unwrap(); + assert!(legacy.chrome.is_none()); + let roundtrip = serde_json::to_value(&legacy).unwrap(); + assert_eq!(roundtrip, old); + let chrome: MiniAppChrome = serde_json::from_value(serde_json::json!({ + "futureChromeField": {"enabled":true}, + "titlebar": {"title":"Custom", "density":"compact", "futureBarField":7, + "actions":[{"id":"export","icon":"download","label":"Export","futureActionField":true}]} + })) + .unwrap(); + chrome.validate().unwrap(); + let mut draft = legacy.clone(); + draft.chrome = Some(chrome.clone()); + let applied = apply_draft_to_active(&legacy, draft, "same".into(), 5000); + assert_eq!(applied.id, legacy.id); + assert_eq!(applied.chrome, Some(chrome.clone())); + assert_eq!(applied.version, legacy.version + 1); + assert_ne!( + miniapp_content_hash(&applied), + miniapp_content_hash(&legacy) + ); + let encoded = serde_json::to_value(&applied).unwrap(); + assert_eq!(encoded["chrome"]["futureChromeField"]["enabled"], true); + assert_eq!(encoded["chrome"]["titlebar"]["futureBarField"], 7); + assert_eq!( + encoded["chrome"]["titlebar"]["actions"][0]["futureActionField"], + true + ); + let rolled_back = prepare_rollback_app(&applied, legacy, 6000); + assert_eq!(rolled_back.id, applied.id); + assert!(rolled_back.chrome.is_none()); +} diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 7b62b9fcd6..48956ad60f 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -158,6 +158,11 @@ boundaries, and bounded disk storage), run: cargo test --locked -p openbitfun-services-integrations --no-default-features --features miniapp-market --lib market_image::tests ``` +For MiniApp instance leases, storage transactions, and Worker lifecycle, use +`cargo test --locked -p openbitfun-services-integrations --no-default-features --features miniapp-runtime --lib miniapp::`. +Framework Worker storage shares the host JSON transaction owner; preserve custom +`worker.js` method overrides before routing built-in `storage.*` requests. + On Windows with an initialized WSL distribution, set `OPENBITFUN_TEST_WSL_DISTRO` and run `cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib wsl_windows_workspace_transport -- --ignored` diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index e0e197ff18..a64d771849 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -175,6 +175,7 @@ mcp = [ "process-tree", ] miniapp-storage = [ + "openbitfun-services-core/json-io", "dep:openbitfun-product-domains", "openbitfun-product-domains/miniapp", "tokio/fs", diff --git a/src/crates/services/services-integrations/src/miniapp/instances.rs b/src/crates/services/services-integrations/src/miniapp/instances.rs new file mode 100644 index 0000000000..2cefa7ee4c --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/instances.rs @@ -0,0 +1,369 @@ +//! One host-owned presentation registry shared by tabs and independently launched shells. +use openbitfun_product_domains::miniapp::instances::*; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +pub struct MiniAppInstances { + state: Mutex, +} +struct State { + snapshot: MiniAppInstanceSnapshot, + leases: HashMap, +} + +impl Default for MiniAppInstances { + fn default() -> Self { + Self { + state: Mutex::new(State { + snapshot: MiniAppInstanceSnapshot { + protocol_version: MINIAPP_PRESENTATION_PROTOCOL, + host_epoch: Uuid::new_v4().to_string(), + revision: 0, + instances: vec![], + }, + leases: HashMap::new(), + }), + } + } +} + +impl MiniAppInstances { + /// A native container may keep committed views alive while its WebView + /// timers are suspended. Pending transfers must still acknowledge ready. + pub fn renew_committed_views(&self, kind: MiniAppViewKind) { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + let views: Vec<_> = state + .snapshot + .instances + .iter() + .filter_map(|instance| instance.primary_view.as_ref()) + .filter(|view| view.kind == kind) + .map(|view| view.view_id.clone()) + .collect(); + for view in views { + state.leases.insert(view, Instant::now()); + } + } + + pub fn snapshot(&self) -> MiniAppInstanceSnapshot { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + let expired: Vec<_> = state + .leases + .iter() + .filter(|(_, at)| at.elapsed() > Duration::from_secs(45)) + .map(|(id, _)| id.clone()) + .collect(); + for id in expired { + state.leases.remove(&id); + for instance in &mut state.snapshot.instances { + instance.detach(&id); + } + state.snapshot.revision += 1; + } + state.snapshot.clone() + } + + pub fn prepare( + &self, + app_id: String, + workspace_path: Option, + kind: MiniAppViewKind, + ) -> Result<(MiniAppInstance, MiniAppView), String> { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + // Existing worker/Agent policies are app-scoped. Until their owner supports + // concurrent workspace scopes, reject an unsafe second scope explicitly. + let index = match state + .snapshot + .instances + .iter() + .position(|i| i.app_id == app_id) + { + Some(index) => { + if state.snapshot.instances[index].workspace_path != workspace_path { + return Err("This MiniApp is bound to another workspace; close its instance before changing workspace".into()); + } + index + } + None => { + state.snapshot.instances.push(MiniAppInstance { + instance_id: Uuid::new_v4().to_string(), + app_id, + workspace_path, + primary_view: None, + pending_view: None, + content_capabilities: vec![], + }); + state.snapshot.instances.len() - 1 + } + }; + let view = state.snapshot.instances[index].prepare(MiniAppView { + view_id: Uuid::new_v4().to_string(), + kind, + })?; + let instance = state.snapshot.instances[index].clone(); + state.leases.insert(view.view_id.clone(), Instant::now()); + state.snapshot.revision += 1; + Ok((instance, view)) + } + + pub fn get(&self, instance_id: &str) -> Result { + self.state + .lock() + .unwrap_or_else(|p| p.into_inner()) + .snapshot + .instances + .iter() + .find(|i| i.instance_id == instance_id) + .cloned() + .ok_or_else(|| "MiniApp instance no longer exists; reconnect to the host".into()) + } + + pub fn touch(&self, instance_id: &str, view_id: &str) -> Result { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + let instance = state + .snapshot + .instances + .iter() + .find(|i| i.instance_id == instance_id) + .ok_or("MiniApp instance no longer exists")? + .clone(); + if !instance.accepts_view(view_id) { + return Err("MiniApp view was released".into()); + } + // A target which never acknowledges ready must expire even if its UI + // keeps polling. Heartbeats renew only committed presentations. + if instance + .primary_view + .as_ref() + .is_some_and(|view| view.view_id == view_id) + { + state.leases.insert(view_id.into(), Instant::now()); + } + Ok(instance) + } + + pub fn ready(&self, instance_id: &str, view_id: &str) -> Result, String> { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + let instance = state + .snapshot + .instances + .iter_mut() + .find(|i| i.instance_id == instance_id) + .ok_or("MiniApp instance no longer exists")?; + let previous = instance.ready(view_id)?; + state.leases.insert(view_id.into(), Instant::now()); + if let Some(previous) = &previous { + state.leases.remove(&previous.view_id); + } + state.snapshot.revision += 1; + Ok(previous) + } + + /// Return an unpresented instance for host-owned shutdown. A transfer's + /// prepared destination counts as a view even before its ready handshake. + pub fn detach(&self, instance_id: &str, view_id: &str) -> Option { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(instance) = state + .snapshot + .instances + .iter_mut() + .find(|i| i.instance_id == instance_id) + { + instance.detach(view_id); + let unpresented = (!instance.has_views()).then(|| instance.clone()); + state.leases.remove(view_id); + state.snapshot.revision += 1; + return unpresented; + } + None + } + + pub fn retire(&self, instance_id: &str) { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(index) = state + .snapshot + .instances + .iter() + .position(|i| i.instance_id == instance_id) + { + let instance = state.snapshot.instances.remove(index); + for view in instance + .primary_view + .iter() + .chain(instance.pending_view.iter()) + { + state.leases.remove(&view.view_id); + } + state.snapshot.revision += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn closing_a_transfer_source_keeps_the_target_and_last_close_requests_shutdown() { + for source_kind in [MiniAppViewKind::Window, MiniAppViewKind::Tab] { + let target_kind = if source_kind == MiniAppViewKind::Window { + MiniAppViewKind::Tab + } else { + MiniAppViewKind::Window + }; + let registry = MiniAppInstances::default(); + let (instance, source) = registry.prepare("notes".into(), None, source_kind).unwrap(); + registry + .ready(&instance.instance_id, &source.view_id) + .unwrap(); + let (_, target) = registry.prepare("notes".into(), None, target_kind).unwrap(); + let (other, _) = registry + .prepare("other".into(), None, MiniAppViewKind::Tab) + .unwrap(); + assert!(registry + .detach(&instance.instance_id, &source.view_id) + .is_none()); + registry + .ready(&instance.instance_id, &target.view_id) + .unwrap(); + // A delayed native Close event from the old presentation is harmless. + assert!(registry + .detach(&instance.instance_id, &source.view_id) + .is_none()); + let stopped = registry + .detach(&instance.instance_id, &target.view_id) + .unwrap(); + assert!(!stopped.has_views()); + assert_eq!(stopped.app_id, "notes"); + assert!(registry.get(&other.instance_id).unwrap().has_views()); + registry.retire(&stopped.instance_id); + assert!(registry + .detach(&instance.instance_id, &target.view_id) + .is_none()); + } + } + + #[test] + fn failed_target_keeps_source_but_abandoned_initial_launch_becomes_unpresented() { + let registry = MiniAppInstances::default(); + let (instance, source) = registry + .prepare("notes".into(), None, MiniAppViewKind::Window) + .unwrap(); + registry + .ready(&instance.instance_id, &source.view_id) + .unwrap(); + let (_, target) = registry + .prepare("notes".into(), None, MiniAppViewKind::Tab) + .unwrap(); + assert!(registry + .detach(&instance.instance_id, &target.view_id) + .is_none()); + assert_eq!( + registry.get(&instance.instance_id).unwrap().primary_view, + Some(source) + ); + let (initial, target) = registry + .prepare("new".into(), None, MiniAppViewKind::Tab) + .unwrap(); + assert!(registry + .detach(&initial.instance_id, &target.view_id) + .is_some()); + } + + #[test] + fn a_crashed_last_window_is_unpresented_after_lease_expiry() { + let registry = MiniAppInstances::default(); + let (instance, window) = registry + .prepare("notes".into(), None, MiniAppViewKind::Window) + .unwrap(); + registry + .ready(&instance.instance_id, &window.view_id) + .unwrap(); + registry + .state + .lock() + .unwrap() + .leases + .insert(window.view_id, Instant::now() - Duration::from_secs(46)); + assert!(!registry.snapshot().instances[0].has_views()); + } + #[test] + fn independent_launch_reuses_instance_and_window_but_fences_workspace() { + let registry = MiniAppInstances::default(); + let (a, view) = registry + .prepare("app".into(), None, MiniAppViewKind::Window) + .unwrap(); + registry.ready(&a.instance_id, &view.view_id).unwrap(); + let (b, second) = registry + .prepare("app".into(), None, MiniAppViewKind::Window) + .unwrap(); + assert_eq!(a.instance_id, b.instance_id); + assert_eq!(view, second); + assert!(registry + .prepare( + "app".into(), + Some("/another".into()), + MiniAppViewKind::Window + ) + .is_err()); + let _ = registry.detach(&a.instance_id, &view.view_id); + assert!(registry.get(&a.instance_id).unwrap().primary_view.is_none()); + registry.retire(&a.instance_id); + assert!(registry + .prepare( + "app".into(), + Some("/another".into()), + MiniAppViewKind::Window + ) + .is_ok()); + } + #[test] + fn native_container_keeps_committed_tabs_alive_but_cannot_commit_pending_views() { + let registry = MiniAppInstances::default(); + let (instance, tab) = registry + .prepare("notes".into(), None, MiniAppViewKind::Tab) + .unwrap(); + registry.ready(&instance.instance_id, &tab.view_id).unwrap(); + let (_, target) = registry + .prepare("notes".into(), None, MiniAppViewKind::Window) + .unwrap(); + { + let mut state = registry.state.lock().unwrap(); + for view in [&tab, &target] { + state.leases.insert( + view.view_id.clone(), + Instant::now() - Duration::from_secs(46), + ); + } + } + registry.renew_committed_views(MiniAppViewKind::Tab); + let snapshot = registry.snapshot(); + assert_eq!(snapshot.instances[0].primary_view.as_ref(), Some(&tab)); + assert!(snapshot.instances[0].pending_view.is_none()); + } + #[test] + fn target_heartbeat_does_not_keep_failed_transfer_alive() { + let registry = MiniAppInstances::default(); + let (instance, window) = registry + .prepare("app".into(), None, MiniAppViewKind::Window) + .unwrap(); + registry + .ready(&instance.instance_id, &window.view_id) + .unwrap(); + let (_, tab) = registry + .prepare("app".into(), None, MiniAppViewKind::Tab) + .unwrap(); + registry.state.lock().unwrap().leases.insert( + tab.view_id.clone(), + Instant::now() - Duration::from_secs(46), + ); + registry.touch(&instance.instance_id, &tab.view_id).unwrap(); + let snapshot = registry.snapshot(); + assert!(snapshot.instances[0].pending_view.is_none()); + assert_eq!( + snapshot.instances[0].primary_view.as_ref().unwrap().view_id, + window.view_id + ); + } +} diff --git a/src/crates/services/services-integrations/src/miniapp/mod.rs b/src/crates/services/services-integrations/src/miniapp/mod.rs index fe88f7cc1d..7ae8e95792 100644 --- a/src/crates/services/services-integrations/src/miniapp/mod.rs +++ b/src/crates/services/services-integrations/src/miniapp/mod.rs @@ -4,6 +4,8 @@ pub mod builtin_io; #[cfg(feature = "miniapp-runtime")] pub mod host_dispatch; +#[cfg(feature = "miniapp-runtime")] +pub mod instances; pub mod storage; #[cfg(feature = "miniapp-runtime")] pub mod worker; diff --git a/src/crates/services/services-integrations/src/miniapp/storage.rs b/src/crates/services/services-integrations/src/miniapp/storage.rs index 3df61e62a5..d64e0ebbc9 100644 --- a/src/crates/services/services-integrations/src/miniapp/storage.rs +++ b/src/crates/services/services-integrations/src/miniapp/storage.rs @@ -12,10 +12,52 @@ use openbitfun_product_domains::miniapp::storage::{ VERSIONS_DIR, WORKER_JS, }; use openbitfun_product_domains::miniapp::types::{MiniApp, MiniAppMeta, MiniAppSource, NpmDep}; +use openbitfun_services_core::json_store::JsonFileStore; use serde_json; use std::path::{Path, PathBuf}; use std::time::Duration; +/// Shared by manager storage and the Worker framework bridge. A malformed file +/// is an error, never an empty replacement. The legacy object format is retained. +pub(crate) async fn read_storage_file(path: &Path) -> MiniAppStorageResult { + let value: serde_json::Map = JsonFileStore + .read_optional(path) + .await + .map_err(|error| { + if error.is_deserialization() { + MiniAppStorageError::parse(error) + } else { + MiniAppStorageError::io(error) + } + })? + .unwrap_or_default(); + Ok(serde_json::Value::Object(value)) +} + +pub(crate) async fn update_storage_file( + path: &Path, + key: &str, + value: serde_json::Value, +) -> MiniAppStorageResult<()> { + JsonFileStore + .update_locked( + path, + serde_json::Map::::new(), + |object| { + object.insert(key.to_string(), value); + }, + ) + .await + .map_err(|error| { + if error.is_deserialization() { + MiniAppStorageError::parse(error) + } else { + MiniAppStorageError::io(error) + } + })?; + Ok(()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MiniAppStorageErrorKind { NotFound, @@ -433,6 +475,7 @@ impl MiniAppStorage { let compiled_html = self.load_compiled_html(app_id).await?; Ok(MiniApp { + chrome: meta.chrome, id: meta.id, name: meta.name, description: meta.description, @@ -682,6 +725,9 @@ impl MiniAppStorage { source_dir: &std::path::Path, app: &MiniApp, ) -> MiniAppStorageResult<()> { + if let Some(chrome) = &app.chrome { + chrome.validate().map_err(MiniAppStorageError::parse)?; + } tokio::fs::create_dir_all(app_dir).await.map_err(|e| { MiniAppStorageError::io(format!( "Failed to create miniapp dir {}: {}", @@ -817,6 +863,7 @@ impl MiniAppStorage { } })?; Ok(MiniApp { + chrome: meta.chrome, id: meta.id, name: meta.name, description: meta.description, @@ -1000,14 +1047,7 @@ impl MiniAppStorage { /// Load app storage (KV JSON). Returns empty object if missing. pub async fn load_app_storage(&self, app_id: &str) -> MiniAppStorageResult { - let p = self.storage_path(app_id); - if !p.exists() { - return Ok(serde_json::json!({})); - } - let c = tokio::fs::read_to_string(&p) - .await - .map_err(|e| MiniAppStorageError::io(format!("Failed to read storage: {}", e)))?; - Ok(serde_json::from_str(&c).unwrap_or_else(|_| serde_json::json!({}))) + read_storage_file(&self.storage_path(app_id)).await } pub async fn load_draft_storage( @@ -1017,13 +1057,7 @@ impl MiniAppStorage { ) -> MiniAppStorageResult { self.ensure_active_drafts_root_readable(app_id, draft_id)?; let p = self.draft_dir(app_id, draft_id).join(STORAGE_JSON); - if !p.exists() { - return Ok(serde_json::json!({})); - } - let c = tokio::fs::read_to_string(&p) - .await - .map_err(|e| MiniAppStorageError::io(format!("Failed to read draft storage: {}", e)))?; - Ok(serde_json::from_str(&c).unwrap_or_else(|_| serde_json::json!({}))) + read_storage_file(&p).await } /// Save app storage (merge with existing or replace). @@ -1034,17 +1068,7 @@ impl MiniAppStorage { value: serde_json::Value, ) -> MiniAppStorageResult<()> { self.ensure_app_dir(app_id).await?; - let mut current = self.load_app_storage(app_id).await?; - let obj = current.as_object_mut().ok_or_else(|| { - MiniAppStorageError::validation("App storage is not an object".to_string()) - })?; - obj.insert(key.to_string(), value); - let p = self.storage_path(app_id); - let json = serde_json::to_string_pretty(¤t).map_err(MiniAppStorageError::parse)?; - tokio::fs::write(&p, json) - .await - .map_err(|e| MiniAppStorageError::io(format!("Failed to write storage: {}", e)))?; - Ok(()) + update_storage_file(&self.storage_path(app_id), key, value).await } pub async fn save_draft_storage( @@ -1059,18 +1083,7 @@ impl MiniAppStorage { tokio::fs::create_dir_all(&dir) .await .map_err(|e| MiniAppStorageError::io(format!("Failed to create draft dir: {}", e)))?; - let mut current = self.load_draft_storage(app_id, draft_id).await?; - let obj = current.as_object_mut().ok_or_else(|| { - MiniAppStorageError::validation("Draft storage is not an object".to_string()) - })?; - obj.insert(key.to_string(), value); - let json = serde_json::to_string_pretty(¤t).map_err(MiniAppStorageError::parse)?; - tokio::fs::write(dir.join(STORAGE_JSON), json) - .await - .map_err(|e| { - MiniAppStorageError::io(format!("Failed to write draft storage: {}", e)) - })?; - Ok(()) + update_storage_file(&dir.join(STORAGE_JSON), key, value).await } pub async fn load_customization_metadata( @@ -1480,6 +1493,42 @@ mod tests { assert!(port.list_app_ids().await.unwrap().is_empty()); } + #[tokio::test] + async fn concurrent_storage_owners_merge_keys_and_preserve_legacy_json() { + let root = TestTempDir::new("openbitfun-miniapp-storage-concurrency"); + let path = root.path().join("storage.json"); + fs::write(&path, r#"{"legacy":{"nested":[1,true,null]}}"#).unwrap(); + let mut writes = Vec::new(); + for index in 0..24 { + let path = path.clone(); + writes.push(tokio::spawn(async move { + update_storage_file(&path, &format!("key-{index}"), serde_json::json!(index)) + .await + .unwrap(); + })); + } + for task in writes { + task.await.unwrap(); + } + let value = read_storage_file(&path).await.unwrap(); + assert_eq!(value["legacy"], serde_json::json!({"nested":[1,true,null]})); + assert_eq!(value.as_object().unwrap().len(), 25); + } + + #[tokio::test] + async fn invalid_storage_is_reported_without_overwriting_original_bytes() { + let root = TestTempDir::new("openbitfun-miniapp-storage-corrupt"); + let path = root.path().join("storage.json"); + for source in ["{partial", "[1,2]", "null"] { + fs::write(&path, source).unwrap(); + assert!(read_storage_file(&path).await.is_err()); + assert!(update_storage_file(&path, "new", serde_json::json!(1)) + .await + .is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), source); + } + } + #[tokio::test] async fn storage_adapter_uses_product_domain_layout_contract() { let root = std::env::temp_dir().join(format!( @@ -1904,8 +1953,47 @@ mod tests { } } + #[tokio::test] + async fn chrome_survives_disk_draft_version_and_legacy_roundtrips() { + let root = TestTempDir::new("openbitfun-miniapp-chrome"); + let storage = MiniAppStorage::new(root.path().join("miniapps")); + let mut app = sample_app("chrome-app"); + storage.save(&app).await.unwrap(); + assert!(storage.load(&app.id).await.unwrap().chrome.is_none()); + app.chrome = Some( + serde_json::from_value( + serde_json::json!({"titlebar":{"title":"Window", "showIcon":false}}), + ) + .unwrap(), + ); + storage.save(&app).await.unwrap(); + storage + .save_draft(&app.id, "draft", &app, &serde_json::json!({})) + .await + .unwrap(); + storage.save_version(&app.id, 1, &app).await.unwrap(); + assert_eq!(storage.load(&app.id).await.unwrap().chrome, app.chrome); + assert_eq!( + storage + .load_draft_app(&app.id, "draft") + .await + .unwrap() + .chrome, + app.chrome + ); + assert_eq!( + storage.load_version(&app.id, 1).await.unwrap().chrome, + app.chrome + ); + let original = fs::read(storage.meta_path(&app.id)).unwrap(); + app.chrome.as_mut().unwrap().titlebar.surface = "invalid-css".into(); + assert!(storage.save(&app).await.is_err()); + assert_eq!(fs::read(storage.meta_path(&app.id)).unwrap(), original); + } + fn sample_app(id: &str) -> MiniApp { MiniApp { + chrome: None, id: id.to_string(), name: "Demo".to_string(), description: "Demo app".to_string(), diff --git a/src/crates/services/services-integrations/src/miniapp/worker.rs b/src/crates/services/services-integrations/src/miniapp/worker.rs index a623663a44..74cf30f535 100644 --- a/src/crates/services/services-integrations/src/miniapp/worker.rs +++ b/src/crates/services/services-integrations/src/miniapp/worker.rs @@ -9,7 +9,7 @@ use std::pin::Pin; use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::Arc; use std::time::Duration; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin}; use tokio::sync::{oneshot, Mutex}; @@ -34,7 +34,7 @@ pub type SharedMiniAppWorkerEventSink = Arc; /// Single JS Worker process: stdin for requests, stderr for RPC responses, stdout for user logs. pub struct JsWorker { _child: Child, - stdin: Mutex>, + stdin: Arc>>, pending: Arc>, last_activity: Arc, } @@ -64,6 +64,9 @@ impl JsWorker { .map_err(|e| format!("Failed to spawn JS Worker: {}", e))?; let stdin_handle = child.stdin.take().ok_or("No stdin")?; + let stdin = Arc::new(Mutex::new(Some(stdin_handle))); + let storage_stdin = Arc::clone(&stdin); + let storage_path = app_dir.join("storage.json"); let stderr = child.stderr.take().ok_or("No stderr")?; let _stdout = child.stdout.take(); @@ -97,6 +100,44 @@ impl JsWorker { Err(_) => continue, }; + if msg.get("hostRequest").and_then(Value::as_str) == Some("storage") { + let result = async { + let key = msg["params"]["key"] + .as_str() + .ok_or("Storage key must be a string".to_string())?; + match msg["method"].as_str() { + Some("storage.get") => { + Ok(super::storage::read_storage_file(&storage_path) + .await + .map_err(|e| e.to_string())? + .get(key) + .cloned() + .unwrap_or(Value::Null)) + } + Some("storage.set") => { + super::storage::update_storage_file( + &storage_path, + key, + msg["params"]["value"].clone(), + ) + .await + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } + _ => Err("Unsupported framework storage method".to_string()), + } + } + .await; + let response = match result { + Ok(value) => serde_json::json!({"hostResponse":msg["id"],"result":value}), + Err(error) => serde_json::json!({"hostResponse":msg["id"],"error":error}), + }; + if let Some(stdin) = storage_stdin.lock().await.as_mut() { + let _ = stdin.write_all(format!("{response}\n").as_bytes()).await; + let _ = stdin.flush().await; + } + continue; + } // Lines with an `id` are RPC responses — route to the pending map. let id = msg.get("id").and_then(Value::as_str).map(String::from); if let Some(id) = id { @@ -136,7 +177,7 @@ impl JsWorker { Ok(Self { _child: child, - stdin: Mutex::new(Some(stdin_handle)), + stdin, pending, last_activity, }) diff --git a/src/crates/services/services-integrations/src/miniapp_market/package.rs b/src/crates/services/services-integrations/src/miniapp_market/package.rs index 943db277b2..2998888d4b 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/package.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/package.rs @@ -179,6 +179,7 @@ pub fn validate_market_package(bytes: &[u8]) -> Result Result, MarketPackageError> { let meta = MarketPackageMeta { + chrome: app.chrome.clone(), name: app.name.clone(), description: app.description.clone(), icon: app.icon.clone(), @@ -302,6 +303,11 @@ fn validate_no_remote_executable_content( } fn validate_package_meta(meta: &MarketPackageMeta) -> Result<(), MarketPackageError> { + if let Some(chrome) = &meta.chrome { + chrome + .validate() + .map_err(|error| MarketPackageError::new("invalid_chrome", error))?; + } if meta.name.trim().is_empty() || meta.name.chars().count() > 80 { return Err(MarketPackageError::new( "invalid_name", @@ -403,6 +409,10 @@ mod tests { #[test] fn generated_package_roundtrips_through_strict_validator() { let app = MiniApp { + chrome: Some( + serde_json::from_value(serde_json::json!({"titlebar":{"title":"Packaged window"}})) + .unwrap(), + ), id: "local-id".to_string(), name: "Safe App".to_string(), description: "A safe application".to_string(), @@ -437,6 +447,7 @@ mod tests { let bytes = build_market_package(&app).unwrap(); let validated = validate_market_package(&bytes).unwrap(); assert_eq!(validated.meta.name, "Safe App"); + assert_eq!(validated.meta.chrome, app.chrome); assert!(!validated.sha256.is_empty()); } diff --git a/src/mobile-web/src/i18n/generatedLocaleContract.ts b/src/mobile-web/src/i18n/generatedLocaleContract.ts index 885800a5eb..096da421f0 100644 --- a/src/mobile-web/src/i18n/generatedLocaleContract.ts +++ b/src/mobile-web/src/i18n/generatedLocaleContract.ts @@ -36,6 +36,12 @@ export type SharedI18nTerms = { export const SHARED_TERMS_BY_LOCALE = { "zh-CN": { + "window": { + "minimize": "最小化应用窗口", + "maximize": "最大化应用窗口", + "restore": "还原应用窗口", + "close": "关闭应用窗口" + }, "product": { "name": "OpenBitFun", "remote": "OpenBitFun Remote" @@ -92,6 +98,12 @@ export const SHARED_TERMS_BY_LOCALE = { } }, "zh-TW": { + "window": { + "minimize": "最小化應用視窗", + "maximize": "最大化應用視窗", + "restore": "還原應用視窗", + "close": "關閉應用視窗" + }, "product": { "name": "OpenBitFun", "remote": "OpenBitFun Remote" @@ -148,6 +160,12 @@ export const SHARED_TERMS_BY_LOCALE = { } }, "en-US": { + "window": { + "minimize": "Minimize app window", + "maximize": "Maximize app window", + "restore": "Restore app window", + "close": "Close app window" + }, "product": { "name": "OpenBitFun", "remote": "OpenBitFun Remote" diff --git a/src/shared/i18n/resources/shared/en-US/terms.json b/src/shared/i18n/resources/shared/en-US/terms.json index c7f073fb9a..71ade45678 100644 --- a/src/shared/i18n/resources/shared/en-US/terms.json +++ b/src/shared/i18n/resources/shared/en-US/terms.json @@ -1,4 +1,10 @@ { + "window": { + "minimize": "Minimize app window", + "maximize": "Maximize app window", + "restore": "Restore app window", + "close": "Close app window" + }, "product": { "name": "OpenBitFun", "remote": "OpenBitFun Remote" diff --git a/src/shared/i18n/resources/shared/zh-CN/terms.json b/src/shared/i18n/resources/shared/zh-CN/terms.json index 8fa482ea7f..6eba6ade31 100644 --- a/src/shared/i18n/resources/shared/zh-CN/terms.json +++ b/src/shared/i18n/resources/shared/zh-CN/terms.json @@ -1,4 +1,10 @@ { + "window": { + "minimize": "最小化应用窗口", + "maximize": "最大化应用窗口", + "restore": "还原应用窗口", + "close": "关闭应用窗口" + }, "product": { "name": "OpenBitFun", "remote": "OpenBitFun Remote" diff --git a/src/shared/i18n/resources/shared/zh-TW/terms.json b/src/shared/i18n/resources/shared/zh-TW/terms.json index 16ae4d8b18..1e6b968dea 100644 --- a/src/shared/i18n/resources/shared/zh-TW/terms.json +++ b/src/shared/i18n/resources/shared/zh-TW/terms.json @@ -1,4 +1,10 @@ { + "window": { + "minimize": "最小化應用視窗", + "maximize": "最大化應用視窗", + "restore": "還原應用視窗", + "close": "關閉應用視窗" + }, "product": { "name": "OpenBitFun", "remote": "OpenBitFun Remote" diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 6f30a334ac..b2c8ce1e49 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -228,6 +228,7 @@ "hostBridges": [ "mark_openbitfun_control_surface_ready", "mark_openbitfun_control_surface_unready", + "miniapp_shell_call", "peer_host_invoke_complete", "product_control_invoke", "report_openbitfun_control_result", @@ -3117,6 +3118,7 @@ "command:miniapp_host_call", "command:miniapp_worker_call", "command:miniapp_runtime_status", + "command:miniapp_presentation", "command:miniapp_worker_list_running", "command:miniapp_worker_stop", "command:miniapp_draft_host_call", diff --git a/src/shared/miniapp-chrome/generated/theme.css b/src/shared/miniapp-chrome/generated/theme.css new file mode 100644 index 0000000000..1bce133a40 --- /dev/null +++ b/src/shared/miniapp-chrome/generated/theme.css @@ -0,0 +1,296 @@ +/* Generated by scripts/generate-miniapp-chrome-theme.mjs. */ +:root { + color-scheme: dark; + --openbitfun-border-width-default: 1px; + --openbitfun-control-button-xs-padding-inline: 6px; + --openbitfun-control-chat-composer-action-icon-size: 14px; + --openbitfun-control-chat-composer-compact-gap: 12px; + --openbitfun-control-chat-composer-compact-height: 42px; + --openbitfun-control-chat-composer-compact-padding-block: 8px; + --openbitfun-control-chat-composer-compact-padding-inline: 8px; + --openbitfun-control-chat-composer-compact-track-height: 24px; + --openbitfun-control-chat-composer-control-height: 24px; + --openbitfun-control-composer-context-blur: 10px; + --openbitfun-control-composer-surface-gap: 12px; + --openbitfun-control-composer-surface-padding: 8px; + --openbitfun-control-composer-surface-radius: 16px; + --openbitfun-control-flow-chat-capsule-inline-size: 18rem; + --openbitfun-control-flow-chat-card-expanded-padding-block: 8px; + --openbitfun-control-flow-chat-card-expanded-padding-inline: 12px; + --openbitfun-control-flow-chat-card-gap: 8px; + --openbitfun-control-flow-chat-content-padding-inline: 0.75rem; + --openbitfun-control-flow-chat-content-padding-inline-mobile: 0.75rem; + --openbitfun-control-flow-chat-control-gap: 8px; + --openbitfun-control-flow-chat-flow-item-gap: 8px; + --openbitfun-control-flow-chat-inline-gap: 4px; + --openbitfun-control-flow-chat-metadata-action-size: 28px; + --openbitfun-control-flow-chat-paragraph-gap: 12px; + --openbitfun-control-flow-chat-row-icon-size: 14px; + --openbitfun-control-flow-chat-transcript-max-width: 900px; + --openbitfun-control-flow-chat-turn-rail-offset: 4px; + --openbitfun-control-flow-chat-turn-rail-width: 30px; + --openbitfun-control-flow-chat-user-message-margin-block-start: 0.06rem; + --openbitfun-control-flow-chat-user-message-max-width: min(72%, 48rem); + --openbitfun-control-flow-chat-user-message-padding-block: 0.46rem; + --openbitfun-control-flow-chat-user-message-radius: calc(8px + 2px); + --openbitfun-control-height-lg: 48px; + --openbitfun-control-height-md: 40px; + --openbitfun-control-height-sm: 32px; + --openbitfun-control-icon-button-standard-icon-size: 16px; + --openbitfun-control-icon-button-standard-size: 30px; + --openbitfun-control-icon-button-xs-icon-size: 14px; + --openbitfun-control-icon-button-xs-size: 22px; + --openbitfun-control-icon-size-lg: 24px; + --openbitfun-control-icon-size-md: 16px; + --openbitfun-control-icon-size-sm: 14px; + --openbitfun-control-icon-size-xs: 12px; + --openbitfun-control-launcher-button-block-size: 40px; + --openbitfun-control-launcher-button-gap: 4px; + --openbitfun-control-launcher-button-icon-size: 12px; + --openbitfun-control-launcher-button-min-inline-size: 72px; + --openbitfun-control-launcher-button-padding-inline: 10px; + --openbitfun-control-launcher-button-radius: 12px; + --openbitfun-control-tool-card-ambient-row-min-block-size: calc(14px * 1.58); + --openbitfun-focus-offset: 2px; + --openbitfun-focus-width: 2px; + --openbitfun-layer-dropdown: 60; + --openbitfun-layer-fullscreen: 280; + --openbitfun-layer-overlay: 100; + --openbitfun-layer-popover: 360; + --openbitfun-layout-toolbar-md-height: 45px; + --openbitfun-motion-duration-fast: 140ms; + --openbitfun-motion-duration-loop: 720ms; + --openbitfun-motion-easing-smooth: cubic-bezier(0.77, 0, 0.175, 1); + --openbitfun-motion-easing-standard: cubic-bezier(0.23, 1, 0.32, 1); + --openbitfun-radius-base: 8px; + --openbitfun-radius-lg: 12px; + --openbitfun-radius-pill: 9999px; + --openbitfun-radius-sm: 6px; + --openbitfun-radius-xs: 4px; + --openbitfun-space-1: 4px; + --openbitfun-space-12: 48px; + --openbitfun-space-2: 8px; + --openbitfun-space-3: 12px; + --openbitfun-space-4: 16px; + --openbitfun-space-6: 24px; + --openbitfun-space-8: 32px; + --openbitfun-type-body-lg-line-height: 1.6; + --openbitfun-type-body-md-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --openbitfun-type-body-md-font-weight: 400; + --openbitfun-type-body-md-line-height: 1.5; + --openbitfun-type-body-sm-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --openbitfun-type-body-sm-font-size: 13px; + --openbitfun-type-body-sm-font-weight: 400; + --openbitfun-type-body-sm-letter-spacing: 0em; + --openbitfun-type-body-sm-line-height: 1.5; + --openbitfun-type-code-md-font-family: 'Fira Code', 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Courier New', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif, monospace; + --openbitfun-type-code-meta-font-family: 'Fira Code', 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Courier New', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif, monospace; + --openbitfun-type-code-meta-font-size: 11px; + --openbitfun-type-code-meta-font-weight: 400; + --openbitfun-type-code-meta-letter-spacing: 0em; + --openbitfun-type-code-meta-line-height: 1.2; + --openbitfun-type-flow-body-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --openbitfun-type-flow-body-font-size: 14px; + --openbitfun-type-flow-body-font-weight: 400; + --openbitfun-type-flow-body-letter-spacing: 0em; + --openbitfun-type-flow-body-line-height: 1.58; + --openbitfun-type-flow-control-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --openbitfun-type-flow-control-font-size: 13px; + --openbitfun-type-flow-control-font-weight: 400; + --openbitfun-type-flow-meta-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --openbitfun-type-flow-meta-font-size: 11px; + --openbitfun-type-flow-meta-font-weight: 400; + --openbitfun-type-flow-meta-line-height: 1.32; + --openbitfun-type-flow-micro-font-size: 10px; + --openbitfun-type-flow-support-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --openbitfun-type-flow-support-font-size: 12px; + --openbitfun-type-flow-support-font-weight: 400; + --openbitfun-type-flow-support-letter-spacing: 0em; + --openbitfun-type-flow-support-line-height: 1.45; + --openbitfun-type-heading-dialog-font-size: 24px; + --openbitfun-type-heading-dialog-letter-spacing: 0em; + --openbitfun-type-heading-navigation-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --openbitfun-type-heading-navigation-font-size: 17px; + --openbitfun-type-heading-navigation-font-weight: 600; + --openbitfun-type-heading-navigation-letter-spacing: 0em; + --openbitfun-type-heading-navigation-line-height: 1.2; + --openbitfun-type-heading-page-font-weight: 700; + --openbitfun-type-label-selected-font-weight: 600; + --openbitfun-type-label-sm-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --openbitfun-type-label-sm-font-size: 12px; + --openbitfun-type-label-sm-font-weight: 500; + --openbitfun-type-meta-line-height: 1.32; + --openbitfun-type-modifier-leading-dense-line-height: 1.25; + --openbitfun-type-modifier-leading-none-line-height: 1; + --openbitfun-type-modifier-tracking-subtle-letter-spacing: 0.01em; + --openbitfun-type-modifier-tracking-wide-letter-spacing: 0.02em; + --openbitfun-type-modifier-tracking-wider-letter-spacing: 0.04em; + --openbitfun-type-support-line-height: 1.55; + --openbitfun-color-accent-default: #60a5fa; + --openbitfun-color-accent-hover: #3b82f6; + --openbitfun-color-action-neutral-content-disabled: #555555; + --openbitfun-color-action-neutral-surface: rgba(255, 255, 255, 0.1); + --openbitfun-color-action-neutral-surface-hover: rgba(255, 255, 255, 0.12); + --openbitfun-color-action-neutral-surface-pressed: rgba(255, 255, 255, 0.15); + --openbitfun-color-action-primary-background: rgba(255, 255, 255, 0.16); + --openbitfun-color-action-primary-content: #f3f3f5; + --openbitfun-color-action-primary-hover: rgba(255, 255, 255, 0.24); + --openbitfun-color-action-primary-pressed: rgba(255, 255, 255, 0.2); + --openbitfun-color-action-quiet-content: #b0b0b0; + --openbitfun-color-action-quiet-hover: rgba(255, 255, 255, 0.06); + --openbitfun-color-border-default: rgba(255, 255, 255, 0.18); + --openbitfun-color-border-strong: rgba(255, 255, 255, 0.3); + --openbitfun-color-border-subtle: rgba(255, 255, 255, 0.12); + --openbitfun-color-composer-border: rgba(255, 255, 255, 0.18); + --openbitfun-color-composer-context-background: rgba(255, 255, 255, 0.06); + --openbitfun-color-content-inverse: #0e0e10; + --openbitfun-color-content-muted: #858585; + --openbitfun-color-content-on-dark: #ffffff; + --openbitfun-color-content-on-light: #000000; + --openbitfun-color-content-primary: #e8e8e8; + --openbitfun-color-content-secondary: #b0b0b0; + --openbitfun-color-control-launcher-background: rgba(255, 255, 255, 0.15); + --openbitfun-color-control-launcher-background-hover: color-mix(in srgb, #059cb0 20%, transparent); + --openbitfun-color-control-launcher-background-pressed: color-mix(in srgb, #059cb0 30%, transparent); + --openbitfun-color-control-launcher-content: #b0b0b0; + --openbitfun-color-control-launcher-content-disabled: #555555; + --openbitfun-color-control-launcher-content-hover: #059cb0; + --openbitfun-color-control-launcher-content-pressed: #059cb0; + --openbitfun-color-field-border-hover: rgba(255, 255, 255, 0.24); + --openbitfun-color-focus-ring: #60a5fa; + --openbitfun-color-overlay-scrim: rgba(0, 0, 0, 0.56); + --openbitfun-color-status-danger-border: rgba(236, 34, 31, 0.3); + --openbitfun-color-status-danger-content: #f47f7d; + --openbitfun-color-status-danger-emphasis: #ec221f; + --openbitfun-color-status-danger-surface: rgba(236, 34, 31, 0.1); + --openbitfun-color-status-success-content: #5fc178; + --openbitfun-color-status-success-surface: rgba(26, 167, 62, 0.1); + --openbitfun-color-status-warning-border: rgba(255, 140, 0, 0.3); + --openbitfun-color-status-warning-content: #ff930f; + --openbitfun-color-status-warning-surface: rgba(255, 140, 0, 0.1); + --openbitfun-color-surface-canvas: #0e0e10; + --openbitfun-color-surface-panel: #1c1c1f; + --openbitfun-color-surface-raised: #1c1c1f; + --openbitfun-color-surface-scene: #1c1c1f; + --openbitfun-color-surface-subtle: rgba(255, 255, 255, 0.06); + --openbitfun-color-surface-tertiary: #0e0e10; + --openbitfun-effect-blur-medium: blur(12px) saturate(1.2); + --openbitfun-opacity-disabled: 0.6; + --openbitfun-shadow-base: 0 4px 8px rgba(0, 0, 0, 0.7); + --openbitfun-shadow-composer: 0 2px 6px rgba(0, 0, 0, 0.32); + --openbitfun-shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.6); +} +:root[data-appearance-mode='light'] { + color-scheme: light; + --openbitfun-color-accent-default: #059cb0; + --openbitfun-color-accent-hover: #059cb0; + --openbitfun-color-action-neutral-content-disabled: rgba(0, 0, 0, 0.30); + --openbitfun-color-action-neutral-surface: rgba(0, 0, 0, 0.05); + --openbitfun-color-action-neutral-surface-hover: rgba(0, 0, 0, 0.08); + --openbitfun-color-action-neutral-surface-pressed: rgba(0, 0, 0, 0.10); + --openbitfun-color-action-primary-background: #101a27; + --openbitfun-color-action-primary-content: #ffffff; + --openbitfun-color-action-primary-hover: #1c1c1f; + --openbitfun-color-action-primary-pressed: #000000; + --openbitfun-color-action-quiet-content: rgba(0, 0, 0, 0.60); + --openbitfun-color-action-quiet-hover: #f3f3f5; + --openbitfun-color-border-default: rgba(16, 26, 39, 0.15); + --openbitfun-color-border-strong: rgba(16, 26, 39, 0.34); + --openbitfun-color-border-subtle: rgba(16, 26, 39, 0.08); + --openbitfun-color-composer-border: rgba(0, 0, 0, 0.08); + --openbitfun-color-composer-context-background: rgba(0, 0, 0, 0.03); + --openbitfun-color-content-inverse: #ffffff; + --openbitfun-color-content-muted: #6a6a6a; + --openbitfun-color-content-on-dark: #ffffff; + --openbitfun-color-content-on-light: #000000; + --openbitfun-color-content-primary: rgba(0, 0, 0, 0.80); + --openbitfun-color-content-secondary: rgba(0, 0, 0, 0.60); + --openbitfun-color-control-launcher-background: rgba(0, 0, 0, 0.08); + --openbitfun-color-control-launcher-background-hover: color-mix(in srgb, #059cb0 20%, transparent); + --openbitfun-color-control-launcher-background-pressed: color-mix(in srgb, #059cb0 30%, transparent); + --openbitfun-color-control-launcher-content: rgba(0, 0, 0, 0.80); + --openbitfun-color-control-launcher-content-disabled: rgba(0, 0, 0, 0.30); + --openbitfun-color-control-launcher-content-hover: #059cb0; + --openbitfun-color-control-launcher-content-pressed: #059cb0; + --openbitfun-color-field-border-hover: rgba(0, 0, 0, 0.20); + --openbitfun-color-focus-ring: #6a6a6a; + --openbitfun-color-overlay-scrim: rgba(16, 26, 39, 0.20); + --openbitfun-color-status-danger-border: rgba(236, 34, 31, 0.3); + --openbitfun-color-status-danger-content: #bd1b19; + --openbitfun-color-status-danger-emphasis: #ec221f; + --openbitfun-color-status-danger-surface: rgba(236, 34, 31, 0.1); + --openbitfun-color-status-success-content: #12722a; + --openbitfun-color-status-success-surface: rgba(26, 167, 62, 0.1); + --openbitfun-color-status-warning-border: rgba(255, 140, 0, 0.3); + --openbitfun-color-status-warning-content: #8a4c00; + --openbitfun-color-status-warning-surface: rgba(255, 140, 0, 0.1); + --openbitfun-color-surface-canvas: #fdfdfd; + --openbitfun-color-surface-panel: #ffffff; + --openbitfun-color-surface-raised: #ffffff; + --openbitfun-color-surface-scene: #ffffff; + --openbitfun-color-surface-subtle: rgba(16, 26, 39, 0.03); + --openbitfun-color-surface-tertiary: #f7f7f7; + --openbitfun-effect-blur-medium: blur(12px) saturate(1.2); + --openbitfun-opacity-disabled: 0.55; + --openbitfun-shadow-base: 0 4px 8px rgba(16, 26, 39, 0.07); + --openbitfun-shadow-composer: 0 2px 12px rgba(0, 0, 0, 0.08); + --openbitfun-shadow-lg: 0 8px 16px rgba(16, 26, 39, 0.09); +} +@media (prefers-color-scheme: light) { + :root:not([data-appearance-mode]) { + color-scheme: light; + --openbitfun-color-accent-default: #059cb0; + --openbitfun-color-accent-hover: #059cb0; + --openbitfun-color-action-neutral-content-disabled: rgba(0, 0, 0, 0.30); + --openbitfun-color-action-neutral-surface: rgba(0, 0, 0, 0.05); + --openbitfun-color-action-neutral-surface-hover: rgba(0, 0, 0, 0.08); + --openbitfun-color-action-neutral-surface-pressed: rgba(0, 0, 0, 0.10); + --openbitfun-color-action-primary-background: #101a27; + --openbitfun-color-action-primary-content: #ffffff; + --openbitfun-color-action-primary-hover: #1c1c1f; + --openbitfun-color-action-primary-pressed: #000000; + --openbitfun-color-action-quiet-content: rgba(0, 0, 0, 0.60); + --openbitfun-color-action-quiet-hover: #f3f3f5; + --openbitfun-color-border-default: rgba(16, 26, 39, 0.15); + --openbitfun-color-border-strong: rgba(16, 26, 39, 0.34); + --openbitfun-color-border-subtle: rgba(16, 26, 39, 0.08); + --openbitfun-color-composer-border: rgba(0, 0, 0, 0.08); + --openbitfun-color-composer-context-background: rgba(0, 0, 0, 0.03); + --openbitfun-color-content-inverse: #ffffff; + --openbitfun-color-content-muted: #6a6a6a; + --openbitfun-color-content-on-dark: #ffffff; + --openbitfun-color-content-on-light: #000000; + --openbitfun-color-content-primary: rgba(0, 0, 0, 0.80); + --openbitfun-color-content-secondary: rgba(0, 0, 0, 0.60); + --openbitfun-color-control-launcher-background: rgba(0, 0, 0, 0.08); + --openbitfun-color-control-launcher-background-hover: color-mix(in srgb, #059cb0 20%, transparent); + --openbitfun-color-control-launcher-background-pressed: color-mix(in srgb, #059cb0 30%, transparent); + --openbitfun-color-control-launcher-content: rgba(0, 0, 0, 0.80); + --openbitfun-color-control-launcher-content-disabled: rgba(0, 0, 0, 0.30); + --openbitfun-color-control-launcher-content-hover: #059cb0; + --openbitfun-color-control-launcher-content-pressed: #059cb0; + --openbitfun-color-field-border-hover: rgba(0, 0, 0, 0.20); + --openbitfun-color-focus-ring: #6a6a6a; + --openbitfun-color-overlay-scrim: rgba(16, 26, 39, 0.20); + --openbitfun-color-status-danger-border: rgba(236, 34, 31, 0.3); + --openbitfun-color-status-danger-content: #bd1b19; + --openbitfun-color-status-danger-emphasis: #ec221f; + --openbitfun-color-status-danger-surface: rgba(236, 34, 31, 0.1); + --openbitfun-color-status-success-content: #12722a; + --openbitfun-color-status-success-surface: rgba(26, 167, 62, 0.1); + --openbitfun-color-status-warning-border: rgba(255, 140, 0, 0.3); + --openbitfun-color-status-warning-content: #8a4c00; + --openbitfun-color-status-warning-surface: rgba(255, 140, 0, 0.1); + --openbitfun-color-surface-canvas: #fdfdfd; + --openbitfun-color-surface-panel: #ffffff; + --openbitfun-color-surface-raised: #ffffff; + --openbitfun-color-surface-scene: #ffffff; + --openbitfun-color-surface-subtle: rgba(16, 26, 39, 0.03); + --openbitfun-color-surface-tertiary: #f7f7f7; + --openbitfun-effect-blur-medium: blur(12px) saturate(1.2); + --openbitfun-opacity-disabled: 0.55; + --openbitfun-shadow-base: 0 4px 8px rgba(16, 26, 39, 0.07); + --openbitfun-shadow-composer: 0 2px 12px rgba(0, 0, 0, 0.08); + --openbitfun-shadow-lg: 0 8px 16px rgba(16, 26, 39, 0.09); + } +} diff --git a/src/shared/miniapp-chrome/icons.generated.json b/src/shared/miniapp-chrome/icons.generated.json new file mode 100644 index 0000000000..b6f52a7b2f --- /dev/null +++ b/src/shared/miniapp-chrome/icons.generated.json @@ -0,0 +1,61 @@ +{ + "app": "", + "return": "", + "popout": "", + "minimize": "", + "maximize": "", + "restore": "", + "close": "", + "download": "", + "save": "", + "search": "", + "settings": "", + "refresh": "", + "plus": "", + "play": "", + "stop": "", + "check": "", + "share": "", + "help": "", + "send": "", + "suggestion": "", + "Aperture": "", + "aperture": "", + "Box": "", + "box": "", + "Bot": "", + "bot": "", + "Code": "", + "code": "", + "Database": "", + "database": "", + "FileText": "", + "file-text": "", + "GitPullRequest": "", + "git-pull-request": "", + "Grid3x3": "", + "grid3x3": "", + "LayoutGrid": "", + "layout-grid": "", + "Presentation": "", + "presentation": "", + "Regex": "", + "regex": "", + "Rocket": "", + "rocket": "", + "Workflow": "", + "workflow": "", + "Wrench": "", + "wrench": "", + "AppWindow": "", + "app-window": "", + "Globe": "", + "globe": "", + "Image": "", + "image": "", + "Settings": "", + "Sparkles": "", + "sparkles": "", + "Terminal": "", + "terminal": "" +} diff --git a/src/shared/miniapp-chrome/labels.generated.json b/src/shared/miniapp-chrome/labels.generated.json new file mode 100644 index 0000000000..ee37ddc05c --- /dev/null +++ b/src/shared/miniapp-chrome/labels.generated.json @@ -0,0 +1,32 @@ +{ + "aliases": { + "zh": "zh-CN", + "zh-hans": "zh-CN", + "zh-cn": "zh-CN", + "en": "en-US", + "en-us": "en-US", + "zh-tw": "zh-TW", + "zh-hant": "zh-TW", + "zh-hk": "zh-TW", + "zh-mo": "zh-TW" + }, + "fallbackLocale": "en-US", + "zh-CN": { + "minimize": "最小化应用窗口", + "maximize": "最大化应用窗口", + "restore": "还原应用窗口", + "close": "关闭应用窗口" + }, + "en-US": { + "minimize": "Minimize app window", + "maximize": "Maximize app window", + "restore": "Restore app window", + "close": "Close app window" + }, + "zh-TW": { + "minimize": "最小化應用視窗", + "maximize": "最大化應用視窗", + "restore": "還原應用視窗", + "close": "關閉應用視窗" + } +} diff --git a/src/shared/miniapp-chrome/presentation-labels.generated.json b/src/shared/miniapp-chrome/presentation-labels.generated.json new file mode 100644 index 0000000000..e1bbab412c --- /dev/null +++ b/src/shared/miniapp-chrome/presentation-labels.generated.json @@ -0,0 +1,116 @@ +{ + "zh-CN": { + "titlebar": "应用窗口控制", + "minimize": "$t(shared:window.minimize)", + "maximize": "$t(shared:window.maximize)", + "restore": "$t(shared:window.restore)", + "close": "$t(shared:window.close)", + "moreActions": "更多应用操作", + "updateInstallation": "更新系统应用", + "uninstall": "从系统卸载", + "uninstallHint": "移除系统图标和启动程序,保留应用数据及运行中的任务。", + "uninstalling": "正在从系统移除「{{name}}」…", + "uninstalled": "已从系统移除「{{name}}」,应用数据和运行中的任务已保留。", + "uninstallFailed": "从系统移除「{{name}}」失败:{{error}}", + "retryInstallation": "无法读取系统应用状态,重试", + "openWindow": "在独立窗口打开", + "openTab": "应用内打开", + "install": "添加为系统应用", + "installing": "正在将「{{name}}」添加到系统…", + "installed": "已将「{{name}}」添加到系统:{{path}}", + "installFailed": "将「{{name}}」添加到系统失败:{{error}}", + "updating": "正在更新「{{name}}」的系统应用…", + "updated": "已更新「{{name}}」的系统应用:{{path}}", + "updateFailed": "更新「{{name}}」的系统应用失败:{{error}}", + "unsupported": "独立窗口和系统图标需要本机 Desktop 工作区。", + "transferWarning": "此操作会在目标位置重新打开应用,保留已保存的数据和已接受的后台任务。请先保存页面中尚未保存的内容;目标就绪后才会关闭原视图。", + "transferring": "正在打开目标视图,就绪后将关闭当前视图。", + "runningWindows": "独立运行的应用", + "focus": "显示窗口", + "retry": "重试", + "send": "发送", + "prompt": "向此应用发送消息", + "eventGap": "部分实时事件未能送达。应用数据和后台任务仍保留在宿主中,可重新加载或使用应用的结果恢复功能。", + "openFailed": "无法打开 MiniApp:{{error}}", + "permissionTitle": "需要 OpenBitFun 授权", + "allowOnce": "仅允许一次", + "reject": "拒绝", + "transferFailed": "目标未能打开,原视图仍然保留。准备好后可以重试。" + }, + "en-US": { + "titlebar": "App window controls", + "minimize": "$t(shared:window.minimize)", + "maximize": "$t(shared:window.maximize)", + "restore": "$t(shared:window.restore)", + "close": "$t(shared:window.close)", + "moreActions": "More app actions", + "updateInstallation": "Update system app", + "uninstall": "Uninstall system app", + "uninstallHint": "Remove the system icon and launcher. App data and running tasks are kept.", + "uninstalling": "Removing \"{{name}}\" from the system…", + "uninstalled": "\"{{name}}\" was removed from the system. App data and running tasks are kept.", + "uninstallFailed": "Could not remove \"{{name}}\" from the system: {{error}}", + "retryInstallation": "Could not read system app status. Retry", + "openWindow": "Open in a window", + "openTab": "Open in app", + "install": "Add system app", + "installing": "Adding \"{{name}}\" to the system…", + "installed": "\"{{name}}\" was added to the system: {{path}}", + "installFailed": "Could not add \"{{name}}\" to the system: {{error}}", + "updating": "Updating the system app for \"{{name}}\"…", + "updated": "The system app for \"{{name}}\" was updated: {{path}}", + "updateFailed": "Could not update the system app for \"{{name}}\": {{error}}", + "unsupported": "Independent windows and system icons require a local Desktop workspace.", + "transferWarning": "This reopens the app in the destination. Saved data and accepted background work are kept. Save any unsaved page state before continuing; the original view closes only after the destination is ready.", + "transferring": "Opening the destination; the current view will close when it is ready.", + "runningWindows": "Independent apps", + "focus": "Show window", + "retry": "Retry", + "send": "Send", + "prompt": "Message this app", + "eventGap": "Some live events were missed. App data and background tasks remain on the host; reload or use the app's result recovery.", + "openFailed": "Could not open MiniApp: {{error}}", + "permissionTitle": "OpenBitFun approval required", + "allowOnce": "Allow once", + "reject": "Reject", + "transferFailed": "The destination did not open. Your original view is still available; retry when ready." + }, + "zh-TW": { + "titlebar": "應用視窗控制", + "minimize": "$t(shared:window.minimize)", + "maximize": "$t(shared:window.maximize)", + "restore": "$t(shared:window.restore)", + "close": "$t(shared:window.close)", + "moreActions": "更多應用操作", + "updateInstallation": "更新系統應用程式", + "uninstall": "從系統解除安裝", + "uninstallHint": "移除系統圖示和啟動程式,保留應用程式資料及執行中的任務。", + "uninstalling": "正在從系統移除「{{name}}」…", + "uninstalled": "已從系統移除「{{name}}」,應用程式資料和執行中的任務已保留。", + "uninstallFailed": "從系統移除「{{name}}」失敗:{{error}}", + "retryInstallation": "無法讀取系統應用程式狀態,重試", + "openWindow": "在獨立視窗開啟", + "openTab": "應用程式內開啟", + "install": "新增為系統應用程式", + "installing": "正在將「{{name}}」新增至系統…", + "installed": "已將「{{name}}」新增至系統:{{path}}", + "installFailed": "將「{{name}}」新增至系統失敗:{{error}}", + "updating": "正在更新「{{name}}」的系統應用程式…", + "updated": "已更新「{{name}}」的系統應用程式:{{path}}", + "updateFailed": "更新「{{name}}」的系統應用程式失敗:{{error}}", + "unsupported": "獨立視窗和系統圖示需要本機 Desktop 工作區。", + "transferWarning": "此操作會在目標位置重新開啟應用程式,保留已儲存資料及已接受的背景任務。請先儲存頁面中尚未儲存的內容;目標就緒後才會關閉原檢視。", + "transferring": "正在開啟目標檢視,就緒後將關閉目前檢視。", + "runningWindows": "獨立執行的應用程式", + "focus": "顯示視窗", + "retry": "重試", + "send": "傳送", + "prompt": "向此應用程式傳送訊息", + "eventGap": "部分即時事件未能送達。應用程式資料和背景任務仍保留在宿主中,可重新載入或使用應用程式的結果復原功能。", + "openFailed": "無法開啟 MiniApp:{{error}}", + "permissionTitle": "需要 OpenBitFun 授權", + "allowOnce": "僅允許一次", + "reject": "拒絕", + "transferFailed": "目標未能開啟,原視圖仍然保留。準備好後可以重試。" + } +} diff --git a/src/shared/miniapp-chrome/titlebar.css b/src/shared/miniapp-chrome/titlebar.css new file mode 100644 index 0000000000..95b666d8d5 --- /dev/null +++ b/src/shared/miniapp-chrome/titlebar.css @@ -0,0 +1,72 @@ +.miniapp-titlebar { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + box-sizing: border-box; + flex: 0 0 auto; + height: var(--openbitfun-layout-toolbar-md-height); + min-width: 0; + padding-inline: var(--openbitfun-space-2); + border-bottom: 1px solid var(--openbitfun-color-border-subtle); + background: var(--openbitfun-color-surface-scene); + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-label-sm-font-family); + font-size: var(--openbitfun-type-label-sm-font-size); + font-weight: var(--openbitfun-type-label-sm-font-weight); + user-select: none; +} +.miniapp-titlebar[data-density='compact'] { height: 32px; } +/* Match the main window's native traffic-light inset and toolbar height. */ +.miniapp-titlebar[data-platform='macos'] { height: var(--openbitfun-layout-toolbar-md-height); padding-left: 78px; } +.miniapp-titlebar[data-platform='macos'][data-fullscreen='true'] { padding-left: var(--openbitfun-space-2); } +.miniapp-titlebar[data-platform='windows'] { padding-right: 0; } +.miniapp-titlebar[data-surface='surface-canvas'] { background: var(--openbitfun-color-surface-canvas); } +.miniapp-titlebar[data-surface='surface-raised'] { background: var(--openbitfun-color-surface-raised); } +.miniapp-titlebar[data-surface='surface-subtle'] { background: var(--openbitfun-color-surface-subtle); } +.miniapp-titlebar[data-foreground='content-secondary'] { color: var(--openbitfun-color-content-secondary); } +.miniapp-titlebar[data-foreground='content-muted'] { color: var(--openbitfun-color-content-muted); } +.miniapp-titlebar[data-border='border-default'] { border-color: var(--openbitfun-color-border-default); } +.miniapp-titlebar[data-border='border-strong'] { border-color: var(--openbitfun-color-border-strong); } +.miniapp-titlebar__identity { + display: flex; + flex: 1 1 auto; + align-items: center; + gap: var(--openbitfun-space-2); + min-width: 0; + align-self: stretch; +} +.miniapp-titlebar[data-title-alignment='center'] .miniapp-titlebar__identity { justify-content: center; } +.miniapp-titlebar__title { + font-family: var(--openbitfun-type-heading-navigation-font-family); + font-size: var(--openbitfun-type-heading-navigation-font-size); + font-weight: var(--openbitfun-type-heading-navigation-font-weight); + line-height: var(--openbitfun-type-heading-navigation-line-height); + letter-spacing: var(--openbitfun-type-heading-navigation-letter-spacing); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.miniapp-titlebar__glyph { display: inline-flex; flex: 0 0 auto; pointer-events: none; } +.miniapp-titlebar__glyph svg { width: 16px; height: 16px; } +.miniapp-titlebar__actions { display: flex; align-items: center; gap: var(--openbitfun-space-1); flex: 0 1 auto; min-width: 0; overflow-x: auto; scrollbar-width: none; } +.miniapp-titlebar__transfer { flex: 0 0 auto; } +.miniapp-titlebar__controls .miniapp-titlebar__glyph svg { width: 10px; height: 10px; } +.miniapp-titlebar__controls.window-controls--windows { align-self: stretch; } +.miniapp-titlebar__controls.window-controls--windows .miniapp-titlebar__glyph svg { width: 12px; height: 12px; } +.miniapp-titlebar__button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--openbitfun-radius-base); + background: transparent; + color: inherit; + cursor: pointer; +} +.miniapp-titlebar[data-button-shape='square'] .miniapp-titlebar__button { border-radius: 0; } +.miniapp-titlebar__button:hover:not(:disabled) { background: var(--openbitfun-color-action-neutral-surface-hover); } +.miniapp-titlebar__button:focus-visible { outline: 2px solid var(--openbitfun-color-accent-default); outline-offset: -2px; } +.miniapp-titlebar__button:disabled { opacity: .5; cursor: default; } diff --git a/src/shared/miniapp-chrome/titlebar.d.ts b/src/shared/miniapp-chrome/titlebar.d.ts new file mode 100644 index 0000000000..b552df7fde --- /dev/null +++ b/src/shared/miniapp-chrome/titlebar.d.ts @@ -0,0 +1,39 @@ +export interface MiniAppChromeAction { + id: string; + label: string; + icon: 'download' | 'save' | 'search' | 'settings' | 'refresh' | 'plus' | 'play' | 'stop' | 'check' | 'share' | 'help'; + labels?: Record; + enabled?: boolean; +} +export interface MiniAppTitlebar { + title?: string | null; + showTitle?: boolean; + showIcon?: boolean; + titleAlignment?: 'start' | 'center'; + density?: 'compact' | 'comfortable'; + buttonShape?: 'square' | 'rounded'; + /** Legacy metadata, retained for round trips. Native platform owns placement. */ + controlsPosition?: 'start' | 'end'; + surface?: 'surface-scene' | 'surface-canvas' | 'surface-raised' | 'surface-subtle'; + foreground?: 'content-primary' | 'content-secondary' | 'content-muted'; + border?: 'border-subtle' | 'border-default' | 'border-strong'; + actions?: MiniAppChromeAction[]; +} +export interface MiniAppChrome { titlebar?: MiniAppTitlebar } +export const defaultTitlebar: Readonly>; +export function resolveTitlebar(chrome?: MiniAppChrome | null): Required; +export function actionLabel(action: MiniAppChromeAction, locale: string): string; +export function mountTitlebar(element: HTMLElement, options: { + chrome?: MiniAppChrome | null; + title: string; + icon: string; + icons: Record; + locale: string; + labels: Record; + platform: 'macos' | 'windows' | 'linux'; + preview?: boolean; + ready?: boolean; + onAction?: (action: string) => void; + onWindowAction?: (action: string) => void; + onDrag?: () => void; +}): { setMaximized(value: boolean): void; setFullscreen(value: boolean): void; setTransferring(value: boolean): void; setReady(value: boolean): void; destroy(): void }; diff --git a/src/shared/miniapp-chrome/titlebar.js b/src/shared/miniapp-chrome/titlebar.js new file mode 100644 index 0000000000..4926e1a137 --- /dev/null +++ b/src/shared/miniapp-chrome/titlebar.js @@ -0,0 +1,126 @@ +// Shared by the lightweight native shell and the Web UI draft preview. +// All SVG comes from the generated Lucide catalog; app strings remain text. +export const defaultTitlebar = Object.freeze({ + title: null, showTitle: true, showIcon: false, titleAlignment: 'start', + density: 'comfortable', buttonShape: 'rounded', controlsPosition: 'end', + surface: 'surface-scene', foreground: 'content-primary', border: 'border-subtle', actions: [], +}); + +export function resolveTitlebar(chrome) { + return { ...defaultTitlebar, ...chrome?.titlebar, actions: chrome?.titlebar?.actions ?? [] }; +} + +export function actionLabel(action, locale) { + return action.labels?.[locale] || action.label; +} + +export function mountTitlebar(element, options) { + const { chrome, title, icon, icons, locale, labels, platform, onAction, onWindowAction, onDrag, preview = false } = options; + const config = resolveTitlebar(chrome); + let contentReady = options.ready !== false; + let transferring = false; + const document = element.ownerDocument; + element.replaceChildren(); + element.classList.add('miniapp-titlebar'); + element.dataset.platform = platform; + delete element.dataset.controlsPosition; + delete element.dataset.fullscreen; + for (const key of ['titleAlignment', 'density', 'buttonShape', 'surface', 'foreground', 'border']) { + element.dataset[key] = config[key]; + } + element.setAttribute('role', 'toolbar'); + element.setAttribute('aria-label', labels.titlebar); + const glyph = name => { + const span = document.createElement('span'); + span.className = 'miniapp-titlebar__glyph'; + span.setAttribute('aria-hidden', 'true'); + // The catalog is compiled into the host, never supplied by app content. + span.innerHTML = icons[name] || icons.app; + return span; + }; + const button = (name, label, callback, disabled = false) => { + const control = document.createElement('button'); + control.type = 'button'; + control.className = 'miniapp-titlebar__button'; + control.title = label; + control.setAttribute('aria-label', label); + control.disabled = preview || disabled; + control.append(glyph(name)); + control.onclick = () => callback?.(); + return control; + }; + const identity = document.createElement('div'); + identity.className = 'miniapp-titlebar__identity'; + if (config.showIcon) identity.append(glyph(icon)); + if (config.showTitle) { + const text = document.createElement('span'); + text.className = 'miniapp-titlebar__title'; + text.textContent = config.title ?? title; + text.title = text.textContent; + identity.append(text); + } + const actions = document.createElement('div'); + actions.className = 'miniapp-titlebar__actions'; + for (const action of config.actions) { + const control = button(action.icon, actionLabel(action, locale), () => onAction?.(action.id), !contentReady || action.enabled === false); + control.dataset.actionId = action.id; + actions.append(control); + } + const transfer = button('return', labels.openTab, () => onWindowAction?.('tab')); + transfer.dataset.windowAction = 'tab'; + transfer.classList.add('miniapp-titlebar__transfer'); + transfer.disabled = preview || !contentReady; + const controls = document.createElement('div'); + controls.className = `miniapp-titlebar__controls window-controls${platform === 'windows' ? ' window-controls--windows' : ''}`; + // AppKit owns the real traffic lights. Only other platforms use the same + // caption buttons as the main window; MiniApp styling cannot move them. + if (platform !== 'macos') { + for (const name of ['minimize', 'maximize', 'close']) { + const control = button(name, labels[name], () => onWindowAction?.(name)); + control.className = `window-controls__btn window-controls__btn--${name}`; + control.dataset.windowAction = name; + controls.append(control); + } + } + element.append(identity, actions, transfer); + if (platform !== 'macos') element.append(controls); + element.onmousedown = event => { + if (preview || event.button !== 0 || event.detail > 1 || event.target.closest('button')) return; + event.preventDefault(); + onDrag?.(); + }; + element.ondblclick = event => { + if (!preview && event.button === 0 && !event.target.closest('button')) { + onWindowAction?.(platform === 'macos' ? 'titlebarDoubleClick' : 'maximize'); + } + }; + return { + setMaximized(maximized) { + const control = controls.querySelector('[data-window-action="maximize"]'); + if (!control) return; + control.replaceChildren(glyph(maximized ? 'restore' : 'maximize')); + control.title = maximized ? labels.restore : labels.maximize; + control.setAttribute('aria-label', control.title); + }, + setFullscreen(fullscreen) { + element.dataset.fullscreen = String(fullscreen); + }, + setTransferring(value) { + transferring = value; + transfer.disabled = preview || !contentReady || transferring; + transfer.setAttribute('aria-busy', String(value)); + }, + setReady(value) { + contentReady = value; + for (const [index, control] of [...actions.children].entries()) { + control.disabled = preview || !contentReady || config.actions[index].enabled === false; + } + transfer.disabled = preview || !contentReady || transferring; + }, + destroy() { + element.onmousedown = null; + element.ondblclick = null; + element.replaceChildren(); + }, + }; +} diff --git a/src/shared/miniapp-chrome/window-controls.css b/src/shared/miniapp-chrome/window-controls.css new file mode 100644 index 0000000000..0186a9e00c --- /dev/null +++ b/src/shared/miniapp-chrome/window-controls.css @@ -0,0 +1,141 @@ +/* Shared desktop caption controls. macOS uses native AppKit traffic lights. */ +.window-controls { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} +.window-controls__btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 1px solid transparent; + border-radius: var(--openbitfun-radius-sm); + color: var(--openbitfun-color-action-quiet-content); + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; + position: relative; + overflow: visible; + outline: none; + user-select: none; +} +.window-controls__btn::after { + display: none; +} +.window-controls__btn svg { + display: block; + flex-shrink: 0; + position: relative; + opacity: 1; + z-index: 2; + stroke: currentColor; + transition: stroke-width 0.3s var(--openbitfun-motion-easing-smooth); +} +.window-controls__btn:hover:not(:disabled) svg { + stroke-width: 2.5; +} +.window-controls__btn::before { + display: none; +} +.window-controls__btn:hover { + background: var(--openbitfun-color-action-quiet-hover); + color: var(--openbitfun-color-content-primary); + border-color: var(--openbitfun-color-border-subtle); +} +.window-controls__btn:active { + opacity: 0.8; +} +.window-controls__btn:focus-visible { + outline: 2px solid color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent); + outline-offset: 2px; +} +.window-controls__btn--minimize:hover { + background: var(--openbitfun-color-action-quiet-hover); + color: var(--openbitfun-color-content-primary); + border-color: var(--openbitfun-color-border-subtle); +} +.window-controls__btn--minimize:focus-visible { + outline-color: color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent); +} +.window-controls__btn--maximize:hover { + background: var(--openbitfun-color-action-quiet-hover); + color: var(--openbitfun-color-content-primary); + border-color: var(--openbitfun-color-border-subtle); +} +.window-controls__btn--maximize:focus-visible { + outline-color: color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent); +} +.window-controls__btn--close:hover { + background: var(--openbitfun-color-action-quiet-hover); + color: var(--openbitfun-color-status-danger-content); + border-color: var(--openbitfun-color-border-subtle); +} +.window-controls__btn--close:focus-visible { + outline-color: var(--openbitfun-color-status-danger-content); +} +.window-controls__btn:disabled { + opacity: var(--openbitfun-opacity-disabled); + cursor: default; + pointer-events: none; +} +.window-controls__btn:disabled:hover { + background: transparent; + color: var(--openbitfun-color-action-quiet-content); + border-color: transparent; +} + +@media (max-width: 768px) { + .window-controls { + gap: 4px; + } + .window-controls__btn { + width: 28px; + height: 28px; + border-radius: var(--openbitfun-radius-sm); + } + .window-controls__btn svg { + width: 12px; + height: 12px; + } +} +@media (prefers-contrast: high) { + .window-controls__btn:hover { + border-width: 2px; + } +} +.window-controls--windows { + align-items: stretch; + gap: 0; + height: 100%; +} +.window-controls--windows .window-controls__btn { + flex: 0 0 46px; + width: 46px; + height: 100%; + border: 0; + border-radius: 0; + color: var(--openbitfun-color-content-primary); + cursor: default; +} +.window-controls--windows .window-controls__btn svg { + width: 12px; + height: 12px; + transition: none; +} +.window-controls--windows .window-controls__btn:focus-visible { + outline-offset: -2px; +} +.window-controls--windows .window-controls__btn--close:hover:not(:disabled) { + background: var(--openbitfun-color-status-danger-content); + color: var(--openbitfun-color-content-on-dark); +} + +@media (prefers-reduced-motion: reduce) { + .window-controls__btn { + transition: none; + } +} diff --git a/src/web-ui/scripts/generate-lucide-static-icons.mjs b/src/web-ui/scripts/generate-lucide-static-icons.mjs index 4dbd9a88fc..1da91e8a41 100644 --- a/src/web-ui/scripts/generate-lucide-static-icons.mjs +++ b/src/web-ui/scripts/generate-lucide-static-icons.mjs @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { ChevronDown, Copy, Minus, Search, Square, X } from 'lucide-react'; +import * as lucide from 'lucide-react'; import { themes } from '@openbitfun/theme-openbitfun'; // Pre-React chrome and native form decorations cannot mount React components. @@ -55,4 +56,29 @@ await update('src/app/styles/components/forms.css', source => { return result; }); +const miniAppIcons = { + app: 'Box', return: 'PictureInPicture', popout: 'SquareArrowOutUpRight', minimize: 'Minus', maximize: 'Square', restore: 'Copy', close: 'X', + download: 'Download', save: 'Save', search: 'Search', settings: 'Settings', refresh: 'RefreshCw', plus: 'Plus', play: 'Play', stop: 'Square', check: 'Check', share: 'Share2', help: 'CircleHelp', send: 'ArrowUp', suggestion: 'ArrowUpRight', +}; +for (const name of ['Aperture','Box','Bot','Code','Database','FileText','GitPullRequest','Grid3x3','LayoutGrid','Presentation','Regex','Rocket','Workflow','Wrench','AppWindow','Globe','Image','Settings','Sparkles','Terminal']) { + miniAppIcons[name] = name; + miniAppIcons[name.replace(/[A-Z]/g, (letter, index) => (index ? '-' : '') + letter.toLowerCase())] = name; +} +await update('../shared/miniapp-chrome/icons.generated.json', () => JSON.stringify(Object.fromEntries( + Object.entries(miniAppIcons).map(([key, name]) => [key, svg(lucide[name])]), +), null, 2) + '\n'); + +const localeContract = JSON.parse(await readFile(new URL('../shared/i18n/contract/locales.json', root), 'utf8')); +const windowLabels = { aliases: {}, fallbackLocale: localeContract.fallbackLocale }; +const presentationLabels = {}; +for (const { id: locale, aliases } of localeContract.locales) { + for (const alias of aliases) windowLabels.aliases[alias.toLowerCase()] = locale; + const terms = JSON.parse(await readFile(new URL(`../shared/i18n/resources/shared/${locale}/terms.json`, root), 'utf8')); + windowLabels[locale] = terms.window; + const miniapp = JSON.parse(await readFile(new URL(`src/locales/${locale}/scenes/miniapp.json`, root), 'utf8')); + presentationLabels[locale] = miniapp.presentation; +} +await update('../shared/miniapp-chrome/labels.generated.json', () => JSON.stringify(windowLabels, null, 2) + '\n'); +await update('../shared/miniapp-chrome/presentation-labels.generated.json', () => JSON.stringify(presentationLabels, null, 2) + '\n'); + console.log(`Lucide static icons ${check ? 'verified' : 'generated'}.`); diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.tsx b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.tsx index 07f3a3abdb..720ebed44d 100644 --- a/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.tsx +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.tsx @@ -3,17 +3,19 @@ import { ScrollArea } from '@openbitfun/ui'; import './GalleryLayout.scss'; interface GalleryLayoutProps extends React.HTMLAttributes { + header?: React.ReactNode; children: React.ReactNode; className?: string; } -const GalleryLayout: React.FC = ({ children, className, ...rootProps }) => ( +const GalleryLayout: React.FC = ({ header, children, className, ...rootProps }) => (
+ {header}
{children} diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx index bfba4d5da5..649bdc2c5c 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx @@ -7,7 +7,7 @@ import { useI18n } from '@/infrastructure/i18n'; import { confirmDanger, } from '@/infrastructure/confirm-dialog'; -import { LogIn, LogOut, Pencil, Check, X } from 'lucide-react'; +import { LogOut, Pencil, Check, X } from 'lucide-react'; import { DeviceSystemGlyph } from '../NavPanel/components/DeviceSystemGlyph'; import { reportedHostKind } from '../NavPanel/deviceInterconnectionOverview'; import { remoteConnectAPI, deviceDisplayName, deviceMetadataLabel } from '@/infrastructure/api/service-api/RemoteConnectAPI'; @@ -854,7 +854,7 @@ export const AccountPanel: React.FC = ({ {t('accountLogin.securityNote')}
-
diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index 981be90c9d..368f875770 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -177,6 +177,11 @@ // Cap the whole tab, including its label padding and close-action reserve. max-inline-size: 220px; } + + &__tabs [data-openbitfun-part='label'] { + // Reserve three full-width characters, excluding tab padding and actions. + min-inline-size: 3em; + } } @media (hover: hover) and (pointer: fine) { diff --git a/src/web-ui/src/app/components/WindowControls/WindowControls.scss b/src/web-ui/src/app/components/WindowControls/WindowControls.scss index 6b50e2aa15..8aa8d3bb65 100644 --- a/src/web-ui/src/app/components/WindowControls/WindowControls.scss +++ b/src/web-ui/src/app/components/WindowControls/WindowControls.scss @@ -1,174 +1,2 @@ -.window-controls { - display: flex; - align-items: center; - gap: 2px; - flex-shrink: 0; - - &__btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - background: transparent; - border: 1px solid transparent; - border-radius: var(--openbitfun-radius-sm); - color: var(--openbitfun-color-action-quiet-content); - cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; - position: relative; - overflow: visible; - outline: none; - user-select: none; - - &::after { - display: none; - } - - svg { - display: block; - flex-shrink: 0; - position: relative; - opacity: 1; - z-index: 2; - stroke: currentColor; - transition: stroke-width 0.3s var(--openbitfun-motion-easing-smooth); - } - - &:hover:not(:disabled) svg { - stroke-width: 2.5; - } - - &::before { - display: none; - } - - &:hover { - background: var(--openbitfun-color-action-quiet-hover); - color: var(--openbitfun-color-content-primary); - border-color: var(--openbitfun-color-border-subtle); - } - - &:active { - opacity: 0.8; - } - - &:focus-visible { - outline: 2px solid color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent); - outline-offset: 2px; - } - - &--minimize { - &:hover { - background: var(--openbitfun-color-action-quiet-hover); - color: var(--openbitfun-color-content-primary); - border-color: var(--openbitfun-color-border-subtle); - } - - &:focus-visible { - outline-color: color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent); - } - } - - &--maximize { - &:hover { - background: var(--openbitfun-color-action-quiet-hover); - color: var(--openbitfun-color-content-primary); - border-color: var(--openbitfun-color-border-subtle); - } - - &:focus-visible { - outline-color: color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent); - } - } - - &--close { - &:hover { - background: var(--openbitfun-color-action-quiet-hover); - color: var(--openbitfun-color-status-danger-content); - border-color: var(--openbitfun-color-border-subtle); - - } - - &:focus-visible { - outline-color: var(--openbitfun-color-status-danger-content); - } - } - - &:disabled { - opacity: var(--openbitfun-opacity-disabled); - cursor: default; - pointer-events: none; - - &:hover { - background: transparent; - color: var(--openbitfun-color-action-quiet-content); - border-color: transparent; - } - } - } -} - -@media (max-width: 768px) { - .window-controls { - gap: 4px; - - &__btn { - width: 28px; - height: 28px; - border-radius: var(--openbitfun-radius-sm); - - svg { - width: 12px; - height: 12px; - } - } - } -} - -@media (prefers-contrast: high) { - .window-controls__btn { - &:hover { - border-width: 2px; - } - } -} - -// Windows caption buttons keep their full hit area even in a narrow window. -.window-controls--windows { - align-items: stretch; - gap: 0; - height: 100%; - - .window-controls__btn { - flex: 0 0 46px; - width: 46px; - height: 100%; - border: 0; - border-radius: 0; - color: var(--openbitfun-color-content-primary); - cursor: default; - - svg { - width: 12px; - height: 12px; - transition: none; - } - - &:focus-visible { - outline-offset: -2px; - } - - &--close:hover:not(:disabled) { - background: var(--openbitfun-color-status-danger-content); - color: var(--openbitfun-color-content-on-dark); - } - } -} - -@media (prefers-reduced-motion: reduce) { - .window-controls__btn { - transition: none; - } -} +// Shared with standalone MiniApp windows and the pre-React startup chrome. +@use "../../../../../shared/miniapp-chrome/window-controls.css"; diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index e303434d47..8c51adb176 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "1cd850650aa8b8df165b6fe69c90f060c240169f4becf163ecaba3fbe037000b", - "ownerDigest": "b96bf9292a92548facbd835b8b89c4865bfdd984b356c9001995f552c6a34f51", + "digest": "d5b8d80b7ff09050f81fab7d2dcc5125db12a0232af1ef8bf94ce571b08efd0f", + "ownerDigest": "0d90452aea46f54ca82cfe25b6d96f1f374e83b1962e2066d378c14c1cab35db", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -5944,6 +5944,10 @@ "category": { "type": "string" }, + "chrome": { + "description": "Per-app titlebar configuration; see MiniApp chrome.titlebar contract. Use {} to restore defaults.", + "type": "object" + }, "css": { "type": "string" }, @@ -6076,6 +6080,10 @@ "category": { "type": "string" }, + "chrome": { + "description": "Per-app titlebar configuration; see MiniApp chrome.titlebar contract. Use {} to restore defaults.", + "type": "object" + }, "css": { "type": "string" }, @@ -22646,6 +22654,10 @@ "category": { "type": "string" }, + "chrome": { + "description": "Per-app titlebar configuration; see MiniApp chrome.titlebar contract. Use {} to restore defaults.", + "type": "object" + }, "css": { "type": "string" }, @@ -22741,6 +22753,10 @@ "category": { "type": "string" }, + "chrome": { + "description": "Per-app titlebar configuration; see MiniApp chrome.titlebar contract. Use {} to restore defaults.", + "type": "object" + }, "css": { "type": "string" }, diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss index 4f4c2ebc6e..7430819723 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss @@ -1,4 +1,4 @@ -$miniapp-gallery-content-inline-size: min(calc(100% - var(--openbitfun-space-6)), 880px); +@use '@/infrastructure/config/components/common/config-page-layout.tokens' as configLayout; .miniapp-gallery-scene { display: flex; @@ -37,14 +37,31 @@ $miniapp-gallery-content-inline-size: min(calc(100% - var(--openbitfun-space-6)) } } -// Both panes share one compact content rail. Keep this geometry at the scene -// owner so the library and submissions cannot drift independently. +// Use the home page frame for both panes, including their header and tabs. .miniapp-gallery-pane { background: var(--openbitfun-color-surface-scene); + &__account-actions { + display: flex; + justify-content: flex-end; + flex-shrink: 0; + padding: var(--openbitfun-space-4) configLayout.$config-page-content-inline-padding 0; + } + + &__detail-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + gap: var(--openbitfun-space-3); + padding: var(--openbitfun-space-4) configLayout.$config-page-content-inline-padding 0; + } + .gallery-page-header { + @include configLayout.content-frame; position: relative; - width: $miniapp-gallery-content-inline-size; + flex-direction: column; + align-items: stretch; padding: 28px 0 0; box-sizing: border-box; background: var(--openbitfun-color-surface-scene); @@ -52,8 +69,14 @@ $miniapp-gallery-content-inline-size: min(calc(100% - var(--openbitfun-space-6)) -webkit-backdrop-filter: none; } + .gallery-page-header__identity { + width: 100%; + text-align: center; + } + .gallery-page-header__subtitle { max-width: 410px; + margin-inline: auto; } .gallery-page-header__title { @@ -61,11 +84,13 @@ $miniapp-gallery-content-inline-size: min(calc(100% - var(--openbitfun-space-6)) } .gallery-page-header__actions { + justify-content: flex-end; + width: 100%; padding-top: 0; } .gallery-zones { - width: $miniapp-gallery-content-inline-size; + @include configLayout.content-frame; gap: var(--openbitfun-space-6); padding: var(--openbitfun-space-4) 0 var(--openbitfun-space-8); } @@ -76,23 +101,29 @@ $miniapp-gallery-content-inline-size: min(calc(100% - var(--openbitfun-space-6)) } .miniapp-gallery-tabs { - width: $miniapp-gallery-content-inline-size; - margin: var(--openbitfun-space-4) auto 0; - box-sizing: border-box; + @include configLayout.content-frame; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--openbitfun-space-2); + margin-block: var(--openbitfun-space-4) 0; + + &__categories { + min-width: 0; + overflow-x: auto; + } } .miniapp-submissions__workspace, .miniapp-submissions__remote-warning, .miniapp-submissions__loading, .miniapp-submissions .gallery-empty { - width: $miniapp-gallery-content-inline-size; + @include configLayout.content-frame; } @container miniapp-gallery-scene (max-width: 760px) { .miniapp-gallery-pane { .gallery-page-header { - flex-direction: column; - width: calc(100% - var(--openbitfun-space-8)); padding-top: var(--openbitfun-space-6); } @@ -102,7 +133,6 @@ $miniapp-gallery-content-inline-size: min(calc(100% - var(--openbitfun-space-6)) } .gallery-zones { - width: calc(100% - var(--openbitfun-space-8)); padding-top: var(--openbitfun-space-5); } @@ -117,16 +147,4 @@ $miniapp-gallery-content-inline-size: min(calc(100% - var(--openbitfun-space-6)) justify-content: flex-start; } } - - .miniapp-gallery-tabs { - width: calc(100% - var(--openbitfun-space-8)); - margin-top: var(--openbitfun-space-4); - } - - .miniapp-submissions__workspace, - .miniapp-submissions__remote-warning, - .miniapp-submissions__loading, - .miniapp-submissions .gallery-empty { - width: calc(100% - var(--openbitfun-space-8)); - } } diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx index 6d1ab10bb0..6596d28721 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx @@ -1,30 +1,41 @@ /** * MiniAppGalleryScene — Mini App gallery scene. - * Opening an app opens a separate scene tab (miniapp:id). + * Installed cards offer an in-app tab or an independent window. */ import { lazyWithRecovery } from '@/shared/utils/lazyWithRecovery'; import React, { Suspense, useState } from 'react'; import { TabGroup } from '@openbitfun/ui'; import { useI18n } from '@/infrastructure/i18n'; +import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; import './MiniAppGalleryScene.scss'; const MiniAppLibraryView = lazyWithRecovery(() => import('./views/MiniAppLibraryView')); const MiniAppSubmissionsView = lazyWithRecovery(() => import('./views/MiniAppSubmissionsView')); -type MiniAppGalleryTab = 'apps' | 'submissions'; +type MiniAppGalleryTab = 'installed' | 'market' | 'submissions'; -const MiniAppGalleryScene: React.FC = () => { +interface MiniAppGallerySceneProps { + initialDetailApp?: MiniAppMeta; + onDetailBack?: () => void; + detailBackLabel?: string; +} + +const MiniAppGalleryScene: React.FC = ({ initialDetailApp, onDetailBack, detailBackLabel }) => { const { t } = useI18n('scenes/miniapp'); - const [activeTab, setActiveTab] = useState('apps'); + const [activeTab, setActiveTab] = useState('installed'); const tabs = ( -
{ value={activeTab} onValueChange={(value) => setActiveTab(value as MiniAppGalleryTab)} /> -
+ ); + const renderLibrary = (content?: React.ReactNode, toolbarActions?: React.ReactNode) => ( + + {content} + ); return (
- {activeTab === 'apps' && } - {activeTab === 'submissions' && } + {activeTab === 'submissions' + ? + : renderLibrary()}
diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppPresence.test.tsx b/src/web-ui/src/app/scenes/miniapps/MiniAppPresence.test.tsx new file mode 100644 index 0000000000..41f98ad5bc --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppPresence.test.tsx @@ -0,0 +1,92 @@ +/** @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MiniAppInstanceSnapshot, MiniAppViewTicket } from '@/infrastructure/api/service-api/MiniAppPresentationAPI'; +import { MiniAppPresence } from './MiniAppPresence'; +import { ensureMiniAppTab, useMiniAppPresentationStore } from './miniAppPresentationStore'; + +const host = vi.hoisted(() => ({ snapshot: vi.fn(), heartbeat: vi.fn(), open: vi.fn(), local: true, + closeScene: vi.fn(), openScene: vi.fn(), closing: false, + listener: undefined as ((snapshot: MiniAppInstanceSnapshot) => void) | undefined, +})); +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { listen: (_event: string, listener: typeof host.listener) => { + host.listener = listener; return () => { host.listener = undefined; }; +} } })); +vi.mock('@/infrastructure/api/service-api/MiniAppPresentationAPI', () => ({ + hasLocalMiniAppPresentation: () => host.local, miniAppPresentationAPI: host, +})); +vi.mock('@/app/stores/sceneStore', () => ({ useSceneStore: { getState: () => ({ closeScene: host.closeScene, openScene: host.openScene }) } })); +vi.mock('./miniAppLifecycle', () => ({ isMiniAppClosing: () => host.closing })); + +const ticket: MiniAppViewTicket = { + instance: { instanceId: 'instance', appId: 'notes', workspacePath: null, + primaryView: { viewId: 'window', kind: 'window' }, pendingView: { viewId: 'tab', kind: 'tab' }, contentCapabilities: [] }, + view: { viewId: 'tab', kind: 'tab' }, +}; +const snapshot = (revision: number, instances = [ticket.instance]): MiniAppInstanceSnapshot => ({ + protocolVersion: 1, hostEpoch: 'host', revision, instances, +}); +let root: Root; +beforeEach(async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.resetAllMocks(); host.local = true; host.closing = false; + useMiniAppPresentationStore.setState({ snapshot: null, tabs: {} }); + host.snapshot.mockResolvedValue(snapshot(0, [])); + host.heartbeat.mockResolvedValue(ticket.instance); + root = createRoot(document.createElement('div')); + await act(async () => { root.render(); }); +}); +afterEach(() => { act(() => root.unmount()); }); + +describe('MiniApp presence during view closure', () => { + it('retains the returned tab ticket until scene removal, preventing an automatic reopen', async () => { + act(() => host.listener!(snapshot(1))); + expect(host.openScene).toHaveBeenCalledExactlyOnceWith('miniapp:notes'); + const closingSnapshot = snapshot(3, [{ ...ticket.instance, primaryView: null, pendingView: null }]); + let finish!: () => void; + host.closeScene.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + act(() => host.listener!(closingSnapshot)); + // This is the old race: the mounted scene reacts to a missing ticket and + // ensures its tab again before asynchronous scene navigation has completed. + expect((await ensureMiniAppTab('notes')).view.viewId).toBe('tab'); + expect(host.open).not.toHaveBeenCalled(); + expect(host.closeScene).toHaveBeenCalledExactlyOnceWith('miniapp:notes'); + act(() => { + useMiniAppPresentationStore.getState().release('notes'); + finish(); + host.listener!(snapshot(2)); + }); + expect(useMiniAppPresentationStore.getState().tabs).toEqual({}); + expect(host.openScene).toHaveBeenCalledTimes(1); + }); + + it('closes a retired instance without discarding its ticket before closeScene commits', () => { + act(() => host.listener!(snapshot(1))); + act(() => host.listener!(snapshot(2, []))); + expect(host.closeScene).toHaveBeenCalledExactlyOnceWith('miniapp:notes'); + expect(useMiniAppPresentationStore.getState().tabs.notes.view.viewId).toBe('tab'); + }); + + it('never rehydrates a tab while its explicit close is draining accepted operations', () => { + host.closing = true; + act(() => host.listener!(snapshot(1))); + expect(host.openScene).not.toHaveBeenCalled(); + expect(useMiniAppPresentationStore.getState().tabs).toEqual({}); + }); + + it('keeps the committed destination tab when the original window closes late', () => { + act(() => host.listener!(snapshot(1))); + act(() => host.listener!(snapshot(2, [{ ...ticket.instance, primaryView: ticket.view, pendingView: null }]))); + expect(host.closeScene).not.toHaveBeenCalled(); + expect(useMiniAppPresentationStore.getState().tabs.notes.view.kind).toBe('tab'); + }); + + it('does not mutate tabs on a peer surface', () => { + host.local = false; + act(() => host.listener!(snapshot(1))); + expect(host.openScene).not.toHaveBeenCalled(); + expect(host.closeScene).not.toHaveBeenCalled(); + expect(useMiniAppPresentationStore.getState().tabs).toEqual({}); + }); +}); diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppPresence.tsx b/src/web-ui/src/app/scenes/miniapps/MiniAppPresence.tsx new file mode 100644 index 0000000000..a053a76843 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppPresence.tsx @@ -0,0 +1,68 @@ +import { useEffect } from 'react'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; +import { miniAppPresentationAPI, hasLocalMiniAppPresentation, type MiniAppInstanceSnapshot } from '@/infrastructure/api/service-api/MiniAppPresentationAPI'; +import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import { useMiniAppPresentationStore } from './miniAppPresentationStore'; +import { createLogger } from '@/shared/utils/logger'; +import { isMiniAppClosing } from './miniAppLifecycle'; + +const log = createLogger('MiniAppPresence'); + +/** Exactly one observer in the main window; independently launched shells never mount it. */ +export function MiniAppPresence() { + useEffect(() => { + let disposed = false; + let refreshing = false; + const apply = (snapshot: MiniAppInstanceSnapshot) => { + if (disposed || !hasLocalMiniAppPresentation() || snapshot.protocolVersion !== 1) return; + const store = useMiniAppPresentationStore.getState(); + if (store.snapshot?.hostEpoch === snapshot.hostEpoch && store.snapshot.revision > snapshot.revision) return; + store.apply(snapshot); + const tabs = useMiniAppPresentationStore.getState().tabs; + for (const instance of snapshot.instances) { + const tab = instance.pendingView?.kind === 'tab' ? instance.pendingView + : instance.primaryView?.kind === 'tab' ? instance.primaryView : null; + if (tab && !tabs[instance.appId] && !isMiniAppClosing(instance.appId)) { + store.bind({ instance, view: tab }); + useSceneStore.getState().openScene(`miniapp:${instance.appId}`); + } + } + for (const [appId, ticket] of Object.entries(tabs)) { + const instance = snapshot.instances.find(item => item.instanceId === ticket.instance.instanceId); + if (store.snapshot?.hostEpoch === snapshot.hostEpoch && !instance) { + void useSceneStore.getState().closeScene(`miniapp:${appId}`); + continue; + } + // Retain the ticket until closeScene commits. Releasing it here makes + // the still-mounted scene request a new tab while its close is in flight. + if (instance && instance.primaryView?.viewId !== ticket.view.viewId && instance.pendingView?.viewId !== ticket.view.viewId) { + void useSceneStore.getState().closeScene(`miniapp:${appId}`); + } + } + }; + const refresh = async () => { + if (disposed || refreshing || !hasLocalMiniAppPresentation()) return; + refreshing = true; + const scope = getActiveSurfaceScope(); + try { + // Heartbeats are owned here, not by mounting or focusing a scene. + for (const ticket of Object.values(useMiniAppPresentationStore.getState().tabs)) { + await miniAppPresentationAPI.heartbeat(ticket).catch(() => undefined); + } + const snapshot = await miniAppPresentationAPI.snapshot(); + if (scope.isCurrent()) apply(snapshot); + } catch (error) { log.debug('MiniApp presence unavailable', { error }); } + finally { refreshing = false; } + }; + const unlisten = api.listen('miniapp-instances-changed', apply); + const unsubscribe = onSurfaceActivated(() => { + void refresh(); + }); + void refresh(); + // Full revisioned snapshots heal subscription races, process restarts and missed events. + const timer = setInterval(() => void refresh(), 5000); + return () => { disposed = true; clearInterval(timer); unlisten(); unsubscribe(); }; + }, []); + return null; +} diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppScene.chrome.test.tsx b/src/web-ui/src/app/scenes/miniapps/MiniAppScene.chrome.test.tsx new file mode 100644 index 0000000000..1c45f1dd81 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppScene.chrome.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { SceneChromeHost, SceneChromeProvider } from '@/app/components/SceneTopBar/SceneChrome'; +import MiniAppScene from './MiniAppScene'; + +const state = vi.hoisted(() => { + const noop = () => {}; + const ticket = (id: string) => ({ instance: { instanceId: id, appId: id, workspacePath: null }, view: { viewId: id, kind: 'tab' } }); + return { + supported: true, open: vi.fn(), ready: vi.fn(), actions: vi.fn(), + miniApps: { composerClaims: {}, markCustomizationActive: noop, markCustomizationIdle: noop, upsertApp: noop }, + presentations: { tabs: { notes: ticket('notes'), other: ticket('other') }, snapshot: { hostEpoch: 'host', instances: [] } }, + scenes: { openTabs: [{ id: 'miniapp:notes' }, { id: 'miniapp:other' }] }, + }; +}); +vi.mock('@/infrastructure/api/service-api/MiniAppAPI', () => ({ miniAppAPI: { getMiniApp: async (id: string) => ({ id, name: id, permissions: {}, compiled_html: '', chrome: { titlebar: { actions: [{ id: 'export', icon: 'download', label: `Export ${id}` }] } } }) } })); +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { listen: () => () => {} } })); +vi.mock('@/infrastructure/appearance', () => ({ useAppearance: () => ({ current: { mode: 'dark' } }) })); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ workspace: null, workspacePath: null }) })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key, currentLanguage: 'en-US' }) })); +vi.mock('@/app/hooks/useSceneManager', () => ({ useSceneManager: () => ({ closeScene: vi.fn() }) })); +vi.mock('@/app/stores/sceneStore', () => ({ useSceneStore: Object.assign((select: (value: unknown) => unknown) => select(state.scenes), { getState: () => state.scenes }) })); +vi.mock('./miniAppStore', () => ({ useMiniAppStore: Object.assign((select: (value: unknown) => unknown) => select(state.miniApps), { getState: () => state.miniApps }) })); +vi.mock('./miniAppPresentationStore', () => ({ + useMiniAppPresentationStore: Object.assign((select: (value: unknown) => unknown) => select(state.presentations), { getState: () => state.presentations }), + ensureMiniAppTab: async (id: 'notes' | 'other') => state.presentations.tabs[id], +})); +vi.mock('@/infrastructure/api/service-api/MiniAppPresentationAPI', () => ({ hasLocalMiniAppPresentation: () => state.supported, miniAppPresentationAPI: { open: state.open, ready: state.ready } })); +vi.mock('./customization/useMiniAppCustomizeHotspot', () => ({ useMiniAppCustomizeHotspot: () => {} })); +vi.mock('./customization/MiniAppCustomizePanel', () => ({ default: () => null })); +vi.mock('./customization/MiniAppDraftPreview', () => ({ default: () => null })); +vi.mock('./components/MiniAppSystemAppActions', () => ({ MiniAppSystemAppActions: () => null })); +vi.mock('./utils/miniAppSystemIcon', () => ({ renderMiniAppSystemIcon: () => '' })); +vi.mock('./components/MiniAppRunner', async () => { + const { useEffect } = await import('react'); + return { default: ({ app, onReady, chromeActionRef }: { app: { id: string }; onReady: () => void; chromeActionRef: { current: unknown } }) => { + useEffect(() => { + chromeActionRef.current = (id: string) => state.actions(app.id, id); + onReady(); + return () => { chromeActionRef.current = null; }; + }, [app.id, onReady, chromeActionRef]); + return null; + } }; +}); + +let container: HTMLDivElement; +let root: ReturnType; +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.useFakeTimers(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)); + vi.stubGlobal('cancelAnimationFrame', clearTimeout); + state.supported = true; vi.clearAllMocks(); state.ready.mockResolvedValue(undefined); + container = document.createElement('div'); document.body.append(container); root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); container.remove(); + document.querySelectorAll('[data-openbitfun-overlay-host]').forEach(element => element.remove()); + vi.useRealTimers(); vi.unstubAllGlobals(); +}); +const render = (active: 'notes' | 'other') => act(async () => root.render( + + + + , +)); + +it('places only the selected MiniApp actions in the main top bar and targets its content', async () => { + await render('notes'); + const host = container.querySelector('[data-testid="main-topbar-right"]')!; + expect(host.querySelector('[aria-label="Export notes"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="Export other"]')).toBeNull(); + expect(container.querySelector('.miniapp-scene__header')).toBeNull(); + expect(host.querySelector('[aria-label="presentation.openWindow"]')?.textContent).toBe(''); + expect(host.querySelector('[aria-label="scene.reload"]')).toBeNull(); + expect(host.querySelector('[aria-label="customize.trigger"] [data-openbitfun-name="creative"]')).not.toBeNull(); + act(() => (host.querySelector('[aria-label="Export notes"]') as HTMLButtonElement).click()); + expect(state.actions).toHaveBeenCalledExactlyOnceWith('notes', 'export'); + await render('other'); + expect(host.querySelector('[aria-label="Export notes"]')).toBeNull(); + expect(host.querySelector('[aria-label="Export other"]')).not.toBeNull(); +}); + +it('disables local window presentation on unsupported driving surfaces', async () => { + state.supported = false; + await render('notes'); + const button = container.querySelector('[aria-label="presentation.openWindow"]')!; + expect(button.disabled).toBe(true); + const trigger = button.closest('[data-openbitfun-tooltip-trigger]')!; + expect(trigger.getAttribute('aria-disabled')).toBe('true'); + act(() => trigger.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))); + act(() => vi.advanceTimersByTime(150)); + act(() => vi.advanceTimersByTime(30)); + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe('presentation.unsupported'); + button.click(); + expect(state.open).not.toHaveBeenCalled(); +}); + +it.each(['customize.trigger', 'presentation.openWindow', 'Export notes'])( + 'shows the %s icon hint below the main top bar on hover and keyboard focus', async label => { + await render('notes'); + const button = container.querySelector(`button[aria-label="${label}"]`)!; + act(() => button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))); + act(() => vi.advanceTimersByTime(150)); + act(() => vi.advanceTimersByTime(30)); + const popup = () => document.querySelector('[role="tooltip"]'); + expect(popup()?.textContent).toBe(label); + expect(popup()?.getAttribute('data-openbitfun-placement')).toBe('bottom'); + expect(button.getAttribute('aria-describedby')).toBe(popup()?.id); + act(() => button.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body }))); + act(() => vi.advanceTimersByTime(500)); + expect(popup()).toBeNull(); + act(() => button.focus()); + act(() => vi.advanceTimersByTime(150)); + act(() => vi.advanceTimersByTime(30)); + expect(popup()?.textContent).toBe(label); + }, +); diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppScene.scss b/src/web-ui/src/app/scenes/miniapps/MiniAppScene.scss index 6f0815cf1b..0e2cd8fa15 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppScene.scss +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppScene.scss @@ -11,39 +11,9 @@ height: 100%; overflow: hidden; - &__header { - display: flex; - align-items: center; - gap: var(--openbitfun-space-2); - padding: var(--openbitfun-space-2) var(--openbitfun-space-3); - border-bottom: 1px solid var(--openbitfun-color-border-subtle); - flex-shrink: 0; - min-height: 40px; - } - - &__header-center { - flex: 1; - min-width: 0; - } - - &__title { - font-family: var(--openbitfun-type-heading-card-font-family); - font-size: var(--openbitfun-type-heading-card-font-size); - font-weight: var(--openbitfun-type-heading-card-font-weight); - line-height: var(--openbitfun-type-heading-card-line-height); - letter-spacing: var(--openbitfun-type-heading-card-letter-spacing); - color: var(--openbitfun-color-content-primary); - white-space: nowrap; - overflow: hidden; - display: block; - - &--loading { - color: var(--openbitfun-color-content-muted); - font-weight: var(--openbitfun-type-label-md-font-weight); - } - } - &__header-actions { + margin-inline-start: auto; + height: 100%; display: flex; align-items: center; gap: var(--openbitfun-space-1); @@ -474,6 +444,8 @@ } &__preview-frame { + display: flex; + flex-direction: column; flex: 1 1 0; min-height: 180px; overflow: hidden; diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppScene.tsx b/src/web-ui/src/app/scenes/miniapps/MiniAppScene.tsx index 8601e7bcc2..b06c245a54 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppScene.tsx +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppScene.tsx @@ -1,10 +1,10 @@ /** * MiniAppScene — standalone scene tab for a single MiniApp. - * Mounts MiniAppRunner; closing its scene stops the app through the shared lifecycle. + * Mounts MiniAppRunner; transfers keep work, closing the final view stops the app. */ -import { OverflowText, Button, Icon, IconButton, Tooltip } from '@openbitfun/ui'; -import React, { useCallback, useEffect, useState } from 'react'; -import { Loader2, AlertTriangle } from 'lucide-react'; +import { Button, ConfirmDialog, Icon, IconButton, Tooltip } from '@openbitfun/ui'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Loader2, AlertTriangle, SquareArrowOutUpRight, MessageSquare } from 'lucide-react'; import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; import { api } from '@/infrastructure/api/service-api/ApiClient'; import type { MiniApp, MiniAppDraft } from '@/infrastructure/api/service-api/MiniAppAPI'; @@ -25,6 +25,17 @@ import MiniAppDraftPreview from './customization/MiniAppDraftPreview'; import { useMiniAppCustomizeHotspot } from './customization/useMiniAppCustomizeHotspot'; import MiniAppRunner from './components/MiniAppRunner'; import './MiniAppScene.scss'; +import { hasLocalMiniAppPresentation, miniAppPresentationAPI } from '@/infrastructure/api/service-api/MiniAppPresentationAPI'; +import { ensureMiniAppTab, useMiniAppPresentationStore } from './miniAppPresentationStore'; +import { isRemoteWorkspace } from '@/shared/types'; +import { MiniAppSystemAppActions } from './components/MiniAppSystemAppActions'; +import { isMiniAppClosing } from './miniAppLifecycle'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import { renderMiniAppSystemIcon } from './utils/miniAppSystemIcon'; + +import { SceneChromeContribution } from '@/app/components/SceneTopBar/SceneChrome'; +import { MiniAppChromeActions } from './components/MiniAppChromeActions'; +import { MiniAppActionTooltip } from './components/MiniAppActionTooltip'; const log = createLogger('MiniAppScene'); const MINIAPP_REFRESH_EVENTS = [ @@ -45,15 +56,35 @@ const MiniAppScene: React.FC = ({ appId }) => { const { current: appearance } = useAppearance(); const appearanceMode = appearance?.mode ?? 'dark'; const { workspace, workspacePath } = useCurrentWorkspace(); + const initialTicket = useMiniAppPresentationStore.getState().tabs[appId]; + const initialPath = useRef(initialTicket ? initialTicket.instance.workspacePath : workspacePath || null).current; + const presentationSupported = hasLocalMiniAppPresentation() && (Boolean(initialTicket) || !(workspace && isRemoteWorkspace(workspace))); + const viewId = useMiniAppPresentationStore(state => state.tabs[appId]?.view.viewId); + const hostEpoch = useMiniAppPresentationStore(state => state.snapshot?.hostEpoch); const { closeScene } = useSceneManager(); const { t, currentLanguage } = useI18n('scenes/miniapp'); + const chromeActionRef = useRef<((actionId: string) => void) | null>(null); + const [contentReady, setContentReady] = useState(false); const [app, setApp] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [strictRuntime, setStrictRuntime] = useState(false); const [key, setKey] = useState(0); const [customizeOpen, setCustomizeOpen] = useState(false); + const [windowConfirm, setWindowConfirm] = useState(false); + const [presentationNotice, setPresentationNotice] = useState(null); + const [presentationBusy, setPresentationBusy] = useState(false); + const pendingTransfer = useRef(null); + const observedInstance = useMiniAppPresentationStore(state => state.snapshot?.instances.find(instance => instance.appId === appId)); + useEffect(() => { + if (observedInstance?.pendingView?.kind === 'window') { + pendingTransfer.current = observedInstance.pendingView.viewId; + } else if (pendingTransfer.current) { + pendingTransfer.current = null; + if (observedInstance?.primaryView?.kind === 'tab') setPresentationNotice(t('presentation.transferFailed')); + } + }, [observedInstance, t]); const [customizeNotice, setCustomizeNotice] = useState(null); const [customizePreview, setCustomizePreview] = useState<{ draft: MiniAppDraft; @@ -79,11 +110,14 @@ const MiniAppScene: React.FC = ({ appId }) => { const load = useCallback(async (id: string) => { const scope = getActiveSurfaceScope(); + if (isMiniAppClosing(id, scope) || !useSceneStore.getState().openTabs.some(tab => tab.id === `miniapp:${id}`)) return; setLoading(true); + setContentReady(false); setError(null); try { - const loaded = await miniAppAPI.getMiniApp(id, appearanceMode, workspacePath || undefined); - if (!scope.isCurrent()) return; + const ticket = presentationSupported ? await ensureMiniAppTab(id, initialPath) : undefined; + const loaded = await miniAppAPI.getMiniApp(id, appearanceMode, (ticket?.instance.workspacePath ?? initialPath) || undefined); + if (!scope.isCurrent() || isMiniAppClosing(id, scope)) return; // An app opened directly may finish loading before the catalog. Shutdown // still needs its actual permissions and worker policy. useMiniAppStore.getState().upsertApp(loaded); @@ -101,13 +135,31 @@ const MiniAppScene: React.FC = ({ appId }) => { } finally { setLoading(false); } - }, [appearanceMode, workspacePath]); + }, [appearanceMode, initialPath, presentationSupported]); + + const markReady = useCallback(() => { + setContentReady(true); + const ticket = useMiniAppPresentationStore.getState().tabs[appId]; + if (ticket && hasLocalMiniAppPresentation()) { + void miniAppPresentationAPI.ready(ticket).catch(error => setPresentationNotice(String(error))); + } + }, [appId]); + + const openWindow = async () => { + setWindowConfirm(false); + setPresentationBusy(true); + try { + await miniAppPresentationAPI.open(appId, 'window', initialPath, app ? renderMiniAppSystemIcon(app.icon) : undefined); + setPresentationNotice(t('presentation.transferring')); + } catch (error) { setPresentationNotice(String(error)); } + finally { setPresentationBusy(false); } + }; useEffect(() => { if (appId) { void load(appId); } - }, [appId, load]); + }, [appId, load, hostEpoch, viewId]); useEffect(() => { const tabId = `miniapp:${appId}` as SceneTabId; @@ -138,13 +190,6 @@ const MiniAppScene: React.FC = ({ appId }) => { }; }, [appId, closeScene, load]); - const handleReload = () => { - if (appId) { - setKey((value) => value + 1); - void load(appId); - } - }; - const handleOpenCustomize = useCallback(() => { setCustomizeNotice(null); setCustomizeOpen(true); @@ -158,6 +203,8 @@ const MiniAppScene: React.FC = ({ appId }) => { const appName = app ? pickLocalizedString(app, currentLanguage, 'name') : 'Mini App'; const hasConversation = Boolean(composerClaim || app?.permissions?.agent?.enabled && (!strictRuntime || app.permissions.host?.chat_composer)); + const windowPending = presentationBusy || observedInstance?.pendingView?.kind === 'window'; + const windowDisabled = !app || !presentationSupported || windowPending; return (
= ({ appId }) => { data-openbitfun-part="root" data-openbitfun-state={customizeOpen ? 'customizing' : undefined} > -
-
- {app ? ( - {appName} - ) : ( - Mini App - )} + +
+ chromeActionRef.current?.(actionId)} /> + {hasConversation && + } onClick={() => openMiniAppConversation(appId)} /> + } + + + setWindowConfirm(true)} aria-label={t('presentation.openWindow')} + icon={} /> + + {app && }
-
- {hasConversation && } - - - } - /> - -
-
+ + {presentationNotice &&
{presentationNotice}
} + void openWindow()} + title={t('presentation.openWindow')} message={t('presentation.transferWarning')} + confirmText={t('presentation.openWindow')} cancelText={t('confirmDelete.cancel')} />
= ({ appId }) => {
)} {customizePreview && (
diff --git a/src/web-ui/src/app/scenes/miniapps/appearance.ts b/src/web-ui/src/app/scenes/miniapps/appearance.ts index c582a2da97..15baf5e725 100644 --- a/src/web-ui/src/app/scenes/miniapps/appearance.ts +++ b/src/web-ui/src/app/scenes/miniapps/appearance.ts @@ -10,6 +10,8 @@ export const miniAppAppearanceDescriptor: AppearanceSurfaceDescriptor = { parts: [ { id: 'root' }, { id: 'header' }, + { id: 'titlebar' }, + { id: 'actionHint' }, { id: 'content' }, { id: 'loading' }, { id: 'error' }, diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppActionTooltip.module.css b/src/web-ui/src/app/scenes/miniapps/components/MiniAppActionTooltip.module.css new file mode 100644 index 0000000000..86ee66fb6d --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppActionTooltip.module.css @@ -0,0 +1,9 @@ +.disabledTrigger { + display: inline-flex; + border-radius: var(--openbitfun-radius-sm); +} + +.disabledTrigger:focus-visible { + outline: 2px solid var(--openbitfun-color-accent-default); + outline-offset: 2px; +} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppActionTooltip.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppActionTooltip.tsx new file mode 100644 index 0000000000..36d8c30a50 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppActionTooltip.tsx @@ -0,0 +1,19 @@ +import type { ReactElement } from 'react'; +import { Tooltip } from '@openbitfun/ui'; +import styles from './MiniAppActionTooltip.module.css'; + +/** Top-bar hints remain discoverable when the native button is disabled. */ +export function MiniAppActionTooltip({ content, disabled = false, children }: { + content: string; + disabled?: boolean; + children: ReactElement; +}) { + return + {disabled ? event.stopPropagation()} onDoubleClick={event => event.stopPropagation()}> + {children} + : children} + ; +} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts index 10d7ac02a1..6afe7cd825 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts @@ -13,16 +13,15 @@ function readRelative(filename: string): string { const cardCss = compile(fileURLToPath(new URL('./MiniAppCard.scss', import.meta.url))).css; describe('Mini App card presentation', () => { - it('keeps destructive actions in details and uses one unified app count', () => { + it('keeps destructive actions in details and a unified app list', () => { const library = readRelative('../views/MiniAppLibraryView.tsx'); expect(readRelative('./MiniAppCard.tsx')).not.toContain('name="delete"'); - expect(library).toContain(''); expect(library).not.toContain('miniapp-running-zone'); expect(library).not.toContain('gallery-zone-badge'); }); - it('merges installed and marketplace apps into the shared library surface', () => { + it('keeps installed, marketplace, and creation tabs in the shared gallery', () => { const library = readRelative('../views/MiniAppLibraryView.tsx'); const submissions = readRelative('../views/MiniAppSubmissionsView.tsx'); const tabs = readRelative('../MiniAppGalleryScene.tsx'); @@ -34,35 +33,38 @@ describe('Mini App card presentation', () => { 'buildMiniAppLibraryItems(marketItems, apps, marketOrigins, sort)', ); expect(library).toContain(' { + it('keeps both gallery panes on the home page content frame', () => { const sceneStyles = readRelative('../MiniAppGalleryScene.scss'); const gallery = readRelative('../views/MiniAppLibraryView.tsx'); const submissions = readRelative('../views/MiniAppSubmissionsView.tsx'); const submissionStyles = readRelative('../views/MiniAppSubmissionsView.scss'); expect(gallery).toContain('className="miniapp-gallery-pane miniapp-gallery"'); - expect(submissions.match(/className="miniapp-gallery-pane miniapp-submissions"/g)).toHaveLength(3); - expect(submissions.match(/ { expect(stylesheet).toMatch(/&__footer \{[\s\S]*?margin-top: auto;/); }); - it('renders one full-width app per row and keeps skeleton geometry aligned', () => { + it('matches the home device grid and keeps preview-card skeletons aligned', () => { const source = readRelative('../views/MiniAppLibraryView.tsx'); const stylesheet = readRelative('../views/MiniAppLibraryView.scss'); const sceneStyles = readRelative('../MiniAppGalleryScene.scss'); - expect(source).toContain('className="miniapp-gallery__list" role="list"'); - expect(source.match(/cardHeight=\{126\}/g)).toHaveLength(2); - expect(stylesheet).toMatch(/&__list \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\);/); + expect(source).toContain(' { @@ -114,7 +118,7 @@ describe('Mini App card presentation', () => { expect(pageHeader).not.toContain(' { expect(row).toContain(' void; +}) { + return actions.map(action => { + const label = actionLabel(action, locale); + const actionDisabled = disabled || action.enabled === false; + return + } + onClick={() => onAction(action.id)} /> + ; + }); +} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx index c2a82dfe39..b9f34f1d39 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx @@ -1,6 +1,7 @@ import { OverflowText, Button, Icon, + StatusPill, ScrollArea, Dialog, DialogBody, @@ -14,7 +15,9 @@ import React, { useMemo, useRef } from 'react'; import { Bot, Cpu, Database, FolderKanban, Play, ShieldCheck, Square } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; -import { renderMiniAppIcon } from '../utils/miniAppIcons'; +import { getMiniAppShowcaseAsset, renderMiniAppIcon } from '../utils/miniAppIcons'; +import { MarketImage } from '@/app/components/GalleryLayout/MarketImage'; +import { MiniAppDetailPage } from './MiniAppDetailPage'; import { pickLocalizedString, pickLocalizedTags } from '../utils/pickLocalizedString'; import { projectMiniAppDetailCapabilities, @@ -25,12 +28,15 @@ import { import './MiniAppDetailModal.scss'; interface MiniAppDetailModalProps { + presentation?: 'dialog' | 'page'; app: MiniAppMeta | null; marketReleaseNumber?: number; isActive: boolean; isCustomizing: boolean; onClose: () => void; onOpen: (appId: string) => void; + launchActions?: React.ReactNode; + systemAppActions?: React.ReactNode; onDelete: (appId: string) => void; onStop?: (appId: string) => void | Promise; } @@ -137,12 +143,15 @@ function sourceLabel(source: MiniAppDetailSource, t: Translate): string { } const MiniAppDetailModal: React.FC = ({ + presentation = 'dialog', app, marketReleaseNumber, isActive, isCustomizing, onClose, onOpen, + launchActions, + systemAppActions, onDelete, onStop, }) => { @@ -181,6 +190,59 @@ const MiniAppDetailModal: React.FC = ({ ? t('detail.status.running') : t('detail.status.installed'); + if (presentation === 'page') { + if (!app) return null; + const showcase = getMiniAppShowcaseAsset(displayedApp.id); + return ( + + {sourceLabel(source, t)} + v{version} + {localizedTags.map(tag => {tag})} + } + actions={<> + {systemAppActions} + {launchActions ?? } + {snapshot.isActive && onStop && } + + } + preview={showcase ? : undefined} + > +
+

{t('detail.highlights')}

+
    + {capabilities.map(capability => { + const copy = capabilityCopy(capability, t); + return
  • + + + {copy.title} + {copy.description} + +
  • ; + })} +
+
+
+

{t('detail.information')}

+
+
{t('detail.versionLabel')}
v{version}
+
{t('detail.sourceLabel')}
{sourceLabel(source, t)}
+
{t('detail.statusLabel')}
{statusCopy}
+
+
+
+ ); + } + return ( = ({ {t('detail.stop')} ) : null} - + } + {systemAppActions} + ); +} + +/** Shared in-scene detail frame for installed and marketplace MiniApps. */ +export function MiniAppDetailPage({ + title, description, icon, badges, meta, actions, preview, loading, children, +}: MiniAppDetailPageProps) { + const pageRef = useRef(null); + + useLayoutEffect(() => { + pageRef.current?.focus({ preventScroll: true }); + }, []); + + return ( +
+
+ +
+
+

{title}

+ {actions &&
{actions}
} +
+ {description?.trim() &&

{description.trim()}

} +
+ {(badges || meta) &&
{badges}{meta}
} +
+ {preview &&
{preview}
} +
{children}
+
+ ); +} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppInstalledCard.scss b/src/web-ui/src/app/scenes/miniapps/components/MiniAppInstalledCard.scss new file mode 100644 index 0000000000..e37c375e7d --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppInstalledCard.scss @@ -0,0 +1,75 @@ +.miniapp-installed-card { + position: relative; + min-width: 0; + + &__surface:not(:hover):not(:active) { + background: transparent; + } + + &__surface:hover { + outline-color: transparent; + } + + &__surface [data-openbitfun-part='actions'] { + gap: var(--openbitfun-space-2); + } + + &__surface [data-openbitfun-component='icon-button']:hover:not(:disabled) .miniapp-installed-card__launch-icon { + color: var(--openbitfun-color-status-success-emphasis); + } + + &:nth-child(even)::before { + content: ''; + position: absolute; + inset-inline-start: calc(-1 * var(--openbitfun-space-3)); + inset-block-start: 50%; + transform: translateY(-50%); + block-size: var(--openbitfun-space-6); + border-inline-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-default); + pointer-events: none; + } + + &__surface [data-openbitfun-part='leading'] { + flex: 0 0 var(--openbitfun-space-8); + inline-size: var(--openbitfun-space-8); + block-size: var(--openbitfun-space-8); + > [data-openbitfun-component='icon'] { + inline-size: 100%; + block-size: 100%; + } + } + + &__copy { + display: flex; + flex-direction: column; + flex: 1; + gap: var(--openbitfun-space-2); + min-width: 0; + } + + &__title { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + min-width: 0; + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-heading-card-font-family); + font-size: var(--openbitfun-type-heading-card-font-size); + font-weight: var(--openbitfun-type-heading-card-font-weight); + line-height: var(--openbitfun-type-heading-card-line-height); + letter-spacing: var(--openbitfun-type-heading-card-letter-spacing); + } + + &__description { + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + } + +} + +@container miniapp-gallery-scene (max-width: 480px) { + .miniapp-installed-card:nth-child(even)::before { + display: none; + } +} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppInstalledCard.test.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppInstalledCard.test.tsx new file mode 100644 index 0000000000..83d0588d7f --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppInstalledCard.test.tsx @@ -0,0 +1,111 @@ +/** @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; +import MiniAppInstalledCard from './MiniAppInstalledCard'; + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ t: (key: string) => key, currentLanguage: 'en-US' }), +})); + +const app = { id: 'notes', name: 'Notes', description: 'Notes app', icon: 'box' } as MiniAppMeta; +const openDetails = vi.fn(); +const launch = vi.fn(); +const addToSystem = vi.fn(); +const remove = vi.fn(); +let root: Root; +let container: HTMLDivElement; +const more = () => container.querySelector('[aria-label="presentation.moreActions"]')!; +const start = () => container.querySelector('[aria-label="card.start"]')!; +const menu = () => document.querySelector('[role="menu"]:not([aria-hidden="true"])'); +const item = (id: string) => menu()!.querySelector(`[data-menu-id="${id}"]`)!; +const render = (systemAppSupported = true, busy = false) => act(async () => { + root.render(); +}); +const openMenu = () => act(async () => { more().click(); }); + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.resetAllMocks(); + addToSystem.mockResolvedValue(undefined); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe('installed MiniApp card actions', () => { + it('opens a menu without deleting the app or opening its details', async () => { + await render(); + expect(container.querySelector('[aria-label="card.delete"]')).toBeNull(); + expect(more().getAttribute('aria-haspopup')).toBe('menu'); + await openMenu(); + expect(more().getAttribute('aria-expanded')).toBe('true'); + expect(menu()!.id).toBe(more().getAttribute('aria-controls')); + expect(item('install').textContent).toBe('presentation.install'); + expect(item('delete').textContent).toBe('card.delete'); + expect(openDetails).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + await act(async () => { item('delete').click(); }); + expect(remove).toHaveBeenCalledOnce(); + expect(menu()).toBeNull(); + expect(launch).not.toHaveBeenCalled(); + }); + + it('dispatches system installation separately from launch and deletion', async () => { + await render(); + await openMenu(); + await act(async () => { item('install').click(); }); + expect(addToSystem).toHaveBeenCalledOnce(); + expect(menu()).toBeNull(); + expect(launch).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + expect(openDetails).not.toHaveBeenCalled(); + }); + + it('keeps deletion available while explaining unsupported system installation', async () => { + await render(false); + await openMenu(); + expect(item('install').disabled).toBe(true); + expect(menu()!.getAttribute('aria-description')).toBe('presentation.unsupported'); + await act(async () => { item('install').click(); }); + expect(addToSystem).not.toHaveBeenCalled(); + expect(item('delete').disabled).toBe(false); + await act(async () => { item('delete').click(); }); + expect(remove).toHaveBeenCalledOnce(); + }); + + it('prevents competing actions until system installation completes', async () => { + let finish!: () => void; + addToSystem.mockReturnValue(new Promise(resolve => { finish = resolve; })); + await render(); + await openMenu(); + await act(async () => { item('install').click(); }); + expect(more().disabled).toBe(true); + expect(start().disabled).toBe(true); + await act(async () => { more().click(); start().click(); }); + expect(menu()).toBeNull(); + expect(launch).not.toHaveBeenCalled(); + await act(async () => { finish(); }); + expect(more().disabled).toBe(false); + expect(start().disabled).toBe(false); + }); + + it('closes with Escape and restores focus to the more button', async () => { + await render(); + await openMenu(); + await act(async () => { + menu()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + expect(menu()).toBeNull(); + expect(document.activeElement).toBe(more()); + expect(remove).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppInstalledCard.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppInstalledCard.tsx new file mode 100644 index 0000000000..5a453b41e0 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppInstalledCard.tsx @@ -0,0 +1,98 @@ +import { ActionCard, Icon, MenuPopover, OverflowText, StatusPill } from '@openbitfun/ui'; +import { MonitorDown, Play } from 'lucide-react'; +import { useId, useRef, useState } from 'react'; +import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useI18n } from '@/infrastructure/i18n'; +import { renderMiniAppIcon } from '../utils/miniAppIcons'; +import { pickLocalizedString } from '../utils/pickLocalizedString'; +import type { MiniAppLibraryStatus } from './MiniAppLibraryRow'; +import './MiniAppInstalledCard.scss'; + +interface MiniAppInstalledCardProps { + app: MiniAppMeta; + itemKey: string; + statuses: MiniAppLibraryStatus[]; + onOpenDetails: () => void; + onLaunch: () => void; + onAddToSystem: () => Promise; + onDelete: () => void; + systemAppSupported: boolean; + launchDisabled?: boolean; + launchDisabledReason?: string; + busy?: boolean; +} + +export default function MiniAppInstalledCard({ + app, itemKey, statuses, onOpenDetails, onLaunch, onAddToSystem, onDelete, systemAppSupported, + launchDisabled, launchDisabledReason, busy, +}: MiniAppInstalledCardProps) { + const { t, currentLanguage } = useI18n('scenes/miniapp'); + const menuId = useId(); + const moreTriggerRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const [addingToSystem, setAddingToSystem] = useState(false); + const addingToSystemRef = useRef(false); + const actionsDisabled = busy || addingToSystem; + const name = pickLocalizedString(app, currentLanguage, 'name'); + const description = pickLocalizedString(app, currentLanguage, 'description'); + const activityStatuses = statuses.filter(status => status.tone === 'info' || status.tone === 'accent' || status.tone === 'warning'); + + const addToSystem = async () => { + if (!systemAppSupported || busy || addingToSystemRef.current) return; + addingToSystemRef.current = true; + setAddingToSystem(true); + try { + await onAddToSystem(); + } finally { + addingToSystemRef.current = false; + setAddingToSystem(false); + } + }; + + return ( +
+ , disabled: launchDisabled || actionsDisabled, loading: busy, onClick: onLaunch }, + { id: 'more', label: t('presentation.moreActions'), tooltip: t('presentation.moreActions'), + icon: , disabled: actionsDisabled, loading: addingToSystem, + 'aria-haspopup': 'menu', 'aria-expanded': menuOpen, 'aria-controls': menuOpen ? menuId : undefined, + onClick: (event) => { + moreTriggerRef.current = event.currentTarget; + setMenuOpen((open) => !open); + } }, + ]} + body={ + + {name} + {activityStatuses.map(status => {status.label})} + + {description} + } + /> + setMenuOpen(false)} + aria-label={t('presentation.moreActions')} + title={systemAppSupported ? undefined : t('presentation.unsupported')} + aria-description={systemAppSupported ? undefined : t('presentation.unsupported')} + items={[ + { id: 'install', label: t('presentation.install'), icon: , + disabled: !systemAppSupported || actionsDisabled, onSelect: () => void addToSystem() }, + { id: 'delete', label: t('card.delete'), icon: , + disabled: actionsDisabled, tone: 'danger', onSelect: onDelete }, + ]} + /> +
+ ); +} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppLaunchActions.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppLaunchActions.tsx new file mode 100644 index 0000000000..8e3fcb9ff3 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppLaunchActions.tsx @@ -0,0 +1,50 @@ +import { Button, Icon, MenuItem, MenuPopover, Tooltip, type MenuItemProps } from '@openbitfun/ui'; +import { forwardRef, useRef, useState } from 'react'; +import { useI18n } from '@/infrastructure/i18n'; + +interface MiniAppLaunchActionsProps { + onOpenTab: () => void; + onOpenWindow: () => void; + disabled?: boolean; + windowSupported: boolean; + busy?: boolean; + presentation?: 'buttons' | 'menu'; +} + +const LaunchMenuItem = forwardRef(function LaunchMenuItem(props, ref) { + const { t } = useI18n('scenes/miniapp'); + return + + ; +}); + +const launchMenuParts = { item: LaunchMenuItem }; + +export function MiniAppLaunchActions({ onOpenTab, onOpenWindow, disabled, windowSupported, busy, presentation = 'buttons' }: MiniAppLaunchActionsProps) { + const { t } = useI18n('scenes/miniapp'); + const anchorRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const open = menuOpen && !disabled && !busy; + if (presentation === 'menu') return <> + + setMenuOpen(false)} + aria-label={t('detail.start')} parts={launchMenuParts} items={[ + { id: 'tab', label: t('presentation.openTab'), onSelect: onOpenTab }, + { id: 'window', label: t('presentation.openWindow'), disabled: !windowSupported, onSelect: onOpenWindow }, + ]} /> + ; + return <> + + + + + ; +} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppLibraryRow.test.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppLibraryRow.test.tsx new file mode 100644 index 0000000000..71f69cc491 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppLibraryRow.test.tsx @@ -0,0 +1,80 @@ +/** @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import MiniAppLibraryRow from './MiniAppLibraryRow'; +import { MiniAppLaunchActions } from './MiniAppLaunchActions'; + +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); + +let root: Root; +let container: HTMLDivElement; +const openTab = vi.fn(); +const openWindow = vi.fn(); +const marketAction = vi.fn(); +const button = (text: string) => [...container.querySelectorAll('button')].find(button => button.textContent === text)!; +const render = (action: 'open' | 'update' | 'get' = 'open', windowSupported = true, busy = false) => act(async () => { + root.render( undefined} onPrimaryAction={marketAction} + launchActions={action !== 'get' ? : undefined} + />); +}); + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.clearAllMocks(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe('MiniApp launch card', () => { + it('offers both destinations before launch and dispatches each choice independently', async () => { + await render(); + expect(button('presentation.openTab')).toBeDefined(); + expect(button('presentation.openWindow')).toBeDefined(); + expect(button('open')).toBeUndefined(); + act(() => button('presentation.openTab').click()); + expect(openTab).toHaveBeenCalledOnce(); + expect(openWindow).not.toHaveBeenCalled(); + act(() => button('presentation.openWindow').click()); + expect(openWindow).toHaveBeenCalledOnce(); + expect(marketAction).not.toHaveBeenCalled(); + }); + + it('keeps both launch choices available alongside an optional update', async () => { + await render('update'); + expect(button('presentation.openTab')).toBeDefined(); + expect(button('presentation.openWindow')).toBeDefined(); + act(() => button('update').click()); + expect(marketAction).toHaveBeenCalledOnce(); + expect(openTab).not.toHaveBeenCalled(); + expect(openWindow).not.toHaveBeenCalled(); + }); + + it('keeps uninstalled apps on the get action', async () => { + await render('get'); + expect(button('presentation.openTab')).toBeUndefined(); + expect(button('presentation.openWindow')).toBeUndefined(); + act(() => button('get').click()); + expect(marketAction).toHaveBeenCalledOnce(); + }); + + it('disables unavailable native launches but preserves the in-app choice', async () => { + await render('open', false); + expect(button('presentation.openWindow').disabled).toBe(true); + expect(button('presentation.openTab').disabled).toBe(false); + act(() => button('presentation.openWindow').click()); + expect(openWindow).not.toHaveBeenCalled(); + await render('open', true, true); + expect(button('presentation.openWindow').disabled).toBe(true); + expect(button('presentation.openTab').disabled).toBe(true); + }); +}); diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppLibraryRow.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppLibraryRow.tsx index 61255e5009..d74b45dd8f 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppLibraryRow.tsx +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppLibraryRow.tsx @@ -28,6 +28,7 @@ interface MiniAppLibraryRowProps { detailsLabel: string; downloadCount?: string; localMeta?: string; + launchActions?: React.ReactNode; metaLabel?: string; name: string; onOpenDetails: () => void; @@ -53,6 +54,7 @@ const MiniAppLibraryRow: React.FC = ({ detailsLabel, downloadCount, localMeta, + launchActions, metaLabel, name, onOpenDetails, @@ -95,7 +97,7 @@ const MiniAppLibraryRow: React.FC = ({ = ({ ))}
) : null} - +
+ {launchActions} + {action !== 'open' || !launchActions ? : null} +
); diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.test.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.test.tsx index dda0c4f408..c57839c04b 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.test.tsx +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.test.tsx @@ -68,7 +68,7 @@ describe('MiniAppRunner strict bridge startup', () => { root?.render(); }); - const iframe = container.querySelector('iframe') as HTMLIFrameElement; + let iframe = container.querySelector('iframe') as HTMLIFrameElement; expect(iframe).toBeTruthy(); expect(iframe.getAttribute('src')).toBe('about:blank'); expect(iframe.getAttribute('srcdoc')).toBeNull(); @@ -79,6 +79,7 @@ describe('MiniAppRunner strict bridge startup', () => { root?.render(); }); + iframe = container.querySelector('iframe') as HTMLIFrameElement; expect(createObjectURL).toHaveBeenCalledTimes(1); expect(createObjectURL.mock.calls[0][0]).toBeInstanceOf(Blob); expect(iframe.getAttribute('src')).toBe('blob:market-lens-1'); @@ -94,6 +95,7 @@ describe('MiniAppRunner strict bridge startup', () => { }); expect(revokeObjectURL).toHaveBeenCalledWith('blob:market-lens-1'); + iframe = container.querySelector('iframe') as HTMLIFrameElement; expect(iframe.getAttribute('src')).toBe('blob:market-lens-2'); await act(async () => { @@ -102,4 +104,34 @@ describe('MiniAppRunner strict bridge startup', () => { root = null; expect(revokeObjectURL).toHaveBeenCalledWith('blob:market-lens-2'); }); + it('delivers only declared enabled chrome actions to its own iframe and releases the binding', async () => { + const ref = { current: null as ((actionId: string) => void) | null }; + const customApp = { ...app, chrome: { titlebar: { actions: [ + { id: 'export', icon: 'download' as const, label: 'Export' }, + { id: 'disabled', icon: 'save' as const, label: 'Save', enabled: false }, + ] } } }; + await act(async () => { root?.render(); }); + const target = container.querySelector('iframe')!.contentWindow!; + const send = vi.spyOn(target, 'postMessage'); + ref.current?.('export'); + ref.current?.('disabled'); + ref.current?.('unknown'); + expect(send).toHaveBeenCalledExactlyOnceWith({ type: 'openbitfun:event', event: 'chrome:action', payload: { actionId: 'export' } }, '*'); + act(() => { root?.unmount(); }); root = null; + expect(ref.current).toBeNull(); + }); + + it('acknowledges a loaded document once, never the initial blank frame', async () => { + const ready = vi.fn(); + await act(async () => { root?.render(); }); + await act(async () => { container.querySelector('iframe')?.dispatchEvent(new Event('load')); }); + expect(ready).not.toHaveBeenCalled(); + bridgeMock.ready = true; + await act(async () => { root?.render(); }); + expect(ready).not.toHaveBeenCalled(); + await act(async () => { container.querySelector('iframe')?.dispatchEvent(new Event('load')); }); + await act(async () => { container.querySelector('iframe')?.dispatchEvent(new Event('load')); }); + expect(ready).toHaveBeenCalledTimes(1); + }); + }); diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.tsx index e3329b7b8e..6ee9937d70 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.tsx +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppRunner.tsx @@ -12,16 +12,35 @@ interface MiniAppRunnerProps { app: MiniApp; runScope?: MiniAppRunScope; strictRuntime?: boolean; + workspacePath?: string | null; + onReady?: () => void; + chromeActionRef?: React.MutableRefObject<((actionId: string) => void) | null>; } -const MiniAppRunner: React.FC = ({ app, runScope, strictRuntime = false }) => { +const MiniAppRunner: React.FC = ({ app, runScope, strictRuntime = false, workspacePath, onReady, chromeActionRef }) => { const iframeRef = useRef(null); const bridgeReady = useMiniAppBridge( iframeRef, app, runScope ?? { kind: 'active', appId: app.id }, strictRuntime, + workspacePath, ); + useEffect(() => { + if (!chromeActionRef) return; + const invoke = (actionId: string) => { + if (!app.chrome?.titlebar?.actions?.some(action => action.id === actionId && action.enabled !== false)) return; + iframeRef.current?.contentWindow?.postMessage({ type: 'openbitfun:event', event: 'chrome:action', payload: { actionId } }, '*'); + }; + chromeActionRef.current = invoke; + return () => { if (chromeActionRef.current === invoke) chromeActionRef.current = null; }; + }, [app.chrome, chromeActionRef]); + const notified = useRef(false); + const onReadyRef = useRef(onReady); + onReadyRef.current = onReady; + const notifyReady = useCallback(() => { + if (!notified.current) { notified.current = true; onReadyRef.current?.(); } + }, []); const [strictDocumentUrl, setStrictDocumentUrl] = useState(); const writeCompiledHtml = useCallback(() => { @@ -36,11 +55,13 @@ const MiniAppRunner: React.FC = ({ app, runScope, strictRunt return false; } + notified.current = false; doc.open(); doc.write(html); + doc.documentElement.dataset.openbitfunMiniappRendered = app.id; doc.close(); return true; - }, [app.compiled_html, strictRuntime]); + }, [app.id, app.compiled_html, strictRuntime]); useEffect(() => { const iframe = iframeRef.current; @@ -71,6 +92,13 @@ const MiniAppRunner: React.FC = ({ app, runScope, strictRunt }; }, [app.id, app.compiled_html, strictRuntime, writeCompiledHtml]); + // A fast document may finish before the asynchronous bridge becomes ready. + useEffect(() => { + const doc = iframeRef.current?.contentDocument; + if (!strictRuntime && bridgeReady && doc?.readyState === 'complete' + && doc.documentElement.dataset.openbitfunMiniappRendered === app.id) notifyReady(); + }, [app.id, app.compiled_html, bridgeReady, strictRuntime, notifyReady]); + useLayoutEffect(() => { const html = app.compiled_html?.trim(); if (!strictRuntime || !bridgeReady || !html) { @@ -81,6 +109,7 @@ const MiniAppRunner: React.FC = ({ app, runScope, strictRunt // Tauri's WKWebView has rendered srcdoc as a blank document in production. // A sandboxed blob navigation is reliable while retaining the strict // runtime's unique opaque origin because allow-same-origin is absent. + notified.current = false; const documentUrl = URL.createObjectURL( new Blob([html], { type: 'text/html;charset=utf-8' }), ); @@ -93,8 +122,10 @@ const MiniAppRunner: React.FC = ({ app, runScope, strictRunt if (strictRuntime) { return (