mirror of
https://github.com/wavetermdev/waveterm
synced 2026-05-22 16:28:32 +00:00
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:
parent
0d04b99b46
commit
665facbc7c
4 changed files with 124 additions and 15 deletions
|
|
@ -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">
|
||||
|
|
|
|||
1
frontend/types/gotypes.d.ts
vendored
1
frontend/types/gotypes.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Reference in a new issue