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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions app/e2e-tests/fixtures/oauthProviderMain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright 2025 The Kubernetes Authors
*
* Licensed 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.
*/

import { app, BrowserWindow } from 'electron';
import fs from 'node:fs';
import { registerOAuthProvider } from '../../electron/oauthProvider';
import { createProtocolHandler } from '../../electron/protocolHandler';

const callbackUrl = process.env.HEADLAMP_OAUTH_CALLBACK_URL;
const outputPath = process.env.HEADLAMP_OAUTH_OUTPUT_PATH;
const protocolScheme = process.env.HEADLAMP_OAUTH_PROTOCOL_SCHEME;
let fixtureWindow: BrowserWindow;

if (!callbackUrl || !outputPath || !protocolScheme) {
throw new Error('OAuth provider e2e fixture requires callback configuration');
}

const protocolHandler = createProtocolHandler({
protocolScheme,
startUrl: 'data:text/html,<title>OAuth provider fixture</title>',
getMainWindow: () => fixtureWindow,
});

app.emit('open-url', { preventDefault() {} } as Electron.Event, callbackUrl);

app.whenReady().then(async () => {
fixtureWindow = new BrowserWindow({ show: false });
await fixtureWindow.loadURL('data:text/html,<title>OAuth provider fixture</title>');
registerOAuthProvider({
id: 'e2e-provider',
callback: { hostname: 'oauth', pathname: '/callback' },
handleCallback(url) {
fs.writeFileSync(outputPath, url.href);
},
});
protocolHandler.setReady();
});
87 changes: 87 additions & 0 deletions app/e2e-tests/tests/oauthProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2025 The Kubernetes Authors
*
* Licensed 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.
*/

import { expect, test } from '@playwright/test';
import { build } from 'esbuild';
import { ChildProcess, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const electronPath = require('electron') as string;
const fixturePath = path.resolve(__dirname, '../fixtures/oauthProviderMain.ts');
const callbackUrl = 'headlamp://oauth/callback?code=e2e-code&state=e2e-state';
const electronEnvironment = { ...process.env };
delete electronEnvironment.ELECTRON_RUN_AS_NODE;

let electronProcess: ChildProcess | undefined;
let electronProcessFailure = '';
let outputPath: string;
let temporaryDirectory: string;

test.describe('OAuth provider registry', () => {
test.skip(process.env.PLAYWRIGHT_TEST_MODE !== 'app', 'Requires Electron app mode');

test.beforeAll(async () => {
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'headlamp-oauth-e2e-'));
const bundlePath = path.join(temporaryDirectory, 'oauthProviderMain.cjs');
outputPath = path.join(temporaryDirectory, 'callback-url.txt');

await build({
bundle: true,
entryPoints: [fixturePath],
external: ['electron'],
format: 'cjs',
outfile: bundlePath,
platform: 'node',
target: 'node20',
});

electronProcess = spawn(electronPath, [bundlePath], {
env: {
...electronEnvironment,
HEADLAMP_OAUTH_CALLBACK_URL: callbackUrl,
HEADLAMP_OAUTH_OUTPUT_PATH: outputPath,
HEADLAMP_OAUTH_PROTOCOL_SCHEME: 'headlamp',
},
stdio: 'pipe',
});
electronProcess.stderr?.on('data', chunk => {
electronProcessFailure += chunk.toString();
});
electronProcess.on('exit', (code, signal) => {
if (!fs.existsSync(outputPath)) {
electronProcessFailure += `Electron exited with code ${code} and signal ${signal}`;
}
});
});

test.afterAll(() => {
electronProcess?.kill();
fs.rmSync(temporaryDirectory, { force: true, recursive: true });
});

test('dispatches a launch callback emitted before Electron is ready', async () => {
await expect
.poll(() => {
if (electronProcessFailure) {
throw new Error(electronProcessFailure);
}
return fs.existsSync(outputPath) && fs.readFileSync(outputPath, 'utf8');
})
.toBe(callbackUrl);
});
});
75 changes: 75 additions & 0 deletions app/e2e-tests/tests/protocolScheme.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright 2025 The Kubernetes Authors
*
* Licensed 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.
*/

import { expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { _electron, ElectronApplication, Page } from 'playwright';

const electronExecutable = process.platform === 'win32' ? 'electron.cmd' : 'electron';
const electronPath = path.resolve(__dirname, `../../node_modules/.bin/${electronExecutable}`);
const appPath = path.resolve(__dirname, '../../');
const manifestPath = path.join(appPath, 'app-build-manifest.json');

let electronApp: ElectronApplication;
let electronPage: Page;
let originalManifest: string;

test.describe('desktop protocol scheme', () => {
test.skip(process.env.PLAYWRIGHT_TEST_MODE !== 'app', 'Requires Electron app mode');

test.beforeAll(async () => {
originalManifest = fs.readFileSync(manifestPath, 'utf8');
const productManifest = JSON.parse(originalManifest);
fs.writeFileSync(
manifestPath,
JSON.stringify({ ...productManifest, protocolScheme: 'test-headlamp' }, null, 2) + '\n'
);

const electronEnv: Record<string, string> = {};
for (const [name, value] of Object.entries(process.env)) {
if (name !== 'ELECTRON_RUN_AS_NODE' && value !== undefined) {
electronEnv[name] = value;
}
}

electronApp = await _electron.launch({
cwd: appPath,
executablePath: electronPath,
args: ['.'],
env: {
...electronEnv,
NODE_ENV: 'development',
ELECTRON_DEV: 'true',
},
});
electronPage = await electronApp.firstWindow();
await electronPage.waitForLoadState('load');
});

test.afterAll(async () => {
await electronApp?.close();
fs.writeFileSync(manifestPath, originalManifest);
});

test('routes a product protocol URL in the desktop app', async () => {
await electronApp.evaluate(({ app }, deepLink) => {
app.emit('open-url', { preventDefault() {} } as Electron.Event, deepLink);
}, 'test-headlamp://cluster?name=local');

await expect.poll(() => electronPage.url()).toMatch(/#\/cluster\?name=local$/);
});
});
49 changes: 17 additions & 32 deletions app/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import {
getPluginBinDirectories,
PluginManager,
} from './plugin-management';
import { readProtocolScheme } from './protocol';
import { createProtocolHandler } from './protocolHandler';
import {
addRunCmdConsent,
environmentOverrides,
Expand Down Expand Up @@ -192,13 +194,24 @@ const MAX_PORT_ATTEMPTS = Math.abs(Number(process.env.HEADLAMP_MAX_PORT_ATTEMPTS

const useExternalServer = process.env.EXTERNAL_SERVER || false;
const shouldCheckForUpdates = process.env.HEADLAMP_CHECK_FOR_UPDATES !== 'false';
const productManifestPath = path.join(
isDev ? path.resolve('./') : process.resourcesPath,
'app-build-manifest.json'
);
const protocolScheme = readProtocolScheme(productManifestPath);

// make it global so that it doesn't get garbage collected
let mainWindow: BrowserWindow | null;
let mcpClient: MCPClient | null = null;
let isQuitting = false;
let hasTray = false;

const protocolHandler = createProtocolHandler({
protocolScheme,
startUrl,
getMainWindow: () => mainWindow,
});

/**
* `Action` is an interface for an action to be performed by the plugin manager.
*
Expand Down Expand Up @@ -1637,6 +1650,8 @@ function startElectron() {

// Inject the backend port into the window object
mainWindow?.webContents.executeJavaScript(`window.headlampBackendPort = ${actualPort};`);

protocolHandler.setReady();
});

mainWindow.webContents.on('dom-ready', () => {
Expand Down Expand Up @@ -1679,15 +1694,7 @@ function startElectron() {

// Force Single Instance Application
const gotTheLock = app.requestSingleInstanceLock();
if (gotTheLock) {
app.on('second-instance', () => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
} else {
if (!gotTheLock) {
app.quit();
return;
}
Expand All @@ -1705,29 +1712,6 @@ function startElectron() {
shell.openExternal(url);
});

app.on('open-url', (event, url) => {
mainWindow?.focus();
let urlObj;
try {
urlObj = new URL(url);
} catch (e) {
dialog.showErrorBox(
i18n.t('Invalid URL'),
i18n.t('Application opened with an invalid URL: {{ url }}', { url })
);
return;
}

const urlParam = urlObj.hostname;
let baseUrl = startUrl;
// this check helps us to avoid adding multiple / to the startUrl when appending the incoming url to it
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, startUrl.length - 1);
}
// load the index.html from build and route to the hostname received in the protocol handler url
mainWindow?.loadURL(baseUrl + '#' + urlParam + urlObj.search);
});

i18n.on('languageChanged', () => {
updateMenuLabels(currentMenu);
setMenu(mainWindow, currentMenu);
Expand All @@ -1737,6 +1721,7 @@ function startElectron() {
mainWindow?.webContents.send('appConfig', {
checkForUpdates: shouldCheckForUpdates,
appVersion,
protocolScheme,
});
});

Expand Down
Loading
Loading