preview updates (mock electron api, wos checks) (#2986)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
This commit is contained in:
Mike Sawka 2026-03-06 16:50:41 -08:00 committed by GitHub
parent 3f4484a9e2
commit 7ef0bcd87f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 133 additions and 17 deletions

View file

@ -7,11 +7,12 @@ description: Guide for adding new Electron APIs to Wave Terminal. Use when imple
Electron APIs allow the frontend to call Electron main process functionality directly via IPC.
## Three Files to Edit
## Four Files to Edit
1. [`frontend/types/custom.d.ts`](frontend/types/custom.d.ts) - TypeScript [`ElectronApi`](frontend/types/custom.d.ts:82) type
2. [`emain/preload.ts`](emain/preload.ts) - Expose method via `contextBridge`
3. [`emain/emain-ipc.ts`](emain/emain-ipc.ts) - Implement IPC handler
4. [`frontend/preview/preview-electron-api.ts`](frontend/preview/preview-electron-api.ts) - Add a no-op stub to keep the `previewElectronApi` object in sync with the `ElectronApi` type
## Three Communication Patterns
@ -54,7 +55,15 @@ electron.ipcMain.handle("capture-screenshot", async (event, rect) => {
});
```
### 4. Call from Frontend
### 4. Add Preview Stub
In [`frontend/preview/preview-electron-api.ts`](frontend/preview/preview-electron-api.ts):
```typescript
captureScreenshot: (_rect: Electron.Rectangle) => Promise.resolve(""),
```
### 5. Call from Frontend
```typescript
import { getApi } from "@/store/global";
@ -167,6 +176,7 @@ webContents.send("zoom-factor-change", newZoomFactor);
- [ ] Include IPC channel name in comment
- [ ] Expose in [`preload.ts`](emain/preload.ts)
- [ ] Implement in [`emain-ipc.ts`](emain/emain-ipc.ts)
- [ ] Add no-op stub to [`preview-electron-api.ts`](frontend/preview/preview-electron-api.ts)
- [ ] IPC channel names match exactly
- [ ] **For sync**: Set `event.returnValue` (or browser hangs!)
- [ ] Test end-to-end

View file

@ -76,8 +76,8 @@ export default [
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_$",
varsIgnorePattern: "^_$",
argsIgnorePattern: "^_[a-z0-9]*$",
varsIgnorePattern: "^_[a-z0-9]*$",
},
],
"prefer-const": "warn",

View file

@ -29,7 +29,13 @@ type PageName = "init" | "notelemetrystar" | "features";
const pageNameAtom: PrimitiveAtom<PageName> = atom<PageName>("init");
const InitPage = ({ isCompact }: { isCompact: boolean }) => {
const InitPage = ({
isCompact,
telemetryUpdateFn,
}: {
isCompact: boolean;
telemetryUpdateFn: (value: boolean) => Promise<void>;
}) => {
const telemetrySetting = useSettingsKeyAtom("telemetry:enabled");
const clientData = useAtomValue(ClientModel.getInstance().clientAtom);
const [telemetryEnabled, setTelemetryEnabled] = useState<boolean>(!!telemetrySetting);
@ -63,7 +69,7 @@ const InitPage = ({ isCompact }: { isCompact: boolean }) => {
const setTelemetry = (value: boolean) => {
fireAndForget(() =>
services.ClientService.TelemetryUpdate(value).then(() => {
telemetryUpdateFn(value).then(() => {
setTelemetryEnabled(value);
})
);
@ -319,7 +325,7 @@ const NewInstallOnboardingModal = () => {
let pageComp: React.JSX.Element = null;
switch (pageName) {
case "init":
pageComp = <InitPage isCompact={isCompact} />;
pageComp = <InitPage isCompact={isCompact} telemetryUpdateFn={services.ClientService.TelemetryUpdate} />;
break;
case "notelemetrystar":
pageComp = <NoTelemetryStarPage isCompact={isCompact} />;

View file

@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc
// Copyright 2026, Command Line Inc
// SPDX-License-Identifier: Apache-2.0
import * as WOS from "@/app/store/wos";
@ -33,4 +33,4 @@ class ClientModel {
}
}
export { ClientModel };
export { ClientModel };

View file

@ -16,7 +16,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom<string>;
const builderIdAtom = atom(initOpts.builderId) as PrimitiveAtom<string>;
const builderAppIdAtom = atom<string>(null) as PrimitiveAtom<string>;
setWaveWindowType(initOpts.builderId != null ? "builder" : "tab");
setWaveWindowType(initOpts.isPreview ? "preview" : initOpts.builderId != null ? "builder" : "tab");
const uiContextAtom = atom((get) => {
const uiContext: UIContext = {
windowid: initOpts.windowId,

View file

@ -4,6 +4,7 @@
// WaveObjectStore
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { isPreviewWindow } from "@/app/store/windowtype";
import { getWebServerEndpoint } from "@/util/endpoints";
import { fetch } from "@/util/fetchutil";
import { fireAndForget } from "@/util/util";
@ -57,7 +58,19 @@ function makeORef(otype: string, oid: string): string {
return `${otype}:${oid}`;
}
const previewMockObjects: Map<string, WaveObj> = new Map();
function mockObjectForPreview<T extends WaveObj>(oref: string, obj: T): void {
if (!isPreviewWindow()) {
throw new Error("mockObjectForPreview can only be called in a preview window");
}
previewMockObjects.set(oref, obj);
}
function GetObject<T>(oref: string): Promise<T> {
if (isPreviewWindow()) {
return Promise.resolve((previewMockObjects.get(oref) as T) ?? null);
}
return callBackendService("object", "GetObject", [oref], true);
}
@ -105,7 +118,9 @@ function callBackendService(service: string, method: string, args: any[], noUICo
const usp = new URLSearchParams();
usp.set("service", service);
usp.set("method", method);
const url = getWebServerEndpoint() + "/wave/service?" + usp.toString();
const webEndpoint = getWebServerEndpoint();
if (webEndpoint == null) throw new Error(`cannot call ${methodName}: no web endpoint`);
const url = webEndpoint + "/wave/service?" + usp.toString();
const fetchPromise = fetch(url, {
method: "POST",
body: JSON.stringify(waveCall),
@ -315,6 +330,7 @@ export {
getWaveObjectLoadingAtom,
loadAndPinWaveObject,
makeORef,
mockObjectForPreview,
reloadWaveObject,
setObjectValue,
splitORef,

View file

@ -0,0 +1,68 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
const previewElectronApi: ElectronApi = {
getAuthKey: () => "",
getIsDev: () => false,
getCursorPoint: () => ({ x: 0, y: 0 }) as Electron.Point,
getPlatform: () => "darwin",
getEnv: (_varName: string) => "",
getUserName: () => "",
getHostName: () => "",
getDataDir: () => "",
getConfigDir: () => "",
getHomeDir: () => "",
getWebviewPreload: () => "",
getAboutModalDetails: () => ({}) as AboutModalDetails,
getZoomFactor: () => 1.0,
showWorkspaceAppMenu: (_workspaceId: string) => {},
showBuilderAppMenu: (_builderId: string) => {},
showContextMenu: (_workspaceId: string, _menu: ElectronContextMenuItem[]) => {},
onContextMenuClick: (_callback: (id: string | null) => void) => {},
onNavigate: (_callback: (url: string) => void) => {},
onIframeNavigate: (_callback: (url: string) => void) => {},
downloadFile: (_path: string) => {},
openExternal: (_url: string) => {},
onFullScreenChange: (_callback: (isFullScreen: boolean) => void) => {},
onZoomFactorChange: (_callback: (zoomFactor: number) => void) => {},
onUpdaterStatusChange: (_callback: (status: UpdaterStatus) => void) => {},
getUpdaterStatus: () => "up-to-date",
getUpdaterChannel: () => "",
installAppUpdate: () => {},
onMenuItemAbout: (_callback: () => void) => {},
updateWindowControlsOverlay: (_rect: Dimensions) => {},
onReinjectKey: (_callback: (waveEvent: WaveKeyboardEvent) => void) => {},
setWebviewFocus: (_focusedId: number) => {},
registerGlobalWebviewKeys: (_keys: string[]) => {},
onControlShiftStateUpdate: (_callback: (state: boolean) => void) => {},
createWorkspace: () => {},
switchWorkspace: (_workspaceId: string) => {},
deleteWorkspace: (_workspaceId: string) => {},
setActiveTab: (_tabId: string) => {},
createTab: () => {},
closeTab: (_workspaceId: string, _tabId: string, _confirmClose: boolean) => Promise.resolve(false),
setWindowInitStatus: (_status: "ready" | "wave-ready") => {},
onWaveInit: (_callback: (initOpts: WaveInitOpts) => void) => {},
onBuilderInit: (_callback: (initOpts: BuilderInitOpts) => void) => {},
sendLog: (_log: string) => {},
onQuicklook: (_filePath: string) => {},
openNativePath: (_filePath: string) => {},
captureScreenshot: (_rect: Electron.Rectangle) => Promise.resolve(""),
setKeyboardChordMode: () => {},
clearWebviewStorage: (_webContentsId: number) => Promise.resolve(),
setWaveAIOpen: (_isOpen: boolean) => {},
closeBuilderWindow: () => {},
incrementTermCommands: (_opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => {},
nativePaste: () => {},
openBuilder: (_appId?: string) => {},
setBuilderWindowAppId: (_appId: string) => {},
doRefresh: () => {},
saveTextFile: (_fileName: string, _content: string) => Promise.resolve(false),
setIsActive: async () => {},
};
function installPreviewElectronApi() {
(window as any).api = previewElectronApi;
}
export { installPreviewElectronApi };

View file

@ -2,11 +2,13 @@
// SPDX-License-Identifier: Apache-2.0
import Logo from "@/app/asset/logo.svg";
import { ClientModel } from "@/app/store/client-model";
import { setWaveWindowType } from "@/app/store/windowtype";
import { getAtoms, initGlobalAtoms } from "@/app/store/global-atoms";
import { GlobalModel } from "@/app/store/global-model";
import { globalStore } from "@/app/store/jotaiStore";
import { loadFonts } from "@/util/fontutil";
import React, { lazy, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { installPreviewElectronApi } from "./preview-electron-api";
import "../app/app.scss";
@ -118,10 +120,23 @@ function PreviewApp() {
return <PreviewIndex />;
}
const PreviewTabId = crypto.randomUUID();
const PreviewWindowId = crypto.randomUUID();
const PreviewClientId = crypto.randomUUID();
function initPreview() {
setWaveWindowType("preview");
// Preview mode has no connected backend client object, but onboarding previews read clientAtom.
ClientModel.getInstance().initialize(null);
installPreviewElectronApi();
const initOpts = {
tabId: PreviewTabId,
windowId: PreviewWindowId,
clientId: PreviewClientId,
environment: "renderer",
platform: "darwin",
isPreview: true,
} as GlobalInitOptions;
initGlobalAtoms(initOpts);
globalStore.set(getAtoms().fullConfigAtom, {} as FullConfigType);
GlobalModel.getInstance().initialize(initOpts);
loadFonts();
const root = createRoot(document.getElementById("main")!);
root.render(<PreviewApp />);

View file

@ -24,7 +24,7 @@ function OnboardingFeaturesV() {
return (
<div className="flex flex-col w-full gap-8">
<OnboardingModalWrapper width="w-[560px]">
<InitPage isCompact={false} />
<InitPage isCompact={false} telemetryUpdateFn={async () => {}} />
</OnboardingModalWrapper>
<OnboardingModalWrapper width="w-[560px]">
<NoTelemetryStarPage isCompact={false} />

View file

@ -59,6 +59,7 @@ declare global {
environment: "electron" | "renderer";
primaryTabStartup?: boolean;
builderId?: string;
isPreview?: boolean;
};
type WaveInitOpts = {