diff --git a/.gitignore b/.gitignore index 4a08c693..c24c3f45 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ a.txt # Node / frontend dependencies node_modules/ +preferences.json diff --git a/Makefile b/Makefile index 899b6619..6acae08f 100644 --- a/Makefile +++ b/Makefile @@ -42,12 +42,20 @@ build-gui: go build -o ${GUI_BINARY} ./gui # TypeScript browser GUI -# dev-gui-browser : build Go binary then start the Vite dev server + Go HTTP server +# dev-gui-browser : build Go binary + start sbom-utility serve + Vite dev server +# typecheck-gui-ts : run TypeScript type-check (no emit) +# lint-gui-ts : run ESLint over gui-ts/src GUI_TS_DIR?=gui-ts dev-gui-browser: build cd ${GUI_TS_DIR} && npm run dev:browser:full +typecheck-gui-ts: + cd ${GUI_TS_DIR} && npm run typecheck + +lint-gui-ts: + cd ${GUI_TS_DIR} && npm run lint + # General supported environments: https://go.dev/doc/install/source#environment # See latest supported combinations using: # $ go install golang.org/x/tools/cmd/goimports@latest @@ -131,4 +139,4 @@ clean: @if [ -f ${GUI_BINARY} ] ; then rm ${GUI_BINARY} ; fi @if [ -d ${RELEASE_DIR} ] ; then rm -f ${RELEASE_DIR}/${BINARY}* ; rm -f ${RELEASE_DIR}/*.json ; rmdir ${RELEASE_DIR} ; fi -.PHONY: config clean build build-gui dev-gui-browser release test_clean test test_cmd unit_tests integration_tests format lint install +.PHONY: config clean build build-gui dev-gui-browser release test_clean test test_cmd unit_tests integration_tests format lint install build-gui-ts dist-gui-ts dev-gui-ts dev-gui-ts-full diff --git a/README.md b/README.md index f25a1d6e..b3d393db 100644 --- a/README.md +++ b/README.md @@ -30,14 +30,13 @@ same validate, license, component, resource, and vulnerability commands in a point-and-click interface — no terminal required. They are independent of each other and of the CLI; you can use one, both, or neither. -| | [Fyne GUI](gui/) | [TypeScript / Electron GUI](gui-ts/) | +| | [Fyne GUI](gui/) | [TypeScript Browser GUI](gui-ts/) | |---|---|---| | **Location** | [`gui/`](gui/) | [`gui-ts/`](gui-ts/) | -| **Language / framework** | Go · [Fyne](https://fyne.io) (BSD-3) | TypeScript · [Electron](https://www.electronjs.org) (MIT · OpenJS Foundation) · React | +| **Language / framework** | Go · [Fyne](https://fyne.io) (BSD-3) | TypeScript · React 18 · Vite 5 | | **Build requirement** | Go toolchain + CGo (C compiler) | Node.js ≥ 20 · `npm ci` (no C compiler) | -| **Distribution** | `fyne package` → `.app` / `.exe` / `.tar.xz` | `npm run dist` → `.dmg` / NSIS `.exe` / AppImage | +| **Runtime** | Native binary | Browser tab backed by `sbom-utility serve` | | **Theming** | Go `fyne.Theme` structs | CSS custom properties (`tokens.css`) — change any colour, font, or spacing without touching TypeScript | -| **Security** | Native binary, no network stack | Electron hardened defaults: `contextIsolation`, `sandbox`, strict CSP, IPC allowlist validation | | **Full documentation** | [gui/README.md](gui/README.md) | [gui-ts/README.md](gui-ts/README.md) | #### Fyne GUI (`gui/`) @@ -56,33 +55,26 @@ make build-gui > See [gui/README.md](gui/README.md) for full build instructions, theming > reference, and distribution (fyne package / fyne-cross). -#### TypeScript / Electron GUI (`gui-ts/`) +#### TypeScript Browser GUI (`gui-ts/`) -A desktop application built with Electron (MIT · OpenJS Foundation), React 18, -Vite 5, and TypeScript 5. Visually polished with a dark sidebar, VS Code-style -BOM source viewer, and a fully documented CSS design-token system that lets -you retheme every colour, font, and spacing without touching any TypeScript. +A browser-based GUI backed by the local `sbom-utility serve` HTTP API. +Built with React 18, Vite 5, and TypeScript 5. No Electron, no C compiler, +no install step beyond `npm ci`. ```bash -# Install dependencies (verifies Node ≥ 20 and the CLI binary first) -./gui-ts/install.sh # macOS / Linux -.\gui-ts\install.ps1 # Windows PowerShell +# Step 1 — build the Go CLI binary (required by the server) +make build -# Launch in development mode (hot-reload) -cd gui-ts && npm run dev +# Step 2 — install npm dependencies (first time only) +cd gui-ts && npm ci -# Build a distributable installer -cd gui-ts && npm run dist:mac # → .dmg -cd gui-ts && npm run dist:win # → NSIS .exe -cd gui-ts && npm run dist:linux # → AppImage + .deb -# — or via Make (from repo root) — -make build-gui-ts # unpackaged build -make dist-gui-ts # full installer +# Step 3 — start the dev server (builds binary, starts serve + Vite) +make dev-gui-browser +# Then open http://localhost:5173 in your browser ``` > See [gui-ts/README.md](gui-ts/README.md) for the full feature inventory, -> security hardening checklist, style customisation guide with worked examples, -> and architecture documentation. +> style customisation guide with worked examples, and architecture documentation. --- @@ -219,6 +211,38 @@ which returns `0` (zero) or "no error": --- +### Configuration and Preferences (`preferences.json`) + +`sbom-utility` supports an optional profile file named `preferences.json` located in the current working directory (`./preferences.json`). This file is shared between the command-line utility and the browser GUI (`gui-ts`). + +#### Configuration Precedence Order + +When resolving configuration settings (such as custom schemas and license policies), `sbom-utility` applies settings using the following precedence: + +1. **Explicit CLI Flags** (e.g., `--config-license `, `--config-schema `) — *highest precedence, always overrides file/default settings*. +2. **Configuration Profile** (`./preferences.json`) — applies project-level or directory-level defaults. +3. **Built-in Application Defaults** (embedded `config.json`, `license.json`) — *used when neither CLI flags nor profile settings are specified*. + +#### Example `preferences.json` + +```json +{ + "ui": { + "defaultBomDirectory": "/path/to/boms", + "editorFontFamily": "ui-monospace, \"Cascadia Code\", monospace", + "editorFontSize": 13, + "autoValidateOnLoad": true + }, + "cli": { + "configSchema": "custom-schemas.json", + "configLicense": "custom-license-policy.json", + "outputFormat": "json" + } +} +``` + +--- + ### Persistent flags This section describes some of the important command line flags that apply to most of the utility's commands. diff --git a/cmd/diff.go b/cmd/diff.go index a6cae875..6bfe1163 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -86,14 +86,14 @@ func preRunTestForFiles(args []string) error { baseFilename := utils.GlobalFlags.PersistentFlags.InputFile if baseFilename == "" { return getLogger().Errorf("Missing required argument(s): %s", FLAG_FILENAME_INPUT) - } else if _, err := os.Stat(baseFilename); err != nil { // lgtm[go/path-injection] + } else if _, err := os.Stat(baseFilename); err != nil { // lgtm[go/path-injection] -- CLI flag, not network input return getLogger().Errorf("File not found: '%s'", baseFilename) } revisedFilename := utils.GlobalFlags.DiffFlags.RevisedFile if revisedFilename == "" { return getLogger().Errorf("Missing required argument(s): %s", FLAG_DIFF_FILENAME_REVISION) - } else if _, err := os.Stat(revisedFilename); err != nil { // lgtm[go/path-injection] + } else if _, err := os.Stat(revisedFilename); err != nil { // lgtm[go/path-injection] -- CLI flag, not network input return getLogger().Errorf("File not found: '%s'", revisedFilename) } diff --git a/cmd/diff_test.go b/cmd/diff_test.go index d867a019..8d988af3 100644 --- a/cmd/diff_test.go +++ b/cmd/diff_test.go @@ -89,8 +89,9 @@ func NewDiffTestInfo(inputFile string, revisedFilename string) *DiffTestInfo { var ti = new(DiffTestInfo) ti.RevisedFilename = revisedFilename var pCommon = &ti.CommonTestInfo - // Default format matches the CLI default: FORMAT_TEXT (line-prefixed +/-/space view). - pCommon.InitBasic(inputFile, FORMAT_TEXT, nil) + // Note: Diff default format is "unified" (standard ---/+++/@@ output via go-difflib). + // To test the legacy go-jsondiff text or JSON formats, set ti.OutputFormat explicitly. + pCommon.InitBasic(inputFile, FORMAT_UNIFIED, nil) return ti } diff --git a/cmd/preferences_test.go b/cmd/preferences_test.go new file mode 100644 index 00000000..79175a81 --- /dev/null +++ b/cmd/preferences_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/CycloneDX/sbom-utility/utils" +) + +func TestPreferencesPrecedence(t *testing.T) { + // Create a temporary preferences.json file in current working directory + tempPrefFile := filepath.Join(".", utils.DEFAULT_PREFERENCES_FILENAME) + defer os.Remove(tempPrefFile) + + prefData := utils.AppPreferences{ + CLI: utils.CLIPreferences{ + ConfigLicensePolicyFile: "test/license/license-policy-test-empty.json", + }, + } + encoded, err := json.Marshal(prefData) + if err != nil { + t.Fatalf("failed to marshal pref: %v", err) + } + if err := os.WriteFile(tempPrefFile, encoded, 0600); err != nil { + t.Fatalf("failed to write temp pref file: %v", err) + } + + // 1. Test profile setting applied when no CLI flag is set + utils.GlobalFlags.ConfigLicensePolicyFile = "" + initConfigurations() + + // Verify policy loaded from preferences.json is used + if LicensePolicyConfig == nil { + t.Fatalf("expected LicensePolicyConfig to be initialized") + } + + // 2. Test explicit CLI flag overrides preferences.json + utils.GlobalFlags.ConfigLicensePolicyFile = "test/license/license-policy-test.json" + initConfigurations() + + // Clean up global flag and restore defaults + utils.GlobalFlags.ConfigLicensePolicyFile = "" + _ = os.Remove(tempPrefFile) + initConfigurations() +} diff --git a/cmd/root.go b/cmd/root.go index 6abe64c2..4962e469 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -245,16 +245,34 @@ func initConfigurations() { // we leave the code below "in place" as we may still want to validate any // input file as JSON SBOM document that matches a known format/version (TODO in the future) + // Load preferences from working directory (if present) + // Precedence order: + // 1. Explicit CLI flags (utils.GlobalFlags) + // 2. Profile settings in ./preferences.json + // 3. Built-in defaults (DEFAULT_SCHEMA_CONFIG, DEFAULT_LICENSE_POLICY_CONFIG) + prefResult := utils.LoadPreferencesFromWorkingDir() + if prefResult.Exists { + getLogger().Debugf("Loaded configuration profile from: '%s'", prefResult.Path) + } + // Load application configuration file (i.e., primarily SBOM supported Formats/Schemas) var schemaConfigFile = utils.GlobalFlags.ConfigSchemaFile + if schemaConfigFile == "" && prefResult.Preferences.CLI.ConfigSchemaFile != "" { + schemaConfigFile = prefResult.Preferences.CLI.ConfigSchemaFile + getLogger().Debugf("Using schema config from preferences.json: '%s'", schemaConfigFile) + } err := SupportedFormatConfig.LoadSchemaConfigFile(schemaConfigFile, DEFAULT_SCHEMA_CONFIG) if err != nil { getLogger().Error(err.Error()) os.Exit(ERROR_APPLICATION) } - // License Policy Configuration (customizable via command line, with default config.) + // License Policy Configuration (customizable via command line / preferences.json, with default config.) var licensePolicyFile = utils.GlobalFlags.ConfigLicensePolicyFile + if licensePolicyFile == "" && prefResult.Preferences.CLI.ConfigLicensePolicyFile != "" { + licensePolicyFile = prefResult.Preferences.CLI.ConfigLicensePolicyFile + getLogger().Debugf("Using license policy config from preferences.json: '%s'", licensePolicyFile) + } LicensePolicyConfig = new(schema.LicensePolicyConfig) err = LicensePolicyConfig.LoadHashPolicyConfigurationFile(licensePolicyFile, DEFAULT_LICENSE_POLICY_CONFIG) if err != nil { diff --git a/cmd/serve.go b/cmd/serve.go index 3629ad1e..87d81756 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -49,6 +49,7 @@ const ( serveVulnPath = "/vulnerability/list" serveDiffPath = "/diff" servePatchPath = "/patch" + servePreferencesPath = "/preferences" ) type serveRunResult struct { @@ -129,6 +130,7 @@ func Serve(port int) error { mux.HandleFunc(serveAPIPrefix+serveVulnPath, handleServeVulnerabilityList) mux.HandleFunc(serveAPIPrefix+serveDiffPath, handleServeDiff) mux.HandleFunc(serveAPIPrefix+servePatchPath, handleServePatch) + mux.HandleFunc(serveAPIPrefix+servePreferencesPath, handleServePreferences) addr := "127.0.0.1:" + strconvItoa(port) getLogger().Infof("Starting browser GUI API server on http://%s", addr) @@ -543,6 +545,59 @@ func runServePatch(request servePatchRequest) (serveRunResult, error) { return result, nil } +func handleServePreferences(writer http.ResponseWriter, request *http.Request) { + switch request.Method { + case http.MethodGet: + result := utils.LoadPreferencesFromWorkingDir() + writeServeJSON(writer, http.StatusOK, result) + + case http.MethodPost: + // Preserve existing file's CLI settings if updating from GUI + current := utils.LoadPreferencesFromWorkingDir() + var incoming utils.AppPreferences + if err := decodeServeJSON(request, &incoming); err != nil { + writeServeError(writer, http.StatusBadRequest, err) + return + } + + // If CLI block was omitted, preserve whatever was on disk + if incoming.CLI.ConfigSchemaFile == "" && incoming.ConfigSchema == "" && current.Preferences.CLI.ConfigSchemaFile != "" { + incoming.CLI.ConfigSchemaFile = current.Preferences.CLI.ConfigSchemaFile + } + if incoming.CLI.ConfigLicensePolicyFile == "" && incoming.ConfigLicense == "" && current.Preferences.CLI.ConfigLicensePolicyFile != "" { + incoming.CLI.ConfigLicensePolicyFile = current.Preferences.CLI.ConfigLicensePolicyFile + } + + incoming.Normalize() + + data, err := json.MarshalIndent(incoming, "", " ") + if err != nil { + writeServeError(writer, http.StatusInternalServerError, err) + return + } + + prefPath := filepath.Join(".", utils.DEFAULT_PREFERENCES_FILENAME) + if err := os.WriteFile(prefPath, data, 0600); err != nil { + writeServeError(writer, http.StatusInternalServerError, err) + return + } + + absPath, err := filepath.Abs(prefPath) + if err != nil { + absPath = prefPath + } + + writeServeJSON(writer, http.StatusOK, utils.PreferencesResult{ + Path: absPath, + Exists: true, + Preferences: incoming, + }) + + default: + writeServeMethodNotAllowed(writer) + } +} + func decodeServeJSON(request *http.Request, value interface{}) error { defer request.Body.Close() return json.NewDecoder(request.Body).Decode(value) diff --git a/gui-ts/src/preload/index.ts b/gui-ts/src/preload/index.ts index abe6b92e..fd6e615b 100644 --- a/gui-ts/src/preload/index.ts +++ b/gui-ts/src/preload/index.ts @@ -55,6 +55,34 @@ export interface PatchParams { patchPath: string } +export interface UIPreferences { + defaultBomDirectory?: string + editorFontFamily: string + editorFontSize: number + autoValidateOnLoad: boolean +} + +export interface CLIPreferences { + configSchema?: string + configLicense?: string + outputFormat?: string +} + +export interface UserPreferences { + ui?: UIPreferences + cli?: CLIPreferences + defaultBomDirectory?: string + editorFontFamily?: string + editorFontSize?: number + autoValidateOnLoad?: boolean +} + +export interface PreferencesResult { + path: string + exists: boolean + preferences: UserPreferences +} + export interface OpenFileResult { /** Operational path used for all backend calls (real FS path in Electron; * server temp path in browser mode). */ @@ -66,7 +94,8 @@ export interface OpenFileResult { export interface SbomBridge { // File system - openFile(): Promise + openFile(defaultDirectory?: string): Promise + pickDirectory?(): Promise readFile(filePath: string): Promise saveFileDialog(defaultPath: string): Promise writeFile(filePath: string, content: string): Promise @@ -80,6 +109,9 @@ export interface SbomBridge { listVulnerabilities(params: ListParams): Promise diffBoms(params: DiffParams): Promise applyPatch(params: PatchParams): Promise + // Preferences + getPreferences(): Promise + savePreferences(prefs: UserPreferences): Promise // App getVersion(): Promise isDarkMode(): Promise @@ -104,6 +136,8 @@ const bridge: SbomBridge = { listVulnerabilities: (params) => ipcRenderer.invoke('bom:listVulnerabilities', params), diffBoms: (params) => ipcRenderer.invoke('bom:diff', params), applyPatch: (params) => ipcRenderer.invoke('bom:patch', params), + getPreferences: () => ipcRenderer.invoke('app:getPreferences'), + savePreferences: (prefs) => ipcRenderer.invoke('app:savePreferences', prefs), getVersion: () => ipcRenderer.invoke('app:version'), isDarkMode: () => ipcRenderer.invoke('app:isDarkMode'), } diff --git a/gui-ts/src/renderer/components/Shell.tsx b/gui-ts/src/renderer/components/Shell.tsx index f8cd3d19..4fe999e1 100644 --- a/gui-ts/src/renderer/components/Shell.tsx +++ b/gui-ts/src/renderer/components/Shell.tsx @@ -24,7 +24,7 @@ const SCREENS: Screen[] = [ export default function Shell() { const { screen, setScreen, bomFile, setBomFile, setBomInfo, isDirty, setDirty, - setValidateBadge, + setValidateBadge, defaultBomDirectory, } = useAppContext() // When the user loads a BOM, auto-switch to View/Validate @@ -37,7 +37,7 @@ export default function Shell() { const pendingLoad = useRef<(() => Promise) | null>(null) async function doLoad() { - const result = await window.sbomBridge.openFile() + const result = await window.sbomBridge.openFile(defaultBomDirectory) if (!result) return setDirty(false) // Reset badge so the status bar shows "Running…" while ValidateScreen runs diff --git a/gui-ts/src/renderer/components/StatusBar.module.css b/gui-ts/src/renderer/components/StatusBar.module.css index 2066c085..9050473b 100644 --- a/gui-ts/src/renderer/components/StatusBar.module.css +++ b/gui-ts/src/renderer/components/StatusBar.module.css @@ -8,8 +8,8 @@ font-size: var(--statusbar-font); flex-shrink: 0; /* Side padding keeps content away from the rounded window corners */ - padding: 0 var(--space-4); - gap: var(--space-4); + padding: 0 var(--space-3); + gap: var(--space-3); user-select: text; } @@ -84,3 +84,43 @@ .validBadge_valid .validDot { background: #30D158; } .validBadge_invalid .validDot { background: #FF453A; } + +/* Interactive status bar button / pill */ +.itemButton { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + background: transparent; + border: none; + color: var(--statusbar-text); + font-family: inherit; + font-size: var(--statusbar-font); + cursor: pointer; + transition: background var(--duration-fast) var(--ease-out); + white-space: nowrap; + flex-shrink: 0; +} + +.itemButton:hover { + background: rgba(255, 255, 255, 0.15); +} + +.itemButton:active { + background: rgba(255, 255, 255, 0.25); +} + +.prefName { + font-family: var(--editor-font-family); + font-size: 11px; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.prefStatus { + font-size: 10px; + opacity: 0.7; +} diff --git a/gui-ts/src/renderer/components/StatusBar.tsx b/gui-ts/src/renderer/components/StatusBar.tsx index 79c45a6a..f441f2a1 100644 --- a/gui-ts/src/renderer/components/StatusBar.tsx +++ b/gui-ts/src/renderer/components/StatusBar.tsx @@ -3,9 +3,21 @@ import { useAppContext } from '../context/AppContext' import styles from './StatusBar.module.css' export default function StatusBar() { - const { bomDisplayName, bomInfo, validateBadge, validateBadgeText } = useAppContext() + const { + bomDisplayName, + bomInfo, + validateBadge, + validateBadgeText, + preferencesPath, + preferencesExists, + setScreen, + } = useAppContext() const { format, specVersion, filePath } = bomInfo + // Short label for the preferences file name + const prefFilename = preferencesPath.split(/[/\\]/).pop() || 'preferences.json' + const prefTooltip = `Preferences: ${preferencesPath}\nStatus: ${preferencesExists ? 'Loaded from file' : 'Default (file not created)'}\nClick to configure preferences` + return (