feat(FN-3406): add shell context hook plumbing for dashboard
Adds a new `useShellContext` hook with tests to the dashboard, integrated into `App.tsx` and `Header.tsx` to provide shell context plumbing throughout the UI. Fusion-Task-Id: FN-3406
This commit is contained in:
@@ -57,6 +57,7 @@ import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
|||||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||||
import { ShellProvider } from "./context/ShellContext";
|
import { ShellProvider } from "./context/ShellContext";
|
||||||
import { useShellConnection } from "./hooks/useShellConnection";
|
import { useShellConnection } from "./hooks/useShellConnection";
|
||||||
|
import { useShellContext as useLaunchShellContext } from "./hooks/useShellContext";
|
||||||
import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal";
|
import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal";
|
||||||
import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager";
|
import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager";
|
||||||
import { NativeShellConnectionStatus } from "./components/NativeShellConnectionStatus";
|
import { NativeShellConnectionStatus } from "./components/NativeShellConnectionStatus";
|
||||||
@@ -153,7 +154,8 @@ export function requiresNativeShellOnboarding(
|
|||||||
function AppInner() {
|
function AppInner() {
|
||||||
const { toasts, addToast, removeToast } = useToast();
|
const { toasts, addToast, removeToast } = useToast();
|
||||||
const { shellApi, state: shellState, ready: shellReady, openConnectionManagerSignal } = useShellConnection();
|
const { shellApi, state: shellState, ready: shellReady, openConnectionManagerSignal } = useShellConnection();
|
||||||
const isElectron = typeof window !== "undefined" && Boolean((window as Window & { electronAPI?: unknown }).electronAPI);
|
const { shellContext } = useLaunchShellContext();
|
||||||
|
const isElectron = shellContext?.shellKind === "desktop";
|
||||||
|
|
||||||
// Warm lazy view chunks during browser idle so first navigation is instant.
|
// Warm lazy view chunks during browser idle so first navigation is instant.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1315,6 +1317,7 @@ function AppInner() {
|
|||||||
<>
|
<>
|
||||||
<Header
|
<Header
|
||||||
isElectron={isElectron}
|
isElectron={isElectron}
|
||||||
|
shellContext={shellContext}
|
||||||
onOpenSettings={openSettingsWithNav}
|
onOpenSettings={openSettingsWithNav}
|
||||||
onOpenGitHubImport={openGitHubImportWithNav}
|
onOpenGitHubImport={openGitHubImportWithNav}
|
||||||
onOpenPlanning={openPlanningWithNav}
|
onOpenPlanning={openPlanningWithNav}
|
||||||
|
|||||||
@@ -5,6 +5,78 @@ async function loadAuthModule() {
|
|||||||
return import("../auth");
|
return import("../auth");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadShellContextModule() {
|
||||||
|
return import("../shell-context");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shell handoff contract", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds and parses a valid remote launch", async () => {
|
||||||
|
const {
|
||||||
|
buildRemoteShellLaunchUrl,
|
||||||
|
parseRemoteShellLaunchFromUrl,
|
||||||
|
SHELL_TOKEN_PARAM,
|
||||||
|
SHELL_KIND_PARAM,
|
||||||
|
SHELL_MODE_PARAM,
|
||||||
|
SHELL_PROFILE_ID_PARAM,
|
||||||
|
SHELL_SERVER_BASE_URL_PARAM,
|
||||||
|
} = await loadShellContextModule();
|
||||||
|
|
||||||
|
const launchUrl = buildRemoteShellLaunchUrl({
|
||||||
|
shellKind: "desktop",
|
||||||
|
shellMode: "remote",
|
||||||
|
profileId: "profile_1",
|
||||||
|
serverBaseUrl: "https://remote.example.com/",
|
||||||
|
serverLabel: "Remote A",
|
||||||
|
token: "daemon-token",
|
||||||
|
capabilities: { canOpenConnectionManager: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsedUrl = new URL(launchUrl);
|
||||||
|
expect(parsedUrl.searchParams.get(SHELL_KIND_PARAM)).toBe("desktop");
|
||||||
|
expect(parsedUrl.searchParams.get(SHELL_MODE_PARAM)).toBe("remote");
|
||||||
|
expect(parsedUrl.searchParams.get(SHELL_PROFILE_ID_PARAM)).toBe("profile_1");
|
||||||
|
expect(parsedUrl.searchParams.get(SHELL_SERVER_BASE_URL_PARAM)).toBe("https://remote.example.com");
|
||||||
|
expect(parsedUrl.searchParams.get(SHELL_TOKEN_PARAM)).toBe("daemon-token");
|
||||||
|
|
||||||
|
expect(parseRemoteShellLaunchFromUrl(launchUrl)).toEqual({
|
||||||
|
shellKind: "desktop",
|
||||||
|
shellMode: "remote",
|
||||||
|
profileId: "profile_1",
|
||||||
|
serverBaseUrl: "https://remote.example.com",
|
||||||
|
serverLabel: "Remote A",
|
||||||
|
token: "daemon-token",
|
||||||
|
capabilities: { canOpenConnectionManager: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed or partial remote launch data", async () => {
|
||||||
|
const { parseRemoteShellLaunchFromUrl } = await loadShellContextModule();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseRemoteShellLaunchFromUrl(
|
||||||
|
"https://remote.example.com/?shellKind=desktop&shellMode=remote&profileId=abc",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseRemoteShellLaunchFromUrl(
|
||||||
|
"https://remote.example.com/?shellKind=desktop&shellMode=remote&serverBaseUrl=https://remote.example.com",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseRemoteShellLaunchFromUrl(
|
||||||
|
"https://remote.example.com/?shellKind=unknown&shellMode=remote&profileId=abc&serverBaseUrl=https://remote.example.com",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("auth helpers", () => {
|
describe("auth helpers", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
@@ -12,14 +84,14 @@ describe("auth helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("captures token from ?token= and cleans URL while preserving other params/hash", async () => {
|
it("captures token from ?token= and cleans URL while preserving other params/hash", async () => {
|
||||||
window.history.replaceState({}, "", "/dashboard?token=daemon-123&view=board#focus");
|
window.history.replaceState({}, "", "/dashboard?token=daemon-123&view=board&shellKind=desktop#focus");
|
||||||
|
|
||||||
const { getAuthToken } = await loadAuthModule();
|
const { getAuthToken } = await loadAuthModule();
|
||||||
|
|
||||||
expect(getAuthToken()).toBe("daemon-123");
|
expect(getAuthToken()).toBe("daemon-123");
|
||||||
expect(window.localStorage.getItem("fn.authToken")).toBe("daemon-123");
|
expect(window.localStorage.getItem("fn.authToken")).toBe("daemon-123");
|
||||||
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
|
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
|
||||||
"/dashboard?view=board#focus",
|
"/dashboard?view=board&shellKind=desktop#focus",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_KEY = "fn.authToken";
|
const STORAGE_KEY = "fn.authToken";
|
||||||
const URL_PARAM = "token";
|
export const URL_TOKEN_PARAM = "token";
|
||||||
/** Query param name used when we can't set an Authorization header (EventSource, WebSocket). */
|
/** Query param name used when we can't set an Authorization header (EventSource, WebSocket). */
|
||||||
export const QUERY_TOKEN_PARAM = "fn_token";
|
export const QUERY_TOKEN_PARAM = "fn_token";
|
||||||
|
|
||||||
@@ -76,13 +76,13 @@ function captureTokenFromUrl(): string | undefined {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
const token = url.searchParams.get(URL_PARAM);
|
const token = url.searchParams.get(URL_TOKEN_PARAM);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
writeStoredToken(token);
|
writeStoredToken(token);
|
||||||
url.searchParams.delete(URL_PARAM);
|
url.searchParams.delete(URL_TOKEN_PARAM);
|
||||||
const cleaned = url.pathname + (url.search ? url.search : "") + url.hash;
|
const cleaned = url.pathname + (url.search ? url.search : "") + url.hash;
|
||||||
window.history.replaceState(window.history.state, "", cleaned);
|
window.history.replaceState(window.history.state, "", cleaned);
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { TaskView } from "../hooks/useViewState";
|
|||||||
import type { PluginDashboardViewEntry } from "../api";
|
import type { PluginDashboardViewEntry } from "../api";
|
||||||
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
|
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
|
||||||
import { getPluginNavIcon } from "./pluginNavIcon";
|
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||||
|
import type { ShellContext as LaunchShellContext } from "../shell-context";
|
||||||
|
|
||||||
export { useViewportMode };
|
export { useViewportMode };
|
||||||
|
|
||||||
@@ -239,6 +240,7 @@ export interface HeaderProps {
|
|||||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean };
|
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean };
|
||||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||||
shellConnectionControl?: ReactNode;
|
shellConnectionControl?: ReactNode;
|
||||||
|
shellContext?: LaunchShellContext | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
@@ -296,6 +298,7 @@ export function Header({
|
|||||||
experimentalFeatures,
|
experimentalFeatures,
|
||||||
pluginDashboardViews = [],
|
pluginDashboardViews = [],
|
||||||
shellConnectionControl,
|
shellConnectionControl,
|
||||||
|
shellContext,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const mode: ViewportMode = useViewportMode();
|
const mode: ViewportMode = useViewportMode();
|
||||||
const isMobile = mode === "mobile";
|
const isMobile = mode === "mobile";
|
||||||
@@ -846,7 +849,7 @@ export function Header({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="header-wrapper">
|
<div className="header-wrapper">
|
||||||
<header className="header">
|
<header className="header" data-shell-kind={shellContext?.shellKind ?? "web"}>
|
||||||
<div className="header-left">
|
<div className="header-left">
|
||||||
<div className="header-brand">
|
<div className="header-brand">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ describe("Header", () => {
|
|||||||
expect(screen.getByText("Fusion")).toBeDefined();
|
expect(screen.getByText("Fusion")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies shell context metadata on the header root", () => {
|
||||||
|
const { container } = renderHeader({ shellContext: { shellKind: "desktop", shellMode: "remote", capabilities: { canOpenConnectionManager: true } } });
|
||||||
|
expect(container.querySelector("header.header")?.getAttribute("data-shell-kind")).toBe("desktop");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders action buttons", () => {
|
it("renders action buttons", () => {
|
||||||
renderHeader();
|
renderHeader();
|
||||||
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
|
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useShellContext } from "../useShellContext";
|
||||||
|
|
||||||
|
const mockUseNativeShellContext = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../context/ShellContext", () => ({
|
||||||
|
useShellContext: () => mockUseNativeShellContext(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useShellContext", () => {
|
||||||
|
it("returns shell context from URL handoff when present", () => {
|
||||||
|
window.history.replaceState({}, "", "/?shellKind=desktop&shellMode=remote&profileId=p1&serverBaseUrl=https://remote.example.com");
|
||||||
|
mockUseNativeShellContext.mockReturnValue({
|
||||||
|
state: { host: "web", activeProfileId: null, profiles: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useShellContext());
|
||||||
|
|
||||||
|
expect(result.current.shellContext).toMatchObject({
|
||||||
|
shellKind: "desktop",
|
||||||
|
shellMode: "remote",
|
||||||
|
profileId: "p1",
|
||||||
|
serverBaseUrl: "https://remote.example.com",
|
||||||
|
});
|
||||||
|
expect(result.current.isDesktopShell).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to normalized desktop local shell context", () => {
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
mockUseNativeShellContext.mockReturnValue({
|
||||||
|
state: { host: "desktop-shell", desktopMode: "local", activeProfileId: null, profiles: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useShellContext());
|
||||||
|
|
||||||
|
expect(result.current.shellContext).toEqual({
|
||||||
|
shellKind: "desktop",
|
||||||
|
shellMode: "local",
|
||||||
|
capabilities: { canOpenConnectionManager: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null context in plain browser sessions", () => {
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
mockUseNativeShellContext.mockReturnValue({
|
||||||
|
state: { host: "web", activeProfileId: null, profiles: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useShellContext());
|
||||||
|
|
||||||
|
expect(result.current.shellContext).toBeNull();
|
||||||
|
expect(result.current.isDesktopShell).toBe(false);
|
||||||
|
expect(result.current.isMobileShell).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
packages/dashboard/app/hooks/useShellContext.ts
Normal file
69
packages/dashboard/app/hooks/useShellContext.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useShellContext as useNativeShellContext } from "../context/ShellContext";
|
||||||
|
import {
|
||||||
|
parseShellContextFromUrl,
|
||||||
|
type ShellContext as LaunchShellContext,
|
||||||
|
type ShellKind,
|
||||||
|
} from "../shell-context";
|
||||||
|
|
||||||
|
export interface UseShellContextResult {
|
||||||
|
shellContext: LaunchShellContext | null;
|
||||||
|
isDesktopShell: boolean;
|
||||||
|
isMobileShell: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHostToKind(host: "web" | "mobile-shell" | "desktop-shell"): ShellKind | null {
|
||||||
|
if (host === "desktop-shell") return "desktop";
|
||||||
|
if (host === "mobile-shell") return "mobile";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShellContext(): UseShellContextResult {
|
||||||
|
const { state } = useNativeShellContext();
|
||||||
|
|
||||||
|
const shellContext = useMemo<LaunchShellContext | null>(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const parsedFromUrl = parseShellContextFromUrl(window.location.href);
|
||||||
|
if (parsedFromUrl) {
|
||||||
|
return parsedFromUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellKind = mapHostToKind(state.host);
|
||||||
|
if (!shellKind) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shellKind === "desktop" && state.desktopMode === "local") {
|
||||||
|
return {
|
||||||
|
shellKind,
|
||||||
|
shellMode: "local",
|
||||||
|
capabilities: {
|
||||||
|
canOpenConnectionManager: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeProfile = state.profiles.find((profile) => profile.id === state.activeProfileId);
|
||||||
|
if (!activeProfile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shellKind,
|
||||||
|
shellMode: "remote",
|
||||||
|
profileId: activeProfile.id,
|
||||||
|
serverBaseUrl: activeProfile.serverUrl.replace(/\/$/, ""),
|
||||||
|
...(activeProfile.name ? { serverLabel: activeProfile.name } : {}),
|
||||||
|
capabilities: {
|
||||||
|
canOpenConnectionManager: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shellContext,
|
||||||
|
isDesktopShell: shellContext?.shellKind === "desktop",
|
||||||
|
isMobileShell: shellContext?.shellKind === "mobile",
|
||||||
|
};
|
||||||
|
}
|
||||||
166
packages/dashboard/app/shell-context.ts
Normal file
166
packages/dashboard/app/shell-context.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { URL_TOKEN_PARAM } from "./auth";
|
||||||
|
|
||||||
|
export type ShellKind = "desktop" | "mobile";
|
||||||
|
export type ShellMode = "local" | "remote";
|
||||||
|
|
||||||
|
export interface ShellCapabilities {
|
||||||
|
canOpenConnectionManager: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellContext {
|
||||||
|
shellKind: ShellKind;
|
||||||
|
shellMode: ShellMode;
|
||||||
|
profileId?: string;
|
||||||
|
serverBaseUrl?: string;
|
||||||
|
serverLabel?: string;
|
||||||
|
capabilities: ShellCapabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteShellLaunch {
|
||||||
|
shellKind: ShellKind;
|
||||||
|
shellMode: "remote";
|
||||||
|
profileId: string;
|
||||||
|
serverBaseUrl: string;
|
||||||
|
serverLabel?: string;
|
||||||
|
token?: string;
|
||||||
|
capabilities?: Partial<ShellCapabilities>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHELL_KIND_PARAM = "shellKind";
|
||||||
|
export const SHELL_MODE_PARAM = "shellMode";
|
||||||
|
export const SHELL_PROFILE_ID_PARAM = "profileId";
|
||||||
|
export const SHELL_SERVER_BASE_URL_PARAM = "serverBaseUrl";
|
||||||
|
export const SHELL_SERVER_LABEL_PARAM = "serverLabel";
|
||||||
|
export const SHELL_CAN_OPEN_CONNECTION_MANAGER_PARAM = "shellCanOpenConnectionManager";
|
||||||
|
export const SHELL_TOKEN_PARAM = URL_TOKEN_PARAM;
|
||||||
|
|
||||||
|
const DEFAULT_CAPABILITIES: ShellCapabilities = {
|
||||||
|
canOpenConnectionManager: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isShellKind(value: string | null): value is ShellKind {
|
||||||
|
return value === "desktop" || value === "mobile";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShellMode(value: string | null): value is ShellMode {
|
||||||
|
return value === "local" || value === "remote";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerBaseUrl(value: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.toString().replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBooleanParam(value: string | null): boolean {
|
||||||
|
return value === "1" || value === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseShellContextFromUrl(url: string): ShellContext | null {
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(url, typeof window !== "undefined" ? window.location.origin : "http://localhost");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellKind = parsedUrl.searchParams.get(SHELL_KIND_PARAM);
|
||||||
|
const shellMode = parsedUrl.searchParams.get(SHELL_MODE_PARAM);
|
||||||
|
|
||||||
|
if (!isShellKind(shellKind) || !isShellMode(shellMode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capabilities: ShellCapabilities = {
|
||||||
|
canOpenConnectionManager: parseBooleanParam(parsedUrl.searchParams.get(SHELL_CAN_OPEN_CONNECTION_MANAGER_PARAM)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shellMode === "local") {
|
||||||
|
return {
|
||||||
|
shellKind,
|
||||||
|
shellMode,
|
||||||
|
capabilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileId = parsedUrl.searchParams.get(SHELL_PROFILE_ID_PARAM);
|
||||||
|
const serverBaseUrlRaw = parsedUrl.searchParams.get(SHELL_SERVER_BASE_URL_PARAM);
|
||||||
|
const serverBaseUrl = serverBaseUrlRaw ? normalizeServerBaseUrl(serverBaseUrlRaw) : null;
|
||||||
|
|
||||||
|
if (!profileId || !serverBaseUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverLabel = parsedUrl.searchParams.get(SHELL_SERVER_LABEL_PARAM) ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
shellKind,
|
||||||
|
shellMode,
|
||||||
|
profileId,
|
||||||
|
serverBaseUrl,
|
||||||
|
...(serverLabel ? { serverLabel } : {}),
|
||||||
|
capabilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRemoteShellLaunchFromUrl(url: string): RemoteShellLaunch | null {
|
||||||
|
const parsed = parseShellContextFromUrl(url);
|
||||||
|
if (!parsed || parsed.shellMode !== "remote" || !parsed.profileId || !parsed.serverBaseUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(url, typeof window !== "undefined" ? window.location.origin : "http://localhost");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = parsedUrl.searchParams.get(SHELL_TOKEN_PARAM) ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
shellKind: parsed.shellKind,
|
||||||
|
shellMode: "remote",
|
||||||
|
profileId: parsed.profileId,
|
||||||
|
serverBaseUrl: parsed.serverBaseUrl,
|
||||||
|
...(parsed.serverLabel ? { serverLabel: parsed.serverLabel } : {}),
|
||||||
|
...(token ? { token } : {}),
|
||||||
|
capabilities: parsed.capabilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRemoteShellLaunchUrl(launch: RemoteShellLaunch): string {
|
||||||
|
const normalizedBaseUrl = normalizeServerBaseUrl(launch.serverBaseUrl);
|
||||||
|
if (!normalizedBaseUrl) {
|
||||||
|
throw new Error("Invalid serverBaseUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!launch.profileId.trim()) {
|
||||||
|
throw new Error("profileId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(normalizedBaseUrl);
|
||||||
|
url.searchParams.set(SHELL_KIND_PARAM, launch.shellKind);
|
||||||
|
url.searchParams.set(SHELL_MODE_PARAM, "remote");
|
||||||
|
url.searchParams.set(SHELL_PROFILE_ID_PARAM, launch.profileId);
|
||||||
|
url.searchParams.set(SHELL_SERVER_BASE_URL_PARAM, normalizedBaseUrl);
|
||||||
|
if (launch.serverLabel) {
|
||||||
|
url.searchParams.set(SHELL_SERVER_LABEL_PARAM, launch.serverLabel);
|
||||||
|
}
|
||||||
|
if (launch.token) {
|
||||||
|
url.searchParams.set(SHELL_TOKEN_PARAM, launch.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const capabilities = { ...DEFAULT_CAPABILITIES, ...launch.capabilities };
|
||||||
|
if (capabilities.canOpenConnectionManager) {
|
||||||
|
url.searchParams.set(SHELL_CAN_OPEN_CONNECTION_MANAGER_PARAM, "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
@@ -128,6 +128,7 @@ describe("ipc handlers", () => {
|
|||||||
expect(channels.has("desktopRuntime:startLocal")).toBe(true);
|
expect(channels.has("desktopRuntime:startLocal")).toBe(true);
|
||||||
expect(channels.has("desktopRuntime:stopLocal")).toBe(true);
|
expect(channels.has("desktopRuntime:stopLocal")).toBe(true);
|
||||||
expect(channels.has("desktopLaunchMode:getMode")).toBe(true);
|
expect(channels.has("desktopLaunchMode:getMode")).toBe(true);
|
||||||
|
expect(channels.has("desktopLaunchMode:getContext")).toBe(true);
|
||||||
expect(channels.has("desktopLaunchMode:setMode")).toBe(true);
|
expect(channels.has("desktopLaunchMode:setMode")).toBe(true);
|
||||||
expect(channels.has("platform:get")).toBe(true);
|
expect(channels.has("platform:get")).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -155,12 +156,14 @@ describe("ipc handlers", () => {
|
|||||||
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
|
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("desktop launch mode handlers return mode and validate payload", async () => {
|
it("desktop launch mode handlers return mode/context and validate payload", async () => {
|
||||||
|
const getDesktopLaunchContext = vi.fn(() => ({ mode: "remote", profileId: "profile_1", serverBaseUrl: "https://remote.example.com" }));
|
||||||
const onDesktopLaunchModeChange = vi.fn(async () => undefined);
|
const onDesktopLaunchModeChange = vi.fn(async () => undefined);
|
||||||
const getDesktopLaunchMode = vi.fn(() => "remote");
|
const getDesktopLaunchMode = vi.fn(() => "remote");
|
||||||
await registerHandlers({ onDesktopLaunchModeChange, getDesktopLaunchMode });
|
await registerHandlers({ onDesktopLaunchModeChange, getDesktopLaunchMode, getDesktopLaunchContext });
|
||||||
|
|
||||||
await expect(mocks.ipcHandlers.get("desktopLaunchMode:getMode")?.({})).resolves.toBe("remote");
|
await expect(mocks.ipcHandlers.get("desktopLaunchMode:getMode")?.({})).resolves.toBe("remote");
|
||||||
|
await expect(mocks.ipcHandlers.get("desktopLaunchMode:getContext")?.({})).resolves.toEqual({ mode: "remote", profileId: "profile_1", serverBaseUrl: "https://remote.example.com" });
|
||||||
await expect(mocks.ipcHandlers.get("desktopLaunchMode:setMode")?.({}, "local")).resolves.toBe("remote");
|
await expect(mocks.ipcHandlers.get("desktopLaunchMode:setMode")?.({}, "local")).resolves.toBe("remote");
|
||||||
await expect(mocks.ipcHandlers.get("desktopLaunchMode:setMode")?.({}, "bad")).rejects.toThrow("Invalid desktop launch mode");
|
await expect(mocks.ipcHandlers.get("desktopLaunchMode:setMode")?.({}, "bad")).rejects.toThrow("Invalid desktop launch mode");
|
||||||
expect(onDesktopLaunchModeChange).toHaveBeenCalledWith("local");
|
expect(onDesktopLaunchModeChange).toHaveBeenCalledWith("local");
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ vi.mock("../native.js", () => ({
|
|||||||
saveWindowState: mocks.saveWindowState,
|
saveWindowState: mocks.saveWindowState,
|
||||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||||
DEFAULT_WINDOW_STATE: mocks.DEFAULT_WINDOW_STATE,
|
DEFAULT_WINDOW_STATE: mocks.DEFAULT_WINDOW_STATE,
|
||||||
|
normalizeDesktopRemoteLaunch: vi.fn((settings) => {
|
||||||
|
const active = settings.profiles.find((profile: { id: string }) => profile.id === settings.activeProfileId);
|
||||||
|
return active ? { mode: "remote", profileId: active.id, serverBaseUrl: active.serverUrl.replace(/\/$/, ""), serverLabel: active.name, authToken: active.authToken ?? undefined } : null;
|
||||||
|
}),
|
||||||
|
buildRemoteShellHandoffUrl: vi.fn((launch) => `https://remote.example.com?shellMode=remote&profileId=${launch.profileId}`),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../local-runtime.js", () => ({
|
vi.mock("../local-runtime.js", () => ({
|
||||||
@@ -180,6 +185,14 @@ vi.mock("../local-runtime.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock renderer module
|
// Mock renderer module
|
||||||
|
vi.mock("../shell-settings.js", () => ({
|
||||||
|
readShellSettings: vi.fn(async () => ({
|
||||||
|
desktopMode: "remote",
|
||||||
|
activeProfileId: "profile_1",
|
||||||
|
profiles: [{ id: "profile_1", name: "Remote", serverUrl: "https://remote.example.com", authToken: "token" }],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../renderer.js", () => ({
|
vi.mock("../renderer.js", () => ({
|
||||||
isDevelopmentMode: vi.fn(() => false),
|
isDevelopmentMode: vi.fn(() => false),
|
||||||
getRendererUrl: vi.fn(() => "file:///path/to/dist/client/index.html"),
|
getRendererUrl: vi.fn(() => "file:///path/to/dist/client/index.html"),
|
||||||
@@ -375,6 +388,17 @@ describe("main integration", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads remote handoff URL when remembered mode is remote", async () => {
|
||||||
|
mocks.loadDesktopLaunchMode.mockResolvedValueOnce("remote");
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
const [{ instance }] = mocks.windowInstances;
|
||||||
|
expect(instance.loadURL).toHaveBeenCalledWith(expect.stringContaining("shellMode=remote"));
|
||||||
|
expect(mocks.startLocal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("starts local runtime when remembered mode is local", async () => {
|
it("starts local runtime when remembered mode is local", async () => {
|
||||||
mocks.loadDesktopLaunchMode.mockResolvedValueOnce("local");
|
mocks.loadDesktopLaunchMode.mockResolvedValueOnce("local");
|
||||||
const { initializeApp } = await importMainModule();
|
const { initializeApp } = await importMainModule();
|
||||||
|
|||||||
@@ -137,11 +137,24 @@ vi.mock("../native.js", () => ({
|
|||||||
saveDesktopLaunchMode: mainDeps.saveDesktopLaunchMode,
|
saveDesktopLaunchMode: mainDeps.saveDesktopLaunchMode,
|
||||||
saveWindowState: mainDeps.saveWindowState,
|
saveWindowState: mainDeps.saveWindowState,
|
||||||
setupAutoUpdater: mainDeps.setupAutoUpdater,
|
setupAutoUpdater: mainDeps.setupAutoUpdater,
|
||||||
|
normalizeDesktopRemoteLaunch: vi.fn((settings) => {
|
||||||
|
const active = settings.profiles.find((profile: { id: string }) => profile.id === settings.activeProfileId);
|
||||||
|
return active ? { mode: "remote", profileId: active.id, serverBaseUrl: active.serverUrl.replace(/\/$/, ""), serverLabel: active.name, authToken: active.authToken ?? undefined } : null;
|
||||||
|
}),
|
||||||
|
buildRemoteShellHandoffUrl: vi.fn((launch) => `https://remote.example.com?shellKind=desktop&shellMode=remote&profileId=${launch.profileId}`),
|
||||||
}));
|
}));
|
||||||
vi.mock("../local-runtime.js", () => ({
|
vi.mock("../local-runtime.js", () => ({
|
||||||
LocalRuntimeManager: mainDeps.LocalRuntimeManager,
|
LocalRuntimeManager: mainDeps.LocalRuntimeManager,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../shell-settings.js", () => ({
|
||||||
|
readShellSettings: vi.fn(async () => ({
|
||||||
|
desktopMode: "remote",
|
||||||
|
activeProfileId: "profile_1",
|
||||||
|
profiles: [{ id: "profile_1", name: "Remote", serverUrl: "https://remote.example.com", authToken: "token" }],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
async function importMainModule() {
|
async function importMainModule() {
|
||||||
return import("../main.ts");
|
return import("../main.ts");
|
||||||
}
|
}
|
||||||
@@ -264,13 +277,16 @@ describe("main process", () => {
|
|||||||
expect(mainDeps.startLocal).not.toHaveBeenCalled();
|
expect(mainDeps.startLocal).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("initializeApp does not start local runtime for remembered remote mode", async () => {
|
it("initializeApp routes remembered remote mode to remote dashboard handoff URL", async () => {
|
||||||
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("remote");
|
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("remote");
|
||||||
const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule();
|
const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule();
|
||||||
|
|
||||||
await initializeApp();
|
await initializeApp();
|
||||||
|
|
||||||
expect(mainDeps.startLocal).not.toHaveBeenCalled();
|
expect(mainDeps.startLocal).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.browserWindowInstance.loadURL).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("shellMode=remote"),
|
||||||
|
);
|
||||||
expect(getCurrentDesktopLaunchMode()).toBe("remote");
|
expect(getCurrentDesktopLaunchMode()).toBe("remote");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -54,13 +54,16 @@ describe("preload", () => {
|
|||||||
await importPreloadModule();
|
await importPreloadModule();
|
||||||
const api = getExposed<{
|
const api = getExposed<{
|
||||||
getDesktopLaunchMode: () => Promise<string>;
|
getDesktopLaunchMode: () => Promise<string>;
|
||||||
|
getDesktopLaunchContext: () => Promise<unknown>;
|
||||||
setDesktopLaunchMode: (mode: "choose" | "local" | "remote") => Promise<string>;
|
setDesktopLaunchMode: (mode: "choose" | "local" | "remote") => Promise<string>;
|
||||||
}>("electronAPI");
|
}>("electronAPI");
|
||||||
|
|
||||||
await api?.getDesktopLaunchMode();
|
await api?.getDesktopLaunchMode();
|
||||||
|
await api?.getDesktopLaunchContext();
|
||||||
await api?.setDesktopLaunchMode("local");
|
await api?.setDesktopLaunchMode("local");
|
||||||
|
|
||||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:getMode");
|
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:getMode");
|
||||||
|
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:getContext");
|
||||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:setMode", "local");
|
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:setMode", "local");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
|
import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
|
||||||
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog } from "./native.js";
|
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog, type NormalizedDesktopRemoteLaunch } from "./native.js";
|
||||||
import { type EngineStatus, updateTrayStatus } from "./tray.js";
|
import { type EngineStatus, updateTrayStatus } from "./tray.js";
|
||||||
import {
|
import {
|
||||||
applyDeleteProfile,
|
applyDeleteProfile,
|
||||||
@@ -42,6 +42,7 @@ interface RegisterIpcOptions {
|
|||||||
stopLocalRuntime?: () => Promise<DesktopRuntimeStatus>;
|
stopLocalRuntime?: () => Promise<DesktopRuntimeStatus>;
|
||||||
getServerPort?: () => number | undefined;
|
getServerPort?: () => number | undefined;
|
||||||
getDesktopLaunchMode?: () => DesktopLaunchMode;
|
getDesktopLaunchMode?: () => DesktopLaunchMode;
|
||||||
|
getDesktopLaunchContext?: () => NormalizedDesktopRemoteLaunch | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDesktopLaunchMode(value: unknown): value is DesktopLaunchMode {
|
function isDesktopLaunchMode(value: unknown): value is DesktopLaunchMode {
|
||||||
@@ -112,6 +113,7 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
|
|||||||
ipcMain.handle("desktopRuntime:startLocal", async () => options.startLocalRuntime?.() ?? { source: "none", state: "stopped" });
|
ipcMain.handle("desktopRuntime:startLocal", async () => options.startLocalRuntime?.() ?? { source: "none", state: "stopped" });
|
||||||
ipcMain.handle("desktopRuntime:stopLocal", async () => options.stopLocalRuntime?.() ?? { source: "none", state: "stopped" });
|
ipcMain.handle("desktopRuntime:stopLocal", async () => options.stopLocalRuntime?.() ?? { source: "none", state: "stopped" });
|
||||||
ipcMain.handle("desktopLaunchMode:getMode", async () => options.getDesktopLaunchMode?.() ?? "choose");
|
ipcMain.handle("desktopLaunchMode:getMode", async () => options.getDesktopLaunchMode?.() ?? "choose");
|
||||||
|
ipcMain.handle("desktopLaunchMode:getContext", async () => options.getDesktopLaunchContext?.() ?? null);
|
||||||
ipcMain.handle("desktopLaunchMode:setMode", async (_event, mode: unknown) => {
|
ipcMain.handle("desktopLaunchMode:setMode", async (_event, mode: unknown) => {
|
||||||
if (!isDesktopLaunchMode(mode)) {
|
if (!isDesktopLaunchMode(mode)) {
|
||||||
throw new Error("Invalid desktop launch mode");
|
throw new Error("Invalid desktop launch mode");
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ import {
|
|||||||
saveDesktopLaunchMode,
|
saveDesktopLaunchMode,
|
||||||
saveWindowState,
|
saveWindowState,
|
||||||
setupAutoUpdater,
|
setupAutoUpdater,
|
||||||
|
normalizeDesktopRemoteLaunch,
|
||||||
|
buildRemoteShellHandoffUrl,
|
||||||
type DesktopLaunchMode,
|
type DesktopLaunchMode,
|
||||||
|
type NormalizedDesktopRemoteLaunch,
|
||||||
type WindowState,
|
type WindowState,
|
||||||
} from "./native.js";
|
} from "./native.js";
|
||||||
import { setupTray } from "./tray.js";
|
import { setupTray } from "./tray.js";
|
||||||
import { getRendererUrl, getRendererFilePath, isUrlRenderer } from "./renderer.js";
|
import { getRendererUrl, getRendererFilePath, isUrlRenderer } from "./renderer.js";
|
||||||
import { LocalRuntimeManager } from "./local-runtime.js";
|
import { LocalRuntimeManager } from "./local-runtime.js";
|
||||||
|
import { readShellSettings } from "./shell-settings.js";
|
||||||
|
|
||||||
// Re-export for backward compatibility
|
// Re-export for backward compatibility
|
||||||
export { IS_DEVELOPMENT } from "./renderer.js";
|
export { IS_DEVELOPMENT } from "./renderer.js";
|
||||||
@@ -39,6 +43,7 @@ let mainWindow: BrowserWindow | null = null;
|
|||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let localRuntimeManager: LocalRuntimeManager | null = null;
|
let localRuntimeManager: LocalRuntimeManager | null = null;
|
||||||
let currentDesktopLaunchMode: DesktopLaunchMode = "choose";
|
let currentDesktopLaunchMode: DesktopLaunchMode = "choose";
|
||||||
|
let currentRemoteLaunch: NormalizedDesktopRemoteLaunch | null = null;
|
||||||
let localRuntimeStartupAttempted = false;
|
let localRuntimeStartupAttempted = false;
|
||||||
|
|
||||||
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
|
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
|
||||||
@@ -64,7 +69,7 @@ export function getCurrentDesktopLaunchMode(): DesktopLaunchMode {
|
|||||||
return currentDesktopLaunchMode;
|
return currentDesktopLaunchMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMainWindow(state?: WindowState): BrowserWindow {
|
export function createMainWindow(state?: WindowState, launchTargetUrl?: string): BrowserWindow {
|
||||||
const hasValidPosition = typeof state?.x === "number" && typeof state?.y === "number";
|
const hasValidPosition = typeof state?.x === "number" && typeof state?.y === "number";
|
||||||
|
|
||||||
const window = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
@@ -79,7 +84,9 @@ export function createMainWindow(state?: WindowState): BrowserWindow {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isUrlRenderer()) {
|
if (launchTargetUrl) {
|
||||||
|
void window.loadURL(launchTargetUrl);
|
||||||
|
} else if (isUrlRenderer()) {
|
||||||
void window.loadURL(getRendererUrl());
|
void window.loadURL(getRendererUrl());
|
||||||
} else {
|
} else {
|
||||||
void window.loadFile(getRendererFilePath());
|
void window.loadFile(getRendererFilePath());
|
||||||
@@ -110,8 +117,20 @@ export async function initializeApp(): Promise<void> {
|
|||||||
|
|
||||||
localRuntimeManager = new LocalRuntimeManager({ rootDir: process.cwd() });
|
localRuntimeManager = new LocalRuntimeManager({ rootDir: process.cwd() });
|
||||||
currentDesktopLaunchMode = rememberedLaunchMode;
|
currentDesktopLaunchMode = rememberedLaunchMode;
|
||||||
|
currentRemoteLaunch = null;
|
||||||
localRuntimeStartupAttempted = false;
|
localRuntimeStartupAttempted = false;
|
||||||
|
|
||||||
|
if (rememberedLaunchMode === "remote") {
|
||||||
|
const shellSettings = await readShellSettings();
|
||||||
|
const normalizedRemoteLaunch = normalizeDesktopRemoteLaunch(shellSettings);
|
||||||
|
if (normalizedRemoteLaunch) {
|
||||||
|
currentRemoteLaunch = normalizedRemoteLaunch;
|
||||||
|
} else {
|
||||||
|
currentDesktopLaunchMode = "choose";
|
||||||
|
await saveDesktopLaunchMode("choose");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (rememberedLaunchMode === "local") {
|
if (rememberedLaunchMode === "local") {
|
||||||
try {
|
try {
|
||||||
await startLocalRuntimeOnce();
|
await startLocalRuntimeOnce();
|
||||||
@@ -129,7 +148,12 @@ export async function initializeApp(): Promise<void> {
|
|||||||
currentDesktopLaunchMode = "local";
|
currentDesktopLaunchMode = "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdWindow = createMainWindow(state ?? undefined);
|
const createdWindow = createMainWindow(
|
||||||
|
state ?? undefined,
|
||||||
|
currentDesktopLaunchMode === "remote" && currentRemoteLaunch
|
||||||
|
? buildRemoteShellHandoffUrl(currentRemoteLaunch)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
buildAppMenu({
|
buildAppMenu({
|
||||||
mainWindow: createdWindow,
|
mainWindow: createdWindow,
|
||||||
@@ -146,11 +170,14 @@ export async function initializeApp(): Promise<void> {
|
|||||||
}
|
}
|
||||||
currentDesktopLaunchMode = mode;
|
currentDesktopLaunchMode = mode;
|
||||||
if (mode === "local") {
|
if (mode === "local") {
|
||||||
|
currentRemoteLaunch = null;
|
||||||
localRuntimeStartupAttempted = false;
|
localRuntimeStartupAttempted = false;
|
||||||
await startLocalRuntimeOnce();
|
await startLocalRuntimeOnce();
|
||||||
} else {
|
} else {
|
||||||
localRuntimeStartupAttempted = false;
|
localRuntimeStartupAttempted = false;
|
||||||
await localRuntimeManager.stopLocal();
|
await localRuntimeManager.stopLocal();
|
||||||
|
const shellSettings = await readShellSettings();
|
||||||
|
currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" });
|
||||||
}
|
}
|
||||||
await saveDesktopLaunchMode(mode);
|
await saveDesktopLaunchMode(mode);
|
||||||
},
|
},
|
||||||
@@ -161,9 +188,12 @@ export async function initializeApp(): Promise<void> {
|
|||||||
currentDesktopLaunchMode = mode;
|
currentDesktopLaunchMode = mode;
|
||||||
localRuntimeStartupAttempted = false;
|
localRuntimeStartupAttempted = false;
|
||||||
if (mode === "local") {
|
if (mode === "local") {
|
||||||
|
currentRemoteLaunch = null;
|
||||||
await startLocalRuntimeOnce();
|
await startLocalRuntimeOnce();
|
||||||
} else {
|
} else {
|
||||||
await localRuntimeManager.stopLocal();
|
await localRuntimeManager.stopLocal();
|
||||||
|
const shellSettings = await readShellSettings();
|
||||||
|
currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" });
|
||||||
}
|
}
|
||||||
await saveDesktopLaunchMode(mode);
|
await saveDesktopLaunchMode(mode);
|
||||||
},
|
},
|
||||||
@@ -172,6 +202,7 @@ export async function initializeApp(): Promise<void> {
|
|||||||
stopLocalRuntime: () => localRuntimeManager?.stopLocal() ?? Promise.resolve({ source: "none", state: "stopped" }),
|
stopLocalRuntime: () => localRuntimeManager?.stopLocal() ?? Promise.resolve({ source: "none", state: "stopped" }),
|
||||||
getServerPort: () => localRuntimeManager?.getServerPort(),
|
getServerPort: () => localRuntimeManager?.getServerPort(),
|
||||||
getDesktopLaunchMode: () => currentDesktopLaunchMode,
|
getDesktopLaunchMode: () => currentDesktopLaunchMode,
|
||||||
|
getDesktopLaunchContext: () => currentRemoteLaunch,
|
||||||
});
|
});
|
||||||
registerDeepLinkProtocol();
|
registerDeepLinkProtocol();
|
||||||
setupDeepLinkHandler(createdWindow);
|
setupDeepLinkHandler(createdWindow);
|
||||||
|
|||||||
@@ -20,6 +20,37 @@ export interface WindowState {
|
|||||||
|
|
||||||
export type DesktopLaunchMode = "choose" | "local" | "remote";
|
export type DesktopLaunchMode = "choose" | "local" | "remote";
|
||||||
|
|
||||||
|
export interface DesktopRemoteProfileLike {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
serverUrl: string;
|
||||||
|
authToken?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopShellSettingsLike {
|
||||||
|
desktopMode: "local" | "remote" | null;
|
||||||
|
activeProfileId: string | null;
|
||||||
|
profiles: DesktopRemoteProfileLike[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedDesktopRemoteLaunch {
|
||||||
|
mode: "remote";
|
||||||
|
profileId: string;
|
||||||
|
serverBaseUrl: string;
|
||||||
|
serverLabel?: string;
|
||||||
|
authToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHELL_HANDOFF_QUERY = {
|
||||||
|
shellKind: "shellKind",
|
||||||
|
shellMode: "shellMode",
|
||||||
|
profileId: "profileId",
|
||||||
|
serverBaseUrl: "serverBaseUrl",
|
||||||
|
serverLabel: "serverLabel",
|
||||||
|
token: "token",
|
||||||
|
canOpenConnectionManager: "shellCanOpenConnectionManager",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const DEFAULT_WINDOW_STATE: WindowState = {
|
export const DEFAULT_WINDOW_STATE: WindowState = {
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 900,
|
height: 900,
|
||||||
@@ -166,6 +197,58 @@ export function setupAutoUpdater(mainWindow?: BrowserWindow): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeServerBaseUrl(serverUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(serverUrl);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.toString().replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDesktopRemoteLaunch(settings: DesktopShellSettingsLike): NormalizedDesktopRemoteLaunch | null {
|
||||||
|
if (settings.desktopMode !== "remote" || !settings.activeProfileId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeProfile = settings.profiles.find((profile) => profile.id === settings.activeProfileId);
|
||||||
|
if (!activeProfile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverBaseUrl = normalizeServerBaseUrl(activeProfile.serverUrl);
|
||||||
|
if (!serverBaseUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "remote",
|
||||||
|
profileId: activeProfile.id,
|
||||||
|
serverBaseUrl,
|
||||||
|
...(activeProfile.name ? { serverLabel: activeProfile.name } : {}),
|
||||||
|
...(activeProfile.authToken ? { authToken: activeProfile.authToken } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRemoteShellHandoffUrl(launch: NormalizedDesktopRemoteLaunch): string {
|
||||||
|
const url = new URL(launch.serverBaseUrl);
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.shellKind, "desktop");
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.shellMode, "remote");
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.profileId, launch.profileId);
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.serverBaseUrl, launch.serverBaseUrl);
|
||||||
|
if (launch.serverLabel) {
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.serverLabel, launch.serverLabel);
|
||||||
|
}
|
||||||
|
if (launch.authToken) {
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.token, launch.authToken);
|
||||||
|
}
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.canOpenConnectionManager, "1");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
function isValidDesktopLaunchMode(value: unknown): value is DesktopLaunchMode {
|
function isValidDesktopLaunchMode(value: unknown): value is DesktopLaunchMode {
|
||||||
return value === "choose" || value === "local" || value === "remote";
|
return value === "choose" || value === "local" || value === "remote";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ const electronApi = {
|
|||||||
getDesktopLaunchMode: (): Promise<"choose" | "local" | "remote"> => ipcRenderer.invoke("desktopLaunchMode:getMode"),
|
getDesktopLaunchMode: (): Promise<"choose" | "local" | "remote"> => ipcRenderer.invoke("desktopLaunchMode:getMode"),
|
||||||
setDesktopLaunchMode: (mode: "choose" | "local" | "remote"): Promise<"choose" | "local" | "remote"> =>
|
setDesktopLaunchMode: (mode: "choose" | "local" | "remote"): Promise<"choose" | "local" | "remote"> =>
|
||||||
ipcRenderer.invoke("desktopLaunchMode:setMode", mode),
|
ipcRenderer.invoke("desktopLaunchMode:setMode", mode),
|
||||||
|
getDesktopLaunchContext: (): Promise<{ mode: "remote"; profileId: string; serverBaseUrl: string; serverLabel?: string; authToken?: string } | null> =>
|
||||||
|
ipcRenderer.invoke("desktopLaunchMode:getContext"),
|
||||||
|
|
||||||
// Tray status
|
// Tray status
|
||||||
updateTrayStatus: (status: string): Promise<void> => ipcRenderer.invoke("tray:updateStatus", status),
|
updateTrayStatus: (status: string): Promise<void> => ipcRenderer.invoke("tray:updateStatus", status),
|
||||||
|
|||||||
1
packages/desktop/src/types.d.ts
vendored
1
packages/desktop/src/types.d.ts
vendored
@@ -36,6 +36,7 @@ export interface FusionAPI {
|
|||||||
stopDesktopLocalRuntime(): Promise<ShellConnectionState["localRuntime"]>;
|
stopDesktopLocalRuntime(): Promise<ShellConnectionState["localRuntime"]>;
|
||||||
getDesktopLaunchMode(): Promise<"choose" | "local" | "remote">;
|
getDesktopLaunchMode(): Promise<"choose" | "local" | "remote">;
|
||||||
setDesktopLaunchMode(mode: "choose" | "local" | "remote"): Promise<"choose" | "local" | "remote">;
|
setDesktopLaunchMode(mode: "choose" | "local" | "remote"): Promise<"choose" | "local" | "remote">;
|
||||||
|
getDesktopLaunchContext(): Promise<{ mode: "remote"; profileId: string; serverBaseUrl: string; serverLabel?: string; authToken?: string } | null>;
|
||||||
|
|
||||||
// Tray status
|
// Tray status
|
||||||
updateTrayStatus(status: string): Promise<void>;
|
updateTrayStatus(status: string): Promise<void>;
|
||||||
|
|||||||
64
packages/mobile/src/__tests__/shell-handoff.test.ts
Normal file
64
packages/mobile/src/__tests__/shell-handoff.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildMobileShellHandoff } from "../plugins/shell-handoff.js";
|
||||||
|
|
||||||
|
describe("buildMobileShellHandoff", () => {
|
||||||
|
it("builds remote handoff URL for valid active profile", () => {
|
||||||
|
const result = buildMobileShellHandoff({
|
||||||
|
host: "mobile-shell",
|
||||||
|
activeProfileId: "profile_1",
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
id: "profile_1",
|
||||||
|
name: "Prod",
|
||||||
|
serverUrl: "https://fusion.example.com/",
|
||||||
|
authToken: "abc123",
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
lastUsedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.kind).toBe("remote-launch");
|
||||||
|
if (result.kind !== "remote-launch") {
|
||||||
|
throw new Error("expected remote-launch");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(result.url);
|
||||||
|
expect(url.searchParams.get("shellKind")).toBe("mobile");
|
||||||
|
expect(url.searchParams.get("shellMode")).toBe("remote");
|
||||||
|
expect(url.searchParams.get("profileId")).toBe("profile_1");
|
||||||
|
expect(url.searchParams.get("serverBaseUrl")).toBe("https://fusion.example.com");
|
||||||
|
expect(url.searchParams.get("token")).toBe("abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns deterministic fallback when no active profile exists", () => {
|
||||||
|
const result = buildMobileShellHandoff({
|
||||||
|
host: "mobile-shell",
|
||||||
|
activeProfileId: null,
|
||||||
|
profiles: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ kind: "fallback", reason: "no-active-profile" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns fallback for invalid server URLs", () => {
|
||||||
|
const result = buildMobileShellHandoff({
|
||||||
|
host: "mobile-shell",
|
||||||
|
activeProfileId: "profile_1",
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
id: "profile_1",
|
||||||
|
name: "Prod",
|
||||||
|
serverUrl: "not-a-url",
|
||||||
|
authToken: null,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
lastUsedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ kind: "fallback", reason: "invalid-server-url" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ export type {
|
|||||||
} from "./plugins/push-notifications.js";
|
} from "./plugins/push-notifications.js";
|
||||||
export { ShareManager } from "./plugins/share.js";
|
export { ShareManager } from "./plugins/share.js";
|
||||||
export { MobileNativeShellBridge } from "./plugins/native-shell.js";
|
export { MobileNativeShellBridge } from "./plugins/native-shell.js";
|
||||||
|
export { buildMobileShellHandoff } from "./plugins/shell-handoff.js";
|
||||||
export { QrScanner, parseQrConnectionPayload } from "./plugins/qr-scanner.js";
|
export { QrScanner, parseQrConnectionPayload } from "./plugins/qr-scanner.js";
|
||||||
export {
|
export {
|
||||||
loadShellProfiles,
|
loadShellProfiles,
|
||||||
@@ -42,6 +43,8 @@ export type {
|
|||||||
ShellConnectionProfile,
|
ShellConnectionProfile,
|
||||||
ShellConnectionProfileInput,
|
ShellConnectionProfileInput,
|
||||||
ShellConnectionState,
|
ShellConnectionState,
|
||||||
|
MobileShellHandoffResult,
|
||||||
|
MobileRemoteShellLaunch,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
interface LifecycleManager {
|
interface LifecycleManager {
|
||||||
|
|||||||
74
packages/mobile/src/plugins/shell-handoff.ts
Normal file
74
packages/mobile/src/plugins/shell-handoff.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type {
|
||||||
|
MobileRemoteShellLaunch,
|
||||||
|
ShellConnectionProfile,
|
||||||
|
ShellConnectionState,
|
||||||
|
MobileShellHandoffResult,
|
||||||
|
} from "../types.js";
|
||||||
|
|
||||||
|
export const SHELL_HANDOFF_QUERY = {
|
||||||
|
shellKind: "shellKind",
|
||||||
|
shellMode: "shellMode",
|
||||||
|
profileId: "profileId",
|
||||||
|
serverBaseUrl: "serverBaseUrl",
|
||||||
|
serverLabel: "serverLabel",
|
||||||
|
token: "token",
|
||||||
|
canOpenConnectionManager: "shellCanOpenConnectionManager",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function normalizeServerBaseUrl(input: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(input);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.toString().replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActiveProfile(state: ShellConnectionState): ShellConnectionProfile | null {
|
||||||
|
if (!state.activeProfileId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return state.profiles.find((profile) => profile.id === state.activeProfileId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMobileShellHandoff(state: ShellConnectionState): MobileShellHandoffResult {
|
||||||
|
const activeProfile = findActiveProfile(state);
|
||||||
|
if (!state.activeProfileId) {
|
||||||
|
return { kind: "fallback", reason: "no-active-profile" };
|
||||||
|
}
|
||||||
|
if (!activeProfile) {
|
||||||
|
return { kind: "fallback", reason: "missing-profile" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverBaseUrl = normalizeServerBaseUrl(activeProfile.serverUrl);
|
||||||
|
if (!serverBaseUrl) {
|
||||||
|
return { kind: "fallback", reason: "invalid-server-url" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const launch: MobileRemoteShellLaunch = {
|
||||||
|
shellKind: "mobile",
|
||||||
|
shellMode: "remote",
|
||||||
|
profileId: activeProfile.id,
|
||||||
|
serverBaseUrl,
|
||||||
|
...(activeProfile.name ? { serverLabel: activeProfile.name } : {}),
|
||||||
|
...(activeProfile.authToken ? { token: activeProfile.authToken } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = new URL(serverBaseUrl);
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.shellKind, launch.shellKind);
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.shellMode, launch.shellMode);
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.profileId, launch.profileId);
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.serverBaseUrl, launch.serverBaseUrl);
|
||||||
|
if (launch.serverLabel) {
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.serverLabel, launch.serverLabel);
|
||||||
|
}
|
||||||
|
if (launch.token) {
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.token, launch.token);
|
||||||
|
}
|
||||||
|
url.searchParams.set(SHELL_HANDOFF_QUERY.canOpenConnectionManager, "1");
|
||||||
|
|
||||||
|
return { kind: "remote-launch", launch, url: url.toString() };
|
||||||
|
}
|
||||||
@@ -38,6 +38,19 @@ export interface ShellConnectionState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MobileRemoteShellLaunch {
|
||||||
|
shellKind: "mobile";
|
||||||
|
shellMode: "remote";
|
||||||
|
profileId: string;
|
||||||
|
serverBaseUrl: string;
|
||||||
|
serverLabel?: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MobileShellHandoffResult =
|
||||||
|
| { kind: "remote-launch"; url: string; launch: MobileRemoteShellLaunch }
|
||||||
|
| { kind: "fallback"; reason: "no-active-profile" | "missing-profile" | "invalid-server-url" };
|
||||||
|
|
||||||
export interface FusionShellApi {
|
export interface FusionShellApi {
|
||||||
getState(): Promise<ShellConnectionState>;
|
getState(): Promise<ShellConnectionState>;
|
||||||
listProfiles(): Promise<ShellConnectionProfile[]>;
|
listProfiles(): Promise<ShellConnectionProfile[]>;
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -335,6 +335,12 @@ importers:
|
|||||||
|
|
||||||
packages/desktop:
|
packages/desktop:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@fusion/core':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core
|
||||||
|
'@fusion/dashboard':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../dashboard
|
||||||
electron-updater:
|
electron-updater:
|
||||||
specifier: ^6.6.0
|
specifier: ^6.6.0
|
||||||
version: 6.8.3
|
version: 6.8.3
|
||||||
|
|||||||
@@ -1,18 +1,84 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { readFileSync, readdirSync, writeFileSync, mkdirSync, renameSync, mkdtempSync, rmSync, realpathSync } from "node:fs";
|
import { readFileSync, readdirSync, writeFileSync, mkdirSync, renameSync, mkdtempSync, rmSync, realpathSync, globSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { cpus, tmpdir } from "node:os";
|
import { cpus, tmpdir } from "node:os";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { ensureTestArtifacts } from "./ensure-test-artifacts.mjs";
|
import { ensureTestArtifacts } from "./ensure-test-artifacts.mjs";
|
||||||
import fg from "fast-glob";
|
|
||||||
import { parse as parseYaml } from "yaml";
|
|
||||||
|
|
||||||
const currentFilePath = fileURLToPath(import.meta.url);
|
const currentFilePath = fileURLToPath(import.meta.url);
|
||||||
const scriptDir = path.dirname(currentFilePath);
|
const scriptDir = path.dirname(currentFilePath);
|
||||||
const checkIsolationScript = path.join(scriptDir, "check-test-isolation.mjs");
|
const checkIsolationScript = path.join(scriptDir, "check-test-isolation.mjs");
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
function fastGlobSync(patterns, options) {
|
||||||
|
const patternList = Array.isArray(patterns) ? patterns : [patterns];
|
||||||
|
const matches = new Set();
|
||||||
|
|
||||||
|
for (const pattern of patternList) {
|
||||||
|
if (typeof pattern !== "string" || pattern.length === 0) continue;
|
||||||
|
const isNegated = pattern.startsWith("!");
|
||||||
|
const body = isNegated ? pattern.slice(1) : pattern;
|
||||||
|
const resolved = globSync(body, {
|
||||||
|
cwd: options?.cwd,
|
||||||
|
absolute: options?.absolute,
|
||||||
|
dot: options?.dot,
|
||||||
|
nodir: options?.onlyFiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of resolved) {
|
||||||
|
if (isNegated) {
|
||||||
|
matches.delete(entry);
|
||||||
|
} else {
|
||||||
|
matches.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...matches];
|
||||||
|
}
|
||||||
|
|
||||||
|
let fgSync = fastGlobSync;
|
||||||
|
try {
|
||||||
|
const loaded = require("fast-glob");
|
||||||
|
if (typeof loaded?.sync === "function") {
|
||||||
|
fgSync = loaded.sync;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to node:fs globSync when fast-glob is not installed.
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkspacePackagesFromYaml(rawYaml) {
|
||||||
|
const lines = rawYaml.split(/\r?\n/);
|
||||||
|
const packages = [];
|
||||||
|
let inPackages = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!inPackages) {
|
||||||
|
if (trimmed === "packages:") {
|
||||||
|
inPackages = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (!trimmed.startsWith("-")) {
|
||||||
|
if (!line.startsWith(" ") && !line.startsWith("\t")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = trimmed.slice(1).trim().replace(/^['"]|['"]$/g, "");
|
||||||
|
if (value) packages.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
|
||||||
const rootDir = process.env.FUSION_PROJECT_DIR
|
const rootDir = process.env.FUSION_PROJECT_DIR
|
||||||
? path.resolve(process.env.FUSION_PROJECT_DIR)
|
? path.resolve(process.env.FUSION_PROJECT_DIR)
|
||||||
@@ -125,10 +191,7 @@ function getBaseBranch() {
|
|||||||
function readWorkspacePatterns(projectRoot = rootDir) {
|
function readWorkspacePatterns(projectRoot = rootDir) {
|
||||||
try {
|
try {
|
||||||
const workspacePath = path.join(projectRoot, "pnpm-workspace.yaml");
|
const workspacePath = path.join(projectRoot, "pnpm-workspace.yaml");
|
||||||
const parsed = parseYaml(readFileSync(workspacePath, "utf8"));
|
return parseWorkspacePackagesFromYaml(readFileSync(workspacePath, "utf8"));
|
||||||
return Array.isArray(parsed?.packages)
|
|
||||||
? parsed.packages.filter((pattern) => typeof pattern === "string")
|
|
||||||
: [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return ["packages/*"];
|
return ["packages/*"];
|
||||||
}
|
}
|
||||||
@@ -139,7 +202,7 @@ function expandWorkspacePattern(projectRoot, pattern) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return fg.sync(workspacePatternToPackageJsonGlob(pattern), {
|
return fgSync(workspacePatternToPackageJsonGlob(pattern), {
|
||||||
absolute: true,
|
absolute: true,
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
dot: false,
|
dot: false,
|
||||||
@@ -153,7 +216,7 @@ function expandWorkspacePatterns(projectRoot, patterns) {
|
|||||||
return patterns.flatMap((pattern) => expandWorkspacePattern(projectRoot, pattern));
|
return patterns.flatMap((pattern) => expandWorkspacePattern(projectRoot, pattern));
|
||||||
}
|
}
|
||||||
|
|
||||||
return fg.sync(patterns.map(workspacePatternToPackageJsonGlob), {
|
return fgSync(patterns.map(workspacePatternToPackageJsonGlob), {
|
||||||
absolute: true,
|
absolute: true,
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
dot: false,
|
dot: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user