feat(desktop): run tours inside the preview pane
The guest page is out-of-process, so the first action injects a self-contained bundle over executeJavaScript — the driver.js IIFE, its stylesheet, and the engine source — parked on window globals so later actions reuse the live instance. Injection is idempotent and vanishes with the page, so a navigation resets the tour. This is what lets a tour walk through any web app open in the in-app browser, not just Hermes itself.
This commit is contained in:
@@ -30,6 +30,7 @@ import { previewConsoleState } from './preview-console-store'
|
||||
import { LocalFilePreview, PreviewEmptyState } from './preview-file'
|
||||
import { PREVIEW_BROWSER_ATTR, registerPreviewNav } from './preview-nav'
|
||||
import { registerPreviewPageReader } from './preview-reader'
|
||||
import { registerPreviewTourRunner } from './preview-tour-runner'
|
||||
|
||||
type PreviewWebview = HTMLElement & {
|
||||
canGoBack?: () => boolean
|
||||
@@ -454,6 +455,25 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest =
|
||||
})
|
||||
}, [isWebPreview, tabId])
|
||||
|
||||
// Publish the TOUR runner for this tab (the tour tool, surface='preview'):
|
||||
// runs injected driver.js actions inside the guest page so the agent can
|
||||
// give guided walkthroughs of whatever web app is open here.
|
||||
useEffect(() => {
|
||||
if (!isWebPreview || !tabId) {
|
||||
return
|
||||
}
|
||||
|
||||
return registerPreviewTourRunner(tabId, async code => {
|
||||
const webview = webviewRef.current
|
||||
|
||||
if (!webview?.executeJavaScript) {
|
||||
throw new Error('preview webview is not ready')
|
||||
}
|
||||
|
||||
return webview.executeJavaScript(code)
|
||||
})
|
||||
}, [isWebPreview, tabId])
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
if (!consoleOpen) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* PREVIEW TOUR RUNNER REGISTRY — the tour tool's script bridge into the
|
||||
* preview pane, the tour analog of preview-nav's handle registry.
|
||||
*
|
||||
* A live browser pane registers a script runner (its webview's
|
||||
* `executeJavaScript`) here, keyed by tab id; `activeTourRunner` resolves the
|
||||
* ACTIVE tab from the store. Kept separate from preview-tour.ts so the pane
|
||||
* component's static import stays tiny — the engine source + driver.js
|
||||
* payload only load when a tour actually runs (gateway-event dynamic-imports
|
||||
* preview-tour.ts).
|
||||
*/
|
||||
|
||||
import { $rightRailActiveTabId } from '@/store/layout'
|
||||
import { $previewTabs } from '@/store/preview'
|
||||
|
||||
/** Runs JS source in the pane's guest page, resolving its completion value. */
|
||||
export type TourScriptRunner = (code: string) => Promise<unknown>
|
||||
|
||||
const runners = new Map<string, TourScriptRunner>()
|
||||
|
||||
/** Register a live preview's script runner; returns an idempotent unregister. */
|
||||
export function registerPreviewTourRunner(tabId: string, runner: TourScriptRunner): () => void {
|
||||
runners.set(tabId, runner)
|
||||
|
||||
return () => {
|
||||
if (runners.get(tabId) === runner) {
|
||||
runners.delete(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The ACTIVE preview tab's script runner. Null = no live page to tour. */
|
||||
export function activeTourRunner(): TourScriptRunner | null {
|
||||
const tabs = $previewTabs.get()
|
||||
const tab = tabs.find(t => t.id === $rightRailActiveTabId.get()) ?? tabs[0]
|
||||
|
||||
return (tab && runners.get(tab.id)) || null
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* PREVIEW TOUR — runs tour actions inside the preview pane's guest page, so a
|
||||
* tour can walk through ANY web app open in the in-app browser, not just
|
||||
* Hermes itself.
|
||||
*
|
||||
* The guest page is out-of-process; nothing here can touch its DOM directly.
|
||||
* Instead the first action injects a self-contained bundle over
|
||||
* `executeJavaScript` — the vendored driver.js IIFE, its stylesheet, and the
|
||||
* same engine/collector SOURCE the app surface runs (see lib/tour/engine.ts's
|
||||
* self-containment contract) — parked on window globals so subsequent actions
|
||||
* reuse the live driver instance. Injection is idempotent and vanishes with
|
||||
* the page (a navigation resets the tour, which is the right behavior).
|
||||
*
|
||||
* Dynamic-imported by run-tour.ts so the raw driver.js payload stays out of
|
||||
* the boot path.
|
||||
*/
|
||||
|
||||
import driverCss from 'driver.js/dist/driver.css?raw'
|
||||
import driverIife from 'driver.js/dist/driver.js.iife.js?raw'
|
||||
|
||||
import { collectTourTargets } from '@/lib/tour/collect-targets'
|
||||
import { runTourEngine, type TourAction, type TourResult } from '@/lib/tour/engine'
|
||||
|
||||
import { activeTourRunner } from './preview-tour-runner'
|
||||
|
||||
/** Build the idempotent inject-and-run script for one tour action. */
|
||||
function buildTourScript(action: TourAction): string {
|
||||
return `(function () {
|
||||
var w = window;
|
||||
if (!w.__hermesTourEngine) {
|
||||
${driverIife}
|
||||
w.__hermesTourHolder = {};
|
||||
w.__hermesTourCollect = (${collectTourTargets.toString()});
|
||||
w.__hermesTourEngine = (${runTourEngine.toString()});
|
||||
}
|
||||
if (!document.getElementById('__hermes-tour-style')) {
|
||||
var style = document.createElement('style');
|
||||
style.id = '__hermes-tour-style';
|
||||
style.textContent = ${JSON.stringify(driverCss)};
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
}
|
||||
return JSON.stringify(w.__hermesTourEngine(
|
||||
w.driver.js.driver,
|
||||
w.__hermesTourHolder,
|
||||
${JSON.stringify(action)},
|
||||
w.__hermesTourCollect,
|
||||
document
|
||||
));
|
||||
})()`
|
||||
}
|
||||
|
||||
/** Run one tour action in the ACTIVE preview tab's page. */
|
||||
export async function runPreviewTour(action: TourAction): Promise<TourResult> {
|
||||
const run = activeTourRunner()
|
||||
|
||||
if (!run) {
|
||||
return { error: 'No live page is open in the preview pane — open one first.', success: false }
|
||||
}
|
||||
|
||||
const raw = await run(buildTourScript(action))
|
||||
|
||||
if (typeof raw !== 'string' || !raw) {
|
||||
return { error: 'The page did not answer the tour action.', success: false }
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as TourResult
|
||||
}
|
||||
Reference in New Issue
Block a user