diff --git a/packages/suite-base/src/App.test.tsx b/packages/suite-base/src/App.test.tsx index 9dacd9b8e7..ea68b883ac 100644 --- a/packages/suite-base/src/App.test.tsx +++ b/packages/suite-base/src/App.test.tsx @@ -21,6 +21,7 @@ import NativeWindowContext, { INativeWindow, } from "@lichtblick/suite-base/context/NativeWindowContext"; import { UserScriptStateProvider } from "@lichtblick/suite-base/context/UserScriptStateContext"; +import AppParametersProvider from "@lichtblick/suite-base/providers/AppParametersProvider"; import CurrentLayoutProvider from "@lichtblick/suite-base/providers/CurrentLayoutProvider"; import EventsProvider from "@lichtblick/suite-base/providers/EventsProvider"; import ExtensionCatalogProvider from "@lichtblick/suite-base/providers/ExtensionCatalogProvider"; @@ -30,8 +31,9 @@ import ProblemsContextProvider from "@lichtblick/suite-base/providers/ProblemsCo import { StudioLogsSettingsProvider } from "@lichtblick/suite-base/providers/StudioLogsSettingsProvider"; import TimelineInteractionStateProvider from "@lichtblick/suite-base/providers/TimelineInteractionStateProvider"; import UserProfileLocalStorageProvider from "@lichtblick/suite-base/providers/UserProfileLocalStorageProvider"; +import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder"; -import { App } from "./App"; +import { App, AppProps } from "./App"; import Workspace from "./Workspace"; function mockProvider(testId: string) { @@ -41,6 +43,7 @@ function mockProvider(testId: string) { // Mocking shared providers and components jest.mock("./providers/LayoutManagerProvider", () => mockProvider("layout-manager-provider")); jest.mock("./providers/PanelCatalogProvider", () => mockProvider("panel-catalog-provider")); +jest.mock("./providers/AppParametersProvider", () => mockProvider("app-parameters-provider")); jest.mock("./components/MultiProvider", () => mockProvider("multi-provider")); jest.mock("./components/StudioToastProvider", () => mockProvider("studio-toast-provider")); jest.mock("./components/GlobalCss", () => mockProvider("global-css")); @@ -70,8 +73,9 @@ const mockAppConfiguration: IAppConfiguration = { }; // Helper to render the App with default props -const setup = (overrides: Partial> = {}) => { - const defaultProps = { +const setup = (overrides: Partial = {}) => { + const defaultProps: AppProps = { + appParameters: {}, appConfiguration: mockAppConfiguration, deepLinks: [], dataSources: [], @@ -89,6 +93,7 @@ describe("App Component", () => { it("renders without crashing", () => { setup(); + expect(screen.getByTestId("app-parameters-provider")).toBeDefined(); expect(screen.getByTestId("color-scheme-theme")).toBeDefined(); expect(screen.getByTestId("css-baseline")).toBeDefined(); expect(screen.getByTestId("error-boundary")).toBeDefined(); @@ -188,6 +193,19 @@ describe("App Component MultiProvider Tests", () => { }); }); + it("verifies that AppParametersProvider is called with correct parameters", () => { + const appParameters = { + [BasicBuilder.string()]: BasicBuilder.string(), + [BasicBuilder.string()]: BasicBuilder.string(), + [BasicBuilder.string()]: BasicBuilder.string(), + }; + setup({ appParameters }); + expect(screen.getByTestId("app-parameters-provider")).toBeDefined(); + + const props = (AppParametersProvider as jest.Mock).mock.calls[0][0]; + expect(props.appParameters).toBe(appParameters); + }); + it("verifies that MultiProvider has rendered all providers when its nativeApp", () => { setup({ nativeAppMenu: {} as INativeAppMenu }); expect(extractProviderTypes()).toContain(NativeAppMenuContext.Provider); diff --git a/packages/suite-base/src/App.tsx b/packages/suite-base/src/App.tsx index 2eb8f594bb..c8b88d1502 100644 --- a/packages/suite-base/src/App.tsx +++ b/packages/suite-base/src/App.tsx @@ -11,8 +11,10 @@ import { HTML5Backend } from "react-dnd-html5-backend"; import { IdbLayoutStorage } from "@lichtblick/suite-base/IdbLayoutStorage"; import GlobalCss from "@lichtblick/suite-base/components/GlobalCss"; +import { AppParametersInput } from "@lichtblick/suite-base/context/AppParametersContext"; import LayoutStorageContext from "@lichtblick/suite-base/context/LayoutStorageContext"; import { UserScriptStateProvider } from "@lichtblick/suite-base/context/UserScriptStateContext"; +import AppParametersProvider from "@lichtblick/suite-base/providers/AppParametersProvider"; import EventsProvider from "@lichtblick/suite-base/providers/EventsProvider"; import LayoutManagerProvider from "@lichtblick/suite-base/providers/LayoutManagerProvider"; import ProblemsContextProvider from "@lichtblick/suite-base/providers/ProblemsContextProvider"; @@ -42,10 +44,11 @@ import PanelCatalogProvider from "./providers/PanelCatalogProvider"; import { LaunchPreference } from "./screens/LaunchPreference"; import { ExtensionLoader } from "./services/ExtensionLoader"; -type AppProps = CustomWindowControlsProps & { - deepLinks: string[]; +export type AppProps = CustomWindowControlsProps & { appConfiguration: IAppConfiguration; + appParameters: AppParametersInput; dataSources: IDataSourceFactory[]; + deepLinks: string[]; extensionLoaders: readonly ExtensionLoader[]; layoutLoaders: readonly LayoutLoader[]; nativeAppMenu?: INativeAppMenu; @@ -70,6 +73,7 @@ function contextMenuHandler(event: MouseEvent) { export function App(props: AppProps): React.JSX.Element { const { appConfiguration, + appParameters = {}, dataSources, extensionLoaders, layoutLoaders, @@ -104,10 +108,6 @@ export function App(props: AppProps): React.JSX.Element { providers.unshift(...extraProviders); } - // The toast and logs provider comes first so they are available to all downstream providers - providers.unshift(); - providers.unshift(); - // Problems provider also must come before other, dependent contexts. providers.unshift(); providers.unshift(); @@ -117,6 +117,10 @@ export function App(props: AppProps): React.JSX.Element { const layoutStorage = useMemo(() => new IdbLayoutStorage(), []); providers.unshift(); + // The toast and logs provider comes first so they are available to all downstream providers + providers.unshift(); + providers.unshift(); + const MaybeLaunchPreference = enableLaunchPreferenceScreen === true ? LaunchPreference : Fragment; useEffect(() => { @@ -128,36 +132,38 @@ export function App(props: AppProps): React.JSX.Element { return ( - - {enableGlobalCss && } - - - - - - - - }> - - - - - - - - - - + + + {enableGlobalCss && } + + + + + + + + }> + + + + + + + + + + + ); } diff --git a/packages/suite-base/src/AppParameters.ts b/packages/suite-base/src/AppParameters.ts new file mode 100644 index 0000000000..b7c2da6d41 --- /dev/null +++ b/packages/suite-base/src/AppParameters.ts @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +export enum AppParametersEnum { + // Used to start with a specific layout passed as parameter + DEFAULT_LAYOUT = "defaultLayout", +} diff --git a/packages/suite-base/src/SharedRoot.tsx b/packages/suite-base/src/SharedRoot.tsx index d0611c6f96..e693324f67 100644 --- a/packages/suite-base/src/SharedRoot.tsx +++ b/packages/suite-base/src/SharedRoot.tsx @@ -10,6 +10,7 @@ import { ISharedRootContext, SharedRootContext, } from "@lichtblick/suite-base/context/SharedRootContext"; +import AppParametersProvider from "@lichtblick/suite-base/providers/AppParametersProvider"; import { ColorSchemeThemeProvider } from "./components/ColorSchemeThemeProvider"; import CssBaseline from "./components/CssBaseline"; @@ -36,29 +37,31 @@ export function SharedRoot( return ( - - {enableGlobalCss && } - - - - {children} - - - - + + + {enableGlobalCss && } + + + + {children} + + + + + ); } diff --git a/packages/suite-base/src/context/AppParametersContext.ts b/packages/suite-base/src/context/AppParametersContext.ts new file mode 100644 index 0000000000..21b5839a82 --- /dev/null +++ b/packages/suite-base/src/context/AppParametersContext.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { createContext, useContext } from "react"; + +import { Immutable } from "@lichtblick/suite"; +import { AppParametersEnum } from "@lichtblick/suite-base/AppParameters"; + +// Defines a type for input parameters, allowing any string keys with string values. +export type AppParametersInput = Readonly>; + +// Defines a type for application parameters, restricting keys to the AppParametersEnum for type-safe usage. +export type AppParameters = Readonly>; + +export type AppParametersContext = Immutable; + +export const AppParametersContext = createContext(undefined); + +export function useAppParameters(): AppParameters { + const context = useContext(AppParametersContext); + if (context == undefined) { + throw new Error("useAppParameters must be used within a AppParametersProvider"); + } + return context; +} diff --git a/packages/suite-base/src/i18n/en/general.ts b/packages/suite-base/src/i18n/en/general.ts index 760a3e7b9a..105d78cf06 100644 --- a/packages/suite-base/src/i18n/en/general.ts +++ b/packages/suite-base/src/i18n/en/general.ts @@ -9,4 +9,6 @@ export const general = { foxglove: "Foxglove", learnMore: "Learn more", + noDefaultLayoutParameter: + "The layout '{{layoutName}}' specified in the app parameters does not exist.", }; diff --git a/packages/suite-base/src/providers/AppParametersProvider.test.tsx b/packages/suite-base/src/providers/AppParametersProvider.test.tsx new file mode 100644 index 0000000000..c124aaee4b --- /dev/null +++ b/packages/suite-base/src/providers/AppParametersProvider.test.tsx @@ -0,0 +1,57 @@ +/** @jest-environment jsdom */ + +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { render } from "@testing-library/react"; + +import { useAppParameters } from "@lichtblick/suite-base/context/AppParametersContext"; +import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder"; + +import AppParametersProvider from "./AppParametersProvider"; + +describe("AppParametersProvider", () => { + it("provides app parameters to its children", () => { + const mockParameters = { defaultLayout: BasicBuilder.string() }; + const TestComponent = () => { + const appParameters = useAppParameters(); + return
{appParameters.defaultLayout}
; + }; + + const { getByText } = render( + + + , + ); + + expect(getByText(mockParameters.defaultLayout)).toBeDefined(); + }); + + it("provides default app parameters when none are given", () => { + const TestComponent = () => { + const appParameters = useAppParameters(); + expect(Object.keys(appParameters)).toHaveLength(0); + return
{Object.keys(appParameters).length}
; + }; + + const { getByText } = render( + + + , + ); + + expect(getByText("0")).toBeDefined(); + }); + + it("should throw an error if useAppParameters is called without AppParametersProvider", () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const TestComponent = () => { + useAppParameters(); + return
; + }; + + expect(() => render()).toThrow(); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/packages/suite-base/src/providers/AppParametersProvider.tsx b/packages/suite-base/src/providers/AppParametersProvider.tsx new file mode 100644 index 0000000000..d76335b526 --- /dev/null +++ b/packages/suite-base/src/providers/AppParametersProvider.tsx @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { PropsWithChildren, useMemo } from "react"; + +import { + AppParameters, + AppParametersContext, + AppParametersInput, +} from "@lichtblick/suite-base/context/AppParametersContext"; + +type Props = PropsWithChildren<{ + appParameters?: AppParametersInput; +}>; + +/** + * AppParametersProvider: + * + * This component provides a context for application parameters within the Lichtblick ecosystem. + * + * Type Safety: + * The `appParameters` input is cast to the `AppParameters` type, ensuring that keys align with + * the expected enumeration. This guarantees proper autocomplete and type-checking for developers. + */ +export default function AppParametersProvider({ + children, + appParameters = {}, +}: Props): React.JSX.Element { + const parameters: AppParameters = useMemo(() => appParameters, [appParameters]); + return ( + {children} + ); +} diff --git a/packages/suite-base/src/providers/CurrentLayoutProvider/index.test.tsx b/packages/suite-base/src/providers/CurrentLayoutProvider/index.test.tsx index f2b139e51d..290785d4e0 100644 --- a/packages/suite-base/src/providers/CurrentLayoutProvider/index.test.tsx +++ b/packages/suite-base/src/providers/CurrentLayoutProvider/index.test.tsx @@ -2,12 +2,13 @@ // SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) // SPDX-License-Identifier: MPL-2.0 + // This Source Code Form is subject to the terms of the Mozilla Public // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ import { act, renderHook } from "@testing-library/react"; -import { SnackbarProvider } from "notistack"; +import { SnackbarProvider, useSnackbar } from "notistack"; import { useEffect } from "react"; import { Condvar } from "@lichtblick/den/async"; @@ -24,9 +25,18 @@ import { UserProfileStorage, UserProfileStorageContext, } from "@lichtblick/suite-base/context/UserProfileStorageContext"; +import AppParametersProvider from "@lichtblick/suite-base/providers/AppParametersProvider"; import CurrentLayoutProvider from "@lichtblick/suite-base/providers/CurrentLayoutProvider"; import { MAX_SUPPORTED_LAYOUT_VERSION } from "@lichtblick/suite-base/providers/CurrentLayoutProvider/constants"; import { ILayoutManager } from "@lichtblick/suite-base/services/ILayoutManager"; +import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder"; + +jest.mock("notistack", () => ({ + ...jest.requireActual("notistack"), + useSnackbar: jest.fn().mockReturnValue({ + enqueueSnackbar: jest.fn(), + }), +})); const TEST_LAYOUT: LayoutData = { layout: "ExamplePanel!1", @@ -51,10 +61,10 @@ function makeMockLayoutManager() { isBusy: false, isOnline: false, error: undefined, - on: jest.fn(/*noop*/), - off: jest.fn(/*noop*/), - setError: jest.fn(/*noop*/), - setOnline: jest.fn(/*noop*/), + on: jest.fn(), + off: jest.fn(), + setError: jest.fn(), + setOnline: jest.fn(), getLayouts: jest.fn(), getLayout: jest.fn(), saveNewLayout: jest.fn().mockImplementation(mockThrow("saveNewLayout")), @@ -75,9 +85,11 @@ function makeMockUserProfile() { function renderTest({ mockLayoutManager, mockUserProfile, + mockAppParameters = {}, }: { mockLayoutManager: ILayoutManager; mockUserProfile: UserProfileStorage; + mockAppParameters?: Record; }) { const childMounted = new Condvar(); const childMountedWait = childMounted.wait(); @@ -102,16 +114,18 @@ function renderTest({ childMounted.notifyAll(); }, []); return ( - - - - - {children} - - - - - + + + + + + {children} + + + + + + ); }, }, @@ -120,8 +134,19 @@ function renderTest({ } describe("CurrentLayoutProvider", () => { + const mockLayoutManager = makeMockLayoutManager(); + const mockUserProfile = makeMockUserProfile(); + + beforeEach(() => { + // Default mocks + mockLayoutManager.getLayout.mockImplementation(async () => undefined); + mockLayoutManager.getLayouts.mockImplementation(() => []); + mockUserProfile.getUserProfile.mockResolvedValue({ currentLayoutId: undefined }); + }); + afterEach(() => { (console.warn as jest.Mock).mockClear(); + jest.clearAllMocks(); }); it("uses currentLayoutId from UserProfile to load from LayoutStorage", async () => { @@ -134,7 +159,6 @@ describe("CurrentLayoutProvider", () => { }; const condvar = new Condvar(); const layoutStorageGetCalledWait = condvar.wait(); - const mockLayoutManager = makeMockLayoutManager(); mockLayoutManager.getLayout.mockImplementation(async () => { condvar.notifyAll(); return { @@ -144,7 +168,6 @@ describe("CurrentLayoutProvider", () => { }; }); - const mockUserProfile = makeMockUserProfile(); mockUserProfile.getUserProfile.mockResolvedValue({ currentLayoutId: "example" }); const { all } = renderTest({ mockLayoutManager, mockUserProfile }); @@ -178,7 +201,6 @@ describe("CurrentLayoutProvider", () => { const condvar = new Condvar(); const layoutStorageGetCalledWait = condvar.wait(); - const mockLayoutManager = makeMockLayoutManager(); mockLayoutManager.getLayout.mockImplementation(async () => { condvar.notifyAll(); return { @@ -188,7 +210,6 @@ describe("CurrentLayoutProvider", () => { }; }); - const mockUserProfile = makeMockUserProfile(); mockUserProfile.getUserProfile.mockResolvedValue({ currentLayoutId: "example" }); const { all } = renderTest({ mockLayoutManager, mockUserProfile }); @@ -204,7 +225,6 @@ describe("CurrentLayoutProvider", () => { }); it("keeps identity of action functions when modifying layout", async () => { - const mockLayoutManager = makeMockLayoutManager(); mockLayoutManager.getLayout.mockImplementation(async () => { return { id: "TEST_ID", @@ -220,7 +240,6 @@ describe("CurrentLayoutProvider", () => { baseline: { data: TEST_LAYOUT, updatedAt: new Date(10).toISOString() }, }; }); - const mockUserProfile = makeMockUserProfile(); mockUserProfile.getUserProfile.mockResolvedValue({ currentLayoutId: "example" }); const { result } = renderTest({ @@ -242,8 +261,6 @@ describe("CurrentLayoutProvider", () => { }); it("selects the first layout in alphabetic order, when there is no selected layout", async () => { - const mockLayoutManager = makeMockLayoutManager(); - mockLayoutManager.getLayout.mockImplementation(async () => undefined); mockLayoutManager.getLayouts.mockImplementation(async () => { return [ { @@ -259,12 +276,43 @@ describe("CurrentLayoutProvider", () => { ]; }); - const mockUserProfile = makeMockUserProfile(); - mockUserProfile.getUserProfile.mockResolvedValue({ currentLayoutId: undefined }); + const { result, all } = renderTest({ + mockLayoutManager, + mockUserProfile, + }); + + await act(async () => { + await result.current.childMounted; + }); + + const selectedLayout = all.find((item) => item.layoutState.selectedLayout?.id)?.layoutState + .selectedLayout?.id; + + expect(selectedLayout).toBeDefined(); + expect(selectedLayout).toBe("layout2"); + }); + + it("should select a layout though app parameters", async () => { + const mockAppParameters = { defaultLayout: "LAYOUT 2" }; + mockLayoutManager.getLayouts.mockImplementation(async () => { + return [ + { + id: "layout1", + name: "LAYOUT 1", + data: { data: TEST_LAYOUT }, + }, + { + id: "layout2", + name: "LAYOUT 2", + data: { data: TEST_LAYOUT }, + }, + ]; + }); const { result, all } = renderTest({ mockLayoutManager, mockUserProfile, + mockAppParameters, }); await act(async () => { @@ -277,4 +325,25 @@ describe("CurrentLayoutProvider", () => { expect(selectedLayout).toBeDefined(); expect(selectedLayout).toBe("layout2"); }); + + it("should show a message to the user if the defaultLayout from app parameter is not found", async () => { + const mockAppParameters = { defaultLayout: BasicBuilder.string() }; + + const { result } = renderTest({ + mockLayoutManager, + mockUserProfile, + mockAppParameters, + }); + + await act(async () => { + await result.current.childMounted; + }); + + const { enqueueSnackbar } = useSnackbar(); + + expect(enqueueSnackbar).toHaveBeenCalledWith( + `The layout '${mockAppParameters.defaultLayout}' specified in the app parameters does not exist.`, + { variant: "warning" }, + ); + }); }); diff --git a/packages/suite-base/src/providers/CurrentLayoutProvider/index.tsx b/packages/suite-base/src/providers/CurrentLayoutProvider/index.tsx index 32f30b3a2b..25fbb2ccff 100644 --- a/packages/suite-base/src/providers/CurrentLayoutProvider/index.tsx +++ b/packages/suite-base/src/providers/CurrentLayoutProvider/index.tsx @@ -8,6 +8,7 @@ import * as _ from "lodash-es"; import { useSnackbar } from "notistack"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { getNodeAtPath } from "react-mosaic-component"; import { useAsync, useAsyncFn, useMountedState } from "react-use"; import shallowequal from "shallowequal"; @@ -17,6 +18,7 @@ import { useShallowMemo } from "@lichtblick/hooks"; import Logger from "@lichtblick/log"; import { VariableValue } from "@lichtblick/suite"; import { useAnalytics } from "@lichtblick/suite-base/context/AnalyticsContext"; +import { useAppParameters } from "@lichtblick/suite-base/context/AppParametersContext"; import CurrentLayoutContext, { ICurrentLayout, LayoutID, @@ -70,6 +72,10 @@ export default function CurrentLayoutProvider({ const analytics = useAnalytics(); const isMounted = useMountedState(); + const { t } = useTranslation("general"); + + const appParameters = useAppParameters(); + const [mosaicId] = useState(() => uuidv4()); const layoutStateListeners = useRef(new Set<(_: LayoutState) => void>()); @@ -274,12 +280,30 @@ export default function CurrentLayoutProvider({ return; } + // For some reason, this needs to go before the setSelectedLayoutId, probably some initialization + const { currentLayoutId } = await getUserProfile(); + // Try to load default layouts, before checking to add the fallback "Default". await loadDefaultLayouts(layoutManager, loaders); + const layouts = await layoutManager.getLayouts(); + + // Check if there's a layout specified by app parameter + const defaultLayoutFromParameters = layouts.find((l) => l.name === appParameters.defaultLayout); + if (defaultLayoutFromParameters) { + await setSelectedLayoutId(defaultLayoutFromParameters.id, { saveToProfile: true }); + return; + } + + // It there is a defaultLayout setted but didnt found a layout, show a error to the user + if (appParameters.defaultLayout) { + enqueueSnackbar(t("noDefaultLayoutParameter", { layoutName: appParameters.defaultLayout }), { + variant: "warning", + }); + } + // Retreive the selected layout id from the user's profile. If there's no layout specified - // or we can't load it then save and select a default layout. - const { currentLayoutId } = await getUserProfile(); + // or we can't load it then save and select a default layout const layout = currentLayoutId ? await layoutManager.getLayout(currentLayoutId) : undefined; if (layout) { @@ -287,7 +311,6 @@ export default function CurrentLayoutProvider({ return; } - const layouts = await layoutManager.getLayouts(); if (layouts.length > 0) { const sortedLayouts = [...layouts].sort((a, b) => a.name.localeCompare(b.name)); await setSelectedLayoutId(sortedLayouts[0]!.id); @@ -302,7 +325,7 @@ export default function CurrentLayoutProvider({ await setSelectedLayoutId(newLayout.id); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [getUserProfile, layoutManager, setSelectedLayoutId]); + }, [getUserProfile, layoutManager, setSelectedLayoutId, enqueueSnackbar]); const { updateSharedPanelState } = useUpdateSharedPanelState(layoutStateRef, setLayoutState); diff --git a/packages/suite-desktop/src/common/types.ts b/packages/suite-desktop/src/common/types.ts index 4cd620a9c5..ba7fbfaed6 100644 --- a/packages/suite-desktop/src/common/types.ts +++ b/packages/suite-desktop/src/common/types.ts @@ -75,6 +75,8 @@ type DesktopLayout = { from: string; }; +export type CLIFlags = Readonly>; + interface Desktop { /** https://www.electronjs.org/docs/tutorial/represented-file */ setRepresentedFilename(path: string | undefined): Promise; @@ -109,6 +111,9 @@ interface Desktop { // was not found (i.e. already uninstalled) uninstallExtension: (id: string) => Promise; + // Get CLI flags passed when the app was launched + getCLIFlags: () => Promise; + /** Handle a double-click on the custom title bar */ handleTitleBarDoubleClick(): void; diff --git a/packages/suite-desktop/src/main/index.ts b/packages/suite-desktop/src/main/index.ts index 93b9f65ef4..964dbbdcd2 100644 --- a/packages/suite-desktop/src/main/index.ts +++ b/packages/suite-desktop/src/main/index.ts @@ -18,6 +18,7 @@ import StudioWindow from "./StudioWindow"; import getDevModeIcon from "./getDevModeIcon"; import injectFilesToOpen from "./injectFilesToOpen"; import installChromeExtensions from "./installChromeExtensions"; +import { parseCLIFlags } from "./parseCLIFlags"; import { registerRosPackageProtocolHandlers, registerRosPackageProtocolSchemes, @@ -120,7 +121,10 @@ export async function main(): Promise { app.emit("open-url", { preventDefault() {} }, link); } - const files = argv.slice(1).filter((arg) => isFileToOpen(arg)); + const files = argv + .slice(1) + .filter((arg) => !arg.startsWith("--")) // Filter out flags + .filter((arg) => isFileToOpen(arg)); for (const file of files) { app.emit("open-file", { preventDefault() {} }, file); } @@ -143,6 +147,7 @@ export async function main(): Promise { // or command line arguments. const filesToOpen: string[] = process.argv .slice(1) + .filter((arg) => !arg.startsWith("--")) // Filter out flags .map((filePath) => path.resolve(filePath)) // Convert to absolute path, linux has some problems to resolve relative paths .filter(isFileToOpen); @@ -209,6 +214,10 @@ export async function main(): Promise { ipcMain.handle("getUserDataPath", () => app.getPath("userData")); ipcMain.handle("getHomePath", () => app.getPath("home")); + // Get the command line flags passed to the app when it was launched + const parsedCLIFlags = parseCLIFlags(process.argv); + ipcMain.handle("getCLIFlags", () => parsedCLIFlags); + // Must be called before app.ready event registerRosPackageProtocolSchemes(); diff --git a/packages/suite-desktop/src/main/parseCLIFlags.test.ts b/packages/suite-desktop/src/main/parseCLIFlags.test.ts new file mode 100644 index 0000000000..e52511d936 --- /dev/null +++ b/packages/suite-desktop/src/main/parseCLIFlags.test.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { parseCLIFlags } from "./parseCLIFlags"; + +describe("parseCLIFlags", () => { + it("should parse single flag correctly", () => { + const argv = ["--flag=value"]; + const result = parseCLIFlags(argv); + expect(result).toEqual({ flag: "value" }); + }); + + it("should parse multiple flags correctly", () => { + const argv = ["--flag1=value1", "--flag2=value2"]; + const result = parseCLIFlags(argv); + expect(result).toEqual({ flag1: "value1", flag2: "value2" }); + }); + + it("should overwrite duplicated flags", () => { + const argv = ["--flag=value1", "--flag=value2"]; + const result = parseCLIFlags(argv); + expect(result).toEqual({ flag: "value2" }); + }); + + it("should ignore arguments that do not start with '--'", () => { + const argv = ["--flag1=value1", "someArg", "someOther=Arg", "--flag2=value2"]; + const result = parseCLIFlags(argv); + expect(result).toEqual({ flag1: "value1", flag2: "value2" }); + }); + + it("should return an empty object if no valid flags are provided", () => { + const argv = ["someArg", "--flag", "--flag1=", "--=value1"]; + const result = parseCLIFlags(argv); + expect(result).toEqual({}); + }); + + it("should return a readonly object", () => { + const argv = ["--flag1=value1"]; + const result = parseCLIFlags(argv); + expect(() => { + (result as any).flag1 = "newValue"; + }).toThrow(); + }); +}); diff --git a/packages/suite-desktop/src/main/parseCLIFlags.ts b/packages/suite-desktop/src/main/parseCLIFlags.ts new file mode 100644 index 0000000000..d0c034393c --- /dev/null +++ b/packages/suite-desktop/src/main/parseCLIFlags.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { CLIFlags } from "../common/types"; + +/** + * Parses CLI flags from the process arguments and returns a readonly record. + * Flags are expected to start with '--'. + * + * @param argv - The process arguments to parse (e.g., process.argv). + * @returns A readonly record containing the flag names as keys and their corresponding values as strings. + */ +export function parseCLIFlags(argv: string[]): CLIFlags { + const flags = argv.reduce>((prev, curr) => { + if (curr.startsWith("--")) { + const [key, value] = curr.slice(2).split("="); + if (key && value) { + prev[key] = value; + } + } + return prev; + }, {}); + + return Object.freeze(flags); +} diff --git a/packages/suite-desktop/src/preload/index.ts b/packages/suite-desktop/src/preload/index.ts index c2e8541eea..06b7d4ebe6 100644 --- a/packages/suite-desktop/src/preload/index.ts +++ b/packages/suite-desktop/src/preload/index.ts @@ -117,6 +117,9 @@ export function main(): void { async updateLanguage() { await ipcRenderer.invoke("updateLanguage"); }, + async getCLIFlags(): Promise>> { + return await (ipcRenderer.invoke("getCLIFlags") as Promise>>); + }, getDeepLinks(): string[] { return deepLinks; }, diff --git a/packages/suite-desktop/src/renderer/Root.tsx b/packages/suite-desktop/src/renderer/Root.tsx index 8747a61d72..636435ce50 100644 --- a/packages/suite-desktop/src/renderer/Root.tsx +++ b/packages/suite-desktop/src/renderer/Root.tsx @@ -30,7 +30,7 @@ import { DesktopExtensionLoader } from "./services/DesktopExtensionLoader"; import { DesktopLayoutLoader } from "./services/DesktopLayoutLoader"; import { NativeAppMenu } from "./services/NativeAppMenu"; import { NativeWindow } from "./services/NativeWindow"; -import { Desktop, NativeMenuBridge, Storage } from "../common/types"; +import { CLIFlags, Desktop, NativeMenuBridge, Storage } from "../common/types"; const desktopBridge = (global as unknown as { desktopBridge: Desktop }).desktopBridge; const storageBridge = (global as unknown as { storageBridge?: Storage }).storageBridge; @@ -38,6 +38,7 @@ const menuBridge = (global as { menuBridge?: NativeMenuBridge }).menuBridge; const ctxbridge = (global as { ctxbridge?: OsContext }).ctxbridge; export default function Root(props: { + appParameters: CLIFlags; appConfiguration: IAppConfiguration; extraProviders: React.JSX.Element[] | undefined; dataSources: IDataSourceFactory[] | undefined; @@ -148,28 +149,27 @@ export default function Root(props: { }, []); return ( - <> - { - nativeWindow.handleTitleBarDoubleClick(); - }} - showCustomWindowControls={ctxbridge?.platform === "linux"} - isMaximized={isMaximized} - onMinimizeWindow={onMinimizeWindow} - onMaximizeWindow={onMaximizeWindow} - onUnmaximizeWindow={onUnmaximizeWindow} - onCloseWindow={onCloseWindow} - extraProviders={props.extraProviders} - /> - + { + nativeWindow.handleTitleBarDoubleClick(); + }} + showCustomWindowControls={ctxbridge?.platform === "linux"} + isMaximized={isMaximized} + onMinimizeWindow={onMinimizeWindow} + onMaximizeWindow={onMaximizeWindow} + onUnmaximizeWindow={onUnmaximizeWindow} + onCloseWindow={onCloseWindow} + extraProviders={props.extraProviders} + /> ); } diff --git a/packages/suite-desktop/src/renderer/index.tsx b/packages/suite-desktop/src/renderer/index.tsx index 9ca2ffe660..90844c7e60 100644 --- a/packages/suite-desktop/src/renderer/index.tsx +++ b/packages/suite-desktop/src/renderer/index.tsx @@ -23,6 +23,9 @@ import { } from "@lichtblick/suite-base"; import Root from "./Root"; +import { Desktop } from "../common/types"; + +const desktopBridge = (global as unknown as { desktopBridge: Desktop }).desktopBridge; const log = Logger.getLogger(__filename); @@ -61,10 +64,13 @@ export async function main(params: MainParams): Promise { await waitForFonts(); await initI18n(); + const cliFlags = await desktopBridge.getCLIFlags(); + const root = createRoot(rootEl); root.render(