用户引导
This commit is contained in:
1 parent
c6fcf51e0c
commit
33b719d460
5 files changed
+456
-42
No files matched your search
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* embed-session.ts 浏览上下文隔离单测。
|
||||
* 运行方式(项目无 vitest):
|
||||
* npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \
|
||||
* --platform=node --outfile=.tmp-tests/embed-session.test.mjs
|
||||
* node --test .tmp-tests/embed-session.test.mjs
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
isChromeHidden,
|
||||
isEmbedActive,
|
||||
isIsolatedSession,
|
||||
readEmbedSession,
|
||||
syncEmbedSessionFromSearch,
|
||||
} from "./embed-session";
|
||||
|
||||
class MemoryStorage {
|
||||
#map = new Map<string, string>();
|
||||
getItem(key: string): string | null {
|
||||
return this.#map.has(key) ? this.#map.get(key)! : null;
|
||||
}
|
||||
setItem(key: string, value: string): void {
|
||||
this.#map.set(key, String(value));
|
||||
}
|
||||
removeItem(key: string): void {
|
||||
this.#map.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
type FakeWindow = {
|
||||
self: FakeWindow;
|
||||
top: FakeWindow;
|
||||
sessionStorage: MemoryStorage;
|
||||
location: { hash: string; search: string };
|
||||
__deerflowTopChromeEmbed?: boolean;
|
||||
};
|
||||
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
function withSharedTab<T>(run: (ctx: {
|
||||
storage: MemoryStorage;
|
||||
asTop: () => void;
|
||||
asIframe: () => void;
|
||||
}) => T): T {
|
||||
const storage = new MemoryStorage();
|
||||
const top = {
|
||||
sessionStorage: storage,
|
||||
location: { hash: "", search: "" },
|
||||
} as FakeWindow;
|
||||
top.self = top;
|
||||
top.top = top;
|
||||
const iframe = {
|
||||
sessionStorage: storage,
|
||||
location: { hash: "#/login/guest?embed=1&theme=blue&hideJump=0", search: "" },
|
||||
} as FakeWindow;
|
||||
iframe.self = iframe;
|
||||
iframe.top = top;
|
||||
|
||||
const asTop = () => {
|
||||
(globalThis as { window: FakeWindow }).window = top;
|
||||
};
|
||||
const asIframe = () => {
|
||||
(globalThis as { window: FakeWindow }).window = iframe;
|
||||
};
|
||||
|
||||
asTop();
|
||||
try {
|
||||
return run({ storage, asTop, asIframe });
|
||||
} finally {
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window: unknown }).window = originalWindow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("nested embed=1 does not hide chrome on the same-origin top window", () => {
|
||||
withSharedTab(({ storage, asTop, asIframe }) => {
|
||||
asTop();
|
||||
syncEmbedSessionFromSearch("");
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0");
|
||||
assert.equal(isEmbedActive(), true);
|
||||
assert.equal(isChromeHidden(), true);
|
||||
assert.ok(storage.getItem("embed.session.nested"));
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("nested iframe reclaims a polluted shared embed.session key", () => {
|
||||
withSharedTab(({ storage, asTop, asIframe }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
"embed.session",
|
||||
JSON.stringify({
|
||||
embed: true,
|
||||
theme: "light",
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: false,
|
||||
}),
|
||||
);
|
||||
assert.equal(isEmbedActive(), true);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1");
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("nested iframe keeps the top window's own embed session", () => {
|
||||
withSharedTab(({ asTop, asIframe }) => {
|
||||
asTop();
|
||||
syncEmbedSessionFromSearch("?embed=1&theme=dark");
|
||||
syncEmbedSessionFromSearch("");
|
||||
assert.equal(isEmbedActive(), true);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1");
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), true);
|
||||
assert.equal(readEmbedSession()?.theme, "dark");
|
||||
});
|
||||
});
|
||||
|
||||
test("nested reclaim strips embed but keeps top isolate", () => {
|
||||
withSharedTab(({ storage, asTop, asIframe }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
"embed.session",
|
||||
JSON.stringify({
|
||||
embed: true,
|
||||
theme: "light",
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: true,
|
||||
}),
|
||||
);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1");
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isIsolatedSession(), true);
|
||||
});
|
||||
});
|
||||
@@ -27,11 +27,133 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
||||
* - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等
|
||||
* localStorage 偏好。
|
||||
*
|
||||
* iframe 的浏览器上下文就是它自己的标签页,sessionStorage 天然「一个 iframe 一份、随关闭消失」;
|
||||
* 正常页标签页里既无这些参数也无这份会话 → 所有 `isXxx()` 恒为 false,完全不受影响。
|
||||
* 同源陷阱:sessionStorage **不是**「一个 iframe 一份」。规范按「顶层标签页 × origin」
|
||||
* 分区,同一标签里所有同源文档(含嵌套 iframe、换路径/换端口反向代理后仍同源的另一套部署)
|
||||
* **共用一份**。全量模式任务管理用 iframe 套外部页、外部页再套咱们 `embed=1` 深链时,内层
|
||||
* 若写入同一个 `embed.session` 键,外层点左侧菜单重算 `PageLayout` 就会把导航栏/侧边栏藏掉,
|
||||
* 路由看起来却没变。因此顶层窗口写 `embed.session`,嵌套 iframe 写 `embed.session.nested`。
|
||||
*/
|
||||
|
||||
const EMBED_SESSION_KEY = "embed.session";
|
||||
/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */
|
||||
const EMBED_SESSION_NESTED_KEY = "embed.session.nested";
|
||||
/**
|
||||
* 顶层窗口认领「本页自己的去 chrome 会话」的标记。嵌套同源 iframe 看到此标记就不会
|
||||
* 清掉顶层合法的 embed/hideChrome 持久化。
|
||||
*/
|
||||
const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed";
|
||||
|
||||
type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean };
|
||||
|
||||
function getWindow(): TopChromeOwnerWindow | undefined {
|
||||
if (typeof globalThis === "undefined") return undefined;
|
||||
return (globalThis as { window?: TopChromeOwnerWindow }).window;
|
||||
}
|
||||
|
||||
function getSessionStorage(): Storage | null {
|
||||
try {
|
||||
return getWindow()?.sessionStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */
|
||||
function isTopLevelWindow(): boolean {
|
||||
const w = getWindow();
|
||||
if (!w) return true;
|
||||
try {
|
||||
return w.self === w.top;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function embedSessionKey(): string {
|
||||
return isTopLevelWindow() ? EMBED_SESSION_KEY : EMBED_SESSION_NESTED_KEY;
|
||||
}
|
||||
|
||||
function parseEmbedSession(raw: string | null): EmbedSession | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const p = JSON.parse(raw) as Partial<EmbedSession>;
|
||||
return {
|
||||
embed: p.embed === true,
|
||||
theme: p.theme === "dark" ? "dark" : "light",
|
||||
hideJump: p.hideJump === true,
|
||||
hideHeader: p.hideHeader === true,
|
||||
hideChrome: p.hideChrome === true,
|
||||
isolate: p.isolate === true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionHidesChrome(session: EmbedSession | null | undefined): boolean {
|
||||
return (
|
||||
session?.embed === true ||
|
||||
session?.hideChrome === true ||
|
||||
session?.hideHeader === true ||
|
||||
session?.hideJump === true
|
||||
);
|
||||
}
|
||||
|
||||
/** 顶层窗口同步过自己的去 chrome 参数后打标,供同源嵌套 iframe 识别「这不是我写脏的」。 */
|
||||
function markTopChromeOwner(session: EmbedSession): void {
|
||||
const w = getWindow();
|
||||
if (!w || !isTopLevelWindow()) return;
|
||||
if (!sessionHidesChrome(session)) return;
|
||||
w[TOP_CHROME_OWNER_FLAG] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧代码把嵌套 iframe 的 embed 写进了共用的 `embed.session`。嵌套侧启动时把这份
|
||||
* 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。
|
||||
*/
|
||||
function migrateLegacySharedSessionIfNested(): void {
|
||||
if (isTopLevelWindow()) return;
|
||||
try {
|
||||
const store = getSessionStorage();
|
||||
if (!store || store.getItem(EMBED_SESSION_NESTED_KEY)) return;
|
||||
const legacy = store.getItem(EMBED_SESSION_KEY);
|
||||
if (legacy) store.setItem(EMBED_SESSION_NESTED_KEY, legacy);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function reclaimPollutedTopChrome(): void {
|
||||
if (isTopLevelWindow()) return;
|
||||
try {
|
||||
const top = getWindow()?.top as TopChromeOwnerWindow | null | undefined;
|
||||
if (!top || top[TOP_CHROME_OWNER_FLAG] === true) return;
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
|
||||
if (!cur) return;
|
||||
const next: EmbedSession = {
|
||||
...cur,
|
||||
embed: false,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
};
|
||||
if (
|
||||
!next.embed &&
|
||||
!next.hideJump &&
|
||||
!next.hideHeader &&
|
||||
!next.hideChrome &&
|
||||
!next.isolate
|
||||
) {
|
||||
store.removeItem(EMBED_SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
store.setItem(EMBED_SESSION_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* 跨源 top:sessionStorage 本就不与外层其它 origin 共享,无需回收 */
|
||||
}
|
||||
}
|
||||
|
||||
export type EmbedTheme = "light" | "dark";
|
||||
|
||||
@@ -101,20 +223,10 @@ export function readEmbedThemeParam(search: string): string {
|
||||
return (toSearchParams(search).get("theme") ?? "").trim();
|
||||
}
|
||||
|
||||
/** 读本标签页的嵌入会话;无 → null。 */
|
||||
/** 读本浏览上下文的嵌入会话;无 → null。顶层与嵌套 iframe 使用不同的 sessionStorage 键。 */
|
||||
export function readEmbedSession(): EmbedSession | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(EMBED_SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw) as Partial<EmbedSession>;
|
||||
return {
|
||||
embed: p.embed === true,
|
||||
theme: p.theme === "dark" ? "dark" : "light",
|
||||
hideJump: p.hideJump === true,
|
||||
hideHeader: p.hideHeader === true,
|
||||
hideChrome: p.hideChrome === true,
|
||||
isolate: p.isolate === true,
|
||||
};
|
||||
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -123,11 +235,14 @@ export function readEmbedSession(): EmbedSession | null {
|
||||
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
|
||||
function writeEmbedSession(next: EmbedSession): void {
|
||||
try {
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
const key = embedSessionKey();
|
||||
if (!next.embed && !next.hideJump && !next.hideHeader && !next.hideChrome && !next.isolate) {
|
||||
sessionStorage.removeItem(EMBED_SESSION_KEY);
|
||||
store.removeItem(key);
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(EMBED_SESSION_KEY, JSON.stringify(next));
|
||||
store.setItem(key, JSON.stringify(next));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
@@ -182,6 +297,7 @@ export function isIsolatedSession(): boolean {
|
||||
* 之前置位的开关不会被抹掉。
|
||||
*/
|
||||
export function syncEmbedSessionFromSearch(search: string): void {
|
||||
migrateLegacySharedSessionIfNested();
|
||||
const cur =
|
||||
readEmbedSession() ??
|
||||
{
|
||||
@@ -219,6 +335,8 @@ export function syncEmbedSessionFromSearch(search: string): void {
|
||||
else if (isolateParam === "off") next.isolate = false;
|
||||
|
||||
writeEmbedSession(next);
|
||||
if (isTopLevelWindow()) markTopChromeOwner(next);
|
||||
else reclaimPollutedTopChrome();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,8 +345,9 @@ export function syncEmbedSessionFromSearch(search: string): void {
|
||||
* 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。
|
||||
*/
|
||||
function currentHashSearch(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const hash = window.location.hash || "";
|
||||
const w = getWindow();
|
||||
if (!w) return "";
|
||||
const hash = w.location?.hash || "";
|
||||
const i = hash.indexOf("?");
|
||||
return i >= 0 ? hash.slice(i) : "";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user