Skip to content
Merged
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ yarn build:win
yarn build:linux
```

On macOS, desktop SSO uses `ASWebAuthenticationSession` through the optional `objc-js` bridge. Authentication runs in a supporting default browser, with Safari as fallback. Windows and Linux retain embedded SSO; the standalone webapp is unchanged.

The macOS flow follows Wire iOS's existing backend contract: a validated callback returns a session cookie to the initiating account. Callback URLs and cookies must not be logged. This is not a single-use-code/PKCE exchange.

macOS packaging unpacks and signs the native bridge using the existing app-signing identity. Browser SSO does not require the former WebAuthn keychain-group entitlement or additional provisioning-profile credentials. Both Jenkins jobs use the configured `node-v23.0.0` tool for the upgraded Electron runtime.

Embedded SSO windows support website `window.prompt()` requests through a local text dialog. This covers passkey labels requested by Keycloak and other providers using the same browser API, without changing the identity provider's theme. The dialog shows the requesting origin, returns entered text on OK and `null` on cancellation, and closes when the requesting page navigates or closes. Prompt messages and entered values are not logged. This addresses prompt compatibility; each provider's complete login flow still needs testing.

On Windows and Linux, SSO opens in an independent window using the shared persistent `persist:wire-sso` session. The backend completion callback is checked against the expected origin before copying its Wire login cookie into the requesting account. SSO website storage is cleared on close and before a new login, while passkey session preferences remain. Removing one sub-app/account does not remove this shared session. Reuse requires the same identity-provider account and relying-party ID; unrelated providers still need separate credentials. Deleting the entire desktop user-data directory or the credential in Keycloak is different. In a signed build, test registration, restart, account removal/re-addition, and login from another sub-app.

To test authentication, launch the signed app with `--enable-logging` and search its `logs/YYYY-MM-DD/electron.log` (inside Electron's user-data directory) for `[Passkeys]`. For macOS browser SSO, search for `[SSO]` to follow session startup, callback validation failures, and cookie installation. Browser authentication does not use the embedded account picker. For embedded WebAuthn, account-picker logs show requests, selection, cancellation, or failure without account names or credential IDs. An account-selection event only occurs when the authenticator needs a choice: its absence does not prove WebAuthn failed. Complete login against the intended identity provider to verify the result. Build checks do not establish authenticator compatibility; test the signed app against the intended IdP.

### Other Linux targets

If you would like to build for another Linux target, run the following command:
Expand Down
45 changes: 44 additions & 1 deletion bin/build-tools/lib/build-macos.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* Wire
* Copyright (C) 2019 Wire Swiss GmbH
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
Expand All @@ -14,19 +14,62 @@
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import * as assert from 'assert';
import * as path from 'path';

import {buildMacOSConfig} from './build-macos';

import {generateUUID} from '../../bin-utils';

const wireJsonPath = path.join(__dirname, '../../../electron/wire.json');
const envFilePath = path.join(__dirname, '../../../.env.defaults');

describe('build-macos', () => {
describe('buildMacOSConfig', () => {
it('packages the browser authentication bridge for every macOS variant', async () => {
const original = {...process.env};
try {
process.env.MACOS_CERTIFICATE_NAME_APPLICATION = '';
process.env.ENABLE_ASAR = 'true';
for (const environment of ['internal', 'production', 'wire-gov']) {
process.env.APP_ENV = environment;
const {packagerConfig} = await buildMacOSConfig(wireJsonPath, envFilePath);
assert.strictEqual((packagerConfig.asar as {unpack: string}).unpack, '**/*.node');
assert.ok(packagerConfig.osxUniversal?.x64ArchFiles);
assert.strictEqual(
(packagerConfig.ignore as RegExp[]).some(pattern => pattern.test('/node_modules/objc-js/dist/index.js')),
false,
);
for (const name of [
'certificate.cer',
'identity.p12',
'identity.pfx',
'private.key',
'profile.provisionprofile',
]) {
assert.ok((packagerConfig.ignore as RegExp[]).some(pattern => pattern.test(`/resources/macos/${name}`)));
}
assert.ok(
!(packagerConfig.ignore as RegExp[]).some(pattern =>
pattern.test('/resources/macos/entitlements/parent.plist'),
),
);
assert.strictEqual(packagerConfig.platform, 'mas');
assert.ok((packagerConfig.extendInfo as Record<string, unknown>).NSAudioCaptureUsageDescription);
}
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in original)) {
delete process.env[key];
}
}
Object.assign(process.env, original);
}
});

it('honors environment variables', async () => {
const bundleId = generateUUID();
const certNameApplication = generateUUID();
Expand Down
51 changes: 43 additions & 8 deletions bin/build-tools/lib/build-macos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,17 @@
import {flatAsync as buildPkg} from '@electron/osx-sign';
import electronPackager, {ArchOption} from 'electron-packager';
import fs from 'fs-extra';
import globby from 'globby';

import {execFile} from 'child_process';
import path from 'path';
import {promisify} from 'util';

import {backupFiles, execAsync, getLogger, restoreFiles} from '../../bin-utils';
import {flipElectronFuses, getCommonConfig} from './commonConfig';
import {CommonConfig, MacOSConfig} from './Config';

import {backupFiles, execAsync, getLogger, restoreFiles} from '../../bin-utils';

const libraryName = path.basename(__filename).replace('.ts', '');
const logger = getLogger('build-tools', libraryName);
const mainDir = path.resolve(__dirname, '../../../');
Expand All @@ -45,6 +50,8 @@ export async function buildMacOSConfig(
const envFileResolved = path.resolve(envFilePath);
const plistInfoResolved = path.resolve('resources/macos/Info.plist.json');
const plistEntries = await fs.readJson(plistInfoResolved);
// Brand configuration can replace Info.plist.json during yarn configure.
plistEntries.NSAudioCaptureUsageDescription ||= 'Allow Wire to share system audio during screen sharing.';
const {commonConfig} = await getCommonConfig(envFileResolved, wireJsonResolved);

const macOSDefaultConfig: MacOSConfig = {
Expand Down Expand Up @@ -80,21 +87,32 @@ export async function buildMacOSConfig(
appCopyright: commonConfig.copyright,
appVersion: commonConfig.version,
arch: architecture,
asar: commonConfig.enableAsar,
asar: commonConfig.enableAsar ? {unpack: '**/*.node'} : false,
buildVersion: commonConfig.buildNumber,
darwinDarkModeSupport: true,
dir: '.',
extendInfo: plistEntries,
helperBundleId: `${macOSConfig.bundleId}.helper`,
icon: 'resources/macos/logo.icns',
ignore: [/\/electron\/renderer\/src$/, /\/\.yarn$/, /\$electron\/src$/, /\/bin$/, /\/jenkins$/],
ignore: [
/\/electron\/renderer\/src$/,
/\/\.yarn$/,
/\$electron\/src$/,
/\/bin$/,
/\/jenkins$/,
// Signing inputs are not runtime resources.
/\/resources\/macos\/.*\.(?:p12|pfx|cer|provisionprofile|mobileprovision|key|p8)$/i,
],
name: commonConfig.name,
osxUniversal: {
mergeASARs: true,
// Both input apps contain the same two architecture-specific prebuilds.
// Preserve them as-is; node-gyp-build selects the appropriate one at runtime.
x64ArchFiles: '**/objc-js/prebuilds/**/*.node',
},
out: commonConfig.buildDir,
overwrite: true,
platform: 'mas', // Mac App Store
platform: 'mas', // Mac App Store
protocols: [{name: `${commonConfig.name} Core Protocol`, schemes: [commonConfig.customProtocolName]}],
prune: true,
quiet: false,
Expand Down Expand Up @@ -181,9 +199,10 @@ export async function buildMacOSWrapper(
}
} catch (error) {
logger.error(error);
throw error;
} finally {
await restoreFiles(backup);
}

await restoreFiles(backup);
}

export async function manualMacOSSign(
Expand All @@ -196,13 +215,29 @@ export async function manualMacOSSign(
const mainEntitlements = 'resources/macos/entitlements/parent.plist';

if (macOSConfig.certNameApplication) {
// Native addons must be signed before the outer app signature. ASAR cannot
// hold loadable Mach-O binaries; packaging extracts these to app.asar.unpacked.
const addons = await globby('Contents/Resources/{app.asar.unpacked,app}/node_modules/**/*.node', {
cwd: appFile,
followSymbolicLinks: false,
});
for (const addon of addons) {
if (path.isAbsolute(addon) || addon.split(/[\\/]/).includes('..') || !addon.startsWith('Contents/Resources/')) {
throw new Error('Native addon must be inside the app resources directory.');
}
// globby returns relative paths without following directory symlinks.
// Sign within the app instead of joining a discovered path to the build root.
await promisify(execFile)('codesign', ['--force', '--sign', macOSConfig.certNameApplication, addon], {
cwd: appFile,
});
}
logger.log(`[SSO] Signed ${addons.length} native addon binaries.`);

const filesToSign = [
'Frameworks/Electron Framework.framework/Versions/A/Electron Framework',
'Frameworks/Electron Framework.framework/Versions/A/Libraries/libEGL.dylib',
'Frameworks/Electron Framework.framework/Versions/A/Libraries/libffmpeg.dylib',
'Frameworks/Electron Framework.framework/Versions/A/Libraries/libGLESv2.dylib',
'Frameworks/Electron Framework.framework/Versions/A/Libraries/libswiftshader_libEGL.dylib',
'Frameworks/Electron Framework.framework/Versions/A/Libraries/libswiftshader_libGLESv2.dylib',
'Frameworks/Electron Framework.framework/Versions/A/Libraries/libvk_swiftshader.dylib',
'Frameworks/Electron Framework.framework/',
`Frameworks/${commonConfig.name} Helper.app/Contents/MacOS/${commonConfig.name} Helper`,
Expand Down
8 changes: 8 additions & 0 deletions bin/build-tools/lib/build-windows-msi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ describe('build-windows-msi', () => {
});

describe('buildWindowsMsiConfig', () => {
beforeEach(() => {
// Keep production defaults independent of local branding and prior tests.
process.env.APP_ENV = 'production';
process.env.APP_NAME = 'Wire';
process.env.WIN_MSI_MANUFACTURER = '';
process.env.WIN_MSI_UPGRADE_CODE = '';
});

it('builds a per-machine MSI with a stable production upgrade identity', async () => {
const {builderConfig, windowsMsiConfig} = await buildWindowsMsiConfig(wireJsonPath, envFilePath, true);

Expand Down
66 changes: 66 additions & 0 deletions electron/css/text-prompt.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/* Match the existing desktop proxy dialog, with space for provider-supplied text. */
body {
box-sizing: border-box;
padding: 24px;
text-align: left;
}
form {
display: flex;
flex-direction: column;
width: 100%;
max-height: 100%;
overflow: auto;
}
h1 {
margin: 0 0 12px;
font-size: 24px;
}
#origin {
flex-shrink: 0;
overflow-wrap: anywhere;
padding-bottom: 16px;
border-bottom: 1px solid #e1e7eb;
}
#message {
display: block;
width: auto;
margin: 0;
text-align: left;
max-height: 90px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
input {
box-sizing: border-box;
flex-shrink: 0;
width: 100%;
margin-top: 16px;
font: inherit;
}
.buttons {
display: flex;
flex-shrink: 0;
justify-content: flex-end;
gap: 8px;
}
button {
width: auto;
min-width: 80px;
min-height: 32px;
margin: 0;
padding: 8px 16px;
}
#ok {
background-color: #3879d9;
color: #fff;
}
#cancel {
background-color: #ddd;
color: #000;
}
input:focus-visible,
button:focus-visible {
outline: 2px solid #3879d9;
outline-offset: 2px;
}
25 changes: 25 additions & 0 deletions electron/html/text-prompt.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'self'; form-action 'none'; base-uri 'none'"
/>
<title></title>
<link rel="stylesheet" href="../css/proxy-prompt.css" />
<link rel="stylesheet" href="../css/text-prompt.css" />
</head>
<body>
<form aria-labelledby="title" aria-describedby="origin">
<h1 id="title"></h1>
<p id="origin"></p>
<label id="message" for="value"></label>
<input id="value" type="text" maxlength="4096" autocomplete="off" aria-describedby="origin" />
<div class="buttons">
<button id="cancel" type="button"></button>
<button id="ok" type="submit"></button>
</div>
</form>
</body>
</html>
Loading
Loading