Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions apps/ui/src/app/deploy/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
import { redirect } from "next/navigation";
import { githubDeployProjectPath } from "@/features/deploy/github-deploy-link";
import { templateDeployProjectPath } from "@/features/deploy/template-deploy-link";
import {
appendMarketingRedirectQuery,
type MarketingRedirectQuery,
} from "@/features/marketing/redirect-query";

export default async function DeployPage({
searchParams,
}: {
searchParams: Promise<{
autoDeploy?: string | string[];
githubRepo?: string | string[];
templateForm?: string | string[];
templateName?: string | string[];
}>;
searchParams: Promise<MarketingRedirectQuery>;
}) {
const { autoDeploy, githubRepo, templateForm, templateName } =
await searchParams;
const query = await searchParams;
const { autoDeploy, githubRepo, templateForm, templateName } = query;
// Only a single, non-empty repo value may claim the GitHub deep link; an
// empty or repeated `githubRepo` must not steal a parallel template link.
if (typeof githubRepo === "string" && githubRepo.trim() !== "") {
redirect(githubDeployProjectPath(githubRepo, autoDeploy));
redirect(
appendMarketingRedirectQuery(
githubDeployProjectPath(githubRepo, autoDeploy),
query
)
);
}
redirect(templateDeployProjectPath(templateName, templateForm));
redirect(
appendMarketingRedirectQuery(
templateDeployProjectPath(templateName, templateForm),
query
)
);
}
12 changes: 10 additions & 2 deletions apps/ui/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { redirect } from "next/navigation";
import {
appendMarketingRedirectQuery,
type MarketingRedirectQuery,
} from "@/features/marketing/redirect-query";

export default function HomePage() {
redirect("/project");
export default async function HomePage({
searchParams,
}: {
searchParams: Promise<MarketingRedirectQuery>;
}) {
redirect(appendMarketingRedirectQuery("/project", await searchParams));
}
90 changes: 90 additions & 0 deletions apps/ui/src/features/analytics/brain-analytics-transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
configureBrainAnalyticsTransport,
sendBrainAnalyticsEvent,
} from "./brain-analytics-transport";

const payload = {
event: "module_view",
module: "brain",
context: "app",
view_name: "project_list",
};

test("iframe events wait for Desktop capability and relay exactly once", () => {
const previousWindow = globalThis.window;
const dataLayer: unknown[] = [];
const relayed: unknown[] = [];
Object.assign(globalThis, { window: { parent: {}, dataLayer } });
configureBrainAnalyticsTransport(undefined);
try {
sendBrainAnalyticsEvent(payload);
assert.deepEqual(dataLayer, []);
assert.equal(relayed.length, 0);
configureBrainAnalyticsTransport((event) => {
relayed.push(event);
return Promise.resolve({ accepted: true });
});
assert.deepEqual(relayed, [payload]);
assert.deepEqual(dataLayer, []);
configureBrainAnalyticsTransport(null);
assert.deepEqual(dataLayer, []);
} finally {
configureBrainAnalyticsTransport(null);
Object.assign(globalThis, { window: previousWindow });
}
});

test("older Desktop falls back to iframe GTM without losing queued events", () => {
const previousWindow = globalThis.window;
const dataLayer: unknown[] = [];
Object.assign(globalThis, { window: { parent: {}, dataLayer } });
configureBrainAnalyticsTransport(undefined);
try {
sendBrainAnalyticsEvent(payload);
configureBrainAnalyticsTransport(null);
assert.deepEqual(dataLayer, [payload]);
} finally {
configureBrainAnalyticsTransport(null);
Object.assign(globalThis, { window: previousWindow });
}
});

test("standalone Brain sends immediately without a Desktop bootstrap", () => {
const previousWindow = globalThis.window;
const browserWindow = { parent: null as unknown, dataLayer: [] as unknown[] };
browserWindow.parent = browserWindow;
Object.assign(globalThis, { window: browserWindow });
configureBrainAnalyticsTransport(undefined);
try {
sendBrainAnalyticsEvent(payload);
assert.deepEqual(browserWindow.dataLayer, [payload]);
} finally {
configureBrainAnalyticsTransport(null);
Object.assign(globalThis, { window: previousWindow });
}
});

test("Desktop acknowledgement failures do not duplicate an event through local GTM", async () => {
const previousWindow = globalThis.window;
const previousWarn = console.warn;
const dataLayer: unknown[] = [];
let attempts = 0;
Object.assign(globalThis, { window: { parent: {}, dataLayer } });
console.warn = () => undefined;
configureBrainAnalyticsTransport(() => {
attempts += 1;
return Promise.reject(new Error("reply lost after delivery"));
});
try {
sendBrainAnalyticsEvent(payload);
await Promise.resolve();
assert.equal(attempts, 1);
assert.deepEqual(dataLayer, []);
} finally {
configureBrainAnalyticsTransport(null);
console.warn = previousWarn;
Object.assign(globalThis, { window: previousWindow });
}
});
45 changes: 45 additions & 0 deletions apps/ui/src/features/analytics/brain-analytics-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";

import { sendGTMEvent } from "@next/third-parties/google";

type AnalyticsPayload = Record<string, unknown>;
type DesktopTransport = (payload: AnalyticsPayload) => Promise<unknown>;

let desktopTransport: DesktopTransport | null | undefined;
const pendingEvents: AnalyticsPayload[] = [];
const MAX_PENDING_EVENTS = 100;

function dispatch(payload: AnalyticsPayload): void {
if (desktopTransport) {
// An acknowledged Desktop owns delivery; never also send through iframe GTM.
desktopTransport(payload).catch(() => {
console.warn("[Brain GTM] Desktop event delivery failed.");
});
} else {
sendGTMEvent(payload);
}
}

/** Resolve once the existing SDK reports the parent's analytics capability. */
export function configureBrainAnalyticsTransport(
transport: DesktopTransport | null | undefined
): void {
desktopTransport = transport;
if (transport === undefined) {
return;
}
for (const payload of pendingEvents.splice(0)) {
dispatch(payload);
}
}

export function sendBrainAnalyticsEvent(payload: AnalyticsPayload): void {
const embedded = window.parent && window.parent !== window;
if (embedded && desktopTransport === undefined) {
if (pendingEvents.length < MAX_PENDING_EVENTS) {
pendingEvents.push(payload);
}
return;
}
dispatch(payload);
}
4 changes: 2 additions & 2 deletions apps/ui/src/features/analytics/brain-gtm.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"use client";

import { sendGTMEvent } from "@next/third-parties/google";
import type { CancellationReasonKey } from "@/features/billing/cancellation-survey/reasons";
import type {
ProjectDrawerSurfaceEntry,
ProjectMainSurfaceEntry,
ProjectSideSurfaceEntry,
} from "@/features/panes/surface-state";
import { sendBrainAnalyticsEvent } from "./brain-analytics-transport";

export const BRAIN_GTM_MAX_PAYLOAD_BYTES = 2048;

Expand Down Expand Up @@ -133,7 +133,7 @@ export function trackBrainGtmEvent(event: BrainGtmEvent): boolean {
console.warn("[Brain GTM] Event payload exceeds the 2KB limit.", payload);
return false;
}
sendGTMEvent(payload);
sendBrainAnalyticsEvent(payload);
return true;
} catch (error) {
try {
Expand Down
41 changes: 41 additions & 0 deletions apps/ui/src/features/marketing/redirect-query.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { appendMarketingRedirectQuery } from "./redirect-query";

test("root redirect retains encoded attribution and consent proof", () => {
const query = { sea_attr: "eyJ2ZXJzaW9uIjoyfQ", consent_token: "a+b/c=" };
const url = new URL(
appendMarketingRedirectQuery("/project", query),
"https://brain.test"
);
assert.equal(url.pathname, "/project");
assert.equal(url.searchParams.get("sea_attr"), query.sea_attr);
assert.equal(url.searchParams.get("consent_token"), query.consent_token);
});

test("deployment redirect retains side intent and excludes credentials", () => {
const url = new URL(
appendMarketingRedirectQuery("/project?side=template#creation", {
sea_attr: "encoded",
consent_token: "proof",
token: "secret",
kubeconfig: "secret",
}),
"https://brain.test"
);
assert.equal(url.searchParams.get("side"), "template");
assert.equal(url.searchParams.get("sea_attr"), "encoded");
assert.equal(url.searchParams.get("token"), null);
assert.equal(url.searchParams.get("kubeconfig"), null);
assert.equal(url.hash, "#creation");
});

test("ambiguous and empty inbound parameters do not replace valid intent", () => {
assert.equal(
appendMarketingRedirectQuery("/project?side=github", {
sea_attr: ["first", "second"],
consent_token: " ",
}),
"/project?side=github"
);
});
19 changes: 19 additions & 0 deletions apps/ui/src/features/marketing/redirect-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export type MarketingRedirectQuery = Record<
string,
string | string[] | undefined
>;

/** Carry attribution across server redirects without forwarding credentials. */
export function appendMarketingRedirectQuery(
path: string,
query: MarketingRedirectQuery
): string {
const url = new URL(path, "https://brain.invalid");
for (const key of ["sea_attr", "consent_token"]) {
const value = query[key];
if (typeof value === "string" && value.trim() !== "") {
url.searchParams.set(key, value);
}
}
return `${url.pathname}${url.search}${url.hash}`;
}
25 changes: 25 additions & 0 deletions apps/ui/src/features/shell/auth-bootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { createSealosApp, sealosApp } from "@labring/sealos-desktop-sdk/app";
import { useAtomValue, useSetAtom } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { useEffect } from "react";
import { configureBrainAnalyticsTransport } from "@/features/analytics/brain-analytics-transport";
import { scheduleChatDevboxWarmup } from "@/features/chat/devbox/devbox.actions";
import { readMarketingAttribution } from "@/features/marketing/attribution-client";
import { applySealosSdkHydration } from "@/features/shell/auth-bootstrap-core";
import {
appTokenAtom,
Expand Down Expand Up @@ -87,6 +89,29 @@ export function SealosSdkBootstrap() {
useEffect(() => {
let cancelled = false;
const cleanup = createSealosApp();
readMarketingAttribution();
if (window.parent !== window) {
sealosApp
.getHostConfig()
.then((config) => {
if (cancelled) {
return;
}
const supported =
"analytics" in config.features &&
config.features.analytics === true;
configureBrainAnalyticsTransport(
supported
? (payload) => sealosApp.runEvents("trackAnalyticsEvent", payload)
: null
);
})
.catch(() => {
if (!cancelled) {
configureBrainAnalyticsTransport(null);
}
});
}
let unsubscribeLanguage: (() => void) | undefined;

const hydrate = async () => {
Expand Down
Loading