From 9f1f62a54819bd730fd66c8364e37f9f524e48c7 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 17 Sep 2026 18:04:32 +0000
Subject: [PATCH 1/2] Add an in-app Organization browser so new pages stay as
tabs.
Embed a tabbed iframe browser with an address bar on Organization, open local openXYOS on enter, and trap target=_blank / window.open into in-app tabs via openXYOS embed postMessage plus desktop host hooks. Remove the system new-window action and keep sidecar recover, CORS self-origin, layout heal, and download-source on the top bar.
Co-authored-by: XYAI Labs
---
CHANGELOG.md | 1 +
dashboard/src/locales/en.json | 4 +
dashboard/src/locales/zh.json | 4 +
.../src/pages/Organization/OrgMiniBrowser.tsx | 131 ++++++++++
.../Organization/Organization.module.less | 28 ++
.../src/pages/Organization/index.test.tsx | 100 ++++++-
dashboard/src/pages/Organization/index.tsx | 247 ++++++++++++------
.../src/pages/Organization/orgBrowser.test.ts | 145 ++++++++++
.../src/pages/Organization/orgBrowser.ts | 221 ++++++++++++++++
.../pages/Organization/orgPageWindowTrap.ts | 53 ++++
dashboard/src/utils/desktopExternalLinks.ts | 7 +-
dashboard/src/utils/orgBrowserHost.test.ts | 21 ++
dashboard/src/utils/orgBrowserHost.ts | 29 ++
desktop/src/openurl.go | 3 +
.../frontend/src/embed/trapWindows.ts | 148 +++++++++++
modules/openxyos/frontend/src/main.tsx | 4 +-
modules/openxyos/package.json | 3 +-
modules/openxyos/scripts/test-embed-trap.ts | 36 +++
tests/unit/desktop/test_nsis_uninstall.py | 23 +-
19 files changed, 1112 insertions(+), 96 deletions(-)
create mode 100644 dashboard/src/pages/Organization/OrgMiniBrowser.tsx
create mode 100644 dashboard/src/pages/Organization/orgBrowser.test.ts
create mode 100644 dashboard/src/pages/Organization/orgBrowser.ts
create mode 100644 dashboard/src/pages/Organization/orgPageWindowTrap.ts
create mode 100644 dashboard/src/utils/orgBrowserHost.test.ts
create mode 100644 dashboard/src/utils/orgBrowserHost.ts
create mode 100644 modules/openxyos/frontend/src/embed/trapWindows.ts
create mode 100644 modules/openxyos/scripts/test-embed-trap.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab3f424e..86752ce7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@
### 变更
+- 组织页改为应用内多标签浏览器(标签栏 + 地址栏 + iframe),进入时打开本机 openXYOS。`target=_blank` / `window.open` 收入应用内标签,不再弹出系统新窗口;去掉「在新窗口打开」。
- 组织页顶部蓝色状态栏增加「下载最新源码」主按钮:点击后先打开系统文件夹选择器,再下载最新 openXYOS 主分支源码。不必先打开「组织OS管理」。成功提示保存路径;管理抽屉仍保留同一操作作为次要入口。
- 对话与工作区共用一份列表:侧栏「对话」进入工作区「对话」标签;点开一条才进入聊天画布。工作区侧栏默认打开「项目」标签。
- 用户可见用词对齐:员工 = 已入编部门;同事 = 同一组织里一起聊的人;专家 / 智能助手 = 尚未入编的帮手。个性化「子智能体」改为「专项助手」。群聊选择「同事或智能助手」。
diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json
index bb0a03fa..bc760a37 100644
--- a/dashboard/src/locales/en.json
+++ b/dashboard/src/locales/en.json
@@ -97,6 +97,10 @@
"optional": "Optional",
"embedTitle": "Organization console",
"openSidecar": "Open in new tab",
+ "browserNewTab": "New tab",
+ "addressPlaceholder": "Enter a URL",
+ "addressGo": "Go",
+ "homeTabTitle": "openXYOS",
"loadFailed": "Could not load organization module status",
"saveFailed": "Could not update organization module",
"actionFailed": "Action failed",
diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json
index 7f626bc4..49547e91 100644
--- a/dashboard/src/locales/zh.json
+++ b/dashboard/src/locales/zh.json
@@ -97,6 +97,10 @@
"optional": "可选",
"embedTitle": "组织控制台",
"openSidecar": "新窗口打开",
+ "browserNewTab": "新标签",
+ "addressPlaceholder": "输入网址",
+ "addressGo": "前往",
+ "homeTabTitle": "openXYOS",
"loadFailed": "无法加载组织模块状态",
"saveFailed": "无法更新组织模块",
"actionFailed": "操作失败",
diff --git a/dashboard/src/pages/Organization/OrgMiniBrowser.tsx b/dashboard/src/pages/Organization/OrgMiniBrowser.tsx
new file mode 100644
index 00000000..1f7e97af
--- /dev/null
+++ b/dashboard/src/pages/Organization/OrgMiniBrowser.tsx
@@ -0,0 +1,131 @@
+import { useMemo, type MutableRefObject } from "react";
+import { Button, Input } from "antd";
+import { useTranslation } from "react-i18next";
+import { ChromeTabBar } from "../../components/ChromeTabBar";
+import {
+ buildEmbedSrc,
+ iframeSandboxFor,
+ type OrgBrowserTab,
+} from "./orgBrowser";
+import styles from "./Organization.module.less";
+
+type OrgMiniBrowserProps = {
+ tabs: OrgBrowserTab[];
+ activeId: string;
+ addressValue: string;
+ sidecarOrigin: string;
+ disabledKeys: string[];
+ previewNonce: string;
+ sidecarUp: boolean;
+ previewBlank: boolean;
+ iframeRefs: MutableRefObject>;
+ onAddressChange: (value: string) => void;
+ onAddressSubmit: () => void;
+ onSelectTab: (id: string) => void;
+ onCloseTab: (id: string) => void;
+ onNewTab: () => void;
+ onFrameLoad: () => void;
+};
+
+export default function OrgMiniBrowser({
+ tabs,
+ activeId,
+ addressValue,
+ sidecarOrigin,
+ disabledKeys,
+ previewNonce,
+ sidecarUp,
+ previewBlank,
+ iframeRefs,
+ onAddressChange,
+ onAddressSubmit,
+ onSelectTab,
+ onCloseTab,
+ onNewTab,
+ onFrameLoad,
+}: OrgMiniBrowserProps) {
+ const { t } = useTranslation();
+ const chromeTabs = useMemo(
+ () =>
+ tabs.map((tab) => ({
+ key: tab.id,
+ label: tab.title,
+ tooltip: tab.url,
+ closable: tabs.length > 1,
+ })),
+ [tabs],
+ );
+
+ return (
+
+
{
+ event.preventDefault();
+ onCloseTab(key);
+ }}
+ onNewTab={onNewTab}
+ newTabTitle={t("organization.browserNewTab")}
+ />
+
+
+ {!sidecarUp && (
+
+ {t("organization.previewOffline")}
+
+ )}
+ {previewBlank && (
+
+ {t("organization.previewBlank")}
+
+ )}
+ {tabs.map((tab) => {
+ const src = buildEmbedSrc(tab.srcUrl, {
+ sidecarOrigin,
+ disabledKeys,
+ nonce: previewNonce,
+ });
+ const active = tab.id === activeId;
+ return (
+
+
+ );
+}
diff --git a/dashboard/src/pages/Organization/Organization.module.less b/dashboard/src/pages/Organization/Organization.module.less
index c291b91e..2332dda6 100644
--- a/dashboard/src/pages/Organization/Organization.module.less
+++ b/dashboard/src/pages/Organization/Organization.module.less
@@ -82,6 +82,27 @@
background: #f4f6ff !important;
}
+.browserChrome {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.addressBar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ padding: 6px 10px;
+ background: var(--fn-bg-secondary);
+ border-bottom: 1px solid var(--fn-border-secondary);
+}
+
+.addressBar :global(.ant-input) {
+ border-radius: 8px;
+}
+
.embedPane {
position: relative;
flex: 1;
@@ -92,12 +113,19 @@
.embed {
display: block;
+ position: absolute;
+ inset: 0;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
+.embedInactive {
+ visibility: hidden;
+ pointer-events: none;
+}
+
.previewOffline {
position: absolute;
inset: 0;
diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx
index 904094ca..92a89cd1 100644
--- a/dashboard/src/pages/Organization/index.test.tsx
+++ b/dashboard/src/pages/Organization/index.test.tsx
@@ -1,8 +1,9 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { describe, expect, it, vi, beforeEach } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import OrganizationPage from "./index";
import { orgModuleApi, type OrgOverview } from "../../api/modules/orgModule";
import { message } from "../../utils/antdMessage";
+import { tryOpenInOrgBrowser } from "../../utils/orgBrowserHost";
const overview: OrgOverview = {
enabled: true,
@@ -84,24 +85,31 @@ describe("OrganizationPage", () => {
pickDesktopFolder.mockReset();
vi.mocked(orgModuleApi.overview).mockResolvedValue(overview);
vi.mocked(orgModuleApi.downloadSource).mockReset();
+ vi.mocked(orgModuleApi.startSidecar).mockReset();
+ vi.mocked(orgModuleApi.pack).mockReset();
vi.mocked(message.success).mockReset();
vi.mocked(message.error).mockReset();
});
- it("defaults to the local embed and keeps extras behind the two status buttons", async () => {
+ it("embeds a multi-tab browser for local openXYOS and keeps extras behind drawers", async () => {
render();
await waitFor(() => {
- expect(
- screen.getByTitle("organization.previewTitle"),
- ).toBeInTheDocument();
+ expect(screen.getByTestId("org-mini-browser")).toBeInTheDocument();
});
- const frame = screen.getByTitle("organization.previewTitle");
- expect(frame).toHaveAttribute("src", "http://127.0.0.1:3780/");
-
+ const frame = screen.getByTestId("org-browser-frame");
+ expect(frame).toHaveAttribute(
+ "src",
+ expect.stringMatching(/^http:\/\/127\.0\.0\.1:3780\/\?freeos_embed=1/),
+ );
+ expect(screen.getByTestId("org-address-bar")).toHaveValue(
+ "http://127.0.0.1:3780/",
+ );
+ expect(screen.queryByText("organization.openSidecar")).toBeNull();
expect(screen.getByTestId("org-download-source")).toHaveTextContent(
"organization.downloadSourceBar",
);
+
expect(screen.queryByText("organization.assembleAction")).toBeNull();
expect(screen.queryByText("organization.startSidecarAction")).toBeNull();
expect(screen.queryByTestId("org-download-source-drawer")).toBeNull();
@@ -116,9 +124,7 @@ describe("OrganizationPage", () => {
await screen.findByText("organization.assembleTitle"),
).toBeInTheDocument();
expect(screen.getByText("organization.loopTitle")).toBeInTheDocument();
- expect(
- screen.getByTestId("org-download-source-drawer"),
- ).toBeInTheDocument();
+ expect(screen.getByTestId("org-download-source-drawer")).toBeInTheDocument();
expect(screen.queryByTestId("org-preview-blank")).toBeNull();
});
@@ -156,6 +162,73 @@ describe("OrganizationPage", () => {
expect(message.error).not.toHaveBeenCalled();
});
+ it("opens trapped window.open / host URLs as extra tabs", async () => {
+ render();
+ await screen.findByTestId("org-mini-browser");
+
+ expect(
+ tryOpenInOrgBrowser("https://github.com/XYAIStudio/openXYOS", "GitHub"),
+ ).toBe(true);
+ await waitFor(() => {
+ expect(screen.getByTestId("org-address-bar")).toHaveValue(
+ "https://github.com/XYAIStudio/openXYOS",
+ );
+ });
+ expect(
+ screen
+ .getAllByTestId("org-browser-frame")
+ .some(
+ (node) =>
+ node.getAttribute("src") ===
+ "https://github.com/XYAIStudio/openXYOS",
+ ),
+ ).toBe(true);
+
+ expect(window.open("https://example.com/docs", "_blank")).toBeNull();
+ await waitFor(() => {
+ expect(screen.getByTestId("org-address-bar")).toHaveValue(
+ "https://example.com/docs",
+ );
+ });
+ });
+
+ it("navigates the active tab from the address bar", async () => {
+ render();
+ const address = await screen.findByTestId("org-address-bar");
+ fireEvent.change(address, {
+ target: { value: "https://example.com/path" },
+ });
+ fireEvent.submit(address.closest("form") as HTMLFormElement);
+ await waitFor(() => {
+ expect(screen.getByTestId("org-browser-frame")).toHaveAttribute(
+ "src",
+ "https://example.com/path",
+ );
+ });
+ });
+
+ it("auto-starts the sidecar when Organization is opened offline", async () => {
+ vi.mocked(orgModuleApi.overview).mockResolvedValue({
+ ...overview,
+ sidecar_reachable: false,
+ sidecar_embed_ok: false,
+ start_available: true,
+ });
+ vi.mocked(orgModuleApi.startSidecar).mockResolvedValue({
+ started: true,
+ already: false,
+ reachable: true,
+ url: "http://127.0.0.1:3780",
+ command: "",
+ detail: "ok",
+ launcher: "",
+ });
+ render();
+ await waitFor(() => {
+ expect(orgModuleApi.startSidecar).toHaveBeenCalled();
+ });
+ });
+
it("surfaces a blank-preview error when livez is up but embed origin fails", async () => {
vi.mocked(orgModuleApi.overview).mockResolvedValue({
...overview,
@@ -217,9 +290,8 @@ describe("OrganizationPage", () => {
"organization.packPreviewEmployees",
);
await waitFor(() => {
- expect(screen.getByTitle("organization.previewTitle")).toHaveAttribute(
- "src",
- expect.stringContaining("/employees"),
+ expect(screen.getByTestId("org-address-bar")).toHaveValue(
+ "http://127.0.0.1:3780/employees",
);
});
});
diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx
index 655ccd86..ebf39b2a 100644
--- a/dashboard/src/pages/Organization/index.tsx
+++ b/dashboard/src/pages/Organization/index.tsx
@@ -4,7 +4,6 @@ import {
ArrowDownUp,
Building2,
Download,
- ExternalLink,
Package,
Play,
Settings2,
@@ -31,6 +30,22 @@ import {
import { message } from "../../utils/antdMessage";
import { resolveOpenxyosSourceDest } from "./pickSourceDest";
import { shouldShowPreviewBlank } from "./sidecarRecover";
+import OrgMiniBrowser from "./OrgMiniBrowser";
+import {
+ DEFAULT_ORG_URL,
+ closeOrgTab,
+ createHomeTab,
+ navigateOrgTab,
+ normalizeOrgUrl,
+ openOrgTab,
+ parseOrgNavigatedMessage,
+ parseOrgOpenTabMessage,
+ sidecarOriginOf,
+ tabTitleFromUrl,
+ type OrgBrowserTab,
+} from "./orgBrowser";
+import { installOrgPageWindowTrap } from "./orgPageWindowTrap";
+import { registerOrgBrowserHost } from "../../utils/orgBrowserHost";
import styles from "./Organization.module.less";
type ActionKey = "assemble" | "pack" | "loop" | "sidecar" | "produce" | null;
@@ -93,14 +108,31 @@ export default function OrganizationPage() {
const [produceIma, setProduceIma] = useState("");
const [landed, setLanded] = useState | null>(null);
const autoStartRef = useRef(false);
- const iframeRef = useRef(null);
- const [previewPath, setPreviewPath] = useState("/");
+ const iframeRefs = useRef>({});
const [previewNonce, setPreviewNonce] = useState("");
-
- const showPreview = useCallback((path: string) => {
- setPreviewPath(path.startsWith("/") ? path : `/${path}`);
- setPreviewNonce(String(Date.now()));
- }, []);
+ const homeTitle = t("organization.homeTabTitle");
+ const [tabs, setTabs] = useState(() => [
+ createHomeTab(DEFAULT_ORG_URL, homeTitle),
+ ]);
+ const [activeId, setActiveId] = useState("org-home");
+ const [addressValue, setAddressValue] = useState(DEFAULT_ORG_URL);
+
+ const showPreview = useCallback(
+ (path: string) => {
+ const dest = normalizeOrgUrl(
+ `${(overview?.sidecar_url || DEFAULT_ORG_URL).replace(/\/$/, "")}${
+ path.startsWith("/") ? path : `/${path}`
+ }`,
+ );
+ setTabs((current) =>
+ navigateOrgTab(current, "org-home", dest, homeTitle),
+ );
+ setActiveId("org-home");
+ setAddressValue(dest);
+ setPreviewNonce(String(Date.now()));
+ },
+ [homeTitle, overview?.sidecar_url],
+ );
const applyOverview = useCallback(
async (quiet = false) => {
@@ -410,12 +442,12 @@ export default function OrganizationPage() {
busy === "assemble"
? t("organization.progressAssemble")
: busy === "pack"
- ? t("organization.progressPack")
- : busy === "loop"
- ? t("organization.progressLoop")
- : busy === "produce"
- ? t("organization.progressProduce")
- : null;
+ ? t("organization.progressPack")
+ : busy === "loop"
+ ? t("organization.progressLoop")
+ : busy === "produce"
+ ? t("organization.progressProduce")
+ : null;
const lastLoop = (loopProof ?? overview?.last_loop) as OrgLoopProof | null;
const lastSync = overview?.last_sync
@@ -429,34 +461,104 @@ export default function OrganizationPage() {
.map((row) => row.key),
[catalog, moduleToggles],
);
- const localConsoleUrl = (
- overview?.sidecar_url || "http://127.0.0.1:3780"
- ).replace(/\/$/, "");
- const previewUrl = useMemo(() => {
- const path = previewPath.startsWith("/") ? previewPath : `/${previewPath}`;
- const qs = new URLSearchParams();
- if (disabledKeys.length) {
- qs.set("freeos_disabled", disabledKeys.join(","));
- }
- if (previewNonce) qs.set("freeos_sync", previewNonce);
- const query = qs.toString();
- return `${localConsoleUrl}${path}${query ? `?${query}` : ""}`;
- }, [localConsoleUrl, disabledKeys, previewPath, previewNonce]);
- const showFrame = Boolean(previewUrl);
+ const localConsoleUrl = (overview?.sidecar_url || DEFAULT_ORG_URL).replace(
+ /\/$/,
+ "",
+ );
+ const sidecarOrigin = sidecarOriginOf(localConsoleUrl);
+
+ const openTab = useCallback(
+ (raw: string, title?: string, reuse = true) => {
+ const url = normalizeOrgUrl(raw);
+ const label = title || tabTitleFromUrl(url, homeTitle);
+ let nextActive = "";
+ let nextAddress = url;
+ setTabs((current) => {
+ const next = openOrgTab(current, url, label, reuse);
+ nextActive = next.activeId;
+ nextAddress =
+ next.tabs.find((tab) => tab.id === next.activeId)?.url ?? url;
+ return next.tabs;
+ });
+ if (nextActive) setActiveId(nextActive);
+ setAddressValue(nextAddress);
+ return true;
+ },
+ [homeTitle],
+ );
- const pushTogglesToPreview = useCallback(() => {
- const frame = iframeRef.current?.contentWindow;
- if (!frame) return;
- frame.postMessage(
- { type: "freeos:module-toggles", disabled: disabledKeys },
- "*",
+ const submitAddress = useCallback(() => {
+ const url = normalizeOrgUrl(addressValue, localConsoleUrl + "/");
+ setTabs((current) =>
+ navigateOrgTab(current, activeId, url, tabTitleFromUrl(url, homeTitle)),
);
+ setAddressValue(url);
+ }, [activeId, addressValue, homeTitle, localConsoleUrl]);
+
+ const pushTogglesToPreview = useCallback(() => {
+ for (const frame of Object.values(iframeRefs.current)) {
+ frame?.contentWindow?.postMessage(
+ { type: "freeos:module-toggles", disabled: disabledKeys },
+ "*",
+ );
+ }
}, [disabledKeys]);
useEffect(() => {
- if (!showFrame) return;
pushTogglesToPreview();
- }, [showFrame, pushTogglesToPreview]);
+ }, [pushTogglesToPreview]);
+
+ useEffect(() => {
+ const home = normalizeOrgUrl(localConsoleUrl + "/");
+ setTabs((current) => {
+ const existing = current.find((tab) => tab.id === "org-home");
+ if (!existing || existing.url === home) return current;
+ if (existing.url !== DEFAULT_ORG_URL) return current;
+ return navigateOrgTab(current, "org-home", home, homeTitle);
+ });
+ setAddressValue((current) =>
+ current === DEFAULT_ORG_URL ? home : current,
+ );
+ }, [homeTitle, localConsoleUrl]);
+
+ useEffect(() => {
+ const onMessage = (event: MessageEvent) => {
+ const opened = parseOrgOpenTabMessage(event.data);
+ if (opened) {
+ openTab(opened.url, opened.title);
+ return;
+ }
+ const navigated = parseOrgNavigatedMessage(event.data);
+ if (!navigated) return;
+ setTabs((current) =>
+ navigateOrgTab(
+ current,
+ activeId,
+ navigated.url,
+ navigated.title || tabTitleFromUrl(navigated.url, homeTitle),
+ false,
+ ),
+ );
+ setAddressValue(navigated.url);
+ };
+ window.addEventListener("message", onMessage);
+ return () => window.removeEventListener("message", onMessage);
+ }, [activeId, homeTitle, openTab]);
+
+ useEffect(() => {
+ const stopHost = registerOrgBrowserHost(openTab);
+ const stopTrap = installOrgPageWindowTrap();
+ return () => {
+ stopTrap();
+ stopHost();
+ };
+ }, [openTab]);
+
+ useEffect(() => {
+ if (sidecarUp) {
+ setPreviewNonce((current) => current || String(Date.now()));
+ }
+ }, [sidecarUp]);
return (
@@ -473,8 +575,8 @@ export default function OrganizationPage() {
{previewBlank
? t("organization.sidecarEmbedFailed")
: sidecarUp
- ? t("organization.sidecarUp")
- : t("organization.sidecarOpening")}
+ ? t("organization.sidecarUp")
+ : t("organization.sidecarOpening")}
{t("organization.lastSync")}: {lastSync}
@@ -506,45 +608,38 @@ export default function OrganizationPage() {
>
{t("organization.manageOs")}
- {showFrame && (
- }
- style={{ color: "#fff" }}
- >
- {t("organization.openSidecar")}
-
- )}
-
- {!sidecarUp && (
-
- {t("organization.previewOffline")}
-
- )}
- {previewBlank && (
-
- {t("organization.previewBlank")}
-
- )}
-
-
+ {
+ setActiveId(id);
+ const tab = tabs.find((row) => row.id === id);
+ if (tab) setAddressValue(tab.url);
+ }}
+ onCloseTab={(id) => {
+ setTabs((current) => {
+ const next = closeOrgTab(current, activeId, id);
+ setActiveId(next.activeId);
+ const tab = next.tabs.find((row) => row.id === next.activeId);
+ if (tab) setAddressValue(tab.url);
+ return next.tabs;
+ });
+ }}
+ onNewTab={() => openTab(localConsoleUrl + "/", homeTitle, false)}
+ onFrameLoad={pushTogglesToPreview}
+ />
diff --git a/dashboard/src/pages/Organization/orgBrowser.test.ts b/dashboard/src/pages/Organization/orgBrowser.test.ts
new file mode 100644
index 00000000..066646bd
--- /dev/null
+++ b/dashboard/src/pages/Organization/orgBrowser.test.ts
@@ -0,0 +1,145 @@
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_ORG_URL,
+ buildEmbedSrc,
+ closeOrgTab,
+ createHomeTab,
+ iframeSandboxFor,
+ navigateOrgTab,
+ normalizeOrgUrl,
+ openOrgTab,
+ parseOrgNavigatedMessage,
+ parseOrgOpenTabMessage,
+ sidecarOriginOf,
+ stripEmbedParams,
+ tabTitleFromUrl,
+} from "./orgBrowser";
+
+describe("normalizeOrgUrl", () => {
+ it("defaults empty input to the local openXYOS console", () => {
+ expect(normalizeOrgUrl("")).toBe(DEFAULT_ORG_URL);
+ });
+
+ it("adds https for bare hosts and rejects non-http schemes", () => {
+ expect(normalizeOrgUrl("example.com/x")).toBe("https://example.com/x");
+ expect(normalizeOrgUrl("javascript:alert(1)")).toBe(DEFAULT_ORG_URL);
+ });
+});
+
+describe("buildEmbedSrc", () => {
+ it("marks sidecar URLs as FreeOS embed mode", () => {
+ expect(
+ buildEmbedSrc("http://127.0.0.1:3780/employees", {
+ sidecarOrigin: "http://127.0.0.1:3780",
+ disabledKeys: ["chat"],
+ nonce: "9",
+ }),
+ ).toBe(
+ "http://127.0.0.1:3780/employees?freeos_embed=1&freeos_disabled=chat&freeos_sync=9",
+ );
+ });
+
+ it("leaves third-party URLs untouched", () => {
+ expect(
+ buildEmbedSrc("https://github.com/XYAIStudio/openXYOS", {
+ sidecarOrigin: "http://127.0.0.1:3780",
+ disabledKeys: ["chat"],
+ nonce: "9",
+ }),
+ ).toBe("https://github.com/XYAIStudio/openXYOS");
+ });
+});
+
+describe("iframeSandboxFor", () => {
+ it("does not sandbox the local sidecar so login cookies work", () => {
+ expect(
+ iframeSandboxFor(
+ "http://127.0.0.1:3780/?freeos_embed=1",
+ "http://127.0.0.1:3780",
+ ),
+ ).toBeUndefined();
+ });
+
+ it("blocks popups on third-party tabs", () => {
+ const sandbox = iframeSandboxFor(
+ "https://example.com/",
+ "http://127.0.0.1:3780",
+ );
+ expect(sandbox).toContain("allow-scripts");
+ expect(sandbox).not.toContain("allow-popups");
+ });
+});
+
+describe("org tab helpers", () => {
+ it("reuses an open URL and can force a new tab", () => {
+ const home = createHomeTab(DEFAULT_ORG_URL, "openXYOS");
+ const reused = openOrgTab([home], DEFAULT_ORG_URL, "openXYOS");
+ expect(reused.tabs).toHaveLength(1);
+ expect(reused.activeId).toBe("org-home");
+ const extra = openOrgTab([home], DEFAULT_ORG_URL, "openXYOS", false);
+ expect(extra.tabs).toHaveLength(2);
+ expect(extra.activeId).not.toBe("org-home");
+ });
+
+ it("keeps iframe src stable when the SPA reports navigation", () => {
+ const home = createHomeTab(DEFAULT_ORG_URL, "openXYOS");
+ const next = navigateOrgTab(
+ [home],
+ "org-home",
+ "http://127.0.0.1:3780/employees",
+ "Employees",
+ false,
+ );
+ expect(next[0]?.url).toBe("http://127.0.0.1:3780/employees");
+ expect(next[0]?.srcUrl).toBe(DEFAULT_ORG_URL);
+ expect(next[0]?.title).toBe("Employees");
+ });
+
+ it("closes a tab and activates a neighbor", () => {
+ const home = createHomeTab(DEFAULT_ORG_URL, "openXYOS");
+ const opened = openOrgTab([home], "https://example.com/", "example", false);
+ const closed = closeOrgTab(opened.tabs, opened.activeId, opened.activeId);
+ expect(closed.tabs).toHaveLength(1);
+ expect(closed.activeId).toBe("org-home");
+ });
+});
+
+describe("embed messages", () => {
+ it("accepts open-tab and navigated payloads", () => {
+ expect(
+ parseOrgOpenTabMessage({
+ type: "openxyos:open-tab",
+ url: "https://github.com/x",
+ title: "GitHub",
+ }),
+ ).toEqual({ url: "https://github.com/x", title: "GitHub" });
+ expect(
+ parseOrgNavigatedMessage({
+ type: "openxyos:navigated",
+ url: "http://127.0.0.1:3780/org?freeos_embed=1&freeos_sync=1",
+ title: "Org",
+ }),
+ ).toEqual({ url: "http://127.0.0.1:3780/org", title: "Org" });
+ expect(parseOrgOpenTabMessage({ type: "openxyos:open-tab" })).toBeNull();
+ });
+
+ it("strips embed query keys from the address bar", () => {
+ expect(
+ stripEmbedParams(
+ "http://127.0.0.1:3780/?freeos_embed=1&freeos_disabled=chat",
+ ),
+ ).toBe("http://127.0.0.1:3780/");
+ });
+
+ it("labels loopback hosts as the home tab", () => {
+ expect(tabTitleFromUrl("http://127.0.0.1:3780/x", "openXYOS")).toBe(
+ "openXYOS",
+ );
+ expect(tabTitleFromUrl("https://github.com/x", "openXYOS")).toBe(
+ "github.com",
+ );
+ expect(sidecarOriginOf("http://127.0.0.1:3780/")).toBe(
+ "http://127.0.0.1:3780",
+ );
+ });
+});
diff --git a/dashboard/src/pages/Organization/orgBrowser.ts b/dashboard/src/pages/Organization/orgBrowser.ts
new file mode 100644
index 00000000..89c5f075
--- /dev/null
+++ b/dashboard/src/pages/Organization/orgBrowser.ts
@@ -0,0 +1,221 @@
+export const DEFAULT_ORG_URL = "http://127.0.0.1:3780/";
+export const OPENXYOS_OPEN_TAB = "openxyos:open-tab";
+export const OPENXYOS_NAVIGATED = "openxyos:navigated";
+export const FREEOS_EMBED_PARAM = "freeos_embed";
+export const FREEOS_DISABLED_PARAM = "freeos_disabled";
+export const FREEOS_SYNC_PARAM = "freeos_sync";
+
+export type OrgBrowserTab = {
+ id: string;
+ url: string;
+ title: string;
+ /** Last URL assigned to the iframe src (not updated by in-page SPA nav). */
+ srcUrl: string;
+};
+
+export function nextOrgTabId(now = Date.now(), nonce = Math.random()): string {
+ return `org-tab-${now.toString(36)}-${nonce.toString(36).slice(2, 8)}`;
+}
+
+export function originOf(url: string): string {
+ return new URL(url).origin;
+}
+
+export function sidecarOriginOf(sidecarUrl: string): string {
+ try {
+ return originOf(sidecarUrl.includes("://") ? sidecarUrl : DEFAULT_ORG_URL);
+ } catch {
+ return originOf(DEFAULT_ORG_URL);
+ }
+}
+
+export function isHttpUrl(url: string): boolean {
+ try {
+ const protocol = new URL(url).protocol;
+ return protocol === "http:" || protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+export function normalizeOrgUrl(
+ raw: string,
+ fallback = DEFAULT_ORG_URL,
+): string {
+ const trimmed = raw.trim();
+ if (!trimmed) return fallback;
+ const candidate = trimmed.includes("://") ? trimmed : `https://${trimmed}`;
+ try {
+ const parsed = new URL(candidate);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ return fallback;
+ }
+ return parsed.href;
+ } catch {
+ return fallback;
+ }
+}
+
+export function isSidecarOriginUrl(
+ url: string,
+ sidecarOrigin: string,
+): boolean {
+ try {
+ return originOf(url) === sidecarOrigin.replace(/\/$/, "");
+ } catch {
+ return false;
+ }
+}
+
+export function tabTitleFromUrl(url: string, homeTitle: string): string {
+ try {
+ const host = new URL(url).hostname;
+ if (host === "127.0.0.1" || host === "localhost" || host === "[::1]") {
+ return homeTitle;
+ }
+ return host || homeTitle;
+ } catch {
+ return homeTitle;
+ }
+}
+
+export function stripEmbedParams(url: string): string {
+ try {
+ const parsed = new URL(url);
+ parsed.searchParams.delete(FREEOS_EMBED_PARAM);
+ parsed.searchParams.delete(FREEOS_DISABLED_PARAM);
+ parsed.searchParams.delete(FREEOS_SYNC_PARAM);
+ return parsed.href;
+ } catch {
+ return url;
+ }
+}
+
+export function buildEmbedSrc(
+ url: string,
+ opts: {
+ sidecarOrigin: string;
+ disabledKeys: string[];
+ nonce: string;
+ },
+): string {
+ const parsed = new URL(normalizeOrgUrl(url));
+ if (!isSidecarOriginUrl(parsed.href, opts.sidecarOrigin)) {
+ return parsed.href;
+ }
+ parsed.searchParams.set(FREEOS_EMBED_PARAM, "1");
+ if (opts.disabledKeys.length) {
+ parsed.searchParams.set(FREEOS_DISABLED_PARAM, opts.disabledKeys.join(","));
+ } else {
+ parsed.searchParams.delete(FREEOS_DISABLED_PARAM);
+ }
+ if (opts.nonce) {
+ parsed.searchParams.set(FREEOS_SYNC_PARAM, opts.nonce);
+ } else {
+ parsed.searchParams.delete(FREEOS_SYNC_PARAM);
+ }
+ return parsed.href;
+}
+
+export function iframeSandboxFor(
+ url: string,
+ sidecarOrigin: string,
+): string | undefined {
+ if (isSidecarOriginUrl(url, sidecarOrigin)) return undefined;
+ return "allow-scripts allow-same-origin allow-forms allow-downloads allow-modals";
+}
+
+export function parseOrgOpenTabMessage(
+ data: unknown,
+): { url: string; title?: string } | null {
+ if (!data || typeof data !== "object") return null;
+ const row = data as { type?: unknown; url?: unknown; title?: unknown };
+ if (row.type !== OPENXYOS_OPEN_TAB) return null;
+ if (typeof row.url !== "string") return null;
+ const url = normalizeOrgUrl(row.url, "");
+ if (!url || !isHttpUrl(url)) return null;
+ const title = typeof row.title === "string" ? row.title.trim() : "";
+ return title ? { url, title } : { url };
+}
+
+export function parseOrgNavigatedMessage(
+ data: unknown,
+): { url: string; title?: string } | null {
+ if (!data || typeof data !== "object") return null;
+ const row = data as { type?: unknown; url?: unknown; title?: unknown };
+ if (row.type !== OPENXYOS_NAVIGATED) return null;
+ if (typeof row.url !== "string") return null;
+ const url = stripEmbedParams(normalizeOrgUrl(row.url, ""));
+ if (!url || !isHttpUrl(url)) return null;
+ const title = typeof row.title === "string" ? row.title.trim() : "";
+ return title ? { url, title } : { url };
+}
+
+export function openOrgTab(
+ tabs: OrgBrowserTab[],
+ url: string,
+ title: string,
+ reuse = true,
+): { tabs: OrgBrowserTab[]; activeId: string } {
+ const href = normalizeOrgUrl(url);
+ if (reuse) {
+ const existing = tabs.find((tab) => tab.url === href);
+ if (existing) {
+ return { tabs, activeId: existing.id };
+ }
+ }
+ const tab: OrgBrowserTab = {
+ id: nextOrgTabId(),
+ url: href,
+ title: title || tabTitleFromUrl(href, "openXYOS"),
+ srcUrl: href,
+ };
+ return { tabs: [...tabs, tab], activeId: tab.id };
+}
+
+export function closeOrgTab(
+ tabs: OrgBrowserTab[],
+ activeId: string,
+ tabId: string,
+): { tabs: OrgBrowserTab[]; activeId: string } {
+ if (tabs.length <= 1) return { tabs, activeId };
+ const index = tabs.findIndex((tab) => tab.id === tabId);
+ if (index < 0) return { tabs, activeId };
+ const next = tabs.filter((tab) => tab.id !== tabId);
+ if (activeId !== tabId) return { tabs: next, activeId };
+ const fallback = next[Math.min(index, next.length - 1)];
+ return { tabs: next, activeId: fallback?.id ?? activeId };
+}
+
+export function navigateOrgTab(
+ tabs: OrgBrowserTab[],
+ tabId: string,
+ url: string,
+ title: string,
+ reload = true,
+): OrgBrowserTab[] {
+ const href = normalizeOrgUrl(url);
+ return tabs.map((tab) =>
+ tab.id === tabId
+ ? {
+ ...tab,
+ url: href,
+ title: title || tab.title,
+ srcUrl: reload ? href : tab.srcUrl,
+ }
+ : tab,
+ );
+}
+
+export function createHomeTab(
+ sidecarUrl: string,
+ title: string,
+): OrgBrowserTab {
+ const url = normalizeOrgUrl(sidecarUrl || DEFAULT_ORG_URL);
+ return {
+ id: "org-home",
+ url,
+ title,
+ srcUrl: url,
+ };
+}
diff --git a/dashboard/src/pages/Organization/orgPageWindowTrap.ts b/dashboard/src/pages/Organization/orgPageWindowTrap.ts
new file mode 100644
index 00000000..629931cf
--- /dev/null
+++ b/dashboard/src/pages/Organization/orgPageWindowTrap.ts
@@ -0,0 +1,53 @@
+import { tryOpenInOrgBrowser } from "../../utils/orgBrowserHost";
+import { isHttpUrl, normalizeOrgUrl } from "./orgBrowser";
+
+function blankLinkFromEvent(event: Event): HTMLAnchorElement | null {
+ const raw = event.target;
+ const node =
+ raw instanceof Element
+ ? raw
+ : raw instanceof Node
+ ? raw.parentElement
+ : null;
+ if (!node) return null;
+ const link = node.closest("a[href][target]");
+ if (!(link instanceof HTMLAnchorElement)) return null;
+ if (link.target.toLowerCase() !== "_blank") return null;
+ if (link.hasAttribute("download")) return null;
+ return link;
+}
+
+function trapUrl(raw: string, title?: string): boolean {
+ const url = normalizeOrgUrl(raw, "");
+ if (!url || !isHttpUrl(url)) return false;
+ return tryOpenInOrgBrowser(url, title);
+}
+
+function onActivate(event: Event): void {
+ if (event instanceof MouseEvent && event.button !== 0) return;
+ const link = blankLinkFromEvent(event);
+ if (!link) return;
+ if (!trapUrl(link.href, link.textContent?.trim() || undefined)) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+}
+
+/** Capture Organization-chrome _blank / window.open into in-app tabs. */
+export function installOrgPageWindowTrap(): () => void {
+ const original = window.open.bind(window);
+ window.open = ((url?: string | URL, target?: string, features?: string) => {
+ const href = url == null ? "" : String(url);
+ const name = target == null ? "_blank" : String(target);
+ if (href && name.toLowerCase() === "_blank" && trapUrl(href)) {
+ return null;
+ }
+ return original(url, target, features);
+ }) as typeof window.open;
+ document.addEventListener("click", onActivate, true);
+ document.addEventListener("auxclick", onActivate, true);
+ return () => {
+ document.removeEventListener("click", onActivate, true);
+ document.removeEventListener("auxclick", onActivate, true);
+ window.open = original;
+ };
+}
diff --git a/dashboard/src/utils/desktopExternalLinks.ts b/dashboard/src/utils/desktopExternalLinks.ts
index 88bdd469..0b871515 100644
--- a/dashboard/src/utils/desktopExternalLinks.ts
+++ b/dashboard/src/utils/desktopExternalLinks.ts
@@ -1,3 +1,5 @@
+import { tryOpenInOrgBrowser } from "./orgBrowserHost";
+
const INSTALL_FLAG = "__OCTOP_EXTERNAL_LINKS_INSTALLED__";
const OPEN_URL_EVENT_PREFIX = "desktop:open-url:";
@@ -31,6 +33,7 @@ let lastUrl = "";
let lastAt = 0;
function openExternal(url: string): boolean {
+ if (tryOpenInOrgBrowser(url)) return true;
const invoke = wailsInvoke();
if (!invoke || !isDesktopExternalURL(url)) return false;
const now = Date.now();
@@ -47,8 +50,8 @@ function linkFromEvent(event: Event): HTMLAnchorElement | null {
raw instanceof Element
? raw
: raw instanceof Node
- ? raw.parentElement
- : null;
+ ? raw.parentElement
+ : null;
if (!node) return null;
const link = node.closest("a[href][target]");
if (!(link instanceof HTMLAnchorElement)) return null;
diff --git a/dashboard/src/utils/orgBrowserHost.test.ts b/dashboard/src/utils/orgBrowserHost.test.ts
new file mode 100644
index 00000000..dd7ba7c6
--- /dev/null
+++ b/dashboard/src/utils/orgBrowserHost.test.ts
@@ -0,0 +1,21 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { registerOrgBrowserHost, tryOpenInOrgBrowser } from "./orgBrowserHost";
+
+describe("orgBrowserHost", () => {
+ afterEach(() => {
+ delete (window as Window & { __FREEOS_ORG_OPEN_TAB__?: unknown })
+ .__FREEOS_ORG_OPEN_TAB__;
+ });
+
+ it("routes a URL to the registered Organization host", () => {
+ const opened: string[] = [];
+ const stop = registerOrgBrowserHost((url) => {
+ opened.push(url);
+ return true;
+ });
+ expect(tryOpenInOrgBrowser("https://example.com/")).toBe(true);
+ expect(opened).toEqual(["https://example.com/"]);
+ stop();
+ expect(tryOpenInOrgBrowser("https://example.com/other")).toBe(false);
+ });
+});
diff --git a/dashboard/src/utils/orgBrowserHost.ts b/dashboard/src/utils/orgBrowserHost.ts
new file mode 100644
index 00000000..de2d3437
--- /dev/null
+++ b/dashboard/src/utils/orgBrowserHost.ts
@@ -0,0 +1,29 @@
+const HOOK = "__FREEOS_ORG_OPEN_TAB__";
+
+type OrgOpenTab = (url: string, title?: string) => boolean;
+
+type HostWindow = Window & {
+ __FREEOS_ORG_OPEN_TAB__?: OrgOpenTab;
+};
+
+/** Register Organization as the in-app tab host for new web windows. */
+export function registerOrgBrowserHost(openTab: OrgOpenTab): () => void {
+ const w = window as HostWindow;
+ w[HOOK] = openTab;
+ return () => {
+ if (w[HOOK] === openTab) {
+ delete w[HOOK];
+ }
+ };
+}
+
+/** True when Organization swallowed the URL as an in-app tab. */
+export function tryOpenInOrgBrowser(url: string, title?: string): boolean {
+ const hook = (window as HostWindow)[HOOK];
+ if (typeof hook !== "function") return false;
+ try {
+ return hook(url, title) === true;
+ } catch {
+ return false;
+ }
+}
diff --git a/desktop/src/openurl.go b/desktop/src/openurl.go
index 82618df0..18ef8b3a 100644
--- a/desktop/src/openurl.go
+++ b/desktop/src/openurl.go
@@ -115,6 +115,9 @@ func injectExternalLinksJS() string {
}
function openExternal(url) {
if (!isOpenable(url)) return false;
+ if (typeof window.__FREEOS_ORG_OPEN_TAB__ === "function") {
+ try { if (window.__FREEOS_ORG_OPEN_TAB__(url)) return true; } catch (e) {}
+ }
var now = Date.now();
if (url === lastUrl && now - lastAt < 800) return true;
lastUrl = url;
diff --git a/modules/openxyos/frontend/src/embed/trapWindows.ts b/modules/openxyos/frontend/src/embed/trapWindows.ts
new file mode 100644
index 00000000..4a0c8b3b
--- /dev/null
+++ b/modules/openxyos/frontend/src/embed/trapWindows.ts
@@ -0,0 +1,148 @@
+export const OPENXYOS_OPEN_TAB = "openxyos:open-tab";
+export const OPENXYOS_NAVIGATED = "openxyos:navigated";
+export const OPENXYOS_READY = "openxyos:ready";
+
+export function isEmbeddedFrame(win: Window = window): boolean {
+ try {
+ return win.self !== win.top;
+ } catch {
+ return true;
+ }
+}
+
+export function shouldTrapWindowTarget(target?: string | null): boolean {
+ if (target == null || target === "") return true;
+ const name = String(target).toLowerCase();
+ return name === "_blank" || name === "_new";
+}
+
+export function resolveOpenUrl(
+ url: string | URL | undefined | null,
+ base: string,
+): string | null {
+ if (url == null || url === "") return null;
+ try {
+ const parsed = new URL(String(url), base);
+ const scheme = parsed.protocol.replace(":", "").toLowerCase();
+ if (scheme !== "http" && scheme !== "https") return null;
+ return parsed.href;
+ } catch {
+ return null;
+ }
+}
+
+export function blankLinkFromEvent(event: Event): HTMLAnchorElement | null {
+ const raw = event.target;
+ const node =
+ raw instanceof Element
+ ? raw
+ : raw instanceof Node
+ ? raw.parentElement
+ : null;
+ if (!node || !node.closest) return null;
+ const link = node.closest("a[href]");
+ if (!(link instanceof HTMLAnchorElement)) return null;
+ if (link.hasAttribute("download")) return null;
+ const target = (link.getAttribute("target") || "").toLowerCase();
+ if (target !== "_blank" && target !== "_new") return null;
+ return link;
+}
+
+function fakeWindow(): WindowProxy {
+ return {
+ closed: false,
+ close() {
+ this.closed = true;
+ },
+ focus() {},
+ blur() {},
+ opener: window,
+ } as unknown as WindowProxy;
+}
+
+function postToParent(win: Window, data: Record): void {
+ if (!win.parent || win.parent === win) return;
+ try {
+ win.parent.postMessage(data, "*");
+ } catch {
+ /* host may ignore */
+ }
+}
+
+/**
+ * When openXYOS is framed by FreeOS Organization, send new windows to the
+ * parent as in-app tabs instead of OS / system windows.
+ */
+export function installOpenxyosEmbedTrap(win: Window = window): () => void {
+ if (!isEmbeddedFrame(win)) return () => {};
+
+ const originalOpen = win.open.bind(win);
+ win.open = ((url?: string | URL, target?: string, features?: string) => {
+ const href = resolveOpenUrl(url, win.location.href);
+ if (href && shouldTrapWindowTarget(target)) {
+ postToParent(win, { type: OPENXYOS_OPEN_TAB, url: href });
+ return fakeWindow();
+ }
+ return originalOpen(url, target, features);
+ }) as typeof win.open;
+
+ const onActivate = (event: Event) => {
+ if (event instanceof MouseEvent && event.button !== 0) return;
+ const link = blankLinkFromEvent(event);
+ if (!link) return;
+ const href = resolveOpenUrl(link.href, win.location.href);
+ if (!href) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ postToParent(win, {
+ type: OPENXYOS_OPEN_TAB,
+ url: href,
+ title: (link.textContent || "").trim() || undefined,
+ });
+ };
+
+ win.document.addEventListener("click", onActivate, true);
+ win.document.addEventListener("auxclick", onActivate, true);
+
+ const notifyNav = () => {
+ postToParent(win, {
+ type: OPENXYOS_NAVIGATED,
+ url: win.location.href,
+ title: win.document.title,
+ });
+ };
+
+ const historyProto = win.history;
+ const originalPush = historyProto.pushState.bind(historyProto);
+ const originalReplace = historyProto.replaceState.bind(historyProto);
+ historyProto.pushState = ((
+ data: unknown,
+ unused: string,
+ url?: string | URL | null,
+ ) => {
+ originalPush(data, unused, url);
+ notifyNav();
+ }) as typeof historyProto.pushState;
+ historyProto.replaceState = ((
+ data: unknown,
+ unused: string,
+ url?: string | URL | null,
+ ) => {
+ originalReplace(data, unused, url);
+ notifyNav();
+ }) as typeof historyProto.replaceState;
+ win.addEventListener("popstate", notifyNav);
+ win.addEventListener("hashchange", notifyNav);
+
+ notifyNav();
+
+ return () => {
+ win.document.removeEventListener("click", onActivate, true);
+ win.document.removeEventListener("auxclick", onActivate, true);
+ win.removeEventListener("popstate", notifyNav);
+ win.removeEventListener("hashchange", notifyNav);
+ historyProto.pushState = originalPush;
+ historyProto.replaceState = originalReplace;
+ win.open = originalOpen;
+ };
+}
diff --git a/modules/openxyos/frontend/src/main.tsx b/modules/openxyos/frontend/src/main.tsx
index c20cdf79..898637e0 100644
--- a/modules/openxyos/frontend/src/main.tsx
+++ b/modules/openxyos/frontend/src/main.tsx
@@ -4,12 +4,14 @@ import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./OpenApp";
import { LocaleProvider } from "./i18n";
+import { installOpenxyosEmbedTrap, OPENXYOS_READY } from "./embed/trapWindows";
import "./index.css";
if (typeof window !== "undefined" && window.self !== window.top) {
document.documentElement.classList.add("ox-embedded");
+ installOpenxyosEmbedTrap(window);
try {
- window.parent.postMessage({ type: "openxyos:ready" }, "*");
+ window.parent.postMessage({ type: OPENXYOS_READY }, "*");
} catch {
/* embed host may ignore */
}
diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json
index 3d13c840..e346852d 100644
--- a/modules/openxyos/package.json
+++ b/modules/openxyos/package.json
@@ -10,9 +10,10 @@
"postinstall": "node scripts/patch-express-types.cjs",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx --max-warnings 0",
- "test": "npm run test:runtime-config && npm run test:cors-origins && npm run test:r0-access-control && npm run test:module-settings && npm run test:llm-onboarding && npm run test:agent-studio && npm run test:freeos-ingest && npm run test:file-security && npm run test:download-token && npm run test:ws-ticket && npm run test:auth-provider && npm run test:pep && npm run test:audit-bundle && npm run test:tool-gateway && npm run test:v100",
+ "test": "npm run test:runtime-config && npm run test:cors-origins && npm run test:embed-trap && npm run test:r0-access-control && npm run test:module-settings && npm run test:llm-onboarding && npm run test:agent-studio && npm run test:freeos-ingest && npm run test:file-security && npm run test:download-token && npm run test:ws-ticket && npm run test:auth-provider && npm run test:pep && npm run test:audit-bundle && npm run test:tool-gateway && npm run test:v100",
"test:runtime-config": "tsx scripts/test-runtime-config.ts",
"test:cors-origins": "tsx scripts/test-cors-origins.ts",
+ "test:embed-trap": "tsx scripts/test-embed-trap.ts",
"test:r0-access-control": "tsx scripts/test-r0-access-control.ts",
"test:file-security": "tsx scripts/test-file-security.ts",
"test:download-token": "tsx scripts/test-download-token.ts",
diff --git a/modules/openxyos/scripts/test-embed-trap.ts b/modules/openxyos/scripts/test-embed-trap.ts
new file mode 100644
index 00000000..2b20f7b3
--- /dev/null
+++ b/modules/openxyos/scripts/test-embed-trap.ts
@@ -0,0 +1,36 @@
+import assert from "node:assert/strict";
+import {
+ isEmbeddedFrame,
+ resolveOpenUrl,
+ shouldTrapWindowTarget,
+} from "../frontend/src/embed/trapWindows";
+
+assert.equal(shouldTrapWindowTarget(undefined), true);
+assert.equal(shouldTrapWindowTarget(""), true);
+assert.equal(shouldTrapWindowTarget("_blank"), true);
+assert.equal(shouldTrapWindowTarget("_BLANK"), true);
+assert.equal(shouldTrapWindowTarget("_new"), true);
+assert.equal(shouldTrapWindowTarget("_self"), false);
+assert.equal(shouldTrapWindowTarget("octop-oauth"), false);
+
+assert.equal(
+ resolveOpenUrl("/privacy-policy", "http://127.0.0.1:3780/login"),
+ "http://127.0.0.1:3780/privacy-policy",
+);
+assert.equal(
+ resolveOpenUrl(
+ "https://github.com/XYAIStudio/openXYOS",
+ "http://127.0.0.1:3780/",
+ ),
+ "https://github.com/XYAIStudio/openXYOS",
+);
+assert.equal(resolveOpenUrl("javascript:alert(1)", "http://127.0.0.1:3780/"), null);
+assert.equal(resolveOpenUrl("", "http://127.0.0.1:3780/"), null);
+
+const top = {} as Window;
+const framed = { self: {} as Window, top } as Window;
+(framed as { self: Window }).self = framed;
+assert.equal(isEmbeddedFrame(framed), true);
+assert.equal(isEmbeddedFrame({ self: top, top } as Window), false);
+
+console.log("test-embed-trap: ok");
diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py
index f0f0cbe6..f6054719 100644
--- a/tests/unit/desktop/test_nsis_uninstall.py
+++ b/tests/unit/desktop/test_nsis_uninstall.py
@@ -9,6 +9,11 @@
NSH = REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "wails_tools.nsh"
DESKTOP_README = REPO / "desktop" / "README.md"
ORG_PAGE = REPO / "dashboard" / "src" / "pages" / "Organization" / "index.tsx"
+ORG_BROWSER = (
+ REPO / "dashboard" / "src" / "pages" / "Organization" / "OrgMiniBrowser.tsx"
+)
+ORG_TABS = REPO / "dashboard" / "src" / "pages" / "Organization" / "orgBrowser.ts"
+OPENURL_GO = REPO / "desktop" / "src" / "openurl.go"
def _uninstall_section(text: str) -> str:
@@ -316,6 +321,9 @@ def test_nsis_does_not_register_openxyos_logon_autostart() -> None:
def test_organization_embeds_local_openxyos_url() -> None:
page = ORG_PAGE.read_text(encoding="utf-8")
+ browser = ORG_BROWSER.read_text(encoding="utf-8")
+ tabs = ORG_TABS.read_text(encoding="utf-8")
+ openurl = OPENURL_GO.read_text(encoding="utf-8")
zh = (REPO / "dashboard" / "src" / "locales" / "zh.json").read_text(encoding="utf-8")
assert "window.prompt" not in page
assert "pickDesktopFolder" in page
@@ -323,9 +331,20 @@ def test_organization_embeds_local_openxyos_url() -> None:
assert "resolveOpenxyosSourceDest" in page
assert 'data-testid="org-download-source"' in page
assert "void downloadSource()" in page
- assert "http://127.0.0.1:3780" in page
- assert "
diff --git a/dashboard/src/pages/Organization/orgPageWindowTrap.ts b/dashboard/src/pages/Organization/orgPageWindowTrap.ts
index 629931cf..9bf8c61f 100644
--- a/dashboard/src/pages/Organization/orgPageWindowTrap.ts
+++ b/dashboard/src/pages/Organization/orgPageWindowTrap.ts
@@ -7,8 +7,8 @@ function blankLinkFromEvent(event: Event): HTMLAnchorElement | null {
raw instanceof Element
? raw
: raw instanceof Node
- ? raw.parentElement
- : null;
+ ? raw.parentElement
+ : null;
if (!node) return null;
const link = node.closest("a[href][target]");
if (!(link instanceof HTMLAnchorElement)) return null;
diff --git a/dashboard/src/utils/desktopExternalLinks.ts b/dashboard/src/utils/desktopExternalLinks.ts
index 0b871515..8c294df4 100644
--- a/dashboard/src/utils/desktopExternalLinks.ts
+++ b/dashboard/src/utils/desktopExternalLinks.ts
@@ -50,8 +50,8 @@ function linkFromEvent(event: Event): HTMLAnchorElement | null {
raw instanceof Element
? raw
: raw instanceof Node
- ? raw.parentElement
- : null;
+ ? raw.parentElement
+ : null;
if (!node) return null;
const link = node.closest("a[href][target]");
if (!(link instanceof HTMLAnchorElement)) return null;
diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py
index f6054719..bfdf9858 100644
--- a/tests/unit/desktop/test_nsis_uninstall.py
+++ b/tests/unit/desktop/test_nsis_uninstall.py
@@ -9,9 +9,7 @@
NSH = REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "wails_tools.nsh"
DESKTOP_README = REPO / "desktop" / "README.md"
ORG_PAGE = REPO / "dashboard" / "src" / "pages" / "Organization" / "index.tsx"
-ORG_BROWSER = (
- REPO / "dashboard" / "src" / "pages" / "Organization" / "OrgMiniBrowser.tsx"
-)
+ORG_BROWSER = REPO / "dashboard" / "src" / "pages" / "Organization" / "OrgMiniBrowser.tsx"
ORG_TABS = REPO / "dashboard" / "src" / "pages" / "Organization" / "orgBrowser.ts"
OPENURL_GO = REPO / "desktop" / "src" / "openurl.go"