Skip to content

Commit 665facb

Browse files
Copilotsawka
andauthored
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>
1 parent 0d04b99 commit 665facb

File tree

4 files changed

+124
-15
lines changed

4 files changed

+124
-15
lines changed

frontend/app/view/webview/webview.tsx

Lines changed: 118 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai";
2222
import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react";
2323
import "./webview.scss";
2424

25+
// User agent strings for mobile emulation
26+
const USER_AGENT_IPHONE =
27+
"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";
28+
const USER_AGENT_ANDROID =
29+
"Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36";
30+
2531
let webviewPreloadUrl = null;
2632

2733
function getWebviewPreloadUrl() {
@@ -61,6 +67,7 @@ export class WebViewModel implements ViewModel {
6167
searchAtoms?: SearchAtoms;
6268
typeaheadOpen: PrimitiveAtom<boolean>;
6369
partitionOverride: PrimitiveAtom<string> | null;
70+
userAgentType: Atom<string>;
6471

6572
constructor(blockId: string, nodeModel: BlockNodeModel) {
6673
this.nodeModel = nodeModel;
@@ -87,6 +94,7 @@ export class WebViewModel implements ViewModel {
8794
this.hideNav = getBlockMetaKeyAtom(blockId, "web:hidenav");
8895
this.typeaheadOpen = atom(false);
8996
this.partitionOverride = null;
97+
this.userAgentType = getBlockMetaKeyAtom(blockId, "web:useragenttype");
9098

9199
this.mediaPlaying = atom(false);
92100
this.mediaMuted = atom(false);
@@ -161,20 +169,36 @@ export class WebViewModel implements ViewModel {
161169
return null;
162170
}
163171
const url = get(this.url);
164-
return [
165-
{
172+
const userAgentType = get(this.userAgentType);
173+
const buttons: IconButtonDecl[] = [];
174+
175+
// Add mobile indicator icon if using mobile user agent
176+
if (userAgentType === "mobile:iphone" || userAgentType === "mobile:android") {
177+
const mobileIcon = userAgentType === "mobile:iphone" ? "mobile-screen" : "mobile-screen-button";
178+
const mobileTitle =
179+
userAgentType === "mobile:iphone" ? "Mobile User Agent: iPhone" : "Mobile User Agent: Android";
180+
buttons.push({
166181
elemtype: "iconbutton",
167-
icon: "arrow-up-right-from-square",
168-
title: "Open in External Browser",
169-
click: () => {
170-
console.log("open external", url);
171-
if (url != null && url != "") {
172-
const externalUrl = this.modifyExternalUrl?.(url) ?? url;
173-
return getApi().openExternal(externalUrl);
174-
}
175-
},
182+
icon: mobileIcon,
183+
title: mobileTitle,
184+
noAction: true,
185+
});
186+
}
187+
188+
buttons.push({
189+
elemtype: "iconbutton",
190+
icon: "arrow-up-right-from-square",
191+
title: "Open in External Browser",
192+
click: () => {
193+
console.log("open external", url);
194+
if (url != null && url != "") {
195+
const externalUrl = this.modifyExternalUrl?.(url) ?? url;
196+
return getApi().openExternal(externalUrl);
197+
}
176198
},
177-
];
199+
});
200+
201+
return buttons;
178202
});
179203
}
180204

@@ -595,6 +619,50 @@ export class WebViewModel implements ViewModel {
595619
zoomSubMenu.push(makeZoomFactorMenuItem("175%", 1.75));
596620
zoomSubMenu.push(makeZoomFactorMenuItem("200%", 2));
597621

622+
// User Agent Type submenu
623+
const curUserAgentType = globalStore.get(this.userAgentType) || "default";
624+
const userAgentSubMenu: ContextMenuItem[] = [
625+
{
626+
label: "Default",
627+
type: "checkbox",
628+
click: () => {
629+
fireAndForget(() => {
630+
return RpcApi.SetMetaCommand(TabRpcClient, {
631+
oref: WOS.makeORef("block", this.blockId),
632+
meta: { "web:useragenttype": null },
633+
});
634+
});
635+
},
636+
checked: curUserAgentType === "default" || curUserAgentType === "",
637+
},
638+
{
639+
label: "Mobile: iPhone",
640+
type: "checkbox",
641+
click: () => {
642+
fireAndForget(() => {
643+
return RpcApi.SetMetaCommand(TabRpcClient, {
644+
oref: WOS.makeORef("block", this.blockId),
645+
meta: { "web:useragenttype": "mobile:iphone" },
646+
});
647+
});
648+
},
649+
checked: curUserAgentType === "mobile:iphone",
650+
},
651+
{
652+
label: "Mobile: Android",
653+
type: "checkbox",
654+
click: () => {
655+
fireAndForget(() => {
656+
return RpcApi.SetMetaCommand(TabRpcClient, {
657+
oref: WOS.makeORef("block", this.blockId),
658+
meta: { "web:useragenttype": "mobile:android" },
659+
});
660+
});
661+
},
662+
checked: curUserAgentType === "mobile:android",
663+
},
664+
];
665+
598666
const isNavHidden = globalStore.get(this.hideNav);
599667
return [
600668
{
@@ -612,6 +680,13 @@ export class WebViewModel implements ViewModel {
612680
{
613681
type: "separator",
614682
},
683+
{
684+
label: "User Agent Type",
685+
submenu: userAgentSubMenu,
686+
},
687+
{
688+
type: "separator",
689+
},
615690
{
616691
label: isNavHidden ? "Un-Hide Navigation" : "Hide Navigation",
617692
click: () =>
@@ -735,6 +810,15 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
735810
const partitionOverride = useAtomValueSafe(model.partitionOverride);
736811
const metaPartition = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:partition"));
737812
const webPartition = partitionOverride || metaPartition || undefined;
813+
const userAgentType = useAtomValue(model.userAgentType) || "default";
814+
815+
// Determine user agent string based on type
816+
let userAgent: string | undefined = undefined;
817+
if (userAgentType === "mobile:iphone") {
818+
userAgent = USER_AGENT_IPHONE;
819+
} else if (userAgentType === "mobile:android") {
820+
userAgent = USER_AGENT_ANDROID;
821+
}
738822

739823
// Search
740824
const searchProps = useSearch({ anchorRef: model.webviewRef, viewModel: model });
@@ -790,6 +874,7 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
790874

791875
// The initial value of the block metadata URL when the component first renders. Used to set the starting src value for the webview.
792876
const [metaUrlInitial] = useState(initialSrc || metaUrl);
877+
const prevUserAgentTypeRef = useRef(userAgentType);
793878

794879
const [webContentsId, setWebContentsId] = useState(null);
795880
const domReady = useAtomValue(model.domReady);
@@ -855,6 +940,26 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
855940
}
856941
}, [metaUrl, initialSrc]);
857942

943+
// Reload webview when user agent type changes
944+
useEffect(() => {
945+
if (prevUserAgentTypeRef.current !== userAgentType && domReady && model.webviewRef.current) {
946+
let newUserAgent: string | undefined = undefined;
947+
if (userAgentType === "mobile:iphone") {
948+
newUserAgent = USER_AGENT_IPHONE;
949+
} else if (userAgentType === "mobile:android") {
950+
newUserAgent = USER_AGENT_ANDROID;
951+
}
952+
953+
if (newUserAgent) {
954+
model.webviewRef.current.setUserAgent(newUserAgent);
955+
} else {
956+
model.webviewRef.current.setUserAgent("");
957+
}
958+
model.webviewRef.current.reload();
959+
}
960+
prevUserAgentTypeRef.current = userAgentType;
961+
}, [userAgentType, domReady]);
962+
858963
useEffect(() => {
859964
const webview = model.webviewRef.current;
860965
if (!webview) {
@@ -957,6 +1062,7 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
9571062
// @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.
9581063
allowpopups="true"
9591064
partition={webPartition}
1065+
useragent={userAgent}
9601066
/>
9611067
{errorText && (
9621068
<div className="webview-error">

frontend/types/gotypes.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,7 @@ declare global {
671671
"web:zoom"?: number;
672672
"web:hidenav"?: boolean;
673673
"web:partition"?: string;
674+
"web:useragenttype"?: string;
674675
"markdown:fontsize"?: number;
675676
"markdown:fixedfontsize"?: number;
676677
"tsunami:*"?: boolean;

pkg/waveobj/metaconsts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ const (
116116
MetaKey_WebZoom = "web:zoom"
117117
MetaKey_WebHideNav = "web:hidenav"
118118
MetaKey_WebPartition = "web:partition"
119+
MetaKey_WebUserAgentType = "web:useragenttype"
119120

120121
MetaKey_MarkdownFontSize = "markdown:fontsize"
121122
MetaKey_MarkdownFixedFontSize = "markdown:fixedfontsize"

pkg/waveobj/wtypemeta.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,10 @@ type MetaTSType struct {
117117
TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"`
118118
TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug
119119

120-
WebZoom float64 `json:"web:zoom,omitempty"`
121-
WebHideNav *bool `json:"web:hidenav,omitempty"`
122-
WebPartition string `json:"web:partition,omitempty"`
120+
WebZoom float64 `json:"web:zoom,omitempty"`
121+
WebHideNav *bool `json:"web:hidenav,omitempty"`
122+
WebPartition string `json:"web:partition,omitempty"`
123+
WebUserAgentType string `json:"web:useragenttype,omitempty"`
123124

124125
MarkdownFontSize float64 `json:"markdown:fontsize,omitempty"`
125126
MarkdownFixedFontSize float64 `json:"markdown:fixedfontsize,omitempty"`

0 commit comments

Comments
 (0)