Skip to content

Commit 6c17d60

Browse files
joaquimrochaillume
andcommitted
app: frontend: Add manifest-declared legal documents
Expose packaged legal documents through a validated desktop IPC capability. This lets downstream builds declare legal content without forking the UI. Co-authored-by: René Dudfield <renedudfield@microsoft.com>
1 parent 9daf238 commit 6c17d60

25 files changed

Lines changed: 451 additions & 20 deletions

app/app-build-manifest.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,17 @@
1313
"name": "prometheus",
1414
"archive": "https://github.com/headlamp-k8s/plugins/releases/download/prometheus-0.9.1/prometheus-0.9.1.tar.gz"
1515
}
16+
],
17+
"legalDocuments": [
18+
{
19+
"id": "license",
20+
"title": "License",
21+
"file": "LICENSE"
22+
},
23+
{
24+
"id": "notices",
25+
"title": "Third-party notices",
26+
"file": "NOTICE"
27+
}
1628
]
1729
}

app/electron/legal-documents.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import fs from 'node:fs';
18+
import path from 'node:path';
19+
20+
/** A legal document declared in the application build manifest. */
21+
export interface LegalDocument {
22+
/** Stable identifier used when requesting the document over IPC. */
23+
id: string;
24+
/** Human-readable title shown in the application. */
25+
title: string;
26+
/** Resource filename relative to the packaged application resources directory. */
27+
file: string;
28+
}
29+
30+
/** Public metadata for a legal document exposed to the renderer. */
31+
export type LegalDocumentSummary = Pick<LegalDocument, 'id' | 'title'>;
32+
33+
/** Result returned when the renderer requests legal document content. */
34+
export interface LegalDocumentResult {
35+
/** Whether the requested document was read successfully. */
36+
success: boolean;
37+
/** Document text when the request succeeds. */
38+
content?: string;
39+
/** User-facing failure reason when the request fails. */
40+
error?: string;
41+
}
42+
43+
const VALID_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
44+
45+
/**
46+
* Validates legal documents from parsed application build metadata.
47+
*
48+
* @param packageConfig - Parsed application build manifest content.
49+
* @returns Valid manifest-declared legal documents.
50+
*/
51+
export function getLegalDocuments(packageConfig: unknown): LegalDocument[] {
52+
if (typeof packageConfig !== 'object' || packageConfig === null) {
53+
return [];
54+
}
55+
56+
const configuredDocuments = (packageConfig as { legalDocuments?: unknown }).legalDocuments;
57+
58+
if (!Array.isArray(configuredDocuments)) {
59+
return [];
60+
}
61+
62+
return configuredDocuments.filter((document): document is LegalDocument => {
63+
if (typeof document !== 'object' || document === null) {
64+
return false;
65+
}
66+
const { id, title, file } = document as Partial<LegalDocument>;
67+
return (
68+
typeof id === 'string' &&
69+
VALID_ID.test(id) &&
70+
typeof title === 'string' &&
71+
title.length > 0 &&
72+
title.length <= 128 &&
73+
typeof file === 'string' &&
74+
file.length > 0 &&
75+
file.length <= 255 &&
76+
!file.includes('/') &&
77+
!file.includes('\\') &&
78+
file !== '.' &&
79+
file !== '..'
80+
);
81+
});
82+
}
83+
84+
/**
85+
* Loads legal document declarations from an application build manifest.
86+
*
87+
* @param packagePath - Path to the application build manifest JSON file.
88+
* @returns Valid legal document declarations, or an empty array when loading fails.
89+
*/
90+
export function loadLegalDocuments(packagePath: string): LegalDocument[] {
91+
try {
92+
return getLegalDocuments(JSON.parse(fs.readFileSync(packagePath, 'utf8')));
93+
} catch {
94+
return [];
95+
}
96+
}
97+
98+
/**
99+
* Reads one configured legal document from packaged application resources.
100+
*
101+
* @param resourcesPath - Root directory containing packaged resources.
102+
* @param documents - Valid legal document declarations.
103+
* @param id - Untrusted document identifier received over IPC.
104+
* @returns Document content on success, otherwise a stable failure result.
105+
*/
106+
export function readLegalDocument(
107+
resourcesPath: string,
108+
documents: LegalDocument[],
109+
id: unknown
110+
): LegalDocumentResult {
111+
const document = typeof id === 'string' ? documents.find(item => item.id === id) : undefined;
112+
if (!document) {
113+
return { success: false, error: 'Unknown legal document' };
114+
}
115+
116+
try {
117+
const filePath = path.resolve(resourcesPath, document.file);
118+
const relativePath = path.relative(path.resolve(resourcesPath), filePath);
119+
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
120+
return { success: false, error: 'Invalid legal document path' };
121+
}
122+
return { success: true, content: fs.readFileSync(filePath, 'utf8') };
123+
} catch {
124+
return { success: false, error: 'Unable to read legal document' };
125+
}
126+
}

app/electron/main.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import yargs from 'yargs';
4242
import { hideBin } from 'yargs/helpers';
4343
import { setupCustomCAs, setupSystemCAs } from './certificates';
4444
import i18n from './i18next.config';
45+
import { loadLegalDocuments, readLegalDocument } from './legal-documents';
4546
import MCPClient from './mcp/MCPClient';
4647
import { filterUserOwnedPids } from './ownedProcesses';
4748
import {
@@ -192,6 +193,11 @@ const MAX_PORT_ATTEMPTS = Math.abs(Number(process.env.HEADLAMP_MAX_PORT_ATTEMPTS
192193

193194
const useExternalServer = process.env.EXTERNAL_SERVER || false;
194195
const shouldCheckForUpdates = process.env.HEADLAMP_CHECK_FOR_UPDATES !== 'false';
196+
const appBuildManifestPath = path.join(
197+
isDev ? path.resolve('./') : process.resourcesPath,
198+
'app-build-manifest.json'
199+
);
200+
const legalDocuments = loadLegalDocuments(appBuildManifestPath);
195201

196202
// make it global so that it doesn't get garbage collected
197203
let mainWindow: BrowserWindow | null;
@@ -811,11 +817,9 @@ async function startServer(flags: string[] = []): Promise<ChildProcessWithoutNul
811817
serverArgs = serverArgs.concat(['--kubeconfig', args.kubeconfig]);
812818
}
813819

814-
const manifestDir = isDev ? path.resolve('./') : process.resourcesPath;
815-
const manifestFile = path.join(manifestDir, 'app-build-manifest.json');
816820
let buildManifest: Record<string, any> = {};
817821
try {
818-
const manifestContent = await fsPromises.readFile(manifestFile, 'utf8');
822+
const manifestContent = await fsPromises.readFile(appBuildManifestPath, 'utf8');
819823
buildManifest = JSON.parse(manifestContent);
820824
} catch (err) {
821825
// If the manifest doesn't exist or can't be read, fall back to empty object
@@ -1740,6 +1744,13 @@ function startElectron() {
17401744
});
17411745
});
17421746

1747+
ipcMain.handle('get-legal-documents', () =>
1748+
legalDocuments.map(({ id, title }) => ({ id, title }))
1749+
);
1750+
ipcMain.handle('get-legal-document', (_event, id: unknown) =>
1751+
readLegalDocument(process.resourcesPath, legalDocuments, id)
1752+
);
1753+
17431754
ipcMain.on('pluginsLoaded', () => {
17441755
loadFullMenu = true;
17451756
console.info('Plugins are loaded. Loading full menu.');

app/electron/preload.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import { contextBridge, ipcRenderer } from 'electron';
18+
import type { LegalDocumentResult, LegalDocumentSummary } from './legal-documents';
1819

1920
// Keeps the mapping between a caller-provided listener and the wrapped one we
2021
// actually register with ipcRenderer, so removeListener can still unsubscribe
@@ -117,4 +118,16 @@ contextBridge.exposeInMainWorld('desktopApi', {
117118
notifyClusterChange: (cluster: string | null) => {
118119
ipcRenderer.send('cluster-changed', cluster);
119120
},
121+
122+
/** @returns Legal documents declared by the packaged application manifest. */
123+
getLegalDocuments: (): Promise<LegalDocumentSummary[]> =>
124+
ipcRenderer.invoke('get-legal-documents'),
125+
/**
126+
* Reads a packaged legal document.
127+
*
128+
* @param id - Stable identifier returned by `getLegalDocuments`.
129+
* @returns Document content or a stable failure result.
130+
*/
131+
getLegalDocument: (id: string): Promise<LegalDocumentResult> =>
132+
ipcRenderer.invoke('get-legal-document', id),
120133
});

app/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,14 @@
162162
{
163163
"from": "./app-build-manifest.json",
164164
"to": "app-build-manifest.json"
165+
},
166+
{
167+
"from": "../LICENSE",
168+
"to": "LICENSE"
169+
},
170+
{
171+
"from": "../NOTICE",
172+
"to": "NOTICE"
165173
}
166174
],
167175
"publish": {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Box from '@mui/material/Box';
18+
import Button from '@mui/material/Button';
19+
import DialogActions from '@mui/material/DialogActions';
20+
import DialogContent from '@mui/material/DialogContent';
21+
import Typography from '@mui/material/Typography';
22+
import { useEffect, useState } from 'react';
23+
import { useTranslation } from 'react-i18next';
24+
import { Dialog } from '../common/Dialog';
25+
26+
/** Public metadata for a legal document provided by the desktop host. */
27+
interface LegalDocument {
28+
/** Stable identifier used to request the document content. */
29+
id: string;
30+
/** Human-readable document title. */
31+
title: string;
32+
}
33+
34+
/**
35+
* Lists legal documents exposed by the desktop host and displays their content.
36+
*
37+
* @returns Legal document controls, or nothing when the host lacks the capability.
38+
*/
39+
export default function LegalDocuments() {
40+
const { t } = useTranslation();
41+
const [documents, setDocuments] = useState<LegalDocument[]>([]);
42+
const [selectedDocument, setSelectedDocument] = useState<LegalDocument | null>(null);
43+
const [content, setContent] = useState('');
44+
const [error, setError] = useState('');
45+
const getLegalDocuments = window.desktopApi?.getLegalDocuments;
46+
47+
useEffect(() => {
48+
let active = true;
49+
getLegalDocuments?.()
50+
.then((items: LegalDocument[]) => {
51+
if (active) {
52+
setDocuments(items);
53+
}
54+
})
55+
.catch(() => {
56+
if (active) {
57+
setError(t('translation|Unable to load legal documents.'));
58+
}
59+
});
60+
return () => {
61+
active = false;
62+
};
63+
}, [getLegalDocuments, t]);
64+
65+
/**
66+
* Requests and presents one legal document.
67+
*
68+
* @param document - Document selected by the user.
69+
* @returns A promise that settles after the host request is handled.
70+
*/
71+
async function openDocument(document: LegalDocument): Promise<void> {
72+
try {
73+
const result = await window.desktopApi?.getLegalDocument?.(document.id);
74+
if (result?.success) {
75+
setSelectedDocument(document);
76+
setContent(result.content ?? '');
77+
setError('');
78+
} else {
79+
setError(result?.error ?? t('translation|Unable to load legal document.'));
80+
}
81+
} catch {
82+
setError(t('translation|Unable to load legal document.'));
83+
}
84+
}
85+
86+
if (!getLegalDocuments) {
87+
return null;
88+
}
89+
90+
return (
91+
<Box sx={{ p: 2 }}>
92+
{documents.map(document => (
93+
<Button key={document.id} onClick={() => openDocument(document)}>
94+
{document.title}
95+
</Button>
96+
))}
97+
{error && (
98+
<Typography role="alert" color="error">
99+
{error}
100+
</Typography>
101+
)}
102+
<Dialog
103+
maxWidth="lg"
104+
open={selectedDocument !== null}
105+
onClose={() => setSelectedDocument(null)}
106+
title={selectedDocument?.title ?? ''}
107+
>
108+
<DialogContent>
109+
<Box
110+
component="pre"
111+
sx={{
112+
whiteSpace: 'pre-wrap',
113+
wordBreak: 'break-word',
114+
fontFamily: 'monospace',
115+
fontSize: '0.875rem',
116+
maxHeight: '70vh',
117+
overflow: 'auto',
118+
}}
119+
>
120+
{content}
121+
</Box>
122+
</DialogContent>
123+
<DialogActions>
124+
<Button onClick={() => setSelectedDocument(null)}>{t('translation|Close')}</Button>
125+
</DialogActions>
126+
</Dialog>
127+
</Box>
128+
);
129+
}

0 commit comments

Comments
 (0)