Add mobile user agent emulation for web widgets (#2454)

This PR adds support for mobile user agent emulation in web widgets,
enabling developers to test mobile-responsive websites directly within
WaveTerm.

## Changes

### New Meta Key: `web:useragenttype`

Added a new metadata key that accepts the following values:
- `"default"` (or `null`) - Uses the standard browser user agent
- `"mobile:iphone"` - Emulates iPhone Safari (iOS 17.0)
- `"mobile:android"` - Emulates Android Chrome (Android 13)

### User Interface

**Settings Menu**: Added a "User Agent Type" submenu to web widget
settings (accessible via right-click → Settings) with radio button
options for Default, Mobile: iPhone, and Mobile: Android.

**Visual Indicator**: When a mobile user agent is active, a mobile
device icon appears in the widget's header toolbar with an appropriate
tooltip indicating the current emulation mode.

### Implementation Details

The implementation leverages Electron's webview `useragent` attribute to
override the default user agent string. The setting is persisted in the
block's metadata and automatically applied when the webview is rendered.

User agent strings used:
- **iPhone**: `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)
AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148
Safari/604.1`
- **Android**: `Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36`

## Use Cases

This feature is particularly useful for:
- Testing mobile-responsive web designs
- Debugging mobile-specific website behaviors
- Viewing mobile versions of websites without needing physical devices
- Web development workflows that require testing across different user
agents

## Files Changed

- `pkg/waveobj/wtypemeta.go` - Added `WebUserAgentType` field to
metadata type
- `frontend/types/gotypes.d.ts` - Generated TypeScript types for the new
meta key
- `frontend/app/view/webview/webview.tsx` - Implemented user agent
selection UI and webview configuration
- `pkg/waveobj/metaconsts.go` - Generated Go constants for the new meta
key


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:
Copilot 2025-10-17 17:45:32 -07:00 committed by GitHub
parent 0d04b99b46
commit 665facbc7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 124 additions and 15 deletions

View file

@ -22,6 +22,12 @@ import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai";
import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react";
import "./webview.scss";
// User agent strings for mobile emulation
const USER_AGENT_IPHONE =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
const USER_AGENT_ANDROID =
"Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36";
let webviewPreloadUrl = null;
function getWebviewPreloadUrl() {
@ -61,6 +67,7 @@ export class WebViewModel implements ViewModel {
searchAtoms?: SearchAtoms;
typeaheadOpen: PrimitiveAtom<boolean>;
partitionOverride: PrimitiveAtom<string> | null;
userAgentType: Atom<string>;
constructor(blockId: string, nodeModel: BlockNodeModel) {
this.nodeModel = nodeModel;
@ -87,6 +94,7 @@ export class WebViewModel implements ViewModel {
this.hideNav = getBlockMetaKeyAtom(blockId, "web:hidenav");
this.typeaheadOpen = atom(false);
this.partitionOverride = null;
this.userAgentType = getBlockMetaKeyAtom(blockId, "web:useragenttype");
this.mediaPlaying = atom(false);
this.mediaMuted = atom(false);
@ -161,20 +169,36 @@ export class WebViewModel implements ViewModel {
return null;
}
const url = get(this.url);
return [
{
const userAgentType = get(this.userAgentType);
const buttons: IconButtonDecl[] = [];
// Add mobile indicator icon if using mobile user agent
if (userAgentType === "mobile:iphone" || userAgentType === "mobile:android") {
const mobileIcon = userAgentType === "mobile:iphone" ? "mobile-screen" : "mobile-screen-button";
const mobileTitle =
userAgentType === "mobile:iphone" ? "Mobile User Agent: iPhone" : "Mobile User Agent: Android";
buttons.push({
elemtype: "iconbutton",
icon: "arrow-up-right-from-square",
title: "Open in External Browser",
click: () => {
console.log("open external", url);
if (url != null && url != "") {
const externalUrl = this.modifyExternalUrl?.(url) ?? url;
return getApi().openExternal(externalUrl);
}
},
icon: mobileIcon,
title: mobileTitle,
noAction: true,
});
}
buttons.push({
elemtype: "iconbutton",
icon: "arrow-up-right-from-square",
title: "Open in External Browser",
click: () => {
console.log("open external", url);
if (url != null && url != "") {
const externalUrl = this.modifyExternalUrl?.(url) ?? url;
return getApi().openExternal(externalUrl);
}
},
];
});
return buttons;
});
}
@ -595,6 +619,50 @@ export class WebViewModel implements ViewModel {
zoomSubMenu.push(makeZoomFactorMenuItem("175%", 1.75));
zoomSubMenu.push(makeZoomFactorMenuItem("200%", 2));
// User Agent Type submenu
const curUserAgentType = globalStore.get(this.userAgentType) || "default";
const userAgentSubMenu: ContextMenuItem[] = [
{
label: "Default",
type: "checkbox",
click: () => {
fireAndForget(() => {
return RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "web:useragenttype": null },
});
});
},
checked: curUserAgentType === "default" || curUserAgentType === "",
},
{
label: "Mobile: iPhone",
type: "checkbox",
click: () => {
fireAndForget(() => {
return RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "web:useragenttype": "mobile:iphone" },
});
});
},
checked: curUserAgentType === "mobile:iphone",
},
{
label: "Mobile: Android",
type: "checkbox",
click: () => {
fireAndForget(() => {
return RpcApi.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", this.blockId),
meta: { "web:useragenttype": "mobile:android" },
});
});
},
checked: curUserAgentType === "mobile:android",
},
];
const isNavHidden = globalStore.get(this.hideNav);
return [
{
@ -612,6 +680,13 @@ export class WebViewModel implements ViewModel {
{
type: "separator",
},
{
label: "User Agent Type",
submenu: userAgentSubMenu,
},
{
type: "separator",
},
{
label: isNavHidden ? "Un-Hide Navigation" : "Hide Navigation",
click: () =>
@ -735,6 +810,15 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
const partitionOverride = useAtomValueSafe(model.partitionOverride);
const metaPartition = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:partition"));
const webPartition = partitionOverride || metaPartition || undefined;
const userAgentType = useAtomValue(model.userAgentType) || "default";
// Determine user agent string based on type
let userAgent: string | undefined = undefined;
if (userAgentType === "mobile:iphone") {
userAgent = USER_AGENT_IPHONE;
} else if (userAgentType === "mobile:android") {
userAgent = USER_AGENT_ANDROID;
}
// Search
const searchProps = useSearch({ anchorRef: model.webviewRef, viewModel: model });
@ -790,6 +874,7 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
// The initial value of the block metadata URL when the component first renders. Used to set the starting src value for the webview.
const [metaUrlInitial] = useState(initialSrc || metaUrl);
const prevUserAgentTypeRef = useRef(userAgentType);
const [webContentsId, setWebContentsId] = useState(null);
const domReady = useAtomValue(model.domReady);
@ -855,6 +940,26 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
}
}, [metaUrl, initialSrc]);
// Reload webview when user agent type changes
useEffect(() => {
if (prevUserAgentTypeRef.current !== userAgentType && domReady && model.webviewRef.current) {
let newUserAgent: string | undefined = undefined;
if (userAgentType === "mobile:iphone") {
newUserAgent = USER_AGENT_IPHONE;
} else if (userAgentType === "mobile:android") {
newUserAgent = USER_AGENT_ANDROID;
}
if (newUserAgent) {
model.webviewRef.current.setUserAgent(newUserAgent);
} else {
model.webviewRef.current.setUserAgent("");
}
model.webviewRef.current.reload();
}
prevUserAgentTypeRef.current = userAgentType;
}, [userAgentType, domReady]);
useEffect(() => {
const webview = model.webviewRef.current;
if (!webview) {
@ -957,6 +1062,7 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
// @ts-ignore This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean.
allowpopups="true"
partition={webPartition}
useragent={userAgent}
/>
{errorText && (
<div className="webview-error">

View file

@ -671,6 +671,7 @@ declare global {
"web:zoom"?: number;
"web:hidenav"?: boolean;
"web:partition"?: string;
"web:useragenttype"?: string;
"markdown:fontsize"?: number;
"markdown:fixedfontsize"?: number;
"tsunami:*"?: boolean;

View file

@ -116,6 +116,7 @@ const (
MetaKey_WebZoom = "web:zoom"
MetaKey_WebHideNav = "web:hidenav"
MetaKey_WebPartition = "web:partition"
MetaKey_WebUserAgentType = "web:useragenttype"
MetaKey_MarkdownFontSize = "markdown:fontsize"
MetaKey_MarkdownFixedFontSize = "markdown:fixedfontsize"

View file

@ -117,9 +117,10 @@ type MetaTSType struct {
TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"`
TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug
WebZoom float64 `json:"web:zoom,omitempty"`
WebHideNav *bool `json:"web:hidenav,omitempty"`
WebPartition string `json:"web:partition,omitempty"`
WebZoom float64 `json:"web:zoom,omitempty"`
WebHideNav *bool `json:"web:hidenav,omitempty"`
WebPartition string `json:"web:partition,omitempty"`
WebUserAgentType string `json:"web:useragenttype,omitempty"`
MarkdownFontSize float64 `json:"markdown:fontsize,omitempty"`
MarkdownFixedFontSize float64 `json:"markdown:fixedfontsize,omitempty"`