From 20c9be2ca2588ef6958922ccabc3ede2125282cd Mon Sep 17 00:00:00 2001 From: Mark Brockhoff Date: Thu, 16 Jul 2026 17:53:58 +0200 Subject: [PATCH 01/45] Upgrade teams to enterprise via Ibis API [WPB-22420] --- e2e-tests/actions/createTeam.ts | 7 +- e2e-tests/backend/IbisApiClient.ts | 108 +++++++++++++++++++++++++++++ e2e-tests/fixtures.ts | 14 +++- 3 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 e2e-tests/backend/IbisApiClient.ts diff --git a/e2e-tests/actions/createTeam.ts b/e2e-tests/actions/createTeam.ts index 0b03005dd80..0ff161ff2a4 100644 --- a/e2e-tests/actions/createTeam.ts +++ b/e2e-tests/actions/createTeam.ts @@ -21,6 +21,7 @@ import {createUser, registerUser} from './createUser'; import {BrigApiClient} from '../backend/BrigApiClient'; import {GalleyApiClient} from '../backend/GalleyApiClient'; +import {IbisApiClient} from '../backend/IbisApiClient'; import {PublicApiClient, RegisteredUser, TeamOwner} from '../backend/PublicApiClient'; export type TeamRole = 'admin' | 'partner' | 'owner' | 'member'; @@ -33,7 +34,7 @@ export type Team = { }; export const createTeam = async ( - api: {publicApi: PublicApiClient; brigApi: BrigApiClient; galleyApi: GalleyApiClient}, + api: {publicApi: PublicApiClient; brigApi: BrigApiClient; galleyApi: GalleyApiClient; ibisApi: IbisApiClient}, teamName: string, options?: { users?: (RegisteredUser | {user: RegisteredUser; role?: TeamRole})[]; @@ -72,9 +73,7 @@ export const createTeam = async ( } if (options?.features && Object.values(options.features).some(Boolean)) { - // The team will be reset right after initialization, so we need to wait a short time for it to finish - // before changing feature configs since they would otherwise be overwritten (See WPB-23698) - await new Promise(resolve => setTimeout(resolve, 5000)); + await api.ibisApi.upgradeTeam(owner); if (options.features.conferenceCalling) { await api.galleyApi.unlockConferenceCallingFeature(teamId); diff --git a/e2e-tests/backend/IbisApiClient.ts b/e2e-tests/backend/IbisApiClient.ts new file mode 100644 index 00000000000..c7fa3431b1c --- /dev/null +++ b/e2e-tests/backend/IbisApiClient.ts @@ -0,0 +1,108 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {faker} from '@faker-js/faker'; +import axios, {AxiosInstance} from 'axios'; + +import {TeamOwner} from './PublicApiClient'; + +export type IbisApiClientConfig = { + baseUrl: string; +}; + +// eslint-disable-next-line valid-jsdoc +/* A manually written api client for the ibis api proxied over nginz, it is used to unlock enterprise features on staging */ +export class IbisApiClient { + private readonly axiosInstance: AxiosInstance; + + constructor({baseUrl}: IbisApiClientConfig) { + this.axiosInstance = axios.create({ + baseURL: baseUrl, + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + upgradeTeam = async (teamOwner: TeamOwner) => { + const billingInfo = { + firstname: teamOwner.firstName, + lastname: teamOwner.lastName, + company: faker.company.name(), + street: '123 Test Street', + zip: '12345', + city: 'Berlin', + country: 'DE', + }; + + for (let i = 0; i < 5; i++) { + const res = await this.axiosInstance.put(`/teams/${teamOwner.teamId}/billing/info`, billingInfo, { + headers: {Authorization: `Bearer ${teamOwner.token}`}, + validateStatus: _status => true, // Since we want the request to be retried we need to prevent axios from throwing automatically + }); + if (res.status !== 412) { + break; + } + + if (i === 4) { + throw new Error(`Failed to set billing information for team with id ${teamOwner.teamId}`); + } + + // eslint-disable-next-line no-console + console.log( + `Failed to upgrade team with id ${teamOwner.teamId}, retrying in ${1 * (i + 1)} seconds...`, + res.data, + ); + await new Promise(res => setTimeout(res, 1_000 * (i + 1))); + } + + await this.axiosInstance.put( + `/teams/${teamOwner.teamId}/billing/card`, + { + // tok_visa is a pre-built test token provided by Stripe for test mode environments. + // It represents the card number 4242424242424242 (Visa, always succeeds) without needing to go through the Stripe.js card tokenization flow. + stripeToken: 'tok_visa', + }, + {headers: {Authorization: `Bearer ${teamOwner.token}`}}, + ); + + const plansResponse = await this.axiosInstance.get(`teams/${teamOwner.teamId}/billing/plan/list`, { + headers: {Authorization: `Bearer ${teamOwner.token}`}, + }); + if (!Array.isArray(plansResponse.data) || plansResponse.data.length < 1) { + throw new Error('No valid enterprise plans found to upgrade to'); + } + + const plan = plansResponse.data.find(plan => plan.premium === true); + + await this.axiosInstance.put( + `/teams/${teamOwner.teamId}/billing/subscription`, + {planId: plan.id}, + {headers: {Authorization: `Bearer ${teamOwner.token}`}}, + ); + + const {data: upgradedTeam} = await this.axiosInstance.get(`teams/${teamOwner.teamId}/billing/team`, { + headers: {Authorization: `Bearer ${teamOwner.token}`}, + }); + if (upgradedTeam.status !== 'active') { + throw new Error('Failed to upgrade team'); + } + }; +} diff --git a/e2e-tests/fixtures.ts b/e2e-tests/fixtures.ts index 21e1243da20..826986ad0ee 100644 --- a/e2e-tests/fixtures.ts +++ b/e2e-tests/fixtures.ts @@ -28,6 +28,7 @@ import {createTeam, Team} from './actions/createTeam'; import {createUser, registerUser} from './actions/createUser'; import {BrigApiClient} from './backend/BrigApiClient'; import {GalleyApiClient} from './backend/GalleyApiClient'; +import {IbisApiClient} from './backend/IbisApiClient'; import {PublicApiClient, RegisteredUser, TeamOwner} from './backend/PublicApiClient'; export type TestOptions = { @@ -40,6 +41,7 @@ type Fixtures = { publicApi: PublicApiClient; brigApi: BrigApiClient; galleyApi: GalleyApiClient; + ibisApi: IbisApiClient; createUser: () => Promise; createPage: () => Promise; @@ -83,6 +85,14 @@ export const test = baseTest.extend({ await use(new GalleyApiClient({baseUrl: process.env.BACKEND_URL, basicAuth: process.env.BACKEND_BASIC_AUTH})); }, + ibisApi: async ({}, use) => { + if (process.env.BACKEND_URL === undefined) { + throw new Error('Missing env var BACKEND_URL'); + } + + await use(new IbisApiClient({baseUrl: process.env.BACKEND_URL})); + }, + app: async ({appOptions}, use, testInfo) => { // Always use a fresh temporary directory for the user data to ensure test isolation const tempUserDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wire-desktop-e2e-tests-')); @@ -120,11 +130,11 @@ export const test = baseTest.extend({ await Promise.all(users.map(user => publicApi.deleteUser(user))); }, - createTeam: async ({publicApi, brigApi, galleyApi}, use) => { + createTeam: async ({publicApi, brigApi, galleyApi, ibisApi}, use) => { const teamOwners: TeamOwner[] = []; await use(async (teamName, options) => { - const team = await createTeam({publicApi, brigApi, galleyApi}, teamName, options); + const team = await createTeam({publicApi, brigApi, galleyApi, ibisApi}, teamName, options); teamOwners.push(team.owner); return team; }); From 2e6721f16b08ab8f94cfcf9d13d48d7c3c0d5d77 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Mon, 17 Aug 2026 11:26:30 +0200 Subject: [PATCH 02/45] Increase timeout for multiple accounts e2e test --- e2e-tests/specs/criticalFlow/multipleAccounts.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e-tests/specs/criticalFlow/multipleAccounts.spec.ts b/e2e-tests/specs/criticalFlow/multipleAccounts.spec.ts index a9a7d18b8c8..2ff654b2a71 100644 --- a/e2e-tests/specs/criticalFlow/multipleAccounts.spec.ts +++ b/e2e-tests/specs/criticalFlow/multipleAccounts.spec.ts @@ -27,6 +27,7 @@ test( 'I want to add multiple accounts to the app and switch between them', {tag: ['@TC-10925', '@crit-flow-desktop']}, async ({app, createUser}) => { + test.setTimeout(120_000); const userA = await createUser(); const userA2 = await createUser(); From d603894c2272e1ada1e74b03d7ce7f380fcfe81f Mon Sep 17 00:00:00 2001 From: Adam Low Date: Tue, 18 Aug 2026 14:54:52 +0100 Subject: [PATCH 03/45] feat(windows): build native MSI installer --- bin/build-tools/build-cli.ts | 21 +- bin/build-tools/lib/Config.ts | 6 + bin/build-tools/lib/build-windows-msi.test.ts | 127 ++++++++++ bin/build-tools/lib/build-windows-msi.ts | 219 ++++++++++++++++++ bin/deploy-tools/lib/S3Deployer.test.ts | 25 ++ bin/deploy-tools/lib/S3Deployer.ts | 5 + docs/windows-msi.md | 56 +++++ electron/src/runtime/lifecycle.ts | 2 +- electron/src/update/squirrel.test.main.ts | 43 ++++ electron/src/update/squirrel.ts | 11 +- electron/src/update/squirrelInstallation.ts | 24 ++ jenkins/deployment.groovy | 11 +- jenkins/windows.groovy | 40 +++- package.json | 4 + 14 files changed, 573 insertions(+), 21 deletions(-) create mode 100644 bin/build-tools/lib/build-windows-msi.test.ts create mode 100644 bin/build-tools/lib/build-windows-msi.ts create mode 100644 docs/windows-msi.md create mode 100644 electron/src/update/squirrel.test.main.ts create mode 100644 electron/src/update/squirrelInstallation.ts diff --git a/bin/build-tools/build-cli.ts b/bin/build-tools/build-cli.ts index 0f7a9356950..40a0a06c95b 100644 --- a/bin/build-tools/build-cli.ts +++ b/bin/build-tools/build-cli.ts @@ -17,15 +17,20 @@ * */ -import {LogFactory} from '@wireapp/commons'; import {program as commander} from 'commander'; +import * as electronBuilder from 'electron-builder'; + import path from 'path'; -import {logEntries} from '../bin-utils'; +import {LogFactory} from '@wireapp/commons'; + import {buildLinuxConfig, buildLinuxWrapper} from './lib/build-linux'; import {buildMacOSConfig, buildMacOSWrapper} from './lib/build-macos'; import {buildWindowsConfig, buildWindowsWrapper} from './lib/build-windows'; import {buildWindowsInstaller, buildWindowsInstallerConfig} from './lib/build-windows-installer'; +import {buildWindowsMsi, buildWindowsMsiConfig} from './lib/build-windows-msi'; + +import {logEntries} from '../bin-utils'; const toolName = path.basename(__filename).replace('.ts', ''); const logger = LogFactory.getLogger(toolName, {forceEnable: true, namespace: '@wireapp/build-tools'}); @@ -34,7 +39,7 @@ const appSource = path.join(__dirname, '../../'); commander .name(toolName) .description( - 'Build the Wire wrapper for your platform.\n\nValid values for platform are: "windows", "windows-installer", "macos", "linux".', + 'Build the Wire wrapper for your platform.\n\nValid values for platform are: "windows", "windows-installer", "windows-msi", "macos", "linux".', ) .option('-e, --env-file ', 'Specify the env file path', path.join(appSource, '.env.defaults')) .option( @@ -71,6 +76,16 @@ const platform = (commander.args[0] || '').toLowerCase(); return buildWindowsInstaller(wireJson, envFile, wInstallerOptions); } + case 'windows-msi': { + const {builderConfig, windowsMsiConfig} = await buildWindowsMsiConfig(wireJson, envFile, manualSign); + const msiArchitecture = architecture ? electronBuilder.archFromString(architecture) : undefined; + + logEntries(windowsMsiConfig, 'windowsMsiConfig', toolName); + logEntries(builderConfig, 'builderConfig', toolName); + + return buildWindowsMsi(builderConfig, packageJson, wireJson, envFile, msiArchitecture); + } + case 'mac': case 'macos': { const {macOSConfig, packagerConfig} = await buildMacOSConfig(wireJson, envFile, manualSign, architecture); diff --git a/bin/build-tools/lib/Config.ts b/bin/build-tools/lib/Config.ts index 44fa52e3713..276e9d67abc 100644 --- a/bin/build-tools/lib/Config.ts +++ b/bin/build-tools/lib/Config.ts @@ -72,3 +72,9 @@ export interface WindowsInstallerConfig { installerIconUrl: string; loadingGif: string; } + +export interface WindowsMsiConfig { + appId: string; + artifactName: string; + upgradeCode: string; +} diff --git a/bin/build-tools/lib/build-windows-msi.test.ts b/bin/build-tools/lib/build-windows-msi.test.ts new file mode 100644 index 00000000000..1d459d125f3 --- /dev/null +++ b/bin/build-tools/lib/build-windows-msi.test.ts @@ -0,0 +1,127 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 assert from 'node:assert'; +import path from 'node:path'; + +import {buildWindowsMsiConfig, customizeMsiProject} from './build-windows-msi'; + +const wireJsonPath = path.join(__dirname, '../../../electron/wire.json'); +const envFilePath = path.join(__dirname, '../../../.env.defaults'); +const originalEnvironment = { + appEnvironment: process.env.APP_ENV, + appName: process.env.APP_NAME, + upgradeCode: process.env.WIN_MSI_UPGRADE_CODE, +}; + +function restoreEnvironmentVariable(name: string, value?: string): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +describe('build-windows-msi', () => { + afterEach(() => { + restoreEnvironmentVariable('APP_ENV', originalEnvironment.appEnvironment); + restoreEnvironmentVariable('APP_NAME', originalEnvironment.appName); + restoreEnvironmentVariable('WIN_MSI_UPGRADE_CODE', originalEnvironment.upgradeCode); + }); + + describe('buildWindowsMsiConfig', () => { + it('builds a per-machine MSI with a stable production upgrade identity', async () => { + const {builderConfig, windowsMsiConfig} = await buildWindowsMsiConfig(wireJsonPath, envFilePath, true); + + assert.strictEqual(windowsMsiConfig.appId, 'com.squirrel.wire.wire'); + assert.strictEqual(windowsMsiConfig.upgradeCode, '620FCDDD-30CB-4241-A347-D34CF682A358'); + assert.strictEqual(builderConfig.msi?.perMachine, true); + assert.strictEqual(builderConfig.msi?.runAfterFinish, false); + assert.strictEqual(builderConfig.msi?.upgradeCode, windowsMsiConfig.upgradeCode); + assert.strictEqual(typeof builderConfig.win?.signtoolOptions?.sign, 'function'); + }); + + it('keeps the standard product upgrade identities distinct', async () => { + process.env.APP_ENV = 'internal'; + process.env.APP_NAME = 'WireInternal'; + const internal = await buildWindowsMsiConfig(wireJsonPath, envFilePath); + + process.env.APP_ENV = 'wire-gov'; + process.env.APP_NAME = 'WireGov'; + const wireGov = await buildWindowsMsiConfig(wireJsonPath, envFilePath); + + assert.strictEqual(internal.windowsMsiConfig.upgradeCode, '673C5C7F-2923-483B-8EDB-34EDBDFCFF8A'); + assert.strictEqual(wireGov.windowsMsiConfig.upgradeCode, '0FC26EF0-E415-4263-9C73-89D05BCD4E1A'); + assert.notStrictEqual(internal.windowsMsiConfig.upgradeCode, wireGov.windowsMsiConfig.upgradeCode); + }); + + it('honors and normalizes a configured upgrade code', async () => { + process.env.WIN_MSI_UPGRADE_CODE = '{C88AA646-1E4B-4C0A-A05A-8BB72BBADBBB}'; + + const {windowsMsiConfig} = await buildWindowsMsiConfig(wireJsonPath, envFilePath); + + assert.strictEqual(windowsMsiConfig.upgradeCode, 'C88AA646-1E4B-4C0A-A05A-8BB72BBADBBB'); + }); + + it('rejects an invalid configured upgrade code', async () => { + process.env.WIN_MSI_UPGRADE_CODE = 'not-a-guid'; + + await assert.rejects(buildWindowsMsiConfig(wireJsonPath, envFilePath), /Invalid Windows MSI upgrade code/); + }); + + it('requires a dedicated upgrade code for a custom-branded product', async () => { + process.env.APP_NAME = 'CustomerWire'; + + await assert.rejects(buildWindowsMsiConfig(wireJsonPath, envFilePath), /must define a permanent/); + }); + }); + + describe('customizeMsiProject', () => { + it('makes the URL protocol and desktop shortcut identity MSI-owned', () => { + const project = ` + + + + + `; + + const result = customizeMsiProject(project, 'wire', 'com.squirrel.wire.wire', 'Wire'); + + assert.match(result, /ShortcutProperty Key="System\.AppUserModel\.ID" Value="com\.squirrel\.wire\.wire"/); + assert.match(result, /RegistryKey Root="HKLM" Key="Software\\Classes\\wire"/); + assert.match(result, /Value=""\[#mainExecutable\]" "%1""/); + }); + + it('fails if electron-builder no longer generates the expected main executable component', () => { + assert.throws( + () => customizeMsiProject('', 'wire', 'com.squirrel.wire.wire', 'Wire'), + /Could not find the main executable/, + ); + }); + + it('fails if electron-builder no longer generates the expected desktop shortcut', () => { + const project = ''; + + assert.throws( + () => customizeMsiProject(project, 'wire', 'com.squirrel.wire.wire', 'Wire'), + /Could not find the desktop shortcut/, + ); + }); + }); +}); diff --git a/bin/build-tools/lib/build-windows-msi.ts b/bin/build-tools/lib/build-windows-msi.ts new file mode 100644 index 00000000000..492a0652f80 --- /dev/null +++ b/bin/build-tools/lib/build-windows-msi.ts @@ -0,0 +1,219 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 electronBuilder from 'electron-builder'; +import fs from 'fs-extra'; + +import path from 'path'; + +import {getCommonConfig} from './commonConfig'; +import {WindowsMsiConfig} from './Config'; + +import {backupFiles, getLogger, restoreFiles} from '../../bin-utils'; + +const libraryName = path.basename(__filename).replace('.ts', ''); +const logger = getLogger('build-tools', libraryName); +const mainDir = path.resolve(__dirname, '../../../'); + +const DEFAULT_MSI_IDENTITIES = { + internal: {productName: 'WireInternal', upgradeCode: '673C5C7F-2923-483B-8EDB-34EDBDFCFF8A'}, + production: {productName: 'Wire', upgradeCode: '620FCDDD-30CB-4241-A347-D34CF682A358'}, + 'wire-gov': {productName: 'WireGov', upgradeCode: '0FC26EF0-E415-4263-9C73-89D05BCD4E1A'}, +} as const; + +interface WindowsMsiConfigResult { + builderConfig: electronBuilder.Configuration; + windowsMsiConfig: WindowsMsiConfig; +} + +function escapeXmlAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function validateUpgradeCode(upgradeCode: string): string { + const normalized = upgradeCode.replace(/^\{(.+)\}$/, '$1').toUpperCase(); + if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[1-5][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/.test(normalized)) { + throw new Error(`Invalid Windows MSI upgrade code "${upgradeCode}".`); + } + return normalized; +} + +// electron-builder's MSI target owns the shortcuts, files and upgrade lifecycle. This small WiX customization makes +// the URL protocol MSI-owned as well and gives the desktop shortcut the same AUMID as the Start Menu shortcut. +export function customizeMsiProject( + project: string, + customProtocolName: string, + appId: string, + productName: string, +): string { + if (!/^[a-z][a-z0-9+.-]*$/i.test(customProtocolName)) { + throw new Error(`Invalid custom protocol name "${customProtocolName}".`); + } + + const mainExecutablePosition = project.indexOf('Id="mainExecutable"'); + if (mainExecutablePosition < 0) { + throw new Error('Could not find the main executable in the generated MSI project.'); + } + + const componentEndPosition = project.indexOf('', mainExecutablePosition); + if (componentEndPosition < 0) { + throw new Error('Could not find the main executable component in the generated MSI project.'); + } + + const protocol = escapeXmlAttribute(customProtocolName); + const description = escapeXmlAttribute(`${productName} URL`); + const protocolRegistry = [ + ` `, + ` `, + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ].join('\n'); + + let customizedProject = + project.slice(0, componentEndPosition) + protocolRegistry + project.slice(componentEndPosition); + + const escapedAppId = escapeXmlAttribute(appId); + const desktopShortcutPattern = /(]*?)\/>/; + if (!desktopShortcutPattern.test(customizedProject)) { + throw new Error('Could not find the desktop shortcut in the generated MSI project.'); + } + customizedProject = customizedProject.replace( + desktopShortcutPattern, + `$1>\n \n `, + ); + + return customizedProject; +} + +export async function buildWindowsMsiConfig( + wireJsonPath: string = path.join(mainDir, 'electron/wire.json'), + envFilePath: string = path.join(mainDir, '.env.defaults'), + manualSign?: boolean, +): Promise { + const wireJsonResolved = path.resolve(wireJsonPath); + const envFileResolved = path.resolve(envFilePath); + const {commonConfig} = await getCommonConfig(envFileResolved, wireJsonResolved); + + const appId = `com.squirrel.wire.${commonConfig.name.toLowerCase()}`; + const defaultIdentity = DEFAULT_MSI_IDENTITIES[commonConfig.environment]; + if (!process.env.WIN_MSI_UPGRADE_CODE && commonConfig.name !== defaultIdentity.productName) { + throw new Error(`Custom Windows product "${commonConfig.name}" must define a permanent WIN_MSI_UPGRADE_CODE.`); + } + const configuredUpgradeCode = process.env.WIN_MSI_UPGRADE_CODE || defaultIdentity.upgradeCode; + const upgradeCode = validateUpgradeCode(configuredUpgradeCode); + + const windowsMsiConfig: WindowsMsiConfig = { + appId, + artifactName: '${productName}-${version}-${arch}.${ext}', + upgradeCode, + }; + + const builderConfig: electronBuilder.Configuration = { + appId: windowsMsiConfig.appId, + buildVersion: commonConfig.version.replace(/-.*$/, ''), + copyright: commonConfig.copyright, + directories: { + buildResources: commonConfig.electronDirectory, + output: commonConfig.distDir, + }, + msi: { + artifactName: windowsMsiConfig.artifactName, + createDesktopShortcut: true, + createStartMenuShortcut: true, + oneClick: true, + perMachine: true, + runAfterFinish: false, + shortcutName: commonConfig.name, + upgradeCode: windowsMsiConfig.upgradeCode, + warningsAsErrors: true, + }, + msiProjectCreated: async projectFile => { + const project = await fs.readFile(projectFile, 'utf8'); + const customizedProject = customizeMsiProject( + project, + commonConfig.customProtocolName, + windowsMsiConfig.appId, + commonConfig.name, + ); + await fs.writeFile(projectFile, customizedProject); + }, + productName: commonConfig.name, + publish: null, + win: { + executableName: commonConfig.name, + icon: `${commonConfig.electronDirectory}/img/logo.ico`, + // Production signing is performed explicitly by Jenkins using the managed signing service. + signtoolOptions: manualSign ? {sign: async () => undefined} : undefined, + target: ['msi'], + }, + }; + + return {builderConfig, windowsMsiConfig}; +} + +export async function buildWindowsMsi( + builderConfig: electronBuilder.Configuration, + packageJsonPath: string, + wireJsonPath: string, + envFilePath: string, + architecture: electronBuilder.Arch = electronBuilder.Arch.x64, +): Promise { + const wireJsonResolved = path.resolve(wireJsonPath); + const packageJsonResolved = path.resolve(packageJsonPath); + const envFileResolved = path.resolve(envFilePath); + const {commonConfig} = await getCommonConfig(envFileResolved, wireJsonResolved); + const architectureName = electronBuilder.Arch[architecture]; + const appDirectory = path.resolve(`${commonConfig.buildDir}/${commonConfig.name}-win32-${architectureName}`); + const targets = electronBuilder.Platform.WINDOWS.createTarget(['msi'], architecture); + + logger.info(`Building ${commonConfig.name} ${commonConfig.version} MSI for Windows ...`); + + if (!(await fs.pathExists(path.join(appDirectory, `${commonConfig.name}.exe`)))) { + throw new Error(`Packaged Windows application not found in "${appDirectory}". Run the Windows build first.`); + } + + const backup = await backupFiles([packageJsonResolved, wireJsonResolved]); + const packageJsonContent = await fs.readJson(packageJsonResolved); + + try { + await fs.writeJson( + packageJsonResolved, + {...packageJsonContent, productName: commonConfig.name, version: commonConfig.version.replace(/-.*$/, '')}, + {spaces: 2}, + ); + await fs.writeJson(wireJsonResolved, commonConfig, {spaces: 2}); + + const builtPackages = await electronBuilder.build({config: builderConfig, prepackaged: appDirectory, targets}); + builtPackages.forEach(builtPackage => logger.log(`Built MSI package "${builtPackage}".`)); + } finally { + await restoreFiles(backup); + } +} diff --git a/bin/deploy-tools/lib/S3Deployer.test.ts b/bin/deploy-tools/lib/S3Deployer.test.ts index a1966c0cd00..7796f27a879 100644 --- a/bin/deploy-tools/lib/S3Deployer.test.ts +++ b/bin/deploy-tools/lib/S3Deployer.test.ts @@ -16,9 +16,20 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ +import assert from 'node:assert'; +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; + import {S3Deployer} from './S3Deployer'; describe('S3Deployer', () => { + const temporaryDirectories: string[] = []; + + afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => fs.remove(directory))); + }); + describe('copyOnS3', () => { it(`doesn't upload anything if dry run is set`, async () => { const s3Deployer = new S3Deployer({ @@ -30,4 +41,18 @@ describe('S3Deployer', () => { await s3Deployer.copyOnS3({bucket: '', s3FromPath: '', s3ToPath: ''}); }); }); + + describe('findUploadFiles', () => { + it('selects a native MSI without requiring Squirrel release artifacts', async () => { + const basePath = await fs.mkdtemp(path.join(os.tmpdir(), 'wire-msi-deployer-')); + temporaryDirectories.push(basePath); + const fileName = 'Wire-3.42.123-x64.msi'; + await fs.ensureFile(path.join(basePath, fileName)); + const s3Deployer = new S3Deployer({accessKeyId: '', dryRun: true, secretAccessKey: ''}); + + const files = await s3Deployer.findUploadFiles('wrapper_windows_production', basePath, '3.42.123'); + + assert.deepStrictEqual(files, [{fileName, filePath: path.join(basePath, fileName)}]); + }); + }); }); diff --git a/bin/deploy-tools/lib/S3Deployer.ts b/bin/deploy-tools/lib/S3Deployer.ts index b7482bed3d7..8e5511e15ec 100644 --- a/bin/deploy-tools/lib/S3Deployer.ts +++ b/bin/deploy-tools/lib/S3Deployer.ts @@ -94,6 +94,11 @@ export class S3Deployer { }, ]; } else if (platform.includes('windows')) { + const msi = await find('*.msi', {cwd: basePath, safeGuard: false}); + if (msi) { + return [{...msi, filePath: path.join(basePath, msi.fileName)}]; + } + const setupExe = await find('*-Setup.exe', {cwd: basePath}); const nupkgFile = await find('*-full.nupkg', {cwd: basePath}); const releasesFile = await find('RELEASES', {cwd: basePath}); diff --git a/docs/windows-msi.md b/docs/windows-msi.md new file mode 100644 index 00000000000..a67d72de2f5 --- /dev/null +++ b/docs/windows-msi.md @@ -0,0 +1,56 @@ +# Windows MSI distribution + +Wire's native MSI is intended for managed, per-machine Windows deployments. It installs the existing packaged Electron application under `Program Files`; it does not wrap or invoke the legacy Squirrel `Setup.exe`. + +## Build + +Build the Windows application before building its MSI: + +```shell +yarn build:win +yarn build:win:msi -m +``` + +`-m` disables electron-builder's automatic signing. Jenkins uses this mode because it signs the packaged Windows executables before creating the MSI, and signs the resulting MSI separately with the managed signing service. + +The standard Wire environments have permanent MSI upgrade codes in `build-windows-msi.ts`. Never change an upgrade code after its first release. A custom-branded build must set `WIN_MSI_UPGRADE_CODE` to its own permanent UUID so that it cannot collide with a standard Wire installation. + +The Squirrel installer remains available as a separate build target during migration. MSI artifacts are published under `win/msi/` and must never replace the `RELEASES` metadata used by existing Squirrel installations. + +Before promoting the first MSI release, update the download-page consumer and the external `Wrapper_Windows_Smoke_Tests` Jenkins job to select and install the versioned `.msi` artifact instead of `Wire-Setup.exe`. + +## Updates + +MSI installations are updated by deploying a newer signed MSI with the same upgrade code and a higher product version. The application only starts the Squirrel updater when a sibling Squirrel `Update.exe` is present, so an MSI installation does not perform application-managed updates. + +For unattended deployment, use standard Windows Installer commands and retain a verbose log: + +```powershell +msiexec.exe /i Wire--x64.msi /qn /norestart /l*v Wire-install.log +msiexec.exe /x Wire--x64.msi /qn /norestart /l*v Wire-uninstall.log +``` + +The endpoint-management policy should close Wire before an upgrade. A deployment must handle Windows Installer exit codes, including reboot-required results, rather than treating every non-zero result as a generic failure. + +## Migration from Squirrel + +Squirrel installs Wire per user under `%LocalAppData%`; the MSI installs it per machine under `Program Files`. Windows Installer cannot safely remove Squirrel installations from every user profile. Deployments must therefore remove the old Squirrel installation in each affected user context before assigning the MSI. Otherwise both installations can coexist and compete for shortcuts, auto-launch, and the `wire://` protocol. + +Do not delete the Squirrel update feed while supported Squirrel installations remain in use. + +## Release acceptance + +A release candidate is acceptable only after it has been exercised on a supported Windows version and satisfies all of the following: + +- The MSI and every packaged executable pass `signtool.exe verify /pa`. +- Interactive and `/qn` installation succeed for a standard managed user with elevation supplied by the deployment system. +- Wire is installed under `Program Files` and appears exactly once in Apps & Features with the correct publisher, version, and icon. +- Start Menu and desktop shortcuts launch Wire and carry the configured application user model ID. +- The configured custom URL protocol launches the installed executable. +- A higher version upgrades in place, leaves one Apps & Features entry, and preserves user data. +- A lower version is rejected and same-version repair does not create a second installation. +- Upgrade behaviour while Wire is running is understood and documented for the deployment policy. +- Silent uninstall removes MSI-owned files, shortcuts, and protocol registration while preserving user data. +- An MSI-installed application neither schedules Squirrel updates nor reports a missing `Update.exe`. +- Production, Internal, Wire-Gov, and custom products do not share upgrade codes. +- The managed Squirrel-to-MSI removal and installation sequence has been tested on a representative existing profile. diff --git a/electron/src/runtime/lifecycle.ts b/electron/src/runtime/lifecycle.ts index e75fef7f030..6edd201bf19 100644 --- a/electron/src/runtime/lifecycle.ts +++ b/electron/src/runtime/lifecycle.ts @@ -38,7 +38,7 @@ export let isFirstInstance: boolean | undefined = undefined; const relaunchListeners: (() => void)[] = []; export async function initSquirrelListener(): Promise { - if (EnvironmentUtil.platform.IS_WINDOWS) { + if (EnvironmentUtil.platform.IS_WINDOWS && Squirrel.isSquirrelInstallation()) { logger.info('Checking for Windows update ...'); await Squirrel.handleSquirrelArgs(); diff --git a/electron/src/update/squirrel.test.main.ts b/electron/src/update/squirrel.test.main.ts new file mode 100644 index 00000000000..ef199bc730a --- /dev/null +++ b/electron/src/update/squirrel.test.main.ts @@ -0,0 +1,43 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'fs-extra'; + +import assert from 'node:assert'; +import os from 'node:os'; +import path from 'node:path'; + +import {isSquirrelInstallation} from './squirrelInstallation'; + +describe('Squirrel updater', () => { + const testDirectory = path.join(os.tmpdir(), `wire-squirrel-test-${process.pid}`); + const updaterPath = path.join(testDirectory, 'Update.exe'); + + afterEach(async () => fs.remove(testDirectory)); + + it('recognizes a Squirrel installation by its updater executable', async () => { + await fs.ensureFile(updaterPath); + + assert.strictEqual(isSquirrelInstallation(updaterPath), true); + }); + + it('does not treat an MSI installation as Squirrel', () => { + assert.strictEqual(isSquirrelInstallation(updaterPath), false); + }); +}); diff --git a/electron/src/update/squirrel.ts b/electron/src/update/squirrel.ts index 9bf9df2f4bc..b53e7673be7 100644 --- a/electron/src/update/squirrel.ts +++ b/electron/src/update/squirrel.ts @@ -19,14 +19,13 @@ // https://github.com/atom/atom/blob/ce18e1b7d65808c42df5b612d124935ab5c06490/src/main-process/squirrel-update.js -import fs from 'fs-extra'; - import * as childProcess from 'child_process'; import * as path from 'path'; import {StringUtil} from '@wireapp/commons'; import {createShortcuts, removeShortcuts} from './shortcuts'; +import {isSquirrelInstallation as updaterExists} from './squirrelInstallation'; import {getLogger} from '../logging/getLogger'; import * as EnvironmentUtil from '../runtime/EnvironmentUtil'; @@ -54,6 +53,10 @@ enum SQUIRREL_EVENT { UPDATED = '--squirrel-updated', } +export function isSquirrelInstallation(updaterPath: string = updateDotExe): boolean { + return updaterExists(updaterPath); +} + function spawn(command: string, args: string[]): Promise { const commandFile = path.basename(command); @@ -90,9 +93,9 @@ function spawn(command: string, args: string[]): Promise { async function spawnUpdate(args: string[]): Promise { logger.info(`Running updater with args ${JSON.stringify(args)} ...`); - const updateDotExeExists = fs.existsSync(updateDotExe); - if (!updateDotExeExists) { + if (!isSquirrelInstallation()) { logger.info(`Could not find updater in "${updateDotExe}".`); + return; } try { diff --git a/electron/src/update/squirrelInstallation.ts b/electron/src/update/squirrelInstallation.ts new file mode 100644 index 00000000000..0fbbef6c994 --- /dev/null +++ b/electron/src/update/squirrelInstallation.ts @@ -0,0 +1,24 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'fs-extra'; + +export function isSquirrelInstallation(updaterPath: string): boolean { + return fs.existsSync(updaterPath); +} diff --git a/jenkins/deployment.groovy b/jenkins/deployment.groovy index a50a7635f94..e1273dd4252 100644 --- a/jenkins/deployment.groovy +++ b/jenkins/deployment.groovy @@ -36,6 +36,7 @@ node('built-in') { def projectName = env.WRAPPER_BUILD.tokenize('#')[0] def version = env.WRAPPER_BUILD.tokenize('#')[1] + def isWindowsMsi = false echo("version: ${version}") def buildNumber = version.tokenize('.')[2] def NODE = tool name: 'node-v20.10.0', type: 'nodejs' @@ -59,6 +60,8 @@ node('built-in') { } } + isWindowsMsi = projectName.contains('Windows') && findFiles(glob: 'wrap/dist/*.msi').length > 0 + currentBuild.displayName = "Deploy ${projectName} ${version}" stage('Install dependencies') { @@ -89,15 +92,15 @@ node('built-in') { def AWS_SECRET_CREDENTIALS_ID = '' if (params.Release == 'Production') { - env.S3_PATH = 'win/prod' + env.S3_PATH = isWindowsMsi ? 'win/msi/prod' : 'win/prod' AWS_ACCESS_KEY_CREDENTIALS_ID = 'AWS_ACCESS_KEY_ID' AWS_SECRET_CREDENTIALS_ID = 'AWS_SECRET_ACCESS_KEY' } else if (params.Release == 'Internal') { - env.S3_PATH = 'win/internal' + env.S3_PATH = isWindowsMsi ? 'win/msi/internal' : 'win/internal' AWS_ACCESS_KEY_CREDENTIALS_ID = 'AWS_ACCESS_KEY_ID' AWS_SECRET_CREDENTIALS_ID = 'AWS_SECRET_ACCESS_KEY' } else if (params.Release == 'Wire-Gov') { - env.S3_PATH = 'win/wire-gov' + env.S3_PATH = isWindowsMsi ? 'win/msi/wire-gov' : 'win/wire-gov' env.S3_BUCKET = 'wire-taco' AWS_ACCESS_KEY_CREDENTIALS_ID = 'AWS_ACCESS_KEY_ID' AWS_SECRET_CREDENTIALS_ID = 'AWS_SECRET_ACCESS_KEY' @@ -389,7 +392,7 @@ node('built-in') { // ------------------------------------------------------------------------ // Windows-specific stage: Update RELEASES file for Squirrel auto-updates // ------------------------------------------------------------------------ - if (projectName.contains('Windows')) { + if (projectName.contains('Windows') && !isWindowsMsi) { stage('Update RELEASES file') { try { withEnv(["PATH+NODE=${NODE}/bin"]) { diff --git a/jenkins/windows.groovy b/jenkins/windows.groovy index 33fec4be868..71199129f91 100644 --- a/jenkins/windows.groovy +++ b/jenkins/windows.groovy @@ -56,21 +56,42 @@ node('windows') { } } - stage('Build installer') { + stage('Sign application') { + if (production) { + withCredentials([ + string(credentialsId: 'SM_API_KEY', variable: 'SM_API_KEY'), + string(credentialsId: 'SM_HOST', variable: 'SM_HOST'), + string(credentialsId: 'SM_CLIENT_CERT_PASSWORD', variable: 'SM_CLIENT_CERT_PASSWORD'), + file (credentialsId: 'SM_CLIENT_CERT_FILE', variable: 'SM_CLIENT_CERT_FILE'), + string(credentialsId: 'SM_KEYPAIR_ALIAS', variable: 'SM_KEYPAIR_ALIAS') + ]) { + try { + bat 'for /r "wrap\\build" %%f in (*.exe) do (smctl sign --keypair-alias %SM_KEYPAIR_ALIAS% --config-file %SM_CLIENT_CERT_FILE% --input %%f -v)' + } catch (e) { + currentBuild.result = 'FAILED' + wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} signing application failed**\n${BUILD_URL}" + throw e + } + } + } else { + echo 'Skipping application signing for non-production build (nightly/custom).' + } + } + + stage('Build MSI') { try { withEnv(["PATH+NODE=${NODE}", 'npm_config_target_arch=x64']) { if (production || custom) { - bat 'yarn build:win:installer' + bat 'yarn build:win:msi -m' } else if (wireGov) { - bat 'yarn build:win:installer:wire-gov' + bat 'yarn build:win:msi:wire-gov' } else { - // For internal builds disable auto-signing and use internal config - bat 'yarn build:win:installer:internal' + bat 'yarn build:win:msi:internal' } } } catch (e) { currentBuild.result = 'FAILED' - wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} building installer failed**\n${BUILD_URL}" + wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} building MSI failed**\n${BUILD_URL}" throw e } } @@ -85,7 +106,7 @@ node('windows') { string(credentialsId: 'SM_KEYPAIR_ALIAS', variable: 'SM_KEYPAIR_ALIAS') ]) { try { - bat 'for %%f in ("wrap\\dist\\*-Setup.exe") do (smctl sign --keypair-alias %SM_KEYPAIR_ALIAS% --config-file %SM_CLIENT_CERT_FILE% --input %%f -v)' + bat 'for %%f in ("wrap\\dist\\*.msi") do (smctl sign --keypair-alias %SM_KEYPAIR_ALIAS% --config-file %SM_CLIENT_CERT_FILE% --input %%f -v)' } catch (e) { currentBuild.result = 'FAILED' wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} signing installer failed**\n${BUILD_URL}" @@ -100,7 +121,8 @@ node('windows') { stage('verify') { if (production) { try { - bat 'for %%f in ("wrap\\dist\\*-Setup.exe") do (signtool.exe verify /v /pa %%f)' + bat 'for /r "wrap\\build" %%f in (*.exe) do (signtool.exe verify /v /pa %%f)' + bat 'for %%f in ("wrap\\dist\\*.msi") do (signtool.exe verify /v /pa %%f)' } catch (e) { currentBuild.result = 'FAILED' wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} verifying installer failed**\n${BUILD_URL}" @@ -118,7 +140,7 @@ node('windows') { stage('Print hash') { try { if (production) { - bat 'certUtil -hashfile "wrap\\dist\\Wire-Setup.exe" SHA256' + bat 'for %%f in ("wrap\\dist\\*.msi") do (certUtil -hashfile %%f SHA256)' } } catch (e) { currentBuild.result = 'FAILED' diff --git a/package.json b/package.json index 24c35f56caf..680dcc93ed7 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "build:win:wire-gov": "cross-env-shell APP_ENV=wire-gov \"yarn configure && yarn build:win\"", "build:win:wire-gov:ia32": "cross-env-shell APP_ENV=wire-gov \"yarn build:win -a ia32\"", "build:win:installer:wire-gov": "cross-env-shell APP_ENV=wire-gov \"yarn build:win:installer -m\"", + "build:win:msi:wire-gov": "cross-env-shell APP_ENV=wire-gov \"yarn build:win:msi -m\"", "build:prepare": "yarn clear:wrap && yarn build:ts && yarn bundle", "build:ts": "yarn clear:ts && tsc -P tsconfig.build.json", "build:ts:bin": "tsc -P tsconfig.bin.json --noEmit", @@ -165,10 +166,13 @@ "build:win": "yarn build:prepare && ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows", "build:win:installer": "rimraf wrap/dist && ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows-installer", "build:win:installer:internal": "cross-env-shell APP_ENV=internal \"yarn build:win:installer -m\"", + "build:win:msi": "rimraf wrap/dist && ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows-msi", + "build:win:msi:internal": "cross-env-shell APP_ENV=internal \"yarn build:win:msi -m\"", "build:win:internal": "cross-env-shell APP_ENV=internal \"yarn configure && yarn build:win\"", "build:win:ia32": "yarn build:win -a ia32", "build:win:installer:internal:ia32": "cross-env-shell APP_ENV=internal \"yarn build:win:installer:ia32 -m\"", "build:win:installer:ia32": "rimraf wrap/dist && yarn build:win -a ia32 && ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows-installer -a ia32", + "build:win:msi:ia32": "rimraf wrap/dist && yarn build:win -a ia32 && ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows-msi -a ia32", "bundle": "webpack --env production", "bundle:dev": "webpack", "clear:coverage": "rimraf coverage", From 36b9f2c9a1da1a1715cf6093641edf21c0bdf2b8 Mon Sep 17 00:00:00 2001 From: Adam Low Date: Tue, 18 Aug 2026 17:41:41 +0100 Subject: [PATCH 04/45] fix(windows): show assisted MSI installer UI --- bin/build-tools/lib/build-windows-msi.test.ts | 1 + bin/build-tools/lib/build-windows-msi.ts | 2 +- docs/windows-msi.md | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/build-tools/lib/build-windows-msi.test.ts b/bin/build-tools/lib/build-windows-msi.test.ts index 1d459d125f3..49c0e58d0f6 100644 --- a/bin/build-tools/lib/build-windows-msi.test.ts +++ b/bin/build-tools/lib/build-windows-msi.test.ts @@ -51,6 +51,7 @@ describe('build-windows-msi', () => { assert.strictEqual(windowsMsiConfig.appId, 'com.squirrel.wire.wire'); assert.strictEqual(windowsMsiConfig.upgradeCode, '620FCDDD-30CB-4241-A347-D34CF682A358'); + assert.strictEqual(builderConfig.msi?.oneClick, false); assert.strictEqual(builderConfig.msi?.perMachine, true); assert.strictEqual(builderConfig.msi?.runAfterFinish, false); assert.strictEqual(builderConfig.msi?.upgradeCode, windowsMsiConfig.upgradeCode); diff --git a/bin/build-tools/lib/build-windows-msi.ts b/bin/build-tools/lib/build-windows-msi.ts index 492a0652f80..e68cacdb600 100644 --- a/bin/build-tools/lib/build-windows-msi.ts +++ b/bin/build-tools/lib/build-windows-msi.ts @@ -148,7 +148,7 @@ export async function buildWindowsMsiConfig( artifactName: windowsMsiConfig.artifactName, createDesktopShortcut: true, createStartMenuShortcut: true, - oneClick: true, + oneClick: false, perMachine: true, runAfterFinish: false, shortcutName: commonConfig.name, diff --git a/docs/windows-msi.md b/docs/windows-msi.md index a67d72de2f5..78735e4f494 100644 --- a/docs/windows-msi.md +++ b/docs/windows-msi.md @@ -13,6 +13,8 @@ yarn build:win:msi -m `-m` disables electron-builder's automatic signing. Jenkins uses this mode because it signs the packaged Windows executables before creating the MSI, and signs the resulting MSI separately with the managed signing service. +Interactive installation uses the standard assisted Windows Installer flow, including welcome, installation location, readiness, progress, and completion pages. The managed deployment defaults create both desktop and Start Menu shortcuts; they are not optional features in the installer UI. + The standard Wire environments have permanent MSI upgrade codes in `build-windows-msi.ts`. Never change an upgrade code after its first release. A custom-branded build must set `WIN_MSI_UPGRADE_CODE` to its own permanent UUID so that it cannot collide with a standard Wire installation. The Squirrel installer remains available as a separate build target during migration. MSI artifacts are published under `win/msi/` and must never replace the `RELEASES` metadata used by existing Squirrel installations. From 32203de4df4d7593af5a47db36a9d0d48a58f9db Mon Sep 17 00:00:00 2001 From: Adam Low Date: Tue, 18 Aug 2026 17:51:18 +0100 Subject: [PATCH 05/45] feat(windows): brand MSI wizard for managed deployment --- bin/build-tools/assets/msi-banner.bmp | Bin 0 -> 85894 bytes bin/build-tools/lib/build-windows-msi.test.ts | 23 ++++++++++-------- bin/build-tools/lib/build-windows-msi.ts | 15 ++++++++++++ docs/windows-msi.md | 12 ++++++++- 4 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 bin/build-tools/assets/msi-banner.bmp diff --git a/bin/build-tools/assets/msi-banner.bmp b/bin/build-tools/assets/msi-banner.bmp new file mode 100644 index 0000000000000000000000000000000000000000..3ec5999b65c828cea6ecda40ad54af6743c11d1f GIT binary patch literal 85894 zcmeI5Jx(P@5P;d0KpQ!BOn`g>2Nsbd5@TP4oD=v6djS%}-hgBiv?~jNKmW5>0h=b=CAYPo-8*zj;6Z^82qZhW;x*^7jLOf71Mk z|1XBVryd&mjsAu2hOYju6jc9MBp?9^NI(J-kbndvAOQ(TKmrnwfCMBU0SQPz0uqpb z1SB8<2}nQ!5|DrdBp?9^NI(J-kbndvAOQ(T;86%%US6J`pP!tZ93LMa9Uc9BRS;;R zqzi*bxiEuDKmrnwKox;V-z4kLpFi*K@9*sFtgo*xFE9UbRS;;RqzePgOz2fK?UR56 zB+w=SGB^wEFJHdw?(UMbXJ%%Ies*^D@#Du&pFZXF>FFt57+{7mLm24tyv>_sED1SVmT;tF3QOz|?yE7#Z5YV#gM$NHnaW$ZYle`31SD`x;Ns$fN0*zMnz%@3)U zmX>gZL)?m30uqpb1R5tm?mj#`+}heA(qA#U8%N!Zp-rgogo`U0$c+|N*Wb_EB zH=3npxWzdkT$ML?7j-QG33QnN2TM2u4uMF@)NE=~@@6RS(-iE(ELY;35CrNnXXh>f z2}qz30&I8iFmrWv_3PKKk$F>%rl2e|u`Fl`%`gZhAqeE|N+WksrxK7rmkF@lv9`8G zs!at=l1qhw28=4TArSg5({}C>kbnegCcx^Luhip|^x8rp5aC>s*L)XsCjkj`nE+p) zfB*hH-`0sFju3_Bjo9Ph;LVR8-~JDS+xCr=56tD{mT&~^GIQrH0SQQ;HUgX;VZ$1C zB-Xr;bEAn6`I)V4G)X@?IvV=duU|)kj;^VxDfDAwW4Uo|fSzy!_25cvcTqFb?krDE8-WxX5k;e;N(WGl(VIf&2n-K6!P`6gP{K0eX5|BV` z1W2jua!^O%P~eSt8ZsFK={PlWgQQPF!AU`1a6-@s-C6QZTmll1Ks^NbwhsH$BzRXIBp?9^be2FoZN@Un1Bcx=V zD=|Y)31u#$NlwrY4-cn-=#v~~KI-i32hWL1KmxT9AdmAhaq1|f*BkNrKe;&9B!kG^ z(Ur?+avSn^t_k~O5TR?EjE*EA0SR=F0Ev}f@QL(F9R#9@j8Mqqp`=c^JD1bIG?ue2 z;WoOGK`0|M#uf=kKmro@Mgrth7R42V$=9X5k;kJemvdwEu)?CzJYF5T;;QVDfCMBUfm;H6!Sc4z zCpDVKt3&r)iwX%yKmrmdBfxRMGWE5t(L7!qy5g$rl7IvxAc0!~>`?QnliT)PskuB( zP&dc#!nP#=2}s~u2~_M;-^ z<;$1%b&;l1DYaqjt5>f|yHHjHy>Ezm2}nQ!5-2CY{zmmF^73I1(0X$G0q`6u0SQQ; zj6lmrM_LY7He+=Xkbnf97Xg;!{48=ib0UyWINL#O6bVS6TLgH0RXY=IEl@ktF^U8v zAb}@DfM4oq`f6GT(exDbF98Wi;OP=zyQ9%#Bsh0R<>}sjuS^0Gcpe1Evkg6j#H~sE zd3;Emg#;w<)Cn}PDt9IR)bG02CjkjOKLT~V{N(ufxGq=E53)0ofCMDaECCkg4}2`i zaQ9N0y-9kPfCMBkkO0R7d%fO3vw{IbJYQH~Vp9SVkicUSU|G%f$iRe-0cNhjAM;ij zTLKc0z(4||X$s42QZ@x4Gy?}}AOQ(TKmrnwfCMBU0SQPz0uqpb1SB8<2}nQ!5|Drd lBp?9^NI(J-kbndvAOQ(TKmrnwfCMBU0SQPz0um@C@GraaYZ?Fm literal 0 HcmV?d00001 diff --git a/bin/build-tools/lib/build-windows-msi.test.ts b/bin/build-tools/lib/build-windows-msi.test.ts index 49c0e58d0f6..46b699f8008 100644 --- a/bin/build-tools/lib/build-windows-msi.test.ts +++ b/bin/build-tools/lib/build-windows-msi.test.ts @@ -94,16 +94,19 @@ describe('build-windows-msi', () => { }); describe('customizeMsiProject', () => { - it('makes the URL protocol and desktop shortcut identity MSI-owned', () => { + it('brands the assisted UI and makes the URL protocol and desktop shortcut identity MSI-owned', () => { const project = ` - - - - - `; + + + + + + + `; - const result = customizeMsiProject(project, 'wire', 'com.squirrel.wire.wire', 'Wire'); + const result = customizeMsiProject(project, 'wire', 'com.squirrel.wire.wire', 'Wire', 'msi-banner.bmp'); + assert.match(result, /WixVariable Id="WixUIBannerBmp" Value="msi-banner\.bmp"/); assert.match(result, /ShortcutProperty Key="System\.AppUserModel\.ID" Value="com\.squirrel\.wire\.wire"/); assert.match(result, /RegistryKey Root="HKLM" Key="Software\\Classes\\wire"/); assert.match(result, /Value=""\[#mainExecutable\]" "%1""/); @@ -111,16 +114,16 @@ describe('build-windows-msi', () => { it('fails if electron-builder no longer generates the expected main executable component', () => { assert.throws( - () => customizeMsiProject('', 'wire', 'com.squirrel.wire.wire', 'Wire'), + () => customizeMsiProject('', 'wire', 'com.squirrel.wire.wire', 'Wire', 'msi-banner.bmp'), /Could not find the main executable/, ); }); it('fails if electron-builder no longer generates the expected desktop shortcut', () => { - const project = ''; + const project = ''; assert.throws( - () => customizeMsiProject(project, 'wire', 'com.squirrel.wire.wire', 'Wire'), + () => customizeMsiProject(project, 'wire', 'com.squirrel.wire.wire', 'Wire', 'msi-banner.bmp'), /Could not find the desktop shortcut/, ); }); diff --git a/bin/build-tools/lib/build-windows-msi.ts b/bin/build-tools/lib/build-windows-msi.ts index e68cacdb600..6e5c2a4c18c 100644 --- a/bin/build-tools/lib/build-windows-msi.ts +++ b/bin/build-tools/lib/build-windows-msi.ts @@ -66,6 +66,7 @@ export function customizeMsiProject( customProtocolName: string, appId: string, productName: string, + bannerFileName: string, ): string { if (!/^[a-z][a-z0-9+.-]*$/i.test(customProtocolName)) { throw new Error(`Invalid custom protocol name "${customProtocolName}".`); @@ -100,6 +101,16 @@ export function customizeMsiProject( let customizedProject = project.slice(0, componentEndPosition) + protocolRegistry + project.slice(componentEndPosition); + const productOpeningTag = /]*)?>/; + if (!productOpeningTag.test(customizedProject)) { + throw new Error('Could not find the product in the generated MSI project.'); + } + const banner = escapeXmlAttribute(bannerFileName); + customizedProject = customizedProject.replace( + productOpeningTag, + `$&\n `, + ); + const escapedAppId = escapeXmlAttribute(appId); const desktopShortcutPattern = /(]*?)\/>/; if (!desktopShortcutPattern.test(customizedProject)) { @@ -156,12 +167,16 @@ export async function buildWindowsMsiConfig( warningsAsErrors: true, }, msiProjectCreated: async projectFile => { + const bannerSource = path.join(mainDir, 'bin/build-tools/assets/msi-banner.bmp'); + const bannerFileName = path.basename(bannerSource); + await fs.copy(bannerSource, path.join(path.dirname(projectFile), bannerFileName)); const project = await fs.readFile(projectFile, 'utf8'); const customizedProject = customizeMsiProject( project, commonConfig.customProtocolName, windowsMsiConfig.appId, commonConfig.name, + bannerFileName, ); await fs.writeFile(projectFile, customizedProject); }, diff --git a/docs/windows-msi.md b/docs/windows-msi.md index 78735e4f494..32f0ad05427 100644 --- a/docs/windows-msi.md +++ b/docs/windows-msi.md @@ -13,7 +13,7 @@ yarn build:win:msi -m `-m` disables electron-builder's automatic signing. Jenkins uses this mode because it signs the packaged Windows executables before creating the MSI, and signs the resulting MSI separately with the managed signing service. -Interactive installation uses the standard assisted Windows Installer flow, including welcome, installation location, readiness, progress, and completion pages. The managed deployment defaults create both desktop and Start Menu shortcuts; they are not optional features in the installer UI. +Interactive installation uses the standard assisted Windows Installer flow with a Wire-branded banner, including welcome, installation location, readiness, progress, and completion pages. The managed deployment defaults create both desktop and Start Menu shortcuts; they are not optional features in the installer UI. The standard Wire environments have permanent MSI upgrade codes in `build-windows-msi.ts`. Never change an upgrade code after its first release. A custom-branded build must set `WIN_MSI_UPGRADE_CODE` to its own permanent UUID so that it cannot collide with a standard Wire installation. @@ -34,6 +34,16 @@ msiexec.exe /x Wire--x64.msi /qn /norestart /l*v Wire-uninstall.log The endpoint-management policy should close Wire before an upgrade. A deployment must handle Windows Installer exit codes, including reboot-required results, rather than treating every non-zero result as a generic failure. +## MDM deployment contract + +Assign the MSI to devices and install it in the local SYSTEM context. It is a per-machine package (`ALLUSERS=1`) and does not require an interactive user session; the assisted wizard is skipped when `/qn` is used. + +Use the endpoint-management system's MSI inventory for detection. A release has a version-specific product code, while the upgrade code remains stable for the product family. A custom detection rule should therefore check the installed product identity and version, not the MSI filename alone. + +Treat Windows Installer success and reboot-required results according to the endpoint-management system's conventions. In particular, `0` is success, while `1641` and `3010` indicate successful installation with a reboot initiated or required. Retain the verbose MSI log when diagnosing a failed deployment. + +Before broad deployment, validate fresh install, upgrade, repair, and uninstall while running as SYSTEM on every supported Windows version. The MSI and all packaged executables must be signed and timestamped with the production certificate; an unsigned local build is only suitable for development testing. + ## Migration from Squirrel Squirrel installs Wire per user under `%LocalAppData%`; the MSI installs it per machine under `Program Files`. Windows Installer cannot safely remove Squirrel installations from every user profile. Deployments must therefore remove the old Squirrel installation in each affected user context before assigning the MSI. Otherwise both installations can coexist and compete for shortcuts, auto-launch, and the `wire://` protocol. From 105d89bbdf85eccc1bff0784349d1c82030620aa Mon Sep 17 00:00:00 2001 From: Adam Low Date: Tue, 18 Aug 2026 18:03:42 +0100 Subject: [PATCH 06/45] fix(windows): sharpen MSI wizard branding --- bin/build-tools/assets/msi-banner.bmp | Bin 85894 -> 85894 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/bin/build-tools/assets/msi-banner.bmp b/bin/build-tools/assets/msi-banner.bmp index 3ec5999b65c828cea6ecda40ad54af6743c11d1f..bdc559de11c33181c110c362f308e77130b0add5 100644 GIT binary patch literal 85894 zcmeI*T}V`E9LI4j?S*8cx5-M03}T^+5}Gpa+KVoP#SKF+C4?Znu!1O<^iE28K{Ujx z2r44IFu^Xo>4kh3d_j!5=>-K1N*ySsc4u4N{W;J2G&pDUoQh>&zXv=%^FRM{=J0X( z?ZeZ&{PJscesa>>wqHL#>t|XIbM-qpDP8-dq~CPB>!+m1Po$vT-)smVfB*srAbKAKDk|#e==k*Mldf-1(ZD=(d!zIr zfB*srI0~5TwtL0v)2C1Ue!orPRjXEQ+_*74Jze`XYu4EMh7B9)>gvYF$1hyCVC&14 zEnB;GtH(>CH2tI(A3nF4r%}5#f#gwZ|CLZtzNxa0j=4$BHJXj=G|pwWm<$89UcAf;e%%1 zT2axuN+1x}zJ0ryr)Onlb$55$XAFnK8oRww`Vc?>0R$Wc6sBg=OkC^Qif`re)~#EW z*UIAggA0H6^z_&{yE3}CxOi-AY+=*=_vuv31A6^z`)p{d3>rV>T_0Yi8}(X^TxwO6hHT^ejj(0^70R#|0z^#A@(~61;#j94mA3uH^FNJMgE8y$buUCI= zZmt%=4;(n4v1aifKYp}5@doG5pI5Uc{(6O?>2Z6b^dW!%0th$?eEIT4>(^VhY*Dy;|^@86F;(Tq)4+xokA@65!%w6xS5 za=a^w2q1s}0&WGAu&u4FW{#|XfwY0U$RH;tNBsv69@N^1+VoNuvsQlO$dN_PvWL!{ zJEzn2e~!t?Nfq7RD18VZfB*uH0!q@eXU{5F_0Nw=OEwO%={{!5oG z*+W%TRjTM!E_%hGEt<0;*ZX<}J=A-GG=JBsdS73kdK~YHA_52?fPh;81?%C%hn2$l z`S~Vu7vJlB`R3m$wWwk{A3b`cD`s?)4no6r*?+LO+jaAVSx!&eui}9|Y)flrJUt3$N zX2-jthyVfzAmCO&X{*-`Duv6-%T4kwzSmnc)vWE+t5>#DV^h>oTZ}hQc5mCZP5oD{ zTv3nP8>J5c1Q0;LQ9w!RKF4QgXSKR*BK!60*LrBeT4Phx(RlG~>Nz^+JPVNHp2;`?0R#|8C;@Ujq5d4)3<3xs;8}ni_e{nK2q1t!LJ5%L3H9gT zW)MIC0nY-Sa=gF4U*E8-Pg5Bf7_iTzu_~(Ts4d1D@V@+bGSm@30D*55@RZ}hV9@9D zDYcItJ*w~X)KhA&u_~K4Z`RQzZ-N|Ol86Qb5J142fTtYSr2~P0zS>P&c6RoO6DPDc z4^?;b#N@aeC3+A*009pIckkZ4e*L<>lFj8gp-`x>uu$K|sX4gWG*(4*H&;-O-@JKK z&p0?Z=%$|@1Q0*~fgdjr4u_kYn+pmG{C>ao8msc-pN12Nsbd5@TP4oD=v6djS%}-hgBiv?~jNKmW5>0h=b=CAYPo-8*zj;6Z^82qZhW;x*^7jLOf71Mk z|1XBVryd&mjsAu2hOYju6jc9MBp?9^NI(J-kbndvAOQ(TKmrnwfCMBU0SQPz0uqpb z1SB8<2}nQ!5|DrdBp?9^NI(J-kbndvAOQ(T;86%%US6J`pP!tZ93LMa9Uc9BRS;;R zqzi*bxiEuDKmrnwKox;V-z4kLpFi*K@9*sFtgo*xFE9UbRS;;RqzePgOz2fK?UR56 zB+w=SGB^wEFJHdw?(UMbXJ%%Ies*^D@#Du&pFZXF>FFt57+{7mLm24tyv>_sED1SVmT;tF3QOz|?yE7#Z5YV#gM$NHnaW$ZYle`31SD`x;Ns$fN0*zMnz%@3)U zmX>gZL)?m30uqpb1R5tm?mj#`+}heA(qA#U8%N!Zp-rgogo`U0$c+|N*Wb_EB zH=3npxWzdkT$ML?7j-QG33QnN2TM2u4uMF@)NE=~@@6RS(-iE(ELY;35CrNnXXh>f z2}qz30&I8iFmrWv_3PKKk$F>%rl2e|u`Fl`%`gZhAqeE|N+WksrxK7rmkF@lv9`8G zs!at=l1qhw28=4TArSg5({}C>kbnegCcx^Luhip|^x8rp5aC>s*L)XsCjkj`nE+p) zfB*hH-`0sFju3_Bjo9Ph;LVR8-~JDS+xCr=56tD{mT&~^GIQrH0SQQ;HUgX;VZ$1C zB-Xr;bEAn6`I)V4G)X@?IvV=duU|)kj;^VxDfDAwW4Uo|fSzy!_25cvcTqFb?krDE8-WxX5k;e;N(WGl(VIf&2n-K6!P`6gP{K0eX5|BV` z1W2jua!^O%P~eSt8ZsFK={PlWgQQPF!AU`1a6-@s-C6QZTmll1Ks^NbwhsH$BzRXIBp?9^be2FoZN@Un1Bcx=V zD=|Y)31u#$NlwrY4-cn-=#v~~KI-i32hWL1KmxT9AdmAhaq1|f*BkNrKe;&9B!kG^ z(Ur?+avSn^t_k~O5TR?EjE*EA0SR=F0Ev}f@QL(F9R#9@j8Mqqp`=c^JD1bIG?ue2 z;WoOGK`0|M#uf=kKmro@Mgrth7R42V$=9X5k;kJemvdwEu)?CzJYF5T;;QVDfCMBUfm;H6!Sc4z zCpDVKt3&r)iwX%yKmrmdBfxRMGWE5t(L7!qy5g$rl7IvxAc0!~>`?QnliT)PskuB( zP&dc#!nP#=2}s~u2~_M;-^ z<;$1%b&;l1DYaqjt5>f|yHHjHy>Ezm2}nQ!5-2CY{zmmF^73I1(0X$G0q`6u0SQQ; zj6lmrM_LY7He+=Xkbnf97Xg;!{48=ib0UyWINL#O6bVS6TLgH0RXY=IEl@ktF^U8v zAb}@DfM4oq`f6GT(exDbF98Wi;OP=zyQ9%#Bsh0R<>}sjuS^0Gcpe1Evkg6j#H~sE zd3;Emg#;w<)Cn}PDt9IR)bG02CjkjOKLT~V{N(ufxGq=E53)0ofCMDaECCkg4}2`i zaQ9N0y-9kPfCMBkkO0R7d%fO3vw{IbJYQH~Vp9SVkicUSU|G%f$iRe-0cNhjAM;ij zTLKc0z(4||X$s42QZ@x4Gy?}}AOQ(TKmrnwfCMBU0SQPz0uqpb1SB8<2}nQ!5|Drd lBp?9^NI(J-kbndvAOQ(TKmrnwfCMBU0SQPz0um@C@GraaYZ?Fm From 68b50ca151dcd040b5db27fc6020ecfadeafe0ff Mon Sep 17 00:00:00 2001 From: Adam Low Date: Tue, 18 Aug 2026 19:34:48 +0100 Subject: [PATCH 07/45] fix(windows): harden MSI release pipeline --- bin/build-tools/lib/Config.ts | 1 + bin/build-tools/lib/build-windows-msi.test.ts | 25 ++++++++++++++++--- bin/build-tools/lib/build-windows-msi.ts | 21 +++++++++++++++- bin/deploy-tools/lib/S3Deployer.test.ts | 9 ++++--- bin/deploy-tools/lib/S3Deployer.ts | 12 ++++++--- docs/windows-msi.md | 7 +++--- jenkins/windows.groovy | 8 +++--- 7 files changed, 65 insertions(+), 18 deletions(-) diff --git a/bin/build-tools/lib/Config.ts b/bin/build-tools/lib/Config.ts index 276e9d67abc..8823354ac76 100644 --- a/bin/build-tools/lib/Config.ts +++ b/bin/build-tools/lib/Config.ts @@ -76,5 +76,6 @@ export interface WindowsInstallerConfig { export interface WindowsMsiConfig { appId: string; artifactName: string; + manufacturer: string; upgradeCode: string; } diff --git a/bin/build-tools/lib/build-windows-msi.test.ts b/bin/build-tools/lib/build-windows-msi.test.ts index 46b699f8008..04d25ba231a 100644 --- a/bin/build-tools/lib/build-windows-msi.test.ts +++ b/bin/build-tools/lib/build-windows-msi.test.ts @@ -27,6 +27,7 @@ const envFilePath = path.join(__dirname, '../../../.env.defaults'); const originalEnvironment = { appEnvironment: process.env.APP_ENV, appName: process.env.APP_NAME, + manufacturer: process.env.WIN_MSI_MANUFACTURER, upgradeCode: process.env.WIN_MSI_UPGRADE_CODE, }; @@ -42,6 +43,7 @@ describe('build-windows-msi', () => { afterEach(() => { restoreEnvironmentVariable('APP_ENV', originalEnvironment.appEnvironment); restoreEnvironmentVariable('APP_NAME', originalEnvironment.appName); + restoreEnvironmentVariable('WIN_MSI_MANUFACTURER', originalEnvironment.manufacturer); restoreEnvironmentVariable('WIN_MSI_UPGRADE_CODE', originalEnvironment.upgradeCode); }); @@ -50,7 +52,9 @@ describe('build-windows-msi', () => { const {builderConfig, windowsMsiConfig} = await buildWindowsMsiConfig(wireJsonPath, envFilePath, true); assert.strictEqual(windowsMsiConfig.appId, 'com.squirrel.wire.wire'); + assert.strictEqual(windowsMsiConfig.manufacturer, 'Wire Swiss GmbH'); assert.strictEqual(windowsMsiConfig.upgradeCode, '620FCDDD-30CB-4241-A347-D34CF682A358'); + assert.deepStrictEqual(builderConfig.extraMetadata?.author, {name: windowsMsiConfig.manufacturer}); assert.strictEqual(builderConfig.msi?.oneClick, false); assert.strictEqual(builderConfig.msi?.perMachine, true); assert.strictEqual(builderConfig.msi?.runAfterFinish, false); @@ -73,11 +77,20 @@ describe('build-windows-msi', () => { }); it('honors and normalizes a configured upgrade code', async () => { - process.env.WIN_MSI_UPGRADE_CODE = '{C88AA646-1E4B-4C0A-A05A-8BB72BBADBBB}'; + process.env.WIN_MSI_UPGRADE_CODE = '{C88AA646-1E4B-FC0A-005A-8BB72BBADBBB}'; const {windowsMsiConfig} = await buildWindowsMsiConfig(wireJsonPath, envFilePath); - assert.strictEqual(windowsMsiConfig.upgradeCode, 'C88AA646-1E4B-4C0A-A05A-8BB72BBADBBB'); + assert.strictEqual(windowsMsiConfig.upgradeCode, 'C88AA646-1E4B-FC0A-005A-8BB72BBADBBB'); + }); + + it('honors a configured manufacturer', async () => { + process.env.WIN_MSI_MANUFACTURER = 'Customer Corporation'; + + const {builderConfig, windowsMsiConfig} = await buildWindowsMsiConfig(wireJsonPath, envFilePath); + + assert.strictEqual(windowsMsiConfig.manufacturer, 'Customer Corporation'); + assert.strictEqual(builderConfig.extraMetadata?.author?.name, windowsMsiConfig.manufacturer); }); it('rejects an invalid configured upgrade code', async () => { @@ -97,6 +110,7 @@ describe('build-windows-msi', () => { it('brands the assisted UI and makes the URL protocol and desktop shortcut identity MSI-owned', () => { const project = ` + = 601]]> @@ -107,6 +121,8 @@ describe('build-windows-msi', () => { const result = customizeMsiProject(project, 'wire', 'com.squirrel.wire.wire', 'Wire', 'msi-banner.bmp'); assert.match(result, /WixVariable Id="WixUIBannerBmp" Value="msi-banner\.bmp"/); + assert.match(result, /Windows 10 or above is required/); + assert.match(result, /VersionNT >= 1000/); assert.match(result, /ShortcutProperty Key="System\.AppUserModel\.ID" Value="com\.squirrel\.wire\.wire"/); assert.match(result, /RegistryKey Root="HKLM" Key="Software\\Classes\\wire"/); assert.match(result, /Value=""\[#mainExecutable\]" "%1""/); @@ -120,7 +136,10 @@ describe('build-windows-msi', () => { }); it('fails if electron-builder no longer generates the expected desktop shortcut', () => { - const project = ''; + const project = ` + = 601]]> + + `; assert.throws( () => customizeMsiProject(project, 'wire', 'com.squirrel.wire.wire', 'Wire', 'msi-banner.bmp'), diff --git a/bin/build-tools/lib/build-windows-msi.ts b/bin/build-tools/lib/build-windows-msi.ts index 6e5c2a4c18c..4a640497f9f 100644 --- a/bin/build-tools/lib/build-windows-msi.ts +++ b/bin/build-tools/lib/build-windows-msi.ts @@ -30,6 +30,7 @@ import {backupFiles, getLogger, restoreFiles} from '../../bin-utils'; const libraryName = path.basename(__filename).replace('.ts', ''); const logger = getLogger('build-tools', libraryName); const mainDir = path.resolve(__dirname, '../../../'); +const DEFAULT_MSI_MANUFACTURER = 'Wire Swiss GmbH'; const DEFAULT_MSI_IDENTITIES = { internal: {productName: 'WireInternal', upgradeCode: '673C5C7F-2923-483B-8EDB-34EDBDFCFF8A'}, @@ -53,7 +54,7 @@ function escapeXmlAttribute(value: string): string { function validateUpgradeCode(upgradeCode: string): string { const normalized = upgradeCode.replace(/^\{(.+)\}$/, '$1').toUpperCase(); - if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[1-5][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/.test(normalized)) { + if (!/^[0-9A-F]{8}(?:-[0-9A-F]{4}){3}-[0-9A-F]{12}$/.test(normalized)) { throw new Error(`Invalid Windows MSI upgrade code "${upgradeCode}".`); } return normalized; @@ -111,6 +112,16 @@ export function customizeMsiProject( `$&\n `, ); + const electronBuilderOsCondition = + /= 601\]\]><\/Condition>/; + if (!electronBuilderOsCondition.test(customizedProject)) { + throw new Error('Could not find the operating system condition in the generated MSI project.'); + } + customizedProject = customizedProject.replace( + electronBuilderOsCondition, + '= 1000]]>', + ); + const escapedAppId = escapeXmlAttribute(appId); const desktopShortcutPattern = /(]*?)\/>/; if (!desktopShortcutPattern.test(customizedProject)) { @@ -140,10 +151,15 @@ export async function buildWindowsMsiConfig( } const configuredUpgradeCode = process.env.WIN_MSI_UPGRADE_CODE || defaultIdentity.upgradeCode; const upgradeCode = validateUpgradeCode(configuredUpgradeCode); + const manufacturer = (process.env.WIN_MSI_MANUFACTURER || DEFAULT_MSI_MANUFACTURER).trim(); + if (!manufacturer) { + throw new Error('Windows MSI manufacturer must not be empty.'); + } const windowsMsiConfig: WindowsMsiConfig = { appId, artifactName: '${productName}-${version}-${arch}.${ext}', + manufacturer, upgradeCode, }; @@ -155,6 +171,9 @@ export async function buildWindowsMsiConfig( buildResources: commonConfig.electronDirectory, output: commonConfig.distDir, }, + extraMetadata: { + author: {name: windowsMsiConfig.manufacturer}, + }, msi: { artifactName: windowsMsiConfig.artifactName, createDesktopShortcut: true, diff --git a/bin/deploy-tools/lib/S3Deployer.test.ts b/bin/deploy-tools/lib/S3Deployer.test.ts index 7796f27a879..77169e31000 100644 --- a/bin/deploy-tools/lib/S3Deployer.test.ts +++ b/bin/deploy-tools/lib/S3Deployer.test.ts @@ -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 @@ -14,10 +14,12 @@ * * 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 assert from 'node:assert'; import fs from 'fs-extra'; + +import assert from 'node:assert'; import os from 'node:os'; import path from 'node:path'; @@ -38,7 +40,7 @@ describe('S3Deployer', () => { secretAccessKey: '', }); - await s3Deployer.copyOnS3({bucket: '', s3FromPath: '', s3ToPath: ''}); + await assert.doesNotReject(() => s3Deployer.copyOnS3({bucket: '', s3FromPath: '', s3ToPath: ''})); }); }); @@ -48,6 +50,7 @@ describe('S3Deployer', () => { temporaryDirectories.push(basePath); const fileName = 'Wire-3.42.123-x64.msi'; await fs.ensureFile(path.join(basePath, fileName)); + await fs.ensureFile(path.join(basePath, 'Wire-3.42.122-x64.msi')); const s3Deployer = new S3Deployer({accessKeyId: '', dryRun: true, secretAccessKey: ''}); const files = await s3Deployer.findUploadFiles('wrapper_windows_production', basePath, '3.42.123'); diff --git a/bin/deploy-tools/lib/S3Deployer.ts b/bin/deploy-tools/lib/S3Deployer.ts index 8e5511e15ec..43a2aae4fef 100644 --- a/bin/deploy-tools/lib/S3Deployer.ts +++ b/bin/deploy-tools/lib/S3Deployer.ts @@ -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 @@ -14,16 +14,20 @@ * * 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 {Logger} from '@wireapp/commons'; -import {getLogger} from '../../bin-utils'; import S3 from 'aws-sdk/clients/s3'; import fs from 'fs-extra'; + import path from 'path'; +import {Logger} from '@wireapp/commons'; + import {find, FindResult, logDry} from './deploy-utils'; +import {getLogger} from '../../bin-utils'; + export interface S3DeployerOptions { accessKeyId: string; dryRun?: boolean; @@ -94,7 +98,7 @@ export class S3Deployer { }, ]; } else if (platform.includes('windows')) { - const msi = await find('*.msi', {cwd: basePath, safeGuard: false}); + const msi = await find(`*-${version}-*.msi`, {cwd: basePath, safeGuard: false}); if (msi) { return [{...msi, filePath: path.join(basePath, msi.fileName)}]; } diff --git a/docs/windows-msi.md b/docs/windows-msi.md index 32f0ad05427..bf6510c5e85 100644 --- a/docs/windows-msi.md +++ b/docs/windows-msi.md @@ -1,6 +1,6 @@ # Windows MSI distribution -Wire's native MSI is intended for managed, per-machine Windows deployments. It installs the existing packaged Electron application under `Program Files`; it does not wrap or invoke the legacy Squirrel `Setup.exe`. +Wire's native MSI is intended for managed, per-machine Windows 10 and later deployments. It installs the existing packaged Electron application under `Program Files`; it does not wrap or invoke the legacy Squirrel `Setup.exe`. ## Build @@ -15,7 +15,7 @@ yarn build:win:msi -m Interactive installation uses the standard assisted Windows Installer flow with a Wire-branded banner, including welcome, installation location, readiness, progress, and completion pages. The managed deployment defaults create both desktop and Start Menu shortcuts; they are not optional features in the installer UI. -The standard Wire environments have permanent MSI upgrade codes in `build-windows-msi.ts`. Never change an upgrade code after its first release. A custom-branded build must set `WIN_MSI_UPGRADE_CODE` to its own permanent UUID so that it cannot collide with a standard Wire installation. +The standard Wire environments have permanent MSI upgrade codes in `build-windows-msi.ts`. Never change an upgrade code after its first release. A custom-branded build must set `WIN_MSI_UPGRADE_CODE` to its own permanent GUID so that it cannot collide with a standard Wire installation. The MSI manufacturer defaults to `Wire Swiss GmbH`; an OEM build can override it with `WIN_MSI_MANUFACTURER`. The Squirrel installer remains available as a separate build target during migration. MSI artifacts are published under `win/msi/` and must never replace the `RELEASES` metadata used by existing Squirrel installations. @@ -54,7 +54,8 @@ Do not delete the Squirrel update feed while supported Squirrel installations re A release candidate is acceptable only after it has been exercised on a supported Windows version and satisfies all of the following: -- The MSI and every packaged executable pass `signtool.exe verify /pa`. +- The Windows WiX build completes with MSI validation and warnings-as-errors enabled. +- The MSI and every packaged executable pass `signtool.exe verify /pa /all /tw`, including their SHA-256 signatures and timestamps. - Interactive and `/qn` installation succeed for a standard managed user with elevation supplied by the deployment system. - Wire is installed under `Program Files` and appears exactly once in Apps & Features with the correct publisher, version, and icon. - Start Menu and desktop shortcuts launch Wire and carry the configured application user model ID. diff --git a/jenkins/windows.groovy b/jenkins/windows.groovy index 71199129f91..0c0ec7f0703 100644 --- a/jenkins/windows.groovy +++ b/jenkins/windows.groovy @@ -66,7 +66,7 @@ node('windows') { string(credentialsId: 'SM_KEYPAIR_ALIAS', variable: 'SM_KEYPAIR_ALIAS') ]) { try { - bat 'for /r "wrap\\build" %%f in (*.exe) do (smctl sign --keypair-alias %SM_KEYPAIR_ALIAS% --config-file %SM_CLIENT_CERT_FILE% --input %%f -v)' + bat 'for /r "wrap\\build" %%f in (*.exe) do (smctl sign --keypair-alias "%SM_KEYPAIR_ALIAS%" --config-file "%SM_CLIENT_CERT_FILE%" --input "%%~ff" --digalg SHA256 --timestamp --failfast --exit-non-zero-on-fail -v || exit /b 1)' } catch (e) { currentBuild.result = 'FAILED' wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} signing application failed**\n${BUILD_URL}" @@ -106,7 +106,7 @@ node('windows') { string(credentialsId: 'SM_KEYPAIR_ALIAS', variable: 'SM_KEYPAIR_ALIAS') ]) { try { - bat 'for %%f in ("wrap\\dist\\*.msi") do (smctl sign --keypair-alias %SM_KEYPAIR_ALIAS% --config-file %SM_CLIENT_CERT_FILE% --input %%f -v)' + bat 'for %%f in ("wrap\\dist\\*.msi") do (smctl sign --keypair-alias "%SM_KEYPAIR_ALIAS%" --config-file "%SM_CLIENT_CERT_FILE%" --input "%%~ff" --digalg SHA256 --timestamp --failfast --exit-non-zero-on-fail -v || exit /b 1)' } catch (e) { currentBuild.result = 'FAILED' wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} signing installer failed**\n${BUILD_URL}" @@ -121,8 +121,8 @@ node('windows') { stage('verify') { if (production) { try { - bat 'for /r "wrap\\build" %%f in (*.exe) do (signtool.exe verify /v /pa %%f)' - bat 'for %%f in ("wrap\\dist\\*.msi") do (signtool.exe verify /v /pa %%f)' + bat 'for /r "wrap\\build" %%f in (*.exe) do (signtool.exe verify /v /pa /all /tw "%%~ff" || exit /b 1)' + bat 'for %%f in ("wrap\\dist\\*.msi") do (signtool.exe verify /v /pa /all /tw "%%~ff" || exit /b 1)' } catch (e) { currentBuild.result = 'FAILED' wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} verifying installer failed**\n${BUILD_URL}" From 736e10b14822259f746cb53ae73d2c568e0cd554 Mon Sep 17 00:00:00 2001 From: Adam Low Date: Wed, 19 Aug 2026 16:41:53 +0100 Subject: [PATCH 08/45] fix(windows): detect Windows 10 correctly in MSI --- bin/build-tools/lib/build-windows-msi.test.ts | 3 ++- bin/build-tools/lib/build-windows-msi.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bin/build-tools/lib/build-windows-msi.test.ts b/bin/build-tools/lib/build-windows-msi.test.ts index 04d25ba231a..936935d77a4 100644 --- a/bin/build-tools/lib/build-windows-msi.test.ts +++ b/bin/build-tools/lib/build-windows-msi.test.ts @@ -122,7 +122,8 @@ describe('build-windows-msi', () => { assert.match(result, /WixVariable Id="WixUIBannerBmp" Value="msi-banner\.bmp"/); assert.match(result, /Windows 10 or above is required/); - assert.match(result, /VersionNT >= 1000/); + assert.match(result, /VersionNT >= 603 AND WindowsBuild >= 10240/); + assert.doesNotMatch(result, /VersionNT >= 1000/); assert.match(result, /ShortcutProperty Key="System\.AppUserModel\.ID" Value="com\.squirrel\.wire\.wire"/); assert.match(result, /RegistryKey Root="HKLM" Key="Software\\Classes\\wire"/); assert.match(result, /Value=""\[#mainExecutable\]" "%1""/); diff --git a/bin/build-tools/lib/build-windows-msi.ts b/bin/build-tools/lib/build-windows-msi.ts index 4a640497f9f..af061b2d048 100644 --- a/bin/build-tools/lib/build-windows-msi.ts +++ b/bin/build-tools/lib/build-windows-msi.ts @@ -119,7 +119,7 @@ export function customizeMsiProject( } customizedProject = customizedProject.replace( electronBuilderOsCondition, - '= 1000]]>', + '= 603 AND WindowsBuild >= 10240)]]>', ); const escapedAppId = escapeXmlAttribute(appId); From a3be121c90cd43fefd4c2ea2d5557259cc4274d7 Mon Sep 17 00:00:00 2001 From: Adam Low Date: Wed, 19 Aug 2026 16:46:46 +0100 Subject: [PATCH 09/45] fix(windows): publish MSI alongside Squirrel --- .../lib/build-windows-installer.test.ts | 26 +++++++- .../lib/build-windows-installer.ts | 5 +- bin/deploy-tools/lib/S3Deployer.test.ts | 28 ++++++++- bin/deploy-tools/lib/S3Deployer.ts | 18 +++++- bin/deploy-tools/s3-cli.ts | 17 +++++- docs/windows-msi.md | 6 +- jenkins/deployment.groovy | 60 ++++++++++++++----- jenkins/windows.groovy | 17 +++--- package.json | 7 ++- 9 files changed, 145 insertions(+), 39 deletions(-) diff --git a/bin/build-tools/lib/build-windows-installer.test.ts b/bin/build-tools/lib/build-windows-installer.test.ts index 723a17bd4e0..e36097e0477 100644 --- a/bin/build-tools/lib/build-windows-installer.test.ts +++ b/bin/build-tools/lib/build-windows-installer.test.ts @@ -17,9 +17,12 @@ */ import * as assert from 'assert'; +import fs from 'fs-extra'; +import os from 'os'; import * as path from 'path'; +import type {Options as ElectronWinstallerOptions} from 'electron-winstaller'; -import {buildWindowsInstallerConfig} from './build-windows-installer'; +import {buildWindowsInstaller, buildWindowsInstallerConfig} from './build-windows-installer'; import {generateUUID} from '../../bin-utils'; const wireJsonPath = path.join(__dirname, '../../../electron/wire.json'); @@ -39,4 +42,25 @@ describe('build-windows-installer', () => { delete process.env.WIN_URL_ICON_INSTALLER; }); }); + + describe('buildWindowsInstaller', () => { + it('rejects when Squirrel packaging fails and restores wire.json', async () => { + const temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'wire-squirrel-installer-test-')); + const temporaryWireJsonPath = path.join(temporaryDirectory, 'wire.json'); + const originalWireJson = await fs.readFile(wireJsonPath, 'utf8'); + await fs.writeFile(temporaryWireJsonPath, originalWireJson); + + try { + await assert.rejects( + buildWindowsInstaller(temporaryWireJsonPath, envFilePath, { + appDirectory: path.join(temporaryDirectory, 'missing-app-directory'), + outputDirectory: path.join(temporaryDirectory, 'output'), + } as ElectronWinstallerOptions), + ); + assert.strictEqual(await fs.readFile(temporaryWireJsonPath, 'utf8'), originalWireJson); + } finally { + await fs.remove(temporaryDirectory); + } + }); + }); }); diff --git a/bin/build-tools/lib/build-windows-installer.ts b/bin/build-tools/lib/build-windows-installer.ts index e3b29b4be6c..a2af82491c8 100755 --- a/bin/build-tools/lib/build-windows-installer.ts +++ b/bin/build-tools/lib/build-windows-installer.ts @@ -99,7 +99,8 @@ export async function buildWindowsInstaller( logger.log(`Built installer in "${buildDir}"`); } catch (error) { logger.error(error); + throw error; + } finally { + await restoreFiles(backup); } - - await restoreFiles(backup); } diff --git a/bin/deploy-tools/lib/S3Deployer.test.ts b/bin/deploy-tools/lib/S3Deployer.test.ts index 77169e31000..46c160b1cde 100644 --- a/bin/deploy-tools/lib/S3Deployer.test.ts +++ b/bin/deploy-tools/lib/S3Deployer.test.ts @@ -45,17 +45,41 @@ describe('S3Deployer', () => { }); describe('findUploadFiles', () => { - it('selects a native MSI without requiring Squirrel release artifacts', async () => { + it('selects the requested native MSI when Squirrel artifacts are also present', async () => { const basePath = await fs.mkdtemp(path.join(os.tmpdir(), 'wire-msi-deployer-')); temporaryDirectories.push(basePath); const fileName = 'Wire-3.42.123-x64.msi'; await fs.ensureFile(path.join(basePath, fileName)); await fs.ensureFile(path.join(basePath, 'Wire-3.42.122-x64.msi')); + await fs.ensureFile(path.join(basePath, 'Wire-Setup.exe')); + await fs.ensureFile(path.join(basePath, 'Wire-3.42.123-full.nupkg')); + await fs.ensureFile(path.join(basePath, 'RELEASES')); const s3Deployer = new S3Deployer({accessKeyId: '', dryRun: true, secretAccessKey: ''}); - const files = await s3Deployer.findUploadFiles('wrapper_windows_production', basePath, '3.42.123'); + const files = await s3Deployer.findUploadFiles('wrapper_windows_production', basePath, '3.42.123', 'msi'); assert.deepStrictEqual(files, [{fileName, filePath: path.join(basePath, fileName)}]); }); + + it('selects Squirrel artifacts when an MSI is also present', async () => { + const basePath = await fs.mkdtemp(path.join(os.tmpdir(), 'wire-squirrel-deployer-')); + temporaryDirectories.push(basePath); + await fs.ensureFile(path.join(basePath, 'Wire-3.42.123-x64.msi')); + await fs.ensureFile(path.join(basePath, 'Wire-Setup.exe')); + await fs.ensureFile(path.join(basePath, 'Wire-3.42.123-full.nupkg')); + await fs.ensureFile(path.join(basePath, 'RELEASES')); + const s3Deployer = new S3Deployer({accessKeyId: '', dryRun: true, secretAccessKey: ''}); + + const files = await s3Deployer.findUploadFiles('wrapper_windows_production', basePath, '3.42.123', 'squirrel'); + + assert.deepStrictEqual(files, [ + { + fileName: 'Wire-3.42.123-full.nupkg', + filePath: path.join(basePath, 'Wire-3.42.123-full.nupkg'), + }, + {fileName: 'Wire-3.42.123-RELEASES', filePath: path.join(basePath, 'RELEASES')}, + {fileName: 'Wire-3.42.123.exe', filePath: path.join(basePath, 'Wire-Setup.exe')}, + ]); + }); }); }); diff --git a/bin/deploy-tools/lib/S3Deployer.ts b/bin/deploy-tools/lib/S3Deployer.ts index 43a2aae4fef..da656e03491 100644 --- a/bin/deploy-tools/lib/S3Deployer.ts +++ b/bin/deploy-tools/lib/S3Deployer.ts @@ -51,6 +51,8 @@ export interface S3CopyOptions { s3ToPath: string; } +export type WindowsArtifactType = 'auto' | 'msi' | 'squirrel'; + export class S3Deployer { private readonly options: Required; private readonly S3Instance: S3; @@ -69,7 +71,12 @@ export class S3Deployer { this.logger = getLogger('deploy-tools', toolName); } - async findUploadFiles(platform: string, basePath: string, version: string): Promise { + async findUploadFiles( + platform: string, + basePath: string, + version: string, + windowsArtifact: WindowsArtifactType = 'auto', + ): Promise { if (platform.includes('linux')) { const appImage = await find('*.AppImage', {cwd: basePath}); const debImage = await find('*.deb', {cwd: basePath}); @@ -98,8 +105,15 @@ export class S3Deployer { }, ]; } else if (platform.includes('windows')) { + if (!['auto', 'msi', 'squirrel'].includes(windowsArtifact)) { + throw new Error(`Invalid Windows artifact type "${windowsArtifact}"`); + } + const msi = await find(`*-${version}-*.msi`, {cwd: basePath, safeGuard: false}); - if (msi) { + if (windowsArtifact === 'msi' && !msi) { + throw new Error(`Could not find an MSI for version "${version}".`); + } + if (msi && windowsArtifact !== 'squirrel') { return [{...msi, filePath: path.join(basePath, msi.fileName)}]; } diff --git a/bin/deploy-tools/s3-cli.ts b/bin/deploy-tools/s3-cli.ts index 8e8e48ba1f4..30bd5367daa 100755 --- a/bin/deploy-tools/s3-cli.ts +++ b/bin/deploy-tools/s3-cli.ts @@ -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 @@ -14,13 +14,16 @@ * * 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 {program as commander} from 'commander'; + import path from 'path'; +import {S3Deployer, WindowsArtifactType} from './lib/S3Deployer'; + import {checkCommanderOptions, getLogger} from '../bin-utils'; -import {S3Deployer} from './lib/S3Deployer'; const toolName = path.basename(__filename).replace(/\.[jt]s$/, ''); const logger = getLogger('deploy-tools', toolName); @@ -34,6 +37,7 @@ commander .option('-k, --secret-key ', 'Specify the AWS secret access key ID') .option('-p, --path ', 'Specify the local path to search for files (e.g. "../../wrap")') .option('-s, --s3path ', 'Specify the base path on S3 (e.g. "apps/windows")') + .option('--windows-artifact ', 'Select Windows artifacts: auto, squirrel, or msi', 'auto') .option('-w, --wrapper-build ', 'Specify the wrapper build (e.g. "Linux#3.7.1234")') .parse(process.argv); @@ -47,6 +51,13 @@ if (!commanderOptions.wrapperBuild.includes('#')) { process.exit(1); } +const windowsArtifact = commanderOptions.windowsArtifact as WindowsArtifactType; +if (!['auto', 'msi', 'squirrel'].includes(windowsArtifact)) { + logger.error(`Invalid Windows artifact type "${windowsArtifact}"`); + commander.outputHelp(); + process.exit(1); +} + (async () => { const searchBasePath = commanderOptions.path || path.join(__dirname, '../../wrap'); const s3BasePath = `${commanderOptions.s3path || ''}/`; @@ -55,7 +66,7 @@ if (!commanderOptions.wrapperBuild.includes('#')) { const s3Deployer = new S3Deployer({accessKeyId, dryRun: commanderOptions.dryRun || false, secretAccessKey}); - const files = await s3Deployer.findUploadFiles(platform, searchBasePath, version); + const files = await s3Deployer.findUploadFiles(platform, searchBasePath, version, windowsArtifact); for (let index = 0; index < files.length; index++) { const file = files[index]; diff --git a/docs/windows-msi.md b/docs/windows-msi.md index bf6510c5e85..f6188559ac3 100644 --- a/docs/windows-msi.md +++ b/docs/windows-msi.md @@ -11,15 +11,17 @@ yarn build:win yarn build:win:msi -m ``` +The Windows Jenkins job uses `yarn build:win:installers` to create the Squirrel and MSI artifacts together without cleaning `wrap/dist` between them. The individual installer commands continue to clean their output for local builds. + `-m` disables electron-builder's automatic signing. Jenkins uses this mode because it signs the packaged Windows executables before creating the MSI, and signs the resulting MSI separately with the managed signing service. Interactive installation uses the standard assisted Windows Installer flow with a Wire-branded banner, including welcome, installation location, readiness, progress, and completion pages. The managed deployment defaults create both desktop and Start Menu shortcuts; they are not optional features in the installer UI. The standard Wire environments have permanent MSI upgrade codes in `build-windows-msi.ts`. Never change an upgrade code after its first release. A custom-branded build must set `WIN_MSI_UPGRADE_CODE` to its own permanent GUID so that it cannot collide with a standard Wire installation. The MSI manufacturer defaults to `Wire Swiss GmbH`; an OEM build can override it with `WIN_MSI_MANUFACTURER`. -The Squirrel installer remains available as a separate build target during migration. MSI artifacts are published under `win/msi/` and must never replace the `RELEASES` metadata used by existing Squirrel installations. +The production job builds and archives both installer families during migration. Squirrel artifacts remain published under `win/` and MSI artifacts are published separately under `win/msi/`; an MSI must never replace the `RELEASES` metadata used by existing Squirrel installations. -Before promoting the first MSI release, update the download-page consumer and the external `Wrapper_Windows_Smoke_Tests` Jenkins job to select and install the versioned `.msi` artifact instead of `Wire-Setup.exe`. +Before promoting the first MSI release, add MSI selection and installation coverage to the download-page consumer and the external Windows smoke tests without removing the existing Squirrel release coverage. ## Updates diff --git a/jenkins/deployment.groovy b/jenkins/deployment.groovy index e1273dd4252..855fdd468d0 100644 --- a/jenkins/deployment.groovy +++ b/jenkins/deployment.groovy @@ -36,7 +36,8 @@ node('built-in') { def projectName = env.WRAPPER_BUILD.tokenize('#')[0] def version = env.WRAPPER_BUILD.tokenize('#')[1] - def isWindowsMsi = false + def hasWindowsMsi = false + def hasWindowsSquirrel = false echo("version: ${version}") def buildNumber = version.tokenize('.')[2] def NODE = tool name: 'node-v20.10.0', type: 'nodejs' @@ -60,7 +61,11 @@ node('built-in') { } } - isWindowsMsi = projectName.contains('Windows') && findFiles(glob: 'wrap/dist/*.msi').length > 0 + hasWindowsMsi = projectName.contains('Windows') && findFiles(glob: 'wrap/dist/*.msi').length > 0 + hasWindowsSquirrel = projectName.contains('Windows') && + findFiles(glob: 'wrap/dist/*-Setup.exe').length > 0 && + findFiles(glob: 'wrap/dist/*-full.nupkg').length > 0 && + findFiles(glob: 'wrap/dist/RELEASES').length > 0 currentBuild.displayName = "Deploy ${projectName} ${version}" @@ -88,44 +93,69 @@ node('built-in') { // 1) Windows S3 Upload // ----------------------------- env.S3_PATH = '' + env.MSI_S3_PATH = '' def AWS_ACCESS_KEY_CREDENTIALS_ID = '' def AWS_SECRET_CREDENTIALS_ID = '' if (params.Release == 'Production') { - env.S3_PATH = isWindowsMsi ? 'win/msi/prod' : 'win/prod' + env.S3_PATH = 'win/prod' + env.MSI_S3_PATH = 'win/msi/prod' AWS_ACCESS_KEY_CREDENTIALS_ID = 'AWS_ACCESS_KEY_ID' AWS_SECRET_CREDENTIALS_ID = 'AWS_SECRET_ACCESS_KEY' } else if (params.Release == 'Internal') { - env.S3_PATH = isWindowsMsi ? 'win/msi/internal' : 'win/internal' + env.S3_PATH = 'win/internal' + env.MSI_S3_PATH = 'win/msi/internal' AWS_ACCESS_KEY_CREDENTIALS_ID = 'AWS_ACCESS_KEY_ID' AWS_SECRET_CREDENTIALS_ID = 'AWS_SECRET_ACCESS_KEY' } else if (params.Release == 'Wire-Gov') { - env.S3_PATH = isWindowsMsi ? 'win/msi/wire-gov' : 'win/wire-gov' + env.S3_PATH = 'win/wire-gov' + env.MSI_S3_PATH = 'win/msi/wire-gov' env.S3_BUCKET = 'wire-taco' AWS_ACCESS_KEY_CREDENTIALS_ID = 'AWS_ACCESS_KEY_ID' AWS_SECRET_CREDENTIALS_ID = 'AWS_SECRET_ACCESS_KEY' } else if (params.Release == 'Custom') { env.S3_BUCKET = params.WIN_S3_BUCKET env.S3_PATH = params.WIN_S3_PATH + env.MSI_S3_PATH = params.WIN_S3_PATH AWS_ACCESS_KEY_CREDENTIALS_ID = params.AWS_CUSTOM_ACCESS_KEY_ID AWS_SECRET_CREDENTIALS_ID = params.AWS_CUSTOM_SECRET_ACCESS_KEY } + if (!hasWindowsSquirrel && !hasWindowsMsi) { + error('No complete Windows Squirrel or MSI artifact set found') + } + try { withCredentials([ string(credentialsId: AWS_ACCESS_KEY_CREDENTIALS_ID, variable: 'AWS_ACCESS_KEY_ID'), string(credentialsId: AWS_SECRET_CREDENTIALS_ID, variable: 'AWS_SECRET_ACCESS_KEY') ]) { - sh ''' - jenkins/ts-node.sh ./bin/deploy-tools/s3-cli.ts \ - --bucket "$S3_BUCKET" \ - --s3path "$S3_PATH" \ - --key-id "$AWS_ACCESS_KEY_ID" \ - --secret-key "$AWS_SECRET_ACCESS_KEY" \ - --wrapper-build "$WRAPPER_BUILD" \ - --path "$SEARCH_PATH" \ - $DRY_RUN - ''' + if (hasWindowsSquirrel) { + sh ''' + jenkins/ts-node.sh ./bin/deploy-tools/s3-cli.ts \ + --bucket "$S3_BUCKET" \ + --s3path "$S3_PATH" \ + --windows-artifact squirrel \ + --key-id "$AWS_ACCESS_KEY_ID" \ + --secret-key "$AWS_SECRET_ACCESS_KEY" \ + --wrapper-build "$WRAPPER_BUILD" \ + --path "$SEARCH_PATH" \ + $DRY_RUN + ''' + } + if (hasWindowsMsi) { + sh ''' + jenkins/ts-node.sh ./bin/deploy-tools/s3-cli.ts \ + --bucket "$S3_BUCKET" \ + --s3path "$MSI_S3_PATH" \ + --windows-artifact msi \ + --key-id "$AWS_ACCESS_KEY_ID" \ + --secret-key "$AWS_SECRET_ACCESS_KEY" \ + --wrapper-build "$WRAPPER_BUILD" \ + --path "$SEARCH_PATH" \ + $DRY_RUN + ''' + } } } catch(e) { currentBuild.result = 'FAILED' diff --git a/jenkins/windows.groovy b/jenkins/windows.groovy index 0c0ec7f0703..33a10cb3ab0 100644 --- a/jenkins/windows.groovy +++ b/jenkins/windows.groovy @@ -78,20 +78,15 @@ node('windows') { } } - stage('Build MSI') { + stage('Build installers') { try { withEnv(["PATH+NODE=${NODE}", 'npm_config_target_arch=x64']) { - if (production || custom) { - bat 'yarn build:win:msi -m' - } else if (wireGov) { - bat 'yarn build:win:msi:wire-gov' - } else { - bat 'yarn build:win:msi:internal' - } + // Build both installer families from the same signed application without cleaning between them. + bat 'yarn build:win:installers' } } catch (e) { currentBuild.result = 'FAILED' - wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} building MSI failed**\n${BUILD_URL}" + wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} building installers failed**\n${BUILD_URL}" throw e } } @@ -106,6 +101,7 @@ node('windows') { string(credentialsId: 'SM_KEYPAIR_ALIAS', variable: 'SM_KEYPAIR_ALIAS') ]) { try { + bat 'for %%f in ("wrap\\dist\\*-Setup.exe") do (smctl sign --keypair-alias "%SM_KEYPAIR_ALIAS%" --config-file "%SM_CLIENT_CERT_FILE%" --input "%%~ff" --digalg SHA256 --timestamp --failfast --exit-non-zero-on-fail -v || exit /b 1)' bat 'for %%f in ("wrap\\dist\\*.msi") do (smctl sign --keypair-alias "%SM_KEYPAIR_ALIAS%" --config-file "%SM_CLIENT_CERT_FILE%" --input "%%~ff" --digalg SHA256 --timestamp --failfast --exit-non-zero-on-fail -v || exit /b 1)' } catch (e) { currentBuild.result = 'FAILED' @@ -122,6 +118,7 @@ node('windows') { if (production) { try { bat 'for /r "wrap\\build" %%f in (*.exe) do (signtool.exe verify /v /pa /all /tw "%%~ff" || exit /b 1)' + bat 'for %%f in ("wrap\\dist\\*-Setup.exe") do (signtool.exe verify /v /pa /all /tw "%%~ff" || exit /b 1)' bat 'for %%f in ("wrap\\dist\\*.msi") do (signtool.exe verify /v /pa /all /tw "%%~ff" || exit /b 1)' } catch (e) { currentBuild.result = 'FAILED' @@ -140,7 +137,7 @@ node('windows') { stage('Print hash') { try { if (production) { - bat 'for %%f in ("wrap\\dist\\*.msi") do (certUtil -hashfile %%f SHA256)' + bat 'for %%f in ("wrap\\dist\\*-Setup.exe" "wrap\\dist\\*.msi") do (certUtil -hashfile "%%~ff" SHA256)' } } catch (e) { currentBuild.result = 'FAILED' diff --git a/package.json b/package.json index 680dcc93ed7..d4144cac04c 100644 --- a/package.json +++ b/package.json @@ -164,9 +164,12 @@ "build:ts:bin": "tsc -P tsconfig.bin.json --noEmit", "build:ts:tests": "tsc -P tsconfig.mocha.json --noEmit", "build:win": "yarn build:prepare && ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows", - "build:win:installer": "rimraf wrap/dist && ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows-installer", + "build:win:installer": "rimraf wrap/dist && yarn build:win:installer:package", + "build:win:installer:package": "ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows-installer", "build:win:installer:internal": "cross-env-shell APP_ENV=internal \"yarn build:win:installer -m\"", - "build:win:msi": "rimraf wrap/dist && ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows-msi", + "build:win:installers": "rimraf wrap/dist && yarn build:win:installer:package -m && yarn build:win:msi:package -m", + "build:win:msi": "rimraf wrap/dist && yarn build:win:msi:package", + "build:win:msi:package": "ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows-msi", "build:win:msi:internal": "cross-env-shell APP_ENV=internal \"yarn build:win:msi -m\"", "build:win:internal": "cross-env-shell APP_ENV=internal \"yarn configure && yarn build:win\"", "build:win:ia32": "yarn build:win -a ia32", From b595c11043887555e90a82448d4ea28c2ee18098 Mon Sep 17 00:00:00 2001 From: Adam Low Date: Wed, 19 Aug 2026 21:54:52 +0100 Subject: [PATCH 10/45] feat(windows): configure managed endpoint from MSI --- bin/build-tools/lib/build-windows-msi.test.ts | 9 ++ bin/build-tools/lib/build-windows-msi.ts | 18 +++- docs/windows-msi.md | 11 +++ electron/src/runtime/EnvironmentUtil.ts | 23 ++++- .../WindowsMsiConfiguration.test.main.ts | 93 ++++++++++++++++++ .../src/settings/WindowsMsiConfiguration.ts | 96 +++++++++++++++++++ 6 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 electron/src/settings/WindowsMsiConfiguration.test.main.ts create mode 100644 electron/src/settings/WindowsMsiConfiguration.ts diff --git a/bin/build-tools/lib/build-windows-msi.test.ts b/bin/build-tools/lib/build-windows-msi.test.ts index 936935d77a4..1c57544608c 100644 --- a/bin/build-tools/lib/build-windows-msi.test.ts +++ b/bin/build-tools/lib/build-windows-msi.test.ts @@ -127,6 +127,15 @@ describe('build-windows-msi', () => { assert.match(result, /ShortcutProperty Key="System\.AppUserModel\.ID" Value="com\.squirrel\.wire\.wire"/); assert.match(result, /RegistryKey Root="HKLM" Key="Software\\Classes\\wire"/); assert.match(result, /Value=""\[#mainExecutable\]" "%1""/); + assert.match(result, /Property Id="WIRE_WEBAPP_URL" Secure="yes"/); + assert.match(result, /Property Id="WIRE_CLEAR_WEBAPP_URL" Secure="yes"/); + assert.match( + result, + /RegistrySearch Id="WireExistingWebAppUrl" Root="HKLM" Key="Software\\Wire\\Wire" Name="WebAppUrl" Type="raw" Win64="yes"/, + ); + assert.match(result, /SetProperty Id="WIRE_WEBAPP_URL" Value="\[WIRE_EXISTING_WEBAPP_URL\]"/); + assert.match(result, /NOT WIRE_WEBAPP_URL AND NOT WIRE_CLEAR_WEBAPP_URL AND WIRE_EXISTING_WEBAPP_URL/); + assert.match(result, /RegistryValue Name="WebAppUrl" Type="string" Value="\[WIRE_WEBAPP_URL\]"/); }); it('fails if electron-builder no longer generates the expected main executable component', () => { diff --git a/bin/build-tools/lib/build-windows-msi.ts b/bin/build-tools/lib/build-windows-msi.ts index af061b2d048..3694de6655b 100644 --- a/bin/build-tools/lib/build-windows-msi.ts +++ b/bin/build-tools/lib/build-windows-msi.ts @@ -85,6 +85,7 @@ export function customizeMsiProject( const protocol = escapeXmlAttribute(customProtocolName); const description = escapeXmlAttribute(`${productName} URL`); + const configurationRegistryKey = escapeXmlAttribute(`Software\\Wire\\${productName}`); const protocolRegistry = [ ` `, ` `, @@ -96,6 +97,9 @@ export function customizeMsiProject( ' ', ' ', ' ', + ` `, + ' ', + ' ', ' ', ].join('\n'); @@ -107,10 +111,16 @@ export function customizeMsiProject( throw new Error('Could not find the product in the generated MSI project.'); } const banner = escapeXmlAttribute(bannerFileName); - customizedProject = customizedProject.replace( - productOpeningTag, - `$&\n `, - ); + const msiProperties = [ + ` `, + ' ', + ' ', + ' ', + ` `, + ' ', + ' NOT WIRE_WEBAPP_URL AND NOT WIRE_CLEAR_WEBAPP_URL AND WIRE_EXISTING_WEBAPP_URL', + ].join('\n'); + customizedProject = customizedProject.replace(productOpeningTag, `$&\n${msiProperties}`); const electronBuilderOsCondition = /= 601\]\]><\/Condition>/; diff --git a/docs/windows-msi.md b/docs/windows-msi.md index f6188559ac3..a331178857e 100644 --- a/docs/windows-msi.md +++ b/docs/windows-msi.md @@ -34,6 +34,16 @@ msiexec.exe /i Wire--x64.msi /qn /norestart /l*v Wire-install.log msiexec.exe /x Wire--x64.msi /qn /norestart /l*v Wire-uninstall.log ``` +An organization can set the web application endpoint while installing or upgrading the MSI: + +```powershell +msiexec.exe /i Wire--x64.msi WIRE_WEBAPP_URL="https://wire.example.com" /qn /norestart +``` + +`WIRE_WEBAPP_URL` is a secure public Windows Installer property. The MSI stores it machine-wide as `WebAppUrl` under `HKLM\Software\Wire\`, retains it when a later MSI is deployed without the property, and removes the MSI-owned value on uninstall. Supply a new value to change the endpoint during an upgrade, or `WIRE_CLEAR_WEBAPP_URL=1` to return to the application's normal environment selection. + +Only credential-free HTTPS URLs are accepted. The machine-wide value takes precedence over `--env` and per-user `init.json`; an invalid managed value fails closed instead of silently connecting to a different environment. Windows Installer logs public properties, so the configured URL must not contain credentials or other secrets. + The endpoint-management policy should close Wire before an upgrade. A deployment must handle Windows Installer exit codes, including reboot-required results, rather than treating every non-zero result as a generic failure. ## MDM deployment contract @@ -62,6 +72,7 @@ A release candidate is acceptable only after it has been exercised on a supporte - Wire is installed under `Program Files` and appears exactly once in Apps & Features with the correct publisher, version, and icon. - Start Menu and desktop shortcuts launch Wire and carry the configured application user model ID. - The configured custom URL protocol launches the installed executable. +- `WIRE_WEBAPP_URL` configures the intended endpoint for every user, survives an upgrade where the property is omitted, and can be replaced or cleared explicitly. - A higher version upgrades in place, leaves one Apps & Features entry, and preserves user data. - A lower version is rejected and same-version repair does not create a second installation. - Upgrade behaviour while Wire is running is understood and documented for the deployment policy. diff --git a/electron/src/runtime/EnvironmentUtil.ts b/electron/src/runtime/EnvironmentUtil.ts index 2feb9cda400..00bb4bf97ce 100644 --- a/electron/src/runtime/EnvironmentUtil.ts +++ b/electron/src/runtime/EnvironmentUtil.ts @@ -19,13 +19,31 @@ import minimist from 'minimist'; +import {getLogger} from '../logging/getLogger'; import {config} from '../settings/config'; import {settings} from '../settings/ConfigurationPersistence'; import {SettingsType} from '../settings/SettingsType'; +import {getWindowsMsiWebAppConfiguration, selectWebAppUrlOverride} from '../settings/WindowsMsiConfiguration'; +const logger = getLogger('EnvironmentUtil'); const argv = minimist(process.argv.slice(1)); const webappUrlSetting = settings.restore(SettingsType.CUSTOM_WEBAPP_URL); -const customWebappUrl: string | undefined = argv[config.ARGUMENT.ENV] || webappUrlSetting; +const windowsMsiWebAppConfiguration = + process.platform === 'win32' ? getWindowsMsiWebAppConfiguration(config.name) : {isConfigured: false}; +if (windowsMsiWebAppConfiguration.issue) { + const message = `MSI webapp configuration issue: ${windowsMsiWebAppConfiguration.issue}`; + if (windowsMsiWebAppConfiguration.isConfigured) { + // Never log the configured value: a malformed URL could contain credentials or other sensitive data. + logger.error(`${message}; refusing to fall back to an unmanaged endpoint.`); + } else { + logger.warn(`${message}; continuing without machine-wide configuration.`); + } +} +const customWebappUrl = selectWebAppUrlOverride( + windowsMsiWebAppConfiguration, + argv[config.ARGUMENT.ENV], + webappUrlSetting, +); export enum ServerType { PRODUCTION = 'PRODUCTION', @@ -96,6 +114,9 @@ export const setEnvironment = (env: ServerType): void => { * @returns {string | undefined} the url of the webapp, or undefined if none is configured */ function getWebappUrl() { + if (windowsMsiWebAppConfiguration.isConfigured && !customWebappUrl) { + return undefined; + } if (app.IS_WIRE_GOV) { return customWebappUrl; } diff --git a/electron/src/settings/WindowsMsiConfiguration.test.main.ts b/electron/src/settings/WindowsMsiConfiguration.test.main.ts new file mode 100644 index 00000000000..9944b3cfbdb --- /dev/null +++ b/electron/src/settings/WindowsMsiConfiguration.test.main.ts @@ -0,0 +1,93 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 assert from 'node:assert'; + +import {getWindowsMsiWebAppConfiguration, selectWebAppUrlOverride, WindowsRegistry} from './WindowsMsiConfiguration'; + +function registryWith(values: Array<{name: string; data: unknown}>): WindowsRegistry { + return { + HKEY: {HKEY_LOCAL_MACHINE: 'HKLM'}, + enumerateValues: (_hive, subkey) => { + assert.strictEqual(subkey, 'SOFTWARE\\Wire\\Wire'); + return values; + }, + }; +} + +describe('WindowsMsiConfiguration', () => { + describe('getWindowsMsiWebAppConfiguration', () => { + it('reads and normalizes an HTTPS webapp URL from the product registry key', () => { + const configuration = getWindowsMsiWebAppConfiguration( + 'Wire', + registryWith([{name: 'WebAppUrl', data: ' https://wire.example.test/client '}]), + ); + + assert.deepStrictEqual(configuration, {isConfigured: true, url: 'https://wire.example.test/client'}); + }); + + it('treats a missing or empty value as unconfigured', () => { + assert.deepStrictEqual(getWindowsMsiWebAppConfiguration('Wire', registryWith([])), {isConfigured: false}); + assert.deepStrictEqual( + getWindowsMsiWebAppConfiguration('Wire', registryWith([{name: 'WebAppUrl', data: ' '}])), + {isConfigured: false}, + ); + }); + + it('fails closed for an unsafe or malformed configured URL', () => { + for (const value of ['http://wire.example.test', 'https://user:password@wire.example.test', 'not a URL']) { + assert.deepStrictEqual( + getWindowsMsiWebAppConfiguration('Wire', registryWith([{name: 'WebAppUrl', data: value}])), + {isConfigured: true, issue: 'invalid-url'}, + ); + } + }); + }); + + describe('selectWebAppUrlOverride', () => { + it('gives valid machine configuration precedence over command-line and per-user settings', () => { + assert.strictEqual( + selectWebAppUrlOverride( + {isConfigured: true, url: 'https://managed.example.test/'}, + 'https://command.example.test', + 'https://user.example.test', + ), + 'https://managed.example.test/', + ); + }); + + it('does not fall back when machine configuration is present but invalid', () => { + assert.strictEqual( + selectWebAppUrlOverride({isConfigured: true}, 'https://command.example.test', 'https://user.example.test'), + undefined, + ); + }); + + it('preserves command-line and per-user precedence when no machine configuration exists', () => { + assert.strictEqual( + selectWebAppUrlOverride({isConfigured: false}, 'https://command.example.test', 'https://user.example.test'), + 'https://command.example.test', + ); + assert.strictEqual( + selectWebAppUrlOverride({isConfigured: false}, undefined, 'https://user.example.test'), + 'https://user.example.test', + ); + }); + }); +}); diff --git a/electron/src/settings/WindowsMsiConfiguration.ts b/electron/src/settings/WindowsMsiConfiguration.ts new file mode 100644 index 00000000000..499790f078e --- /dev/null +++ b/electron/src/settings/WindowsMsiConfiguration.ts @@ -0,0 +1,96 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +const WEBAPP_URL_VALUE = 'WebAppUrl'; + +interface RegistryValue { + name: string; + data: unknown; +} + +export interface WindowsRegistry { + HKEY: {HKEY_LOCAL_MACHINE: unknown}; + enumerateValues: (hive: unknown, subkey: string) => ReadonlyArray; +} + +export interface WindowsMsiWebAppConfiguration { + isConfigured: boolean; + issue?: 'invalid-registry-type' | 'invalid-url' | 'registry-read-failed' | 'registry-unavailable'; + url?: string; +} + +function loadRegistry(): WindowsRegistry | undefined { + try { + // Native and Windows-only. Loading lazily keeps it out of non-Windows runtime paths. + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('registry-js'); + } catch { + return undefined; + } +} + +function registryKey(productName: string): string { + return `SOFTWARE\\Wire\\${productName}`; +} + +export function getWindowsMsiWebAppConfiguration( + productName: string, + registry: WindowsRegistry | undefined = loadRegistry(), +): WindowsMsiWebAppConfiguration { + if (!registry) { + return {isConfigured: false, issue: 'registry-unavailable'}; + } + + let value: RegistryValue | undefined; + try { + value = registry + .enumerateValues(registry.HKEY.HKEY_LOCAL_MACHINE, registryKey(productName)) + .find(entry => entry.name.toLowerCase() === WEBAPP_URL_VALUE.toLowerCase()); + } catch { + return {isConfigured: false, issue: 'registry-read-failed'}; + } + + if (!value || (typeof value.data === 'string' && value.data.trim() === '')) { + return {isConfigured: false}; + } + if (typeof value.data !== 'string') { + return {isConfigured: true, issue: 'invalid-registry-type'}; + } + + try { + const url = new URL(value.data.trim()); + if (url.protocol !== 'https:' || url.username || url.password) { + throw new Error('Only credential-free HTTPS URLs are allowed.'); + } + return {isConfigured: true, url: url.toString()}; + } catch { + return {isConfigured: true, issue: 'invalid-url'}; + } +} + +export function selectWebAppUrlOverride( + msiConfiguration: WindowsMsiWebAppConfiguration, + commandLineUrl?: string, + perUserUrl?: string, +): string | undefined { + if (msiConfiguration.isConfigured) { + return msiConfiguration.url; + } + return commandLineUrl || perUserUrl; +} From 5e9876258b7c6f73d716bf59ad782606743f45f0 Mon Sep 17 00:00:00 2001 From: Adam Low Date: Mon, 24 Aug 2026 10:07:03 +0100 Subject: [PATCH 11/45] fix(windows): keep Squirrel feed promotion with MSI --- .../lib/JenkinsWindowsDeployment.test.ts | 33 +++++++++++++++++++ jenkins/deployment.groovy | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 bin/deploy-tools/lib/JenkinsWindowsDeployment.test.ts diff --git a/bin/deploy-tools/lib/JenkinsWindowsDeployment.test.ts b/bin/deploy-tools/lib/JenkinsWindowsDeployment.test.ts new file mode 100644 index 00000000000..2600c6b9e54 --- /dev/null +++ b/bin/deploy-tools/lib/JenkinsWindowsDeployment.test.ts @@ -0,0 +1,33 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'node:fs'; +import path from 'node:path'; + +import assert from 'node:assert'; + +const repositoryRoot = path.resolve(process.cwd()); +const deploymentPipeline = fs.readFileSync(path.join(repositoryRoot, 'jenkins/deployment.groovy'), 'utf8'); + +describe('Jenkins Windows deployment', () => { + it('updates the Squirrel feed whenever a complete Squirrel artifact set is present', () => { + assert.doesNotMatch(deploymentPipeline, /\bisWindowsMsi\b/); + assert.match(deploymentPipeline, /if \(hasWindowsSquirrel\) \{\s+stage\('Update RELEASES file'\)/); + }); +}); diff --git a/jenkins/deployment.groovy b/jenkins/deployment.groovy index 855fdd468d0..81bb0a86225 100644 --- a/jenkins/deployment.groovy +++ b/jenkins/deployment.groovy @@ -422,7 +422,7 @@ node('built-in') { // ------------------------------------------------------------------------ // Windows-specific stage: Update RELEASES file for Squirrel auto-updates // ------------------------------------------------------------------------ - if (projectName.contains('Windows') && !isWindowsMsi) { + if (hasWindowsSquirrel) { stage('Update RELEASES file') { try { withEnv(["PATH+NODE=${NODE}/bin"]) { From 3588bc9af75abdf0639a7a2b74d2168c8b760331 Mon Sep 17 00:00:00 2001 From: Adam Low Date: Mon, 24 Aug 2026 10:07:49 +0100 Subject: [PATCH 12/45] fix(windows): presign MSI from its S3 path --- .../lib/JenkinsWindowsPresignedUrls.test.ts | 43 +++++++++++++++++++ jenkins/deployment.groovy | 16 ++++++- 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 bin/deploy-tools/lib/JenkinsWindowsPresignedUrls.test.ts diff --git a/bin/deploy-tools/lib/JenkinsWindowsPresignedUrls.test.ts b/bin/deploy-tools/lib/JenkinsWindowsPresignedUrls.test.ts new file mode 100644 index 00000000000..e66c3f84f10 --- /dev/null +++ b/bin/deploy-tools/lib/JenkinsWindowsPresignedUrls.test.ts @@ -0,0 +1,43 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'node:fs'; +import path from 'node:path'; + +import assert from 'node:assert'; + +const deploymentPipeline = fs.readFileSync(path.resolve(process.cwd(), 'jenkins/deployment.groovy'), 'utf8'); + +describe('Jenkins Windows presigned URLs', () => { + it('uses the dedicated MSI prefix when generating MSI presigned URLs', () => { + assert.match( + deploymentPipeline, + /def s3PathForArtifact\([^)]*\) \{[\s\S]*?projectName\.contains\('Windows'\)[\s\S]*?artifactName\.toLowerCase\(\)\.endsWith\('\.msi'\)[\s\S]*?return windowsMsiPath[\s\S]*?return defaultPath[\s\S]*?\}/, + ); + assert.strictEqual((deploymentPipeline.match(/s3PathForArtifact\(projectName, fileObj\.name,/g) || []).length, 2); + assert.strictEqual( + ( + deploymentPipeline.match( + /aws s3 presign s3:\/\/\$\{env\.S3_BUCKET\}\/\$\{artifactS3Path\}\/\$\{fileObj\.name\}/g, + ) || [] + ).length, + 2, + ); + }); +}); diff --git a/jenkins/deployment.groovy b/jenkins/deployment.groovy index 81bb0a86225..00540d195b7 100644 --- a/jenkins/deployment.groovy +++ b/jenkins/deployment.groovy @@ -23,6 +23,16 @@ Production Wire-Gov Custom (needs special env variables) */ +def s3PathForArtifact(def projectName, def artifactName, def defaultPath, def windowsMsiPath) { + if (projectName.contains('Windows') && artifactName.toLowerCase().endsWith('.msi')) { + if (!windowsMsiPath) { + throw new IllegalStateException('Windows MSI S3 path is not configured') + } + return windowsMsiPath + } + return defaultPath +} + node('built-in') { def jenkinsbot_secret = '' withCredentials([string(credentialsId: 'JENKINSBOT_WRAPPER_CHAT', variable: 'JENKINSBOT_SECRET')]) { @@ -369,9 +379,10 @@ node('built-in') { sh "rm -f ${presignedFile}" artifacts.each { fileObj -> + def artifactS3Path = s3PathForArtifact(projectName, fileObj.name, env.S3_PATH, env.MSI_S3_PATH) def presignedUrl = sh( script: """ - /var/lib/jenkins/bin/aws s3 presign s3://${env.S3_BUCKET}/${env.S3_PATH}/${fileObj.name} --expires-in 604800 + /var/lib/jenkins/bin/aws s3 presign s3://${env.S3_BUCKET}/${artifactS3Path}/${fileObj.name} --expires-in 604800 """, returnStdout: true ).trim() @@ -396,9 +407,10 @@ node('built-in') { sh "rm -f ${presignedFile}" artifacts.each { fileObj -> + def artifactS3Path = s3PathForArtifact(projectName, fileObj.name, env.S3_PATH, env.MSI_S3_PATH) def presignedUrl = sh( script: """ - /var/lib/jenkins/bin/aws s3 presign s3://${env.S3_BUCKET}/${env.S3_PATH}/${fileObj.name} --expires-in 604800 + /var/lib/jenkins/bin/aws s3 presign s3://${env.S3_BUCKET}/${artifactS3Path}/${fileObj.name} --expires-in 604800 """, returnStdout: true ).trim() From d045c5db483920ee486b6741d62b588dca7a3e19 Mon Sep 17 00:00:00 2001 From: Adam Low Date: Mon, 24 Aug 2026 10:08:41 +0100 Subject: [PATCH 13/45] fix(windows): require complete installer outputs --- .../lib/JenkinsWindowsArtifacts.test.ts | 36 +++++++++++++++++++ jenkins/windows.groovy | 4 +++ 2 files changed, 40 insertions(+) create mode 100644 bin/deploy-tools/lib/JenkinsWindowsArtifacts.test.ts diff --git a/bin/deploy-tools/lib/JenkinsWindowsArtifacts.test.ts b/bin/deploy-tools/lib/JenkinsWindowsArtifacts.test.ts new file mode 100644 index 00000000000..493c0895167 --- /dev/null +++ b/bin/deploy-tools/lib/JenkinsWindowsArtifacts.test.ts @@ -0,0 +1,36 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'node:fs'; +import path from 'node:path'; + +import assert from 'node:assert'; + +const windowsPipeline = fs.readFileSync(path.resolve(process.cwd(), 'jenkins/windows.groovy'), 'utf8'); + +describe('Jenkins Windows build artifacts', () => { + it('fails the build unless both complete installer families were produced', () => { + for (const artifact of ['*-Setup.exe', '*-full.nupkg', 'RELEASES', '*.msi']) { + assert.ok( + windowsPipeline.includes(`if not exist "wrap\\\\dist\\\\${artifact}"`), + `Missing an explicit Jenkins artifact gate for ${artifact}`, + ); + } + }); +}); diff --git a/jenkins/windows.groovy b/jenkins/windows.groovy index 33a10cb3ab0..c38add0e937 100644 --- a/jenkins/windows.groovy +++ b/jenkins/windows.groovy @@ -83,6 +83,10 @@ node('windows') { withEnv(["PATH+NODE=${NODE}", 'npm_config_target_arch=x64']) { // Build both installer families from the same signed application without cleaning between them. bat 'yarn build:win:installers' + bat 'if not exist "wrap\\dist\\*-Setup.exe" (echo Missing Squirrel Setup executable & exit /b 1)' + bat 'if not exist "wrap\\dist\\*-full.nupkg" (echo Missing Squirrel package & exit /b 1)' + bat 'if not exist "wrap\\dist\\RELEASES" (echo Missing Squirrel RELEASES file & exit /b 1)' + bat 'if not exist "wrap\\dist\\*.msi" (echo Missing MSI package & exit /b 1)' } } catch (e) { currentBuild.result = 'FAILED' From 69d1412699772af1c89f78f7e9e1064316b8b886 Mon Sep 17 00:00:00 2001 From: Adam Low Date: Mon, 24 Aug 2026 10:09:38 +0100 Subject: [PATCH 14/45] fix(windows): reject ambiguous artifact selection --- bin/deploy-tools/lib/S3Deployer.test.ts | 15 +++++++++++++++ bin/deploy-tools/lib/S3Deployer.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/bin/deploy-tools/lib/S3Deployer.test.ts b/bin/deploy-tools/lib/S3Deployer.test.ts index 46c160b1cde..bf68139b3fb 100644 --- a/bin/deploy-tools/lib/S3Deployer.test.ts +++ b/bin/deploy-tools/lib/S3Deployer.test.ts @@ -81,5 +81,20 @@ describe('S3Deployer', () => { {fileName: 'Wire-3.42.123.exe', filePath: path.join(basePath, 'Wire-Setup.exe')}, ]); }); + + it('rejects ambiguous automatic selection when both Windows installer families are present', async () => { + const basePath = await fs.mkdtemp(path.join(os.tmpdir(), 'wire-ambiguous-deployer-')); + temporaryDirectories.push(basePath); + await fs.ensureFile(path.join(basePath, 'Wire-3.42.123-x64.msi')); + await fs.ensureFile(path.join(basePath, 'Wire-Setup.exe')); + await fs.ensureFile(path.join(basePath, 'Wire-3.42.123-full.nupkg')); + await fs.ensureFile(path.join(basePath, 'RELEASES')); + const s3Deployer = new S3Deployer({accessKeyId: '', dryRun: true, secretAccessKey: ''}); + + await assert.rejects( + s3Deployer.findUploadFiles('wrapper_windows_production', basePath, '3.42.123'), + /contains both Squirrel and MSI artifacts; select one explicitly/, + ); + }); }); }); diff --git a/bin/deploy-tools/lib/S3Deployer.ts b/bin/deploy-tools/lib/S3Deployer.ts index da656e03491..2e51a5d8faa 100644 --- a/bin/deploy-tools/lib/S3Deployer.ts +++ b/bin/deploy-tools/lib/S3Deployer.ts @@ -113,6 +113,18 @@ export class S3Deployer { if (windowsArtifact === 'msi' && !msi) { throw new Error(`Could not find an MSI for version "${version}".`); } + if (msi && windowsArtifact === 'auto') { + const [setupExe, nupkgFile, releasesFile] = await Promise.all([ + find('*-Setup.exe', {cwd: basePath, safeGuard: false}), + find('*-full.nupkg', {cwd: basePath, safeGuard: false}), + find('RELEASES', {cwd: basePath, safeGuard: false}), + ]); + if (setupExe && nupkgFile && releasesFile) { + throw new Error( + 'Windows artifact directory contains both Squirrel and MSI artifacts; select one explicitly.', + ); + } + } if (msi && windowsArtifact !== 'squirrel') { return [{...msi, filePath: path.join(basePath, msi.fileName)}]; } From 19268e651bfa5f6ea6d708203796ac5b6d35f32d Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Mon, 24 Aug 2026 14:06:33 +0200 Subject: [PATCH 15/45] Centralize desktop log infrastructure Move desktop log path construction, file discovery, and startup migration helpers into focused logging modules. Resolve the log directory only after Electron's final userData path has been configured so portable and custom user-data modes use the correct location. Keep the existing logging layout and runtime behavior unchanged. --- electron/src/lib/LocalAccountDeletion.ts | 4 +- electron/src/logging/getLogger.ts | 12 +- electron/src/logging/logFiles.test.main.ts | 48 ++ electron/src/logging/logFiles.ts | 36 + .../src/logging/logMigration.test.main.ts | 52 ++ electron/src/logging/logMigration.ts | 61 ++ electron/src/logging/logPaths.test.main.ts | 68 ++ electron/src/logging/logPaths.ts | 73 ++ electron/src/logging/loggerUtils.ts | 17 +- electron/src/main.ts | 781 +---------------- electron/src/mainProcess.ts | 784 ++++++++++++++++++ .../src/runtime/configurePortableUserData.ts | 81 ++ .../src/runtime/portableUserData.test.main.ts | 80 ++ electron/src/runtime/portableUserData.ts | 43 + electron/src/sso/SingleSignOn.ts | 13 +- electron/test/withTemporaryDirectory.ts | 40 + package.json | 1 + yarn.lock | 8 + 18 files changed, 1404 insertions(+), 798 deletions(-) create mode 100644 electron/src/logging/logFiles.test.main.ts create mode 100644 electron/src/logging/logFiles.ts create mode 100644 electron/src/logging/logMigration.test.main.ts create mode 100644 electron/src/logging/logMigration.ts create mode 100644 electron/src/logging/logPaths.test.main.ts create mode 100644 electron/src/logging/logPaths.ts create mode 100644 electron/src/mainProcess.ts create mode 100644 electron/src/runtime/configurePortableUserData.ts create mode 100644 electron/src/runtime/portableUserData.test.main.ts create mode 100644 electron/src/runtime/portableUserData.ts create mode 100644 electron/test/withTemporaryDirectory.ts diff --git a/electron/src/lib/LocalAccountDeletion.ts b/electron/src/lib/LocalAccountDeletion.ts index 43d66a7a41c..87ac1f04d97 100644 --- a/electron/src/lib/LocalAccountDeletion.ts +++ b/electron/src/lib/LocalAccountDeletion.ts @@ -26,9 +26,9 @@ import * as path from 'path'; import {ValidationUtil} from '@wireapp/commons'; import {getLogger} from '../logging/getLogger'; +import {getLogDirectory} from '../logging/logPaths'; const USER_DATA_DIR = app.getPath('userData'); -const LOG_DIR = path.join(USER_DATA_DIR, 'logs'); const logger = getLogger(path.basename(__filename)); @@ -81,7 +81,7 @@ export async function deleteAccount(id: number, accountId: string, partitionId?: if (!ValidationUtil.isUUIDv4(accountId)) { throw new Error('Account is not an UUID'); } - const sessionFolder = path.join(LOG_DIR, accountId); + const sessionFolder = path.join(getLogDirectory(), accountId); await fs.remove(sessionFolder); logger.log(`Deleted logs folder for account "${truncatedId}".`); diff --git a/electron/src/logging/getLogger.ts b/electron/src/logging/getLogger.ts index c9176d0ab88..e21e07b4322 100644 --- a/electron/src/logging/getLogger.ts +++ b/electron/src/logging/getLogger.ts @@ -17,21 +17,15 @@ * */ -import * as Electron from 'electron'; import * as logdown from 'logdown'; -import * as path from 'path'; - import {LogFactory, LoggerOptions} from '@wireapp/commons'; +import {getLogDirectory, getMainProcessLogPath} from './logPaths'; + import {config} from '../settings/config'; const mainProcess = process || require('@electron/remote').process; -const app = Electron.app || require('@electron/remote').app; - -const logDir = path.join(app.getPath('userData'), 'logs'); -const logFile = path.join(logDir, 'electron.log'); - const isDevelopment = config.environment !== 'production'; const forceLogging = mainProcess.argv.includes('--enable-logging'); @@ -40,7 +34,7 @@ export const ENABLE_LOGGING = isDevelopment || forceLogging; export function getLogger(name: string): logdown.Logger { const options: LoggerOptions = { - logFilePath: logFile, + logFilePath: getMainProcessLogPath({logDirectory: getLogDirectory()}), namespace: LOGGER_NAMESPACE, separator: '/', }; diff --git a/electron/src/logging/logFiles.test.main.ts b/electron/src/logging/logFiles.test.main.ts new file mode 100644 index 00000000000..01c0337e9f7 --- /dev/null +++ b/electron/src/logging/logFiles.test.main.ts @@ -0,0 +1,48 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'fs-extra'; + +import * as assert from 'assert'; +import * as path from 'path'; + +import {getLogFilenames} from './logFiles'; + +import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; + +describe('desktop log file discovery', () => { + it( + 'discovers nested log and old files without following symbolic links', + withTemporaryDirectory('wire-log-files-', async (temporaryLogDirectory: string) => { + await fs.outputFile(path.join(temporaryLogDirectory, 'electron.log'), 'main'); + await fs.outputFile(path.join(temporaryLogDirectory, 'account', 'console.log'), 'webview'); + await fs.outputFile(path.join(temporaryLogDirectory, 'account', 'console.old'), 'legacy'); + await fs.outputFile(path.join(temporaryLogDirectory, 'account', 'notes.txt'), 'ignore'); + + const symbolicLinkTarget = path.join(temporaryLogDirectory, 'account'); + const symbolicLinkPath = path.join(temporaryLogDirectory, 'linked-account'); + await fs.symlink(symbolicLinkTarget, symbolicLinkPath, 'dir'); + + const actualPaths = getLogFilenames({absolute: false, baseDirectory: temporaryLogDirectory}).sort(); + const expectedPaths = ['account/console.log', 'account/console.old', 'electron.log']; + + assert.deepStrictEqual(actualPaths, expectedPaths); + }), + ); +}); diff --git a/electron/src/logging/logFiles.ts b/electron/src/logging/logFiles.ts new file mode 100644 index 00000000000..8290946ff54 --- /dev/null +++ b/electron/src/logging/logFiles.ts @@ -0,0 +1,36 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 globby from 'globby'; + +export type LogFileDiscoveryOptions = { + absolute: boolean; + baseDirectory: string; +}; + +export function getLogFilenames(parameters: LogFileDiscoveryOptions): string[] { + const {absolute, baseDirectory} = parameters; + + return globby.sync('**/*.{log,old}', { + absolute, + cwd: baseDirectory, + followSymbolicLinks: false, + onlyFiles: true, + }); +} diff --git a/electron/src/logging/logMigration.test.main.ts b/electron/src/logging/logMigration.test.main.ts new file mode 100644 index 00000000000..f0b0faaebae --- /dev/null +++ b/electron/src/logging/logMigration.test.main.ts @@ -0,0 +1,52 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'fs-extra'; + +import * as assert from 'assert'; +import * as path from 'path'; + +import {renameWebViewLogFiles} from './logMigration'; + +import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; + +describe('desktop log migration', () => { + it( + 'renames every discovered log file to the existing old extension', + withTemporaryDirectory('wire-log-migration-', async (temporaryLogDirectory: string) => { + const currentLogPath = path.join(temporaryLogDirectory, 'account', 'console.log'); + const currentMainLogPath = path.join(temporaryLogDirectory, 'electron.log'); + await fs.outputFile(currentLogPath, 'webview'); + await fs.outputFile(currentMainLogPath, 'main'); + + function ignoreLogMessage(_message: string): void {} + + const logger = { + error: ignoreLogMessage, + log: ignoreLogMessage, + }; + renameWebViewLogFiles(temporaryLogDirectory, logger); + + assert.strictEqual(await fs.pathExists(currentLogPath), false); + assert.strictEqual(await fs.pathExists(currentMainLogPath), false); + assert.strictEqual(await fs.pathExists(currentLogPath.replace('.log', '.old')), true); + assert.strictEqual(await fs.pathExists(currentMainLogPath.replace('.log', '.old')), true); + }), + ); +}); diff --git a/electron/src/logging/logMigration.ts b/electron/src/logging/logMigration.ts new file mode 100644 index 00000000000..b9cf2f5324c --- /dev/null +++ b/electron/src/logging/logMigration.ts @@ -0,0 +1,61 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'fs-extra'; + +import {getLogFilenames} from './logFiles'; + +export type LogMigrationLogger = { + error: (message: string) => void; + log: (message: string) => void; +}; + +export type RenameFileExtensionsParameters = { + files: readonly string[]; + logger: Pick; + newExtension: string; + oldExtension: string; +}; + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function renameFileExtensions(parameters: RenameFileExtensionsParameters): void { + const {files, logger, newExtension, oldExtension} = parameters; + for (const file of files) { + try { + const fileStat = fs.statSync(file); + if (fileStat.isFile() && file.endsWith(oldExtension)) { + fs.renameSync(file, file.replace(oldExtension, newExtension)); + } + } catch (error) { + logger.error(`Failed to rename log file: "${getErrorMessage(error)}"`); + } + } +} + +export function renameWebViewLogFiles(logDirectory: string, logger: LogMigrationLogger): void { + try { + const logFiles = getLogFilenames({absolute: true, baseDirectory: logDirectory}); + renameFileExtensions({files: logFiles, logger, newExtension: '.old', oldExtension: '.log'}); + } catch (error) { + logger.log(`Failed to read log directory with error: ${getErrorMessage(error)}`); + } +} diff --git a/electron/src/logging/logPaths.test.main.ts b/electron/src/logging/logPaths.test.main.ts new file mode 100644 index 00000000000..21b29beb18f --- /dev/null +++ b/electron/src/logging/logPaths.test.main.ts @@ -0,0 +1,68 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {app} from 'electron'; + +import * as assert from 'assert'; +import * as path from 'path'; + +import {getLogDirectory, getMainProcessLogPath, getSsoLogPath, getWebViewLogPath} from './logPaths'; + +describe('desktop log paths', () => { + it('resolves the log root from the final Electron user-data path', () => { + const originalUserDataPath = app.getPath('userData'); + const configuredUserDataPath = path.join(originalUserDataPath, 'configured-user-data'); + + app.setPath('userData', configuredUserDataPath); + const actualLogDirectory = getLogDirectory(); + app.setPath('userData', originalUserDataPath); + + assert.strictEqual(actualLogDirectory, path.join(configuredUserDataPath, 'logs')); + }); + + it('preserves the existing main-process log path', () => { + const actualPath = getMainProcessLogPath({logDirectory: path.join('user-data', 'logs')}); + const expectedPath = path.join('user-data', 'logs', 'electron.log'); + + assert.strictEqual(actualPath, expectedPath); + }); + + it('preserves the existing webview directory format', () => { + const actualPath = getWebViewLogPath({ + accountIndex: 2, + date: new Date('2026-07-10T12:34:56.000Z'), + logDirectory: path.join('user-data', 'logs'), + webViewId: 'account-webview-id', + }); + const expectedPath = path.join('user-data', 'logs', '2_2026_07_10_12_34_56_account-webview-id', 'console.log'); + + assert.strictEqual(actualPath, expectedPath); + }); + + it('preserves the existing SSO log path', () => { + const actualPath = getSsoLogPath({ + logDirectory: path.join('user-data', 'logs'), + logFileName: 'console.log', + webViewId: 'sso-webview-id', + }); + const expectedPath = path.join('user-data', 'logs', 'sso-webview-id', 'console.log'); + + assert.strictEqual(actualPath, expectedPath); + }); +}); diff --git a/electron/src/logging/logPaths.ts b/electron/src/logging/logPaths.ts new file mode 100644 index 00000000000..ce5195a7d55 --- /dev/null +++ b/electron/src/logging/logPaths.ts @@ -0,0 +1,73 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 Electron from 'electron'; + +import * as path from 'path'; + +import {DateUtil} from '@wireapp/commons'; + +import {config} from '../settings/config'; + +const app = Electron.app || require('@electron/remote').app; + +export const LOG_DIRECTORY_NAME = 'logs'; +export const MAIN_PROCESS_LOG_FILE_NAME = 'electron.log'; + +export type WebViewLogPathParameters = { + accountIndex: number; + date: Date; + logDirectory: string; + webViewId: string; +}; + +export type MainProcessLogPathParameters = { + logDirectory: string; +}; + +export type SsoLogPathParameters = { + logDirectory: string; + logFileName: string; + webViewId: string; +}; + +export function getLogDirectory(): string { + return path.join(app.getPath('userData'), LOG_DIRECTORY_NAME); +} + +export function getMainProcessLogPath(parameters: MainProcessLogPathParameters): string { + return path.join(parameters.logDirectory, MAIN_PROCESS_LOG_FILE_NAME); +} + +export function getWebViewLogPath(parameters: WebViewLogPathParameters): string { + const {accountIndex, date, logDirectory, webViewId} = parameters; + const {date: formattedDate, time: formattedTime} = DateUtil.isoFormat(date); + const directoryName = `${accountIndex}_${formattedDate.replaceAll('-', '_')}_${formattedTime.replaceAll( + ':', + '_', + )}_${webViewId}`; + + return path.join(logDirectory, directoryName, config.logFileName); +} + +export function getSsoLogPath(parameters: SsoLogPathParameters): string { + const {logDirectory, logFileName, webViewId} = parameters; + + return path.join(logDirectory, webViewId, logFileName); +} diff --git a/electron/src/logging/loggerUtils.ts b/electron/src/logging/loggerUtils.ts index d8f02130371..4b4abd0c88f 100644 --- a/electron/src/logging/loggerUtils.ts +++ b/electron/src/logging/loggerUtils.ts @@ -17,29 +17,28 @@ * */ -import {app} from 'electron'; import * as fs from 'fs-extra'; -import globby from 'globby'; import * as path from 'path'; -import {getLogger} from '../logging/getLogger'; +import {getLogger} from './getLogger'; +import {getLogFilenames as getLogFilenamesFromRoot, LogFileDiscoveryOptions} from './logFiles'; +import {getLogDirectory} from './logPaths'; const logger = getLogger(path.basename(__filename)); -export const logDir = path.join(app.getPath('userData'), 'logs'); - -export function getLogFilenames(base: string = logDir, absolute: boolean = false): string[] { - return globby.sync('**/*.{log,old}', {absolute, cwd: base, followSymbolicLinks: false, onlyFiles: true}); +export function getLogFilenames(parameters: LogFileDiscoveryOptions): string[] { + return getLogFilenamesFromRoot(parameters); } export async function gatherLogs(): Promise> { const logFiles: Record = {}; + const logDirectory = getLogDirectory(); - const relativeFilePaths = getLogFilenames(); + const relativeFilePaths = getLogFilenames({absolute: false, baseDirectory: logDirectory}); for (const relativeFilePath of relativeFilePaths) { - const resolvedPath = path.join(logDir, relativeFilePath); + const resolvedPath = path.join(logDirectory, relativeFilePath); try { const fileContent = await fs.readFile(resolvedPath); logFiles[relativeFilePath] = fileContent; diff --git a/electron/src/main.ts b/electron/src/main.ts index 5ee097f189b..652c78d24c5 100644 --- a/electron/src/main.ts +++ b/electron/src/main.ts @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2018 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 @@ -17,781 +17,12 @@ * */ -import * as remoteMain from '@electron/remote/main'; -import { - app, - dialog, - BrowserWindow, - BrowserWindowConstructorOptions, - Event as ElectronEvent, - ipcMain, - Menu, - WebContents, - desktopCapturer, - safeStorage, - HandlerDetails, -} from 'electron'; -import electronDl from 'electron-dl'; -import windowStateKeeper from 'electron-window-state'; -import fs from 'fs-extra'; -import {getProxySettings} from 'get-proxy-settings'; -import logdown from 'logdown'; -import minimist from 'minimist'; +import {configurePortableUserDataAtStartup} from './runtime/configurePortableUserData'; -import * as path from 'path'; -import {URL, pathToFileURL} from 'url'; +configurePortableUserDataAtStartup(); -import {DateUtil, LogFactory} from '@wireapp/commons'; -import {WebAppEvents} from '@wireapp/webapp-events'; - -import * as ProxyAuth from './auth/ProxyAuth'; -import {getPictureInPictureCallWindowOptions, isPictureInPictureCallWindow} from './calling/PictureInPictureCall'; -import { - attachTo as attachCertificateVerifyProcManagerTo, - setCertificateVerifyProc, -} from './lib/CertificateVerifyProcManager'; -import {CustomProtocolHandler} from './lib/CoreProtocol'; -import {downloadImage} from './lib/download'; -import {EVENT_TYPE} from './lib/eventType'; -import {forwardWrapperReloadRequest} from './lib/forwardWrapperReloadRequest'; -import {deleteAccount} from './lib/LocalAccountDeletion'; -import {getOpenGraphDataAsync} from './lib/openGraph'; -import {showErrorDialog} from './lib/showDialog'; -import * as locale from './locale'; -import {ENABLE_LOGGING, getLogger} from './logging/getLogger'; -import {getLogFilenames} from './logging/loggerUtils'; -import {getManagedConfig} from './managed/ManagedConfig'; -import {developerMenu, openDevTools} from './menu/developer'; -import * as systemMenu from './menu/system'; -import {TrayHandler} from './menu/TrayHandler'; -import * as EnvironmentUtil from './runtime/EnvironmentUtil'; -import * as lifecycle from './runtime/lifecycle'; -import {OriginValidator} from './runtime/OriginValidator'; -import {config} from './settings/config'; -import {settings} from './settings/ConfigurationPersistence'; -import {SettingsType} from './settings/SettingsType'; -import {SingleSignOn} from './sso/SingleSignOn'; -import {initMacAutoUpdater} from './update/macosAutoUpdater'; -import {AboutWindow} from './window/AboutWindow'; -import {ProxyPromptWindow} from './window/ProxyPromptWindow'; -import {WindowManager} from './window/WindowManager'; -import * as WindowUtil from './window/WindowUtil'; - -const logger = getLogger(path.basename(__filename)); - -remoteMain.initialize(); - -const APP_PATH = path.join(app.getAppPath(), config.electronDirectory); -const INDEX_HTML = path.join(APP_PATH, 'renderer/index.html'); -const LOG_DIR = path.join(app.getPath('userData'), 'logs'); -const LOG_FILE = path.join(LOG_DIR, 'electron.log'); -const PRELOAD_JS = path.join(APP_PATH, 'dist/preload/preload-app.js'); -const PRELOAD_RENDERER_JS = path.join(APP_PATH, 'dist/preload/preload-webview.js'); -const WRAPPER_CSS = path.join(APP_PATH, 'css/wrapper.css'); -const ICON = path.join(APP_PATH, 'img/download-dialog/logo@2x.png'); - -const WINDOW_SIZE = { - DEFAULT_HEIGHT: 768, - DEFAULT_WIDTH: 1024, - MIN_HEIGHT: 512, - MIN_WIDTH: 398, -}; - -let proxyInfoArg: URL | undefined; - -const customProtocolHandler = new CustomProtocolHandler(); - -// Config -const argv = minimist(process.argv.slice(1)); -const fileBasedProxyConfig = settings.restore(SettingsType.PROXY_SERVER_URL); - -const currentLocale = locale.getCurrent(); -const startHidden = Boolean(argv[config.ARGUMENT.STARTUP] || argv[config.ARGUMENT.HIDDEN]); -const customDownloadPath = settings.restore(SettingsType.DOWNLOAD_PATH); -const appHomePath = (path: string) => `${app.getPath('home')}\\${path}`; -const isInternalBuild = (): boolean => config.environment === 'internal'; - -if (customDownloadPath) { - electronDl({ - directory: appHomePath(customDownloadPath), - saveAs: false, - onCompleted: () => { - dialog.showMessageBox({ - type: 'none', - icon: ICON, - title: locale.getText('enforcedDownloadComplete'), - message: locale.getText('enforcedDownloadMessage', { - path: appHomePath(customDownloadPath) ?? app.getPath('downloads'), - }), - buttons: [locale.getText('enforcedDownloadButton')], - }); - }, - }); -} - -if (argv[config.ARGUMENT.VERSION]) { - console.info(config.version); - app.exit(); -} - -logger.info(`Initializing ${config.name} v${config.version} ...`); - -if (argv[config.ARGUMENT.PROXY_SERVER] || fileBasedProxyConfig) { - try { - proxyInfoArg = new URL(argv[config.ARGUMENT.PROXY_SERVER] || fileBasedProxyConfig); - if (!argv[config.ARGUMENT.PROXY_SERVER] && fileBasedProxyConfig) { - logger.info(`Using proxy server URL from "init.json": ${fileBasedProxyConfig}`); - app.commandLine.appendSwitch('proxy-server', fileBasedProxyConfig); - } - if (!/^(https?|socks[45]):$/.test(proxyInfoArg.protocol)) { - throw new Error('Invalid protocol for the proxy server specified.'); - } - if (proxyInfoArg.origin === 'null') { - proxyInfoArg = undefined; - throw new Error('No protocol for the proxy server specified.'); - } - } catch (error) { - logger.error(`Could not parse authenticated proxy URL: "${(error as any).message}"`); - } +function loadMainProcess(): void { + require('./mainProcess'); } -const iconFileName = `logo.${EnvironmentUtil.platform.IS_WINDOWS ? 'ico' : 'png'}`; -const iconPath = path.join(APP_PATH, 'img', iconFileName); -// This needs to stay global, see -// https://github.com/electron/electron/blob/v4.2.12/docs/faq.md#my-apps-windowtray-disappeared-after-a-few-minutes -let tray: TrayHandler; - -let isFullScreen = false; -let isQuitting = false; -let main: BrowserWindow; - -Object.entries(config).forEach(([key, value]) => { - if (typeof value === 'undefined' || (typeof value === 'number' && isNaN(value))) { - logger.warn(`Configuration key "${key}" not defined.`); - } -}); - -// Squirrel setup -app.setAppUserModelId(config.appUserModelId); - -// Disable mDNS IP masking so local/private IPs are exposed (prevents Windows firewall prompt) -app.commandLine.appendSwitch('disable-features', 'webrtc-hide-local-ips-with-mdns'); - -// Allow both public and private interfaces for WebRTC -app.commandLine.appendSwitch('force-webrtc-ip-handling-policy', 'default_public_and_private_interfaces'); - -// IPC events -const bindIpcEvents = (): void => { - ipcMain.on(EVENT_TYPE.ACTION.SAVE_PICTURE, (_event, bytes: Uint8Array, timestamp?: string) => { - return downloadImage(bytes, timestamp); - }); - - ipcMain.on(EVENT_TYPE.ACTION.NOTIFICATION_CLICK, () => WindowManager.showPrimaryWindow()); - ipcMain.on(EVENT_TYPE.WEBAPP.APP_LOADED, () => WindowManager.flushActionsQueue()); - - ipcMain.on(EVENT_TYPE.UI.BADGE_COUNT, (_event, {count, ignoreFlash}: {count?: number; ignoreFlash?: boolean}) => { - tray.showUnreadCount(main, count, ignoreFlash); - }); - - ipcMain.on(EVENT_TYPE.ACCOUNT.DELETE_DATA, async (_event, id: number, accountId: string, partitionId?: string) => { - await deleteAccount(id, accountId, partitionId); - main.webContents.send(EVENT_TYPE.ACCOUNT.DATA_DELETED); - }); - ipcMain.on(EVENT_TYPE.WRAPPER.RELOAD, () => forwardWrapperReloadRequest(main.webContents)); - ipcMain.on(EVENT_TYPE.WRAPPER.RELAUNCH, () => lifecycle.relaunch()); - ipcMain.on(EVENT_TYPE.ABOUT.SHOW, () => AboutWindow.showWindow()); - - // Answered synchronously: the webview preload reads this via `ipcRenderer.sendSync` while it builds - // `window.desktopAppConfig`. The value is pre-read and memoized, so the handler does no I/O here. - ipcMain.on(EVENT_TYPE.MANAGED.GET_CONFIG, event => { - event.returnValue = getManagedConfig(); - }); - - ipcMain.handle(EVENT_TYPE.ACTION.GET_OG_DATA, (_event, url) => getOpenGraphDataAsync(url)); - - ipcMain.on(EVENT_TYPE.ACTION.CHANGE_DOWNLOAD_LOCATION, (_event, downloadPath?: string) => { - if (EnvironmentUtil.platform.IS_WINDOWS) { - if (downloadPath) { - fs.ensureDirSync(appHomePath(downloadPath)); - } - //save the downloadPath locally - settings.save(SettingsType.DOWNLOAD_PATH, downloadPath); - settings.persistToFile(); - } - }); -}; - -const checkConfigV0FullScreen = (mainWindowState: windowStateKeeper.State): void => { - // if a user still has the old config version 0 and had the window maximized last time - if (typeof mainWindowState.isMaximized === 'undefined' && isFullScreen === true) { - main.maximize(); - } -}; - -const initWindowStateKeeper = (): windowStateKeeper.State => { - const loadedWindowBounds = settings.restore(SettingsType.WINDOW_BOUNDS, { - height: WINDOW_SIZE.DEFAULT_HEIGHT, - width: WINDOW_SIZE.DEFAULT_WIDTH, - }); - - // load version 0 full screen setting - const showInFullScreen = settings.restore(SettingsType.FULL_SCREEN, 'not-set-in-v0'); - - const stateKeeperOptions: windowStateKeeper.Options = { - defaultHeight: loadedWindowBounds.height, - defaultWidth: loadedWindowBounds.width, - path: path.join(app.getPath('userData'), 'config'), - }; - - if (showInFullScreen !== 'not-set-in-v0') { - stateKeeperOptions.fullScreen = showInFullScreen as boolean; - stateKeeperOptions.maximize = showInFullScreen as boolean; - isFullScreen = showInFullScreen as boolean; - } - - return windowStateKeeper(stateKeeperOptions); -}; - -function getMainWindowUrl() { - const baseUrl = EnvironmentUtil.web.getWebappUrl(); - const mainURL = pathToFileURL(INDEX_HTML); - mainURL.searchParams.set('focus', String(!startHidden)); - - if (!baseUrl) { - // No URL configured in init.json (only applies to Wire Gov builds, which don't fall back to a default). - mainURL.searchParams.set('noUrlConfigured', 'true'); - return mainURL; - } - - const webappURL = new URL(baseUrl); - webappURL.searchParams.set('hl', currentLocale); - - if (ENABLE_LOGGING) { - webappURL.searchParams.set('enableLogging', '@wireapp/*'); - } - - if (customProtocolHandler.hashLocation) { - webappURL.hash = customProtocolHandler.hashLocation; - } - mainURL.searchParams.set('env', encodeURIComponent(webappURL.href)); - return mainURL; -} - -// App Windows -const showMainWindow = async (mainWindowState: windowStateKeeper.State): Promise => { - const showMenuBar = settings.restore(SettingsType.SHOW_MENU_BAR, true); - - const options: BrowserWindowConstructorOptions = { - autoHideMenuBar: !showMenuBar, - backgroundColor: '#f7f8fa', - height: mainWindowState.height, - icon: iconPath, - minHeight: WINDOW_SIZE.MIN_HEIGHT, - minWidth: WINDOW_SIZE.MIN_WIDTH, - show: false, - title: config.name, - webPreferences: { - backgroundThrottling: false, - contextIsolation: false, - nodeIntegration: false, - preload: PRELOAD_JS, - sandbox: false, - webviewTag: true, - }, - width: mainWindowState.width, - // eslint-disable-next-line id-length - x: mainWindowState.x, - // eslint-disable-next-line id-length - y: mainWindowState.y, - }; - - ipcMain.handle(EVENT_TYPE.ACTION.GET_DESKTOP_SOURCES, (event, opts) => desktopCapturer.getSources(opts)); - ipcMain.handle(EVENT_TYPE.ACTION.ENCRYPT, (event, plaintext: string) => safeStorage.encryptString(plaintext)); - ipcMain.handle(EVENT_TYPE.ACTION.DECRYPT, (event, encrypted: Uint8Array) => - safeStorage.decryptString(Buffer.from(encrypted)), - ); - - main = new BrowserWindow(options); - - remoteMain.enable(main.webContents); - - main.setMenuBarVisibility(showMenuBar); - - mainWindowState.manage(main); - attachCertificateVerifyProcManagerTo(main); - checkConfigV0FullScreen(mainWindowState); - - if (typeof argv[config.ARGUMENT.DEVTOOLS] !== 'undefined') { - openDevTools(argv[config.ARGUMENT.DEVTOOLS]).catch(() => - logger.warn(`Could not open DevTools with index "${argv[config.ARGUMENT.DEVTOOLS]}". Does the account exist?`), - ); - } - - if (!startHidden) { - if (!WindowUtil.isInView(main)) { - main.center(); - } - - WindowManager.setPrimaryWindowId(main.id); - setTimeout(() => main.show(), 800); - } - - main.webContents.on('will-navigate', event => { - // Prevent any kind of navigation inside the main window - event.preventDefault(); - }); - - // Handle the new window event in the main Browser Window - main.webContents.setWindowOpenHandler(details => { - return {action: 'deny'}; - }); - - main.on('focus', () => { - systemMenu.registerGlobalShortcuts(); - main.flashFrame(false); - }); - - main.on('blur', () => systemMenu.unregisterGlobalShortcuts()); - - main.on('page-title-updated', () => tray.showUnreadCount(main)); - - main.on('close', event => { - if (!isQuitting) { - event.preventDefault(); - logger.log('Closing window...'); - - if (main.isFullScreen()) { - logger.log('Fullscreen detected, leaving full screen before hiding...'); - main.once('leave-full-screen', () => main.hide()); - main.setFullScreen(false); - } else { - main.hide(); - } - systemMenu.unregisterGlobalShortcuts(); - } - }); - - app.on('render-process-gone', async (event, _, details) => { - logger.error('WebContents crashed. Will reload the window.'); - logger.error(JSON.stringify(details)); - try { - main.reload(); - } catch (error) { - showErrorDialog(`Could not reload the window: ${(error as any).message}`); - logger.error('Could not reload the window:', error); - } - }); - - app.on('child-process-gone', (event, details) => { - logger.error('child process gone'); - logger.error(event); - logger.error(details); - }); - - main.webContents.setZoomFactor(1); - - const mainURL = getMainWindowUrl(); - await main.loadURL(mainURL.href); - const wrapperCSSContent = await fs.readFile(WRAPPER_CSS, 'utf8'); - await main.webContents.insertCSS(wrapperCSSContent); -}; - -// App Events -const handleAppEvents = (): void => { - app.on('window-all-closed', async () => { - if (!EnvironmentUtil.platform.IS_MAC_OS) { - await lifecycle.quit(); - } - }); - - app.on('activate', () => { - if (main) { - main.show(); - } - }); - - app.on('before-quit', () => { - isQuitting = true; - }); - - app.on('login', async (event, webContents, _responseDetails, authInfo, callback) => { - if (authInfo.isProxy) { - event.preventDefault(); - const {host, port} = authInfo; - - const systemProxy = await getProxySettings(); - const systemProxySettings = systemProxy && (systemProxy.http || systemProxy.https); - if (systemProxySettings) { - const { - credentials: {username, password}, - protocol, - } = systemProxySettings; - proxyInfoArg = ProxyAuth.generateProxyURL({host, port}, {password, protocol, username}); - logger.log('Found system proxy settings, applying settings on the main window...'); - - await applyProxySettings(proxyInfoArg, main.webContents); - return callback(username, password); - } - - if (proxyInfoArg) { - ipcMain.once( - EVENT_TYPE.PROXY_PROMPT.SUBMITTED, - async (_event, promptData: {password: string; username: string}) => { - logger.log('Proxy info was submitted via prompt'); - - const {username, password} = promptData; - // remove the colon from the protocol to align it with other usages of `generateProxyURL` - const protocol = proxyInfoArg?.protocol?.replace(':', ''); - proxyInfoArg = ProxyAuth.generateProxyURL( - {host, port}, - { - ...promptData, - protocol, - }, - ); - - logger.log('Proxy prompt was submitted, applying proxy settings on the main window...'); - await applyProxySettings(proxyInfoArg, main.webContents); - callback(username, password); - }, - ); - - ipcMain.once(EVENT_TYPE.PROXY_PROMPT.CANCELED, async () => { - logger.log('Proxy prompt was canceled'); - - // TODO: check if we should use `mode: 'auto_detect'` here - await webContents.session.setProxy({}); - - try { - main.reload(); - } catch (error) { - showErrorDialog(`Could not reload the window: ${(error as any).message}`); - logger.error('Could not reload the window:', error); - } - }); - - await ProxyPromptWindow.showWindow(); - } - } - }); - - // System Menu, Tray Icon & Show window - app.on('ready', async () => { - const mainWindowState = initWindowStateKeeper(); - const appMenu = systemMenu.createMenu(isFullScreen); - if (EnvironmentUtil.app.IS_DEVELOPMENT) { - app.commandLine.appendSwitch('enable-webrtc-internals'); - appMenu.append(developerMenu); - } - - Menu.setApplicationMenu(appMenu); - tray = new TrayHandler(); - if (!EnvironmentUtil.platform.IS_MAC_OS) { - tray.initTray(); - } - await showMainWindow(mainWindowState); - - app.on('ready', async () => { - const mainWindowState = initWindowStateKeeper(); - const appMenu = systemMenu.createMenu(isFullScreen); - if (EnvironmentUtil.app.IS_DEVELOPMENT) { - appMenu.append(developerMenu); - } - - Menu.setApplicationMenu(appMenu); - tray = new TrayHandler(); - if (!EnvironmentUtil.platform.IS_MAC_OS) { - tray.initTray(); - } - await showMainWindow(mainWindowState); - - if (EnvironmentUtil.platform.IS_MAC_OS && isInternalBuild()) { - initMacAutoUpdater(main); - } - }); - }); -}; - -const renameFileExtensions = (files: string[], oldExtension: string, newExtension: string): void => { - for (const file of files) { - try { - const fileStat = fs.statSync(file); - if (fileStat.isFile() && file.endsWith(oldExtension)) { - fs.renameSync(file, file.replace(oldExtension, newExtension)); - } - } catch (error) { - logger.error(`Failed to rename log file: "${(error as any).message}"`); - } - } -}; - -const renameWebViewLogFiles = (): void => { - // Rename "console.log" to "console.old" (for every log directory of every account) - try { - const logFiles = getLogFilenames(LOG_DIR, true); - renameFileExtensions(logFiles, '.log', '.old'); - } catch (error) { - logger.log(`Failed to read log directory with error: ${(error as any).message}`); - } -}; - -const addLinuxWorkarounds = (): void => { - if (EnvironmentUtil.platform.IS_LINUX) { - // Fix indicator icon on Unity - // Source: https://bugs.launchpad.net/ubuntu/+bug/1559249 - - if ( - EnvironmentUtil.linuxDesktop.isUbuntuUnity || - EnvironmentUtil.linuxDesktop.isPopOS || - EnvironmentUtil.linuxDesktop.isGnomeX11 - ) { - process.env.XDG_CURRENT_DESKTOP = 'Unity'; - } - } -}; - -const handlePortableFlags = (): void => { - if (argv[config.ARGUMENT.USER_DATA_DIR] || argv[config.ARGUMENT.PORTABLE]) { - const USER_PATH = argv[config.ARGUMENT.USER_DATA_DIR] - ? path.resolve(argv[config.ARGUMENT.USER_DATA_DIR]) - : path.join(process.env.APPIMAGE || process.execPath, '../Data'); - - logger.log(`Saving user data to "${USER_PATH}".`); - app.setPath('userData', USER_PATH); - } -}; - -const applyProxySettings = async (authenticatedProxyDetails: URL, webContents: Electron.WebContents): Promise => { - const proxyURL = authenticatedProxyDetails.origin.split('://')[1]; - const proxyProtocol = authenticatedProxyDetails.protocol; - const isSocksProxy = proxyProtocol === 'socks4:' || proxyProtocol === 'socks5:'; - - logger.info(`Setting proxy on the window to URL "${proxyURL}" with protocol "${proxyProtocol}"...`); - webContents.session.allowNTLMCredentialsForDomains(authenticatedProxyDetails.hostname); - - const proxyRules = isSocksProxy ? `socks=${proxyURL}` : `http=${proxyURL};https=${proxyURL}`; - await webContents.session.setProxy({pacScript: '', proxyBypassRules: '', proxyRules}); -}; - -class ElectronWrapperInit { - logger: logdown.Logger; - ssoWindow: SingleSignOn | null; - - constructor() { - this.logger = getLogger('ElectronWrapperInit'); - this.ssoWindow = null; - ipcMain.on(WebAppEvents.LIFECYCLE.SSO_WINDOW_CLOSE, this.closeSSOWindow); - ipcMain.on(WebAppEvents.LIFECYCLE.SSO_WINDOW_FOCUS, this.focusSSOWindow); - } - - async run(): Promise { - this.logger.log('webviewProtection init'); - this.webviewProtection(); - } - - closeSSOWindow = () => { - if (this.ssoWindow) { - this.ssoWindow?.close(); - this.ssoWindow = null; - } - }; - - focusSSOWindow = () => { - if (this.ssoWindow) { - this.ssoWindow.focus(); - } - }; - - sendSSOWindowCloseEvent = () => { - if (this.ssoWindow) { - main.webContents.send(WebAppEvents.LIFECYCLE.SSO_WINDOW_CLOSED); - } - }; - - // hardening - webviewProtection(): void { - const openLinkInNewWindowHandler = ( - details: HandlerDetails, - ): {action: 'deny'} | {action: 'allow'; overrideBrowserWindowOptions?: BrowserWindowConstructorOptions} => { - if (SingleSignOn.isSingleSignOnLoginWindow(details.frameName)) { - return { - action: 'allow', - overrideBrowserWindowOptions: SingleSignOn.getSingleSignOnLoginWindowOptions(main, details.url), - }; - } - - if (isPictureInPictureCallWindow(details.frameName)) { - return { - action: 'allow', - overrideBrowserWindowOptions: getPictureInPictureCallWindowOptions(), - }; - } - - this.logger.log('Opening an external window from a webview.'); - void WindowUtil.openExternal(details.url); - return {action: 'deny'}; - }; - - const openLinkInNewWindow = ( - win: BrowserWindow, - url: string, - event: ElectronEvent, - frameName: string, - options: BrowserWindowConstructorOptions, - ): Promise | void => { - if (SingleSignOn.isSingleSignOnLoginWindow(frameName)) { - const singleSignOn = new SingleSignOn(win, event, url, options).init(); - return new Promise(() => { - singleSignOn - .then(sso => { - this.ssoWindow = sso; - this.ssoWindow.onClose = this.sendSSOWindowCloseEvent; - }) - .catch(error => console.info(error)); - }); - } - }; - - // Keeping this Function for future use - const willNavigateInWebview = (event: ElectronEvent, url: string, baseUrl: string): void => { - // Ensure navigation is to an allowed domain - if (OriginValidator.isMatchingHost(url, baseUrl)) { - this.logger.log(`Navigating inside . URL: ${url}`); - } else { - // ToDo: Add a back button to the webview to navigate back to the main app - this.logger.log(`Navigating outside . URL: ${url}`); - } - }; - - const enableSpellChecking = settings.restore(SettingsType.ENABLE_SPELL_CHECKING, true); - - app.on('web-contents-created', async (webviewEvent: ElectronEvent, contents: WebContents) => { - remoteMain.enable(contents); - // disable new Windows by default on everything - contents.setWindowOpenHandler(() => { - return {action: 'deny'}; - }); - switch (contents.getType()) { - case 'window': { - contents.on('will-attach-webview', (_event, webPreferences, params) => { - // Use secure defaults - params.autosize = 'false'; - params.contextIsolation = 'true'; - params.plugins = 'false'; - webPreferences.allowRunningInsecureContent = false; - webPreferences.contextIsolation = false; - webPreferences.experimentalFeatures = false; - webPreferences.nodeIntegration = false; - webPreferences.preload = PRELOAD_RENDERER_JS; - webPreferences.spellcheck = enableSpellChecking; - webPreferences.webSecurity = true; - webPreferences.sandbox = false; - }); - break; - } - case 'webview': { - if (proxyInfoArg?.origin && contents.session) { - this.logger.log('Found proxy settings in arguments, applying settings on the webview...'); - await applyProxySettings(proxyInfoArg, contents); - } - // Open webview links outside of the app - contents.setWindowOpenHandler(openLinkInNewWindowHandler); - contents.on('did-create-window', async (win, {url, frameName, options}) => { - await openLinkInNewWindow(win, url, webviewEvent, frameName, options); - }); - contents.on('will-navigate', (event: ElectronEvent, url: string) => { - willNavigateInWebview(event, url, contents.getURL()); - }); - if (ENABLE_LOGGING) { - const colorCodeRegex = /%c(.+?)%c/gm; - const stylingRegex = /(color:#|font-weight:)[^;]+; /gm; - const accessTokenRegex = /access_token=[^ &]+/gm; - const {date, time} = DateUtil.isoFormat(new Date()); - - contents.on('console-message', async (_event, _level, message) => { - const webViewId = lifecycle.getWebViewId(contents); - /* - * Note: WebContents with ID `1` is the main window, `2` is the - * sidebar and `3` is the first account. - */ - const accountIndex = contents.id - 2; - - if (webViewId) { - const logFilePath = path.join( - LOG_DIR, - `${accountIndex}_${date.replaceAll('-', '_')}_${time.replaceAll(':', '_')}_${webViewId}`, - config.logFileName, - ); - try { - await LogFactory.writeMessage( - message.replace(colorCodeRegex, '$1').replace(stylingRegex, '').replace(accessTokenRegex, ''), - logFilePath, - ); - } catch (error) { - logger.error(`Cannot write to log file "${logFilePath}": ${(error as any).message}`, error); - } - } - }); - } - - if (enableSpellChecking) { - try { - const availableSpellCheckerLanguages = contents.session.availableSpellCheckerLanguages; - const foundLanguages = locale.supportedSpellCheckLanguages[currentLocale].filter(language => - availableSpellCheckerLanguages.includes(language), - ); - contents.session.setSpellCheckerLanguages(foundLanguages); - } catch (error) { - logger.error(error); - contents.session.setSpellCheckerLanguages([]); - } - } - - // Disable TLS < v1.2 - contents.session.setSSLConfig({minVersion: 'tls1.2'}); - - contents.session.setCertificateVerifyProc(setCertificateVerifyProc); - - contents.on('before-input-event', (_event, input) => { - if (input.type === 'keyUp' && input.key === 'Alt') { - const mainBrowserWindow = WindowManager.getPrimaryWindow(); - - if (mainBrowserWindow) { - const isAutoHide = mainBrowserWindow.isMenuBarAutoHide(); - const isVisible = mainBrowserWindow.isMenuBarVisible(); - if (isAutoHide) { - mainBrowserWindow.setMenuBarVisibility(!isVisible); - } - } - } - }); - - break; - } - } - }); - } -} - -customProtocolHandler.registerCoreProtocol(); -handlePortableFlags(); -lifecycle - .checkSingleInstance() - .then(() => lifecycle.initSquirrelListener()) - .catch(error => logger.error(error)); - -// Reloads the entire view when a `relaunch` is triggered (MacOS only, as other platform will quit and restart the app) -lifecycle.addRelaunchListeners(async () => { - const mainURL = getMainWindowUrl(); - await main.loadURL(mainURL.href); -}); - -// Stop further execution on update to prevent second tray icon -if (lifecycle.isFirstInstance) { - addLinuxWorkarounds(); - bindIpcEvents(); - handleAppEvents(); - renameWebViewLogFiles(); - fs.ensureFileSync(LOG_FILE); - new ElectronWrapperInit().run().catch(error => logger.error(error)); -} +loadMainProcess(); diff --git a/electron/src/mainProcess.ts b/electron/src/mainProcess.ts new file mode 100644 index 00000000000..17b4bba190c --- /dev/null +++ b/electron/src/mainProcess.ts @@ -0,0 +1,784 @@ +/* + * Wire + * Copyright (C) 2018 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 remoteMain from '@electron/remote/main'; +import { + app, + dialog, + BrowserWindow, + BrowserWindowConstructorOptions, + Event as ElectronEvent, + ipcMain, + Menu, + WebContents, + desktopCapturer, + safeStorage, + HandlerDetails, +} from 'electron'; +import electronDl from 'electron-dl'; +import windowStateKeeper from 'electron-window-state'; +import fs from 'fs-extra'; +import {getProxySettings} from 'get-proxy-settings'; +import logdown from 'logdown'; +import minimist from 'minimist'; + +import * as path from 'path'; +import {URL, pathToFileURL} from 'url'; + +import {LogFactory} from '@wireapp/commons'; +import {WebAppEvents} from '@wireapp/webapp-events'; + +import * as ProxyAuth from './auth/ProxyAuth'; +import {getPictureInPictureCallWindowOptions, isPictureInPictureCallWindow} from './calling/PictureInPictureCall'; +import { + attachTo as attachCertificateVerifyProcManagerTo, + setCertificateVerifyProc, +} from './lib/CertificateVerifyProcManager'; +import {CustomProtocolHandler} from './lib/CoreProtocol'; +import {downloadImage} from './lib/download'; +import {EVENT_TYPE} from './lib/eventType'; +import {forwardWrapperReloadRequest} from './lib/forwardWrapperReloadRequest'; +import {deleteAccount} from './lib/LocalAccountDeletion'; +import {getOpenGraphDataAsync} from './lib/openGraph'; +import {showErrorDialog} from './lib/showDialog'; +import * as locale from './locale'; +import {ENABLE_LOGGING, getLogger} from './logging/getLogger'; +import {renameWebViewLogFiles} from './logging/logMigration'; +import {getLogDirectory, getMainProcessLogPath, getWebViewLogPath} from './logging/logPaths'; +import {getManagedConfig} from './managed/ManagedConfig'; +import {developerMenu, openDevTools} from './menu/developer'; +import * as systemMenu from './menu/system'; +import {TrayHandler} from './menu/TrayHandler'; +import {getConfiguredPortableUserDataPath} from './runtime/configurePortableUserData'; +import * as EnvironmentUtil from './runtime/EnvironmentUtil'; +import * as lifecycle from './runtime/lifecycle'; +import {OriginValidator} from './runtime/OriginValidator'; +import {config} from './settings/config'; +import {settings} from './settings/ConfigurationPersistence'; +import {SettingsType} from './settings/SettingsType'; +import {SingleSignOn} from './sso/SingleSignOn'; +import {initMacAutoUpdater} from './update/macosAutoUpdater'; +import {AboutWindow} from './window/AboutWindow'; +import {ProxyPromptWindow} from './window/ProxyPromptWindow'; +import {WindowManager} from './window/WindowManager'; +import * as WindowUtil from './window/WindowUtil'; + +const MAIN_PROCESS_LOGGER_NAME = 'main.js'; +const logger = getLogger(MAIN_PROCESS_LOGGER_NAME); +const configuredUserDataPath = getConfiguredPortableUserDataPath(); + +remoteMain.initialize(); + +const APP_PATH = path.join(app.getAppPath(), config.electronDirectory); +const INDEX_HTML = path.join(APP_PATH, 'renderer/index.html'); +const PRELOAD_JS = path.join(APP_PATH, 'dist/preload/preload-app.js'); +const PRELOAD_RENDERER_JS = path.join(APP_PATH, 'dist/preload/preload-webview.js'); +const WRAPPER_CSS = path.join(APP_PATH, 'css/wrapper.css'); +const ICON = path.join(APP_PATH, 'img/download-dialog/logo@2x.png'); + +const WINDOW_SIZE = { + DEFAULT_HEIGHT: 768, + DEFAULT_WIDTH: 1024, + MIN_HEIGHT: 512, + MIN_WIDTH: 398, +}; + +let proxyInfoArg: URL | undefined; + +const customProtocolHandler = new CustomProtocolHandler(); + +// Config +const argv = minimist(process.argv.slice(1)); +const fileBasedProxyConfig = settings.restore(SettingsType.PROXY_SERVER_URL); + +const currentLocale = locale.getCurrent(); +const startHidden = Boolean(argv[config.ARGUMENT.STARTUP] || argv[config.ARGUMENT.HIDDEN]); +const customDownloadPath = settings.restore(SettingsType.DOWNLOAD_PATH); +const appHomePath = (path: string) => `${app.getPath('home')}\\${path}`; +const isInternalBuild = (): boolean => config.environment === 'internal'; + +if (customDownloadPath) { + electronDl({ + directory: appHomePath(customDownloadPath), + saveAs: false, + onCompleted: () => { + dialog.showMessageBox({ + type: 'none', + icon: ICON, + title: locale.getText('enforcedDownloadComplete'), + message: locale.getText('enforcedDownloadMessage', { + path: appHomePath(customDownloadPath) ?? app.getPath('downloads'), + }), + buttons: [locale.getText('enforcedDownloadButton')], + }); + }, + }); +} + +if (argv[config.ARGUMENT.VERSION]) { + console.info(config.version); + app.exit(); +} + +logger.info(`Initializing ${config.name} v${config.version} ...`); + +if (argv[config.ARGUMENT.PROXY_SERVER] || fileBasedProxyConfig) { + try { + proxyInfoArg = new URL(argv[config.ARGUMENT.PROXY_SERVER] || fileBasedProxyConfig); + if (!argv[config.ARGUMENT.PROXY_SERVER] && fileBasedProxyConfig) { + logger.info(`Using proxy server URL from "init.json": ${fileBasedProxyConfig}`); + app.commandLine.appendSwitch('proxy-server', fileBasedProxyConfig); + } + if (!/^(https?|socks[45]):$/.test(proxyInfoArg.protocol)) { + throw new Error('Invalid protocol for the proxy server specified.'); + } + if (proxyInfoArg.origin === 'null') { + proxyInfoArg = undefined; + throw new Error('No protocol for the proxy server specified.'); + } + } catch (error) { + logger.error(`Could not parse authenticated proxy URL: "${(error as any).message}"`); + } +} + +const iconFileName = `logo.${EnvironmentUtil.platform.IS_WINDOWS ? 'ico' : 'png'}`; +const iconPath = path.join(APP_PATH, 'img', iconFileName); +// This needs to stay global, see +// https://github.com/electron/electron/blob/v4.2.12/docs/faq.md#my-apps-windowtray-disappeared-after-a-few-minutes +let tray: TrayHandler; + +let isFullScreen = false; +let isQuitting = false; +let main: BrowserWindow; + +Object.entries(config).forEach(([key, value]) => { + if (typeof value === 'undefined' || (typeof value === 'number' && isNaN(value))) { + logger.warn(`Configuration key "${key}" not defined.`); + } +}); + +// Squirrel setup +app.setAppUserModelId(config.appUserModelId); + +// Disable mDNS IP masking so local/private IPs are exposed (prevents Windows firewall prompt) +app.commandLine.appendSwitch('disable-features', 'webrtc-hide-local-ips-with-mdns'); + +// Allow both public and private interfaces for WebRTC +app.commandLine.appendSwitch('force-webrtc-ip-handling-policy', 'default_public_and_private_interfaces'); + +// IPC events +const bindIpcEvents = (): void => { + ipcMain.on(EVENT_TYPE.ACTION.SAVE_PICTURE, (_event, bytes: Uint8Array, timestamp?: string) => { + return downloadImage(bytes, timestamp); + }); + + ipcMain.on(EVENT_TYPE.ACTION.NOTIFICATION_CLICK, () => WindowManager.showPrimaryWindow()); + ipcMain.on(EVENT_TYPE.WEBAPP.APP_LOADED, () => WindowManager.flushActionsQueue()); + + ipcMain.on(EVENT_TYPE.UI.BADGE_COUNT, (_event, {count, ignoreFlash}: {count?: number; ignoreFlash?: boolean}) => { + tray.showUnreadCount(main, count, ignoreFlash); + }); + + ipcMain.on(EVENT_TYPE.ACCOUNT.DELETE_DATA, async (_event, id: number, accountId: string, partitionId?: string) => { + await deleteAccount(id, accountId, partitionId); + main.webContents.send(EVENT_TYPE.ACCOUNT.DATA_DELETED); + }); + ipcMain.on(EVENT_TYPE.WRAPPER.RELOAD, () => forwardWrapperReloadRequest(main.webContents)); + ipcMain.on(EVENT_TYPE.WRAPPER.RELAUNCH, () => lifecycle.relaunch()); + ipcMain.on(EVENT_TYPE.ABOUT.SHOW, () => AboutWindow.showWindow()); + + // Answered synchronously: the webview preload reads this via `ipcRenderer.sendSync` while it builds + // `window.desktopAppConfig`. The value is pre-read and memoized, so the handler does no I/O here. + ipcMain.on(EVENT_TYPE.MANAGED.GET_CONFIG, event => { + event.returnValue = getManagedConfig(); + }); + + ipcMain.handle(EVENT_TYPE.ACTION.GET_OG_DATA, (_event, url) => getOpenGraphDataAsync(url)); + + ipcMain.on(EVENT_TYPE.ACTION.CHANGE_DOWNLOAD_LOCATION, (_event, downloadPath?: string) => { + if (EnvironmentUtil.platform.IS_WINDOWS) { + if (downloadPath) { + fs.ensureDirSync(appHomePath(downloadPath)); + } + //save the downloadPath locally + settings.save(SettingsType.DOWNLOAD_PATH, downloadPath); + settings.persistToFile(); + } + }); +}; + +const checkConfigV0FullScreen = (mainWindowState: windowStateKeeper.State): void => { + // if a user still has the old config version 0 and had the window maximized last time + if (typeof mainWindowState.isMaximized === 'undefined' && isFullScreen === true) { + main.maximize(); + } +}; + +const initWindowStateKeeper = (): windowStateKeeper.State => { + const loadedWindowBounds = settings.restore(SettingsType.WINDOW_BOUNDS, { + height: WINDOW_SIZE.DEFAULT_HEIGHT, + width: WINDOW_SIZE.DEFAULT_WIDTH, + }); + + // load version 0 full screen setting + const showInFullScreen = settings.restore(SettingsType.FULL_SCREEN, 'not-set-in-v0'); + + const stateKeeperOptions: windowStateKeeper.Options = { + defaultHeight: loadedWindowBounds.height, + defaultWidth: loadedWindowBounds.width, + path: path.join(app.getPath('userData'), 'config'), + }; + + if (showInFullScreen !== 'not-set-in-v0') { + stateKeeperOptions.fullScreen = showInFullScreen as boolean; + stateKeeperOptions.maximize = showInFullScreen as boolean; + isFullScreen = showInFullScreen as boolean; + } + + return windowStateKeeper(stateKeeperOptions); +}; + +function getMainWindowUrl() { + const baseUrl = EnvironmentUtil.web.getWebappUrl(); + const mainURL = pathToFileURL(INDEX_HTML); + mainURL.searchParams.set('focus', String(!startHidden)); + + if (!baseUrl) { + // No URL configured in init.json (only applies to Wire Gov builds, which don't fall back to a default). + mainURL.searchParams.set('noUrlConfigured', 'true'); + + return mainURL; + } + + const webappURL = new URL(baseUrl); + webappURL.searchParams.set('hl', currentLocale); + + if (ENABLE_LOGGING) { + webappURL.searchParams.set('enableLogging', '@wireapp/*'); + } + + if (customProtocolHandler.hashLocation) { + webappURL.hash = customProtocolHandler.hashLocation; + } + mainURL.searchParams.set('env', encodeURIComponent(webappURL.href)); + + return mainURL; +} + +// App Windows +const showMainWindow = async (mainWindowState: windowStateKeeper.State): Promise => { + const showMenuBar = settings.restore(SettingsType.SHOW_MENU_BAR, true); + + const options: BrowserWindowConstructorOptions = { + autoHideMenuBar: !showMenuBar, + backgroundColor: '#f7f8fa', + height: mainWindowState.height, + icon: iconPath, + minHeight: WINDOW_SIZE.MIN_HEIGHT, + minWidth: WINDOW_SIZE.MIN_WIDTH, + show: false, + title: config.name, + webPreferences: { + backgroundThrottling: false, + contextIsolation: false, + nodeIntegration: false, + preload: PRELOAD_JS, + sandbox: false, + webviewTag: true, + }, + width: mainWindowState.width, + // eslint-disable-next-line id-length + x: mainWindowState.x, + // eslint-disable-next-line id-length + y: mainWindowState.y, + }; + + ipcMain.handle(EVENT_TYPE.ACTION.GET_DESKTOP_SOURCES, (event, opts) => desktopCapturer.getSources(opts)); + ipcMain.handle(EVENT_TYPE.ACTION.ENCRYPT, (event, plaintext: string) => safeStorage.encryptString(plaintext)); + ipcMain.handle(EVENT_TYPE.ACTION.DECRYPT, (event, encrypted: Uint8Array) => + safeStorage.decryptString(Buffer.from(encrypted)), + ); + + main = new BrowserWindow(options); + + remoteMain.enable(main.webContents); + + main.setMenuBarVisibility(showMenuBar); + + mainWindowState.manage(main); + attachCertificateVerifyProcManagerTo(main); + checkConfigV0FullScreen(mainWindowState); + + if (typeof argv[config.ARGUMENT.DEVTOOLS] !== 'undefined') { + openDevTools(argv[config.ARGUMENT.DEVTOOLS]).catch(() => + logger.warn(`Could not open DevTools with index "${argv[config.ARGUMENT.DEVTOOLS]}". Does the account exist?`), + ); + } + + if (!startHidden) { + if (!WindowUtil.isInView(main)) { + main.center(); + } + + WindowManager.setPrimaryWindowId(main.id); + setTimeout(() => main.show(), 800); + } + + main.webContents.on('will-navigate', event => { + // Prevent any kind of navigation inside the main window + event.preventDefault(); + }); + + // Handle the new window event in the main Browser Window + main.webContents.setWindowOpenHandler(details => { + return {action: 'deny'}; + }); + + main.on('focus', () => { + systemMenu.registerGlobalShortcuts(); + main.flashFrame(false); + }); + + main.on('blur', () => systemMenu.unregisterGlobalShortcuts()); + + main.on('page-title-updated', () => tray.showUnreadCount(main)); + + main.on('close', event => { + if (!isQuitting) { + event.preventDefault(); + logger.log('Closing window...'); + + if (main.isFullScreen()) { + logger.log('Fullscreen detected, leaving full screen before hiding...'); + main.once('leave-full-screen', () => main.hide()); + main.setFullScreen(false); + } else { + main.hide(); + } + systemMenu.unregisterGlobalShortcuts(); + } + }); + + app.on('render-process-gone', async (event, _, details) => { + logger.error('WebContents crashed. Will reload the window.'); + logger.error(JSON.stringify(details)); + try { + main.reload(); + } catch (error) { + showErrorDialog(`Could not reload the window: ${(error as any).message}`); + logger.error('Could not reload the window:', error); + } + }); + + app.on('child-process-gone', (event, details) => { + logger.error('child process gone'); + logger.error(event); + logger.error(details); + }); + + main.webContents.setZoomFactor(1); + + const mainURL = getMainWindowUrl(); + await main.loadURL(mainURL.href); + const wrapperCSSContent = await fs.readFile(WRAPPER_CSS, 'utf8'); + await main.webContents.insertCSS(wrapperCSSContent); +}; + +// App Events +const handleAppEvents = (): void => { + app.on('window-all-closed', async () => { + if (!EnvironmentUtil.platform.IS_MAC_OS) { + await lifecycle.quit(); + } + }); + + app.on('activate', () => { + if (main) { + main.show(); + } + }); + + app.on('before-quit', () => { + isQuitting = true; + }); + + app.on('login', async (event, webContents, _responseDetails, authInfo, callback) => { + if (authInfo.isProxy) { + event.preventDefault(); + const {host, port} = authInfo; + + const systemProxy = await getProxySettings(); + const systemProxySettings = systemProxy && (systemProxy.http || systemProxy.https); + if (systemProxySettings) { + const { + credentials: {username, password}, + protocol, + } = systemProxySettings; + proxyInfoArg = ProxyAuth.generateProxyURL({host, port}, {password, protocol, username}); + logger.log('Found system proxy settings, applying settings on the main window...'); + + await applyProxySettings(proxyInfoArg, main.webContents); + + return callback(username, password); + } + + if (proxyInfoArg) { + ipcMain.once( + EVENT_TYPE.PROXY_PROMPT.SUBMITTED, + async (_event, promptData: {password: string; username: string}) => { + logger.log('Proxy info was submitted via prompt'); + + const {username, password} = promptData; + // remove the colon from the protocol to align it with other usages of `generateProxyURL` + const protocol = proxyInfoArg?.protocol?.replace(':', ''); + proxyInfoArg = ProxyAuth.generateProxyURL( + {host, port}, + { + ...promptData, + protocol, + }, + ); + + logger.log('Proxy prompt was submitted, applying proxy settings on the main window...'); + await applyProxySettings(proxyInfoArg, main.webContents); + callback(username, password); + }, + ); + + ipcMain.once(EVENT_TYPE.PROXY_PROMPT.CANCELED, async () => { + logger.log('Proxy prompt was canceled'); + + // TODO: check if we should use `mode: 'auto_detect'` here + await webContents.session.setProxy({}); + + try { + main.reload(); + } catch (error) { + showErrorDialog(`Could not reload the window: ${(error as any).message}`); + logger.error('Could not reload the window:', error); + } + }); + + await ProxyPromptWindow.showWindow(); + } + } + }); + + // System Menu, Tray Icon & Show window + app.on('ready', async () => { + const mainWindowState = initWindowStateKeeper(); + const appMenu = systemMenu.createMenu(isFullScreen); + if (EnvironmentUtil.app.IS_DEVELOPMENT) { + app.commandLine.appendSwitch('enable-webrtc-internals'); + appMenu.append(developerMenu); + } + + Menu.setApplicationMenu(appMenu); + tray = new TrayHandler(); + if (!EnvironmentUtil.platform.IS_MAC_OS) { + tray.initTray(); + } + await showMainWindow(mainWindowState); + + app.on('ready', async () => { + const mainWindowState = initWindowStateKeeper(); + const appMenu = systemMenu.createMenu(isFullScreen); + if (EnvironmentUtil.app.IS_DEVELOPMENT) { + appMenu.append(developerMenu); + } + + Menu.setApplicationMenu(appMenu); + tray = new TrayHandler(); + if (!EnvironmentUtil.platform.IS_MAC_OS) { + tray.initTray(); + } + await showMainWindow(mainWindowState); + + if (EnvironmentUtil.platform.IS_MAC_OS && isInternalBuild()) { + initMacAutoUpdater(main); + } + }); + }); +}; + +const addLinuxWorkarounds = (): void => { + if (EnvironmentUtil.platform.IS_LINUX) { + // Fix indicator icon on Unity + // Source: https://bugs.launchpad.net/ubuntu/+bug/1559249 + + if ( + EnvironmentUtil.linuxDesktop.isUbuntuUnity || + EnvironmentUtil.linuxDesktop.isPopOS || + EnvironmentUtil.linuxDesktop.isGnomeX11 + ) { + process.env.XDG_CURRENT_DESKTOP = 'Unity'; + } + } +}; + +const handlePortableFlags = (): void => { + configuredUserDataPath.match({ + Just: logConfiguredUserDataPath, + Nothing: handleMissingUserDataPath, + }); +}; + +function logConfiguredUserDataPath(userDataPath: string): void { + logger.log(`Saving user data to "${userDataPath}".`); +} + +function handleMissingUserDataPath(): void {} + +const applyProxySettings = async (authenticatedProxyDetails: URL, webContents: Electron.WebContents): Promise => { + const proxyURL = authenticatedProxyDetails.origin.split('://')[1]; + const proxyProtocol = authenticatedProxyDetails.protocol; + const isSocksProxy = proxyProtocol === 'socks4:' || proxyProtocol === 'socks5:'; + + logger.info(`Setting proxy on the window to URL "${proxyURL}" with protocol "${proxyProtocol}"...`); + webContents.session.allowNTLMCredentialsForDomains(authenticatedProxyDetails.hostname); + + const proxyRules = isSocksProxy ? `socks=${proxyURL}` : `http=${proxyURL};https=${proxyURL}`; + await webContents.session.setProxy({pacScript: '', proxyBypassRules: '', proxyRules}); +}; + +class ElectronWrapperInit { + logger: logdown.Logger; + ssoWindow: SingleSignOn | null; + + constructor() { + this.logger = getLogger('ElectronWrapperInit'); + this.ssoWindow = null; + ipcMain.on(WebAppEvents.LIFECYCLE.SSO_WINDOW_CLOSE, this.closeSSOWindow); + ipcMain.on(WebAppEvents.LIFECYCLE.SSO_WINDOW_FOCUS, this.focusSSOWindow); + } + + async run(): Promise { + this.logger.log('webviewProtection init'); + this.webviewProtection(); + } + + closeSSOWindow = () => { + if (this.ssoWindow) { + this.ssoWindow?.close(); + this.ssoWindow = null; + } + }; + + focusSSOWindow = () => { + if (this.ssoWindow) { + this.ssoWindow.focus(); + } + }; + + sendSSOWindowCloseEvent = () => { + if (this.ssoWindow) { + main.webContents.send(WebAppEvents.LIFECYCLE.SSO_WINDOW_CLOSED); + } + }; + + // hardening + webviewProtection(): void { + const openLinkInNewWindowHandler = ( + details: HandlerDetails, + ): {action: 'deny'} | {action: 'allow'; overrideBrowserWindowOptions?: BrowserWindowConstructorOptions} => { + if (SingleSignOn.isSingleSignOnLoginWindow(details.frameName)) { + return { + action: 'allow', + overrideBrowserWindowOptions: SingleSignOn.getSingleSignOnLoginWindowOptions(main, details.url), + }; + } + + if (isPictureInPictureCallWindow(details.frameName)) { + return { + action: 'allow', + overrideBrowserWindowOptions: getPictureInPictureCallWindowOptions(), + }; + } + + this.logger.log('Opening an external window from a webview.'); + void WindowUtil.openExternal(details.url); + + return {action: 'deny'}; + }; + + const openLinkInNewWindow = ( + win: BrowserWindow, + url: string, + event: ElectronEvent, + frameName: string, + options: BrowserWindowConstructorOptions, + ): Promise | void => { + if (SingleSignOn.isSingleSignOnLoginWindow(frameName)) { + const singleSignOn = new SingleSignOn(win, event, url, options).init(); + + return new Promise(() => { + singleSignOn + .then(sso => { + this.ssoWindow = sso; + this.ssoWindow.onClose = this.sendSSOWindowCloseEvent; + }) + .catch(error => console.info(error)); + }); + } + }; + + // Keeping this Function for future use + const willNavigateInWebview = (event: ElectronEvent, url: string, baseUrl: string): void => { + // Ensure navigation is to an allowed domain + if (OriginValidator.isMatchingHost(url, baseUrl)) { + this.logger.log(`Navigating inside . URL: ${url}`); + } else { + // ToDo: Add a back button to the webview to navigate back to the main app + this.logger.log(`Navigating outside . URL: ${url}`); + } + }; + + const enableSpellChecking = settings.restore(SettingsType.ENABLE_SPELL_CHECKING, true); + + app.on('web-contents-created', async (webviewEvent: ElectronEvent, contents: WebContents) => { + remoteMain.enable(contents); + // disable new Windows by default on everything + contents.setWindowOpenHandler(() => { + return {action: 'deny'}; + }); + switch (contents.getType()) { + case 'window': { + contents.on('will-attach-webview', (_event, webPreferences, params) => { + // Use secure defaults + params.autosize = 'false'; + params.contextIsolation = 'true'; + params.plugins = 'false'; + webPreferences.allowRunningInsecureContent = false; + webPreferences.contextIsolation = false; + webPreferences.experimentalFeatures = false; + webPreferences.nodeIntegration = false; + webPreferences.preload = PRELOAD_RENDERER_JS; + webPreferences.spellcheck = enableSpellChecking; + webPreferences.webSecurity = true; + webPreferences.sandbox = false; + }); + break; + } + case 'webview': { + if (proxyInfoArg?.origin && contents.session) { + this.logger.log('Found proxy settings in arguments, applying settings on the webview...'); + await applyProxySettings(proxyInfoArg, contents); + } + // Open webview links outside of the app + contents.setWindowOpenHandler(openLinkInNewWindowHandler); + contents.on('did-create-window', async (win, {url, frameName, options}) => { + await openLinkInNewWindow(win, url, webviewEvent, frameName, options); + }); + contents.on('will-navigate', (event: ElectronEvent, url: string) => { + willNavigateInWebview(event, url, contents.getURL()); + }); + if (ENABLE_LOGGING) { + const colorCodeRegex = /%c(.+?)%c/gm; + const stylingRegex = /(color:#|font-weight:)[^;]+; /gm; + const accessTokenRegex = /access_token=[^ &]+/gm; + const logDate = new Date(); + + contents.on('console-message', async (_event, _level, message) => { + const webViewId = lifecycle.getWebViewId(contents); + /* + * Note: WebContents with ID `1` is the main window, `2` is the + * sidebar and `3` is the first account. + */ + const accountIndex = contents.id - 2; + + if (webViewId) { + const logFilePath = getWebViewLogPath({ + accountIndex, + date: logDate, + logDirectory: getLogDirectory(), + webViewId, + }); + try { + await LogFactory.writeMessage( + message.replace(colorCodeRegex, '$1').replace(stylingRegex, '').replace(accessTokenRegex, ''), + logFilePath, + ); + } catch (error) { + logger.error(`Cannot write to log file "${logFilePath}": ${(error as any).message}`, error); + } + } + }); + } + + if (enableSpellChecking) { + try { + const availableSpellCheckerLanguages = contents.session.availableSpellCheckerLanguages; + const foundLanguages = locale.supportedSpellCheckLanguages[currentLocale].filter(language => + availableSpellCheckerLanguages.includes(language), + ); + contents.session.setSpellCheckerLanguages(foundLanguages); + } catch (error) { + logger.error(error); + contents.session.setSpellCheckerLanguages([]); + } + } + + // Disable TLS < v1.2 + contents.session.setSSLConfig({minVersion: 'tls1.2'}); + + contents.session.setCertificateVerifyProc(setCertificateVerifyProc); + + contents.on('before-input-event', (_event, input) => { + if (input.type === 'keyUp' && input.key === 'Alt') { + const mainBrowserWindow = WindowManager.getPrimaryWindow(); + + if (mainBrowserWindow) { + const isAutoHide = mainBrowserWindow.isMenuBarAutoHide(); + const isVisible = mainBrowserWindow.isMenuBarVisible(); + if (isAutoHide) { + mainBrowserWindow.setMenuBarVisibility(!isVisible); + } + } + } + }); + + break; + } + } + }); + } +} + +customProtocolHandler.registerCoreProtocol(); +handlePortableFlags(); +lifecycle + .checkSingleInstance() + .then(() => lifecycle.initSquirrelListener()) + .catch(error => logger.error(error)); + +// Reloads the entire view when a `relaunch` is triggered (MacOS only, as other platform will quit and restart the app) +lifecycle.addRelaunchListeners(async () => { + const mainURL = getMainWindowUrl(); + await main.loadURL(mainURL.href); +}); + +// Stop further execution on update to prevent second tray icon +if (lifecycle.isFirstInstance) { + addLinuxWorkarounds(); + bindIpcEvents(); + handleAppEvents(); + renameWebViewLogFiles(getLogDirectory(), logger); + fs.ensureFileSync(getMainProcessLogPath({logDirectory: getLogDirectory()})); + new ElectronWrapperInit().run().catch(error => logger.error(error)); +} diff --git a/electron/src/runtime/configurePortableUserData.ts b/electron/src/runtime/configurePortableUserData.ts new file mode 100644 index 00000000000..4c36a559217 --- /dev/null +++ b/electron/src/runtime/configurePortableUserData.ts @@ -0,0 +1,81 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 Electron from 'electron'; +import minimist from 'minimist'; +import {Maybe} from 'true-myth'; + +import {PortableUserDataPathParameters, resolvePortableUserDataPath} from './portableUserData'; + +import {config} from '../settings/config'; + +const app = Electron.app || require('@electron/remote').app; + +type CommandLineArguments = Record; + +export type ConfigurePortableUserDataParameters = { + portableUserDataPathParameters: PortableUserDataPathParameters; + setUserDataPath: (userDataPath: string) => void; +}; + +function getPortableUserDataPathParameters(): PortableUserDataPathParameters { + const commandLineArguments = getCommandLineArguments(); + + return { + appImagePath: Maybe.of(process.env.APPIMAGE), + executablePath: process.execPath, + portableModeEnabled: Boolean(commandLineArguments[config.ARGUMENT.PORTABLE]), + userDataDirectoryArgument: getStringCommandLineArgument(commandLineArguments, config.ARGUMENT.USER_DATA_DIR), + }; +} + +function getCommandLineArguments(): CommandLineArguments { + return minimist(process.argv.slice(1)) as CommandLineArguments; +} + +function getStringCommandLineArgument(commandLineArguments: CommandLineArguments, argumentName: string): Maybe { + const argumentValue = commandLineArguments[argumentName]; + + return typeof argumentValue === 'string' ? Maybe.just(argumentValue) : Maybe.nothing(); +} + +function setConfiguredUserDataPath(userDataPath: string): void { + app.setPath('userData', userDataPath); +} + +export function configurePortableUserData(parameters: ConfigurePortableUserDataParameters): Maybe { + const configuredUserDataPath = resolvePortableUserDataPath(parameters.portableUserDataPathParameters); + + if (configuredUserDataPath.isJust) { + parameters.setUserDataPath(configuredUserDataPath.value); + } + + return configuredUserDataPath; +} + +export function configurePortableUserDataAtStartup(): void { + configurePortableUserData({ + portableUserDataPathParameters: getPortableUserDataPathParameters(), + setUserDataPath: setConfiguredUserDataPath, + }); +} + +export function getConfiguredPortableUserDataPath(): Maybe { + return resolvePortableUserDataPath(getPortableUserDataPathParameters()); +} diff --git a/electron/src/runtime/portableUserData.test.main.ts b/electron/src/runtime/portableUserData.test.main.ts new file mode 100644 index 00000000000..de23977f969 --- /dev/null +++ b/electron/src/runtime/portableUserData.test.main.ts @@ -0,0 +1,80 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {Maybe} from 'true-myth'; + +import * as assert from 'assert'; +import * as path from 'path'; + +import {configurePortableUserData} from './configurePortableUserData'; +import {resolvePortableUserDataPath} from './portableUserData'; + +describe('portable user-data path', () => { + it('resolves a custom user-data directory to an absolute path', () => { + const actualPath = resolvePortableUserDataPath({ + executablePath: '/Applications/Wire.app/Contents/MacOS/Wire', + portableModeEnabled: false, + userDataDirectoryArgument: Maybe.just('custom-user-data'), + appImagePath: Maybe.nothing(), + }); + const expectedPath = path.resolve('custom-user-data'); + + assert.strictEqual(actualPath.unwrapOr(''), expectedPath); + }); + + it('resolves portable mode below the AppImage path', () => { + const actualPath = resolvePortableUserDataPath({ + executablePath: '/opt/Wire/Wire', + portableModeEnabled: true, + userDataDirectoryArgument: Maybe.nothing(), + appImagePath: Maybe.just('/opt/Wire/Wire.AppImage'), + }); + const expectedPath = path.join('/opt/Wire/Wire.AppImage', '../Data'); + + assert.strictEqual(actualPath.unwrapOr(''), expectedPath); + }); + + it('returns Nothing when no portable or custom path is configured', () => { + const actualPath = resolvePortableUserDataPath({ + executablePath: '/opt/Wire/Wire', + portableModeEnabled: false, + userDataDirectoryArgument: Maybe.nothing(), + appImagePath: Maybe.nothing(), + }); + + assert.strictEqual(actualPath.isNothing, true); + }); + + it('configures the resolved user-data path through the injected boundary', () => { + const configuredUserDataPaths: string[] = []; + const expectedPath = path.resolve('custom-user-data'); + const actualPath = configurePortableUserData({ + portableUserDataPathParameters: { + executablePath: '/Applications/Wire.app/Contents/MacOS/Wire', + portableModeEnabled: false, + userDataDirectoryArgument: Maybe.just('custom-user-data'), + appImagePath: Maybe.nothing(), + }, + setUserDataPath: userDataPath => configuredUserDataPaths.push(userDataPath), + }); + + assert.strictEqual(actualPath.unwrapOr(''), expectedPath); + assert.deepStrictEqual(configuredUserDataPaths, [expectedPath]); + }); +}); diff --git a/electron/src/runtime/portableUserData.ts b/electron/src/runtime/portableUserData.ts new file mode 100644 index 00000000000..bcc35af2203 --- /dev/null +++ b/electron/src/runtime/portableUserData.ts @@ -0,0 +1,43 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {Maybe} from 'true-myth'; + +import * as path from 'path'; + +export type PortableUserDataPathParameters = { + executablePath: string; + portableModeEnabled: boolean; + userDataDirectoryArgument: Maybe; + appImagePath: Maybe; +}; + +export function resolvePortableUserDataPath(parameters: PortableUserDataPathParameters): Maybe { + if (parameters.userDataDirectoryArgument.isJust) { + return Maybe.just(path.resolve(parameters.userDataDirectoryArgument.value)); + } + + if (parameters.portableModeEnabled) { + const executablePath = parameters.appImagePath.unwrapOr(parameters.executablePath); + + return Maybe.just(path.join(executablePath, '../Data')); + } + + return Maybe.nothing(); +} diff --git a/electron/src/sso/SingleSignOn.ts b/electron/src/sso/SingleSignOn.ts index 9c9415efc81..58e6877f0c8 100644 --- a/electron/src/sso/SingleSignOn.ts +++ b/electron/src/sso/SingleSignOn.ts @@ -18,7 +18,6 @@ */ import { - app, BrowserWindow, BrowserWindowConstructorOptions, Event as ElectronEvent, @@ -37,6 +36,7 @@ import {LogFactory} from '@wireapp/commons'; import {executeJavaScriptWithoutResult} from '../lib/ElectronUtil'; import {ENABLE_LOGGING, getLogger} from '../logging/getLogger'; +import {getLogDirectory, getSsoLogPath} from '../logging/logPaths'; import {getWebViewId} from '../runtime/lifecycle'; import {config} from '../settings/config'; import * as WindowUtil from '../window/WindowUtil'; @@ -44,7 +44,6 @@ import * as WindowUtil from '../window/WindowUtil'; const minimist = require('minimist'); const argv = minimist(process.argv.slice(1)); -const LOG_DIR = path.join(app.getPath('userData'), 'logs'); export class SingleSignOn { private static readonly ALLOWED_BACKEND_ORIGINS = config.backendOrigins; @@ -109,6 +108,7 @@ export class SingleSignOn { if (typeof argv[config.ARGUMENT.DEVTOOLS] !== 'undefined') { this.ssoWindow?.webContents.openDevTools({mode: 'detach'}); } + return this; }; @@ -141,6 +141,7 @@ export class SingleSignOn { // Prevent new windows (open external pages in OS browser) ssoWindow.webContents.setWindowOpenHandler((details: HandlerDetails): {action: 'deny'} => { void WindowUtil.openExternal(details.url, true); + return {action: 'deny'}; }); @@ -158,7 +159,11 @@ export class SingleSignOn { ssoWindow.webContents.on('console-message', async (_event, _level, message) => { const webViewId = getWebViewId(ssoWindow.webContents); if (webViewId) { - const logFilePath = path.join(LOG_DIR, webViewId, config.logFileName); + const logFilePath = getSsoLogPath({ + logDirectory: getLogDirectory(), + logFileName: config.logFileName, + webViewId, + }); try { await LogFactory.writeMessage(message, logFilePath); } catch (error: any) { @@ -284,6 +289,7 @@ export class SingleSignOn { if (type === SingleSignOn.RESPONSE_TYPES.AUTH_SUCCESS) { if (!this.session) { await this.dispatchResponse(SingleSignOn.RESPONSE_TYPES.AUTH_ERROR_SESS_NOT_AVAILABLE); + return; } @@ -293,6 +299,7 @@ export class SingleSignOn { } catch (error) { SingleSignOn.logger.warn(error); await this.dispatchResponse(SingleSignOn.RESPONSE_TYPES.AUTH_ERROR_COOKIE); + return; } } diff --git a/electron/test/withTemporaryDirectory.ts b/electron/test/withTemporaryDirectory.ts new file mode 100644 index 00000000000..1420aa381d5 --- /dev/null +++ b/electron/test/withTemporaryDirectory.ts @@ -0,0 +1,40 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'fs-extra'; + +import * as os from 'os'; +import * as path from 'path'; + +export type TemporaryDirectoryTest = (temporaryDirectory: string) => Promise; + +export function withTemporaryDirectory( + temporaryDirectoryPrefix: string, + testFunction: TemporaryDirectoryTest, +): () => Promise { + return async function runTemporaryDirectoryTest(): Promise { + const temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), temporaryDirectoryPrefix)); + + try { + await testFunction(temporaryDirectory); + } finally { + await fs.remove(temporaryDirectory); + } + }; +} diff --git a/package.json b/package.json index 24c35f56caf..698d0171766 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "redux": "4.2.1", "redux-logger": "3.0.6", "redux-thunk": "2.4.2", + "true-myth": "7.4.0", "uuid": "9.0.1" }, "description": "The most secure collaboration platform.", diff --git a/yarn.lock b/yarn.lock index 32ac312923c..de57b0c4999 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19514,6 +19514,13 @@ __metadata: languageName: node linkType: hard +"true-myth@npm:7.4.0": + version: 7.4.0 + resolution: "true-myth@npm:7.4.0" + checksum: 2f94f5d73d8109be27797ef91e106b5bca3aca25a8db793f89e189d8e311749b84eea52e35a9dc17a7827161aa90b42644b7c17a68bb3e1518e2193b93669968 + languageName: node + linkType: hard + "truncate-utf8-bytes@npm:^1.0.0": version: 1.0.2 resolution: "truncate-utf8-bytes@npm:1.0.2" @@ -20971,6 +20978,7 @@ __metadata: sinon: 17.0.2 sort-json: 2.0.1 style-loader: 4.0.0 + true-myth: 7.4.0 ts-node: 10.9.2 typescript: 5.7.3 uuid: 9.0.1 From d051014059f4709cd348de41f016ece9cd77c146 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Mon, 24 Aug 2026 16:06:58 +0200 Subject: [PATCH 16/45] Fix desktop log rotation, retention and directory organization Centralize log paths and filesystem operations, preserve logs across restarts, add bounded rotation and retention and organize logs by UTC day and account while maintaining legacy-layout support. --- electron/src/lib/LocalAccountDeletion.ts | 59 ++++- .../src/lib/accountLogDeletion.test.main.ts | 81 +++++++ electron/src/lib/accountLogDeletion.ts | 88 +++++++ .../src/logging/boundedLogWriter.test.main.ts | 200 +++++++++++++++ electron/src/logging/boundedLogWriter.ts | 188 ++++++++++++++ electron/src/logging/desktopLogWriter.ts | 87 +++++++ electron/src/logging/getLogger.ts | 19 +- electron/src/logging/logCleanup.test.main.ts | 155 ++++++++++++ electron/src/logging/logCleanup.ts | 229 ++++++++++++++++++ .../logging/logCleanupScheduler.test.main.ts | 58 +++++ electron/src/logging/logCleanupScheduler.ts | 36 +++ electron/src/logging/logFiles.test.main.ts | 2 +- .../src/logging/logMigration.test.main.ts | 52 ---- electron/src/logging/logMigration.ts | 61 ----- electron/src/logging/logPaths.test.main.ts | 36 ++- electron/src/logging/logPaths.ts | 48 ++-- .../src/logging/logRetention.test.main.ts | 104 ++++++++ electron/src/logging/logRetention.ts | 135 +++++++++++ electron/src/logging/loggerUtils.ts | 8 + electron/src/mainProcess.ts | 107 +++++--- electron/src/runtime/lifecycle.ts | 21 +- electron/src/sso/SingleSignOn.ts | 27 ++- 22 files changed, 1592 insertions(+), 209 deletions(-) create mode 100644 electron/src/lib/accountLogDeletion.test.main.ts create mode 100644 electron/src/lib/accountLogDeletion.ts create mode 100644 electron/src/logging/boundedLogWriter.test.main.ts create mode 100644 electron/src/logging/boundedLogWriter.ts create mode 100644 electron/src/logging/desktopLogWriter.ts create mode 100644 electron/src/logging/logCleanup.test.main.ts create mode 100644 electron/src/logging/logCleanup.ts create mode 100644 electron/src/logging/logCleanupScheduler.test.main.ts create mode 100644 electron/src/logging/logCleanupScheduler.ts delete mode 100644 electron/src/logging/logMigration.test.main.ts delete mode 100644 electron/src/logging/logMigration.ts create mode 100644 electron/src/logging/logRetention.test.main.ts create mode 100644 electron/src/logging/logRetention.ts diff --git a/electron/src/lib/LocalAccountDeletion.ts b/electron/src/lib/LocalAccountDeletion.ts index 87ac1f04d97..85f196f0ec2 100644 --- a/electron/src/lib/LocalAccountDeletion.ts +++ b/electron/src/lib/LocalAccountDeletion.ts @@ -25,13 +25,22 @@ import * as path from 'path'; import {ValidationUtil} from '@wireapp/commons'; +import {deleteAccountLogDirectories} from './accountLogDeletion'; + import {getLogger} from '../logging/getLogger'; +import {getLogFilenames} from '../logging/logFiles'; import {getLogDirectory} from '../logging/logPaths'; -const USER_DATA_DIR = app.getPath('userData'); - const logger = getLogger(path.basename(__filename)); +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function getUserDataDirectory(): string { + return app.getPath('userData'); +} + const clearStorage = async (session: Session): Promise => { await session.clearStorageData(); await session.clearCache(); @@ -53,8 +62,8 @@ export async function deleteAccount(id: number, accountId: string, partitionId?: logger.log(`Deleting session data for account "${truncatedId}"...`); await clearStorage(webviewWebContent.session); logger.log(`Deleted session data for account "${truncatedId}".`); - } catch (error: any) { - logger.error(`Failed to delete session data for account "${truncatedId}", reason: "${error.message}".`); + } catch (error) { + logger.error(`Failed to delete session data for account "${truncatedId}", reason: "${getErrorMessage(error)}".`); } // Delete the webview partition @@ -66,12 +75,14 @@ export async function deleteAccount(id: number, accountId: string, partitionId?: if (!ValidationUtil.isUUIDv4(partitionId)) { throw new Error('Partition is not an UUID'); } - const partitionDir = path.join(USER_DATA_DIR, 'Partitions', partitionId); + const partitionDir = path.join(getUserDataDirectory(), 'Partitions', partitionId); await fs.remove(partitionDir); logger.log(`Deleted partition "${partitionId}" for account "${truncatedId}".`); - } catch (error: any) { + } catch (error) { + const errorMessage = getErrorMessage(error); + logger.log( - `Unable to delete partition "${partitionId}" for account "${truncatedId}", reason: "${error.message}".`, + `Unable to delete partition "${partitionId}" for account "${truncatedId}", reason: "${errorMessage}".`, ); } } @@ -81,11 +92,37 @@ export async function deleteAccount(id: number, accountId: string, partitionId?: if (!ValidationUtil.isUUIDv4(accountId)) { throw new Error('Account is not an UUID'); } - const sessionFolder = path.join(getLogDirectory(), accountId); - await fs.remove(sessionFolder); + const logDirectory = getLogDirectory(); + const logFilePaths = getLogFilenames({absolute: true, baseDirectory: logDirectory}); + await deleteAccountLogDirectories({ + accountId, + dependencies: { + async isSafeDirectory(directoryPath: string): Promise { + try { + const directoryStatistics = await fs.lstat(directoryPath); + + return directoryStatistics.isDirectory() && directoryStatistics.isSymbolicLink() === false; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false; + } + + throw error; + } + }, + async removeDirectory(directoryPath: string): Promise { + await fs.remove(directoryPath); + }, + reportFailure: (message: string, error: unknown): void => { + logger.error(`${message}: ${getErrorMessage(error)}`); + }, + }, + filePaths: logFilePaths, + logDirectory, + }); logger.log(`Deleted logs folder for account "${truncatedId}".`); - } catch (error: any) { - logger.error(`Failed to delete logs folder for account "${truncatedId}", reason: "${error.message}".`); + } catch (error) { + logger.error(`Failed to delete logs folder for account "${truncatedId}", reason: "${getErrorMessage(error)}".`); } } diff --git a/electron/src/lib/accountLogDeletion.test.main.ts b/electron/src/lib/accountLogDeletion.test.main.ts new file mode 100644 index 00000000000..83e93bbf4be --- /dev/null +++ b/electron/src/lib/accountLogDeletion.test.main.ts @@ -0,0 +1,81 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {deleteAccountLogDirectories, getAccountLogDirectories} from './accountLogDeletion'; + +const logDirectory = path.join('user-data', 'logs'); +const accountId = '11111111-1111-4111-8111-111111111111'; +const otherAccountId = '22222222-2222-4222-8222-222222222222'; + +describe('account log deletion', () => { + it('matches new daily and supported legacy layouts by exact account identity', () => { + const filePaths = [ + path.join(logDirectory, '2026-07-10', 'accounts', accountId, 'console.log'), + path.join(logDirectory, accountId, 'console.log'), + path.join(logDirectory, `2_2026_07_10_12_34_56_${accountId}`, 'console.log'), + path.join(logDirectory, `2_2026_07_10_12_34_56_${otherAccountId}`, 'console.log'), + path.join(logDirectory, '2026-07-10', 'accounts', `${accountId}-suffix`, 'console.log'), + ]; + + const actualDirectories = getAccountLogDirectories({accountId, filePaths, logDirectory}); + const expectedDirectories = [ + path.join(logDirectory, accountId), + path.join(logDirectory, '2026-07-10', 'accounts', accountId), + path.join(logDirectory, `2_2026_07_10_12_34_56_${accountId}`), + ].toSorted(); + + assert.deepStrictEqual(actualDirectories, expectedDirectories); + }); + + it('skips unsafe directories and continues after deletion failures', async () => { + const removedDirectories: string[] = []; + const failures: string[] = []; + const filePaths = [ + path.join(logDirectory, accountId, 'console.log'), + path.join(logDirectory, '2026-07-10', 'accounts', accountId, 'console.log'), + ]; + + await deleteAccountLogDirectories({ + accountId, + dependencies: { + async isSafeDirectory(directoryPath: string): Promise { + return directoryPath === path.join(logDirectory, accountId); + }, + async removeDirectory(directoryPath: string): Promise { + if (directoryPath === path.join(logDirectory, accountId)) { + throw new Error('test failure'); + } + + removedDirectories.push(directoryPath); + }, + reportFailure: (message: string): void => { + failures.push(message); + }, + }, + filePaths, + logDirectory, + }); + + assert.deepStrictEqual(removedDirectories, []); + assert.strictEqual(failures.length, 1); + }); +}); diff --git a/electron/src/lib/accountLogDeletion.ts b/electron/src/lib/accountLogDeletion.ts new file mode 100644 index 00000000000..1880e00de36 --- /dev/null +++ b/electron/src/lib/accountLogDeletion.ts @@ -0,0 +1,88 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 path from 'path'; + +export type AccountLogDirectoryParameters = { + accountId: string; + filePaths: readonly string[]; + logDirectory: string; +}; + +export type AccountLogDeletionDependencies = { + isSafeDirectory: (directoryPath: string) => Promise; + removeDirectory: (directoryPath: string) => Promise; + reportFailure: (message: string, error: unknown) => void; +}; + +export type DeleteAccountLogDirectoriesParameters = AccountLogDirectoryParameters & { + dependencies: AccountLogDeletionDependencies; +}; + +function getRelativePathSegments(logDirectory: string, filePath: string): string[] { + return path.relative(logDirectory, filePath).split(path.sep); +} + +function isNewAccountLogPath(pathSegments: readonly string[], accountId: string): boolean { + return pathSegments.length >= 4 && pathSegments[1] === 'accounts' && pathSegments[2] === accountId; +} + +function isLegacyTimestampedAccountLogPath(pathSegments: readonly string[], accountId: string): boolean { + if (pathSegments.length < 2) { + return false; + } + + const directoryName = pathSegments[pathSegments.length - 2]; + + return directoryName.endsWith(`_${accountId}`); +} + +export function getAccountLogDirectories(parameters: AccountLogDirectoryParameters): readonly string[] { + const accountDirectories = new Set([path.join(parameters.logDirectory, parameters.accountId)]); + + for (const filePath of parameters.filePaths) { + const pathSegments = getRelativePathSegments(parameters.logDirectory, filePath); + + if (isNewAccountLogPath(pathSegments, parameters.accountId)) { + accountDirectories.add(path.join(parameters.logDirectory, pathSegments[0], 'accounts', parameters.accountId)); + } + + if (isLegacyTimestampedAccountLogPath(pathSegments, parameters.accountId)) { + accountDirectories.add(path.dirname(filePath)); + } + } + + return [...accountDirectories].toSorted(); +} + +export async function deleteAccountLogDirectories(parameters: DeleteAccountLogDirectoriesParameters): Promise { + const accountDirectories = getAccountLogDirectories(parameters); + + for (const directoryPath of accountDirectories) { + try { + const isSafeDirectory = await parameters.dependencies.isSafeDirectory(directoryPath); + + if (isSafeDirectory) { + await parameters.dependencies.removeDirectory(directoryPath); + } + } catch (error) { + parameters.dependencies.reportFailure(`Failed to delete account log directory "${directoryPath}"`, error); + } + } +} diff --git a/electron/src/logging/boundedLogWriter.test.main.ts b/electron/src/logging/boundedLogWriter.test.main.ts new file mode 100644 index 00000000000..a0cf91292ca --- /dev/null +++ b/electron/src/logging/boundedLogWriter.test.main.ts @@ -0,0 +1,200 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'fs-extra'; + +import * as assert from 'assert'; +import * as path from 'path'; + +import {BoundedLogWriter, BoundedLogWriterDependencies, createBoundedLogWriter} from './boundedLogWriter'; + +import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; + +function createTestFileSystemDependencies(currentTimeMilliseconds: number): BoundedLogWriterDependencies { + return { + async appendFile(filePath: string, content: string): Promise { + await fs.appendFile(filePath, content); + }, + async ensureDirectory(directoryPath: string): Promise { + await fs.ensureDir(directoryPath); + }, + getCurrentTimeMilliseconds: (): number => { + return currentTimeMilliseconds; + }, + async getFileSize(filePath: string): Promise { + if ((await fs.pathExists(filePath)) === false) { + return 0; + } + + const fileStatistics = await fs.stat(filePath); + + return fileStatistics.size; + }, + async moveFile(sourceFilePath: string, destinationFilePath: string): Promise { + await fs.move(sourceFilePath, destinationFilePath, {overwrite: false}); + }, + async pathExists(filePath: string): Promise { + return fs.pathExists(filePath); + }, + }; +} + +async function writeLogMessages( + boundedLogWriter: BoundedLogWriter, + logFilePath: string, + messages: readonly string[], +): Promise { + await Promise.all( + messages.map(message => { + return boundedLogWriter.write({logFilePath, message}); + }), + ); +} + +function waitMilliseconds(milliseconds: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, milliseconds); + }); +} + +function ignoreLogRotation(): Promise { + return Promise.resolve(); +} + +describe('bounded desktop log writer', () => { + it( + 'preserves the order of concurrent writes to one file', + withTemporaryDirectory('wire-bounded-log-writer-', async (temporaryLogDirectory: string) => { + const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); + const boundedLogWriter = createBoundedLogWriter({ + afterRotation: ignoreLogRotation, + dependencies: createTestFileSystemDependencies(1), + maximumFileSizeBytes: 1024, + }); + + await writeLogMessages(boundedLogWriter, logFilePath, ['first', 'second', 'third']); + + const actualLogContent = await fs.readFile(logFilePath, 'utf8'); + const expectedLogContent = 'first\nsecond\nthird\n'; + + assert.strictEqual(actualLogContent, expectedLogContent); + }), + ); + + it( + 'does not serialize writes to different files', + withTemporaryDirectory('wire-bounded-log-parallel-', async (temporaryLogDirectory: string) => { + const firstLogFilePath = path.join(temporaryLogDirectory, 'first.log'); + const secondLogFilePath = path.join(temporaryLogDirectory, 'second.log'); + let activeAppendCount = 0; + let maximumConcurrentAppendCount = 0; + const baseDependencies = createTestFileSystemDependencies(5); + const dependencies: BoundedLogWriterDependencies = { + ...baseDependencies, + async appendFile(filePath: string, content: string): Promise { + activeAppendCount += 1; + maximumConcurrentAppendCount = Math.max(maximumConcurrentAppendCount, activeAppendCount); + await waitMilliseconds(5); + await fs.appendFile(filePath, content); + activeAppendCount -= 1; + }, + }; + const boundedLogWriter = createBoundedLogWriter({ + afterRotation: ignoreLogRotation, + dependencies, + maximumFileSizeBytes: 1024, + }); + + await Promise.all([ + boundedLogWriter.write({logFilePath: firstLogFilePath, message: 'first'}), + boundedLogWriter.write({logFilePath: secondLogFilePath, message: 'second'}), + ]); + + assert.strictEqual(maximumConcurrentAppendCount, 2); + }), + ); + + it( + 'rotates before an entry exceeds the configured file size', + withTemporaryDirectory('wire-bounded-log-rotation-', async (temporaryLogDirectory: string) => { + const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); + let rotationCount = 0; + const boundedLogWriter = createBoundedLogWriter({ + async afterRotation(): Promise { + rotationCount += 1; + }, + dependencies: createTestFileSystemDependencies(2), + maximumFileSizeBytes: 10, + }); + + await boundedLogWriter.write({logFilePath, message: '12345'}); + await boundedLogWriter.write({logFilePath, message: '6789'}); + + const actualCurrentLogContent = await fs.readFile(logFilePath, 'utf8'); + const actualRotatedLogContent = await fs.readFile(`${logFilePath}.2-0.old`, 'utf8'); + + assert.strictEqual(actualCurrentLogContent, '6789\n'); + assert.strictEqual(actualRotatedLogContent, '12345\n'); + assert.strictEqual(rotationCount, 1); + }), + ); + + it( + 'chooses a new rotated path when the first candidate already exists', + withTemporaryDirectory('wire-bounded-log-collision-', async (temporaryLogDirectory: string) => { + const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); + const existingRotatedLogPath = `${logFilePath}.3-0.old`; + const boundedLogWriter = createBoundedLogWriter({ + afterRotation: ignoreLogRotation, + dependencies: createTestFileSystemDependencies(3), + maximumFileSizeBytes: 10, + }); + + await fs.outputFile(existingRotatedLogPath, 'existing\n'); + await boundedLogWriter.write({logFilePath, message: '12345'}); + await boundedLogWriter.write({logFilePath, message: '6789'}); + + const actualRotatedLogContent = await fs.readFile(`${logFilePath}.3-1.old`, 'utf8'); + + assert.strictEqual(actualRotatedLogContent, '12345\n'); + assert.strictEqual(await fs.readFile(existingRotatedLogPath, 'utf8'), 'existing\n'); + }), + ); + + it( + 'allows an individual entry larger than the configured file size', + withTemporaryDirectory('wire-bounded-log-large-entry-', async (temporaryLogDirectory: string) => { + const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); + const boundedLogWriter = createBoundedLogWriter({ + afterRotation: ignoreLogRotation, + dependencies: createTestFileSystemDependencies(4), + maximumFileSizeBytes: 5, + }); + + await boundedLogWriter.write({logFilePath, message: '123456'}); + await boundedLogWriter.write({logFilePath, message: 'x'}); + + const actualCurrentLogContent = await fs.readFile(logFilePath, 'utf8'); + const actualRotatedLogContent = await fs.readFile(`${logFilePath}.4-0.old`, 'utf8'); + + assert.strictEqual(actualCurrentLogContent, 'x\n'); + assert.strictEqual(actualRotatedLogContent, '123456\n'); + }), + ); +}); diff --git a/electron/src/logging/boundedLogWriter.ts b/electron/src/logging/boundedLogWriter.ts new file mode 100644 index 00000000000..762f1a2bdf6 --- /dev/null +++ b/electron/src/logging/boundedLogWriter.ts @@ -0,0 +1,188 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {Maybe} from 'true-myth'; + +import * as os from 'os'; +import * as path from 'path'; + +export type BoundedLogWriterDependencies = { + appendFile: (filePath: string, content: string) => Promise; + ensureDirectory: (directoryPath: string) => Promise; + getCurrentTimeMilliseconds: () => number; + getFileSize: (filePath: string) => Promise; + moveFile: (sourceFilePath: string, destinationFilePath: string) => Promise; + pathExists: (filePath: string) => Promise; +}; + +export type CreateBoundedLogWriterParameters = { + afterRotation: (parameters: LogRotationNotificationParameters) => Promise; + dependencies: BoundedLogWriterDependencies; + maximumFileSizeBytes: number; +}; + +export type WriteLogMessageParameters = { + logFilePath: string; + message: string; +}; + +export type LogRotationNotificationParameters = { + activeFilePaths: ReadonlySet; + logFilePath: string; +}; + +export type BoundedLogWriter = { + getActiveFilePaths: () => ReadonlySet; + write: (parameters: WriteLogMessageParameters) => Promise; +}; + +type PendingWrites = Map>; + +type FindRotatedLogPathParameters = { + collisionIndex: number; + currentTimeMilliseconds: number; + logFilePath: string; + pathExists: (filePath: string) => Promise; +}; + +type RotateLogFileParameters = { + collisionIndex: number; + currentTimeMilliseconds: number; + dependencies: BoundedLogWriterDependencies; + logFilePath: string; +}; + +type WriteLogEntryParameters = { + afterRotation: (parameters: LogRotationNotificationParameters) => Promise; + dependencies: BoundedLogWriterDependencies; + getActiveFilePaths: () => ReadonlySet; + maximumFileSizeBytes: number; + writeParameters: WriteLogMessageParameters; +}; + +function isExistingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && (error.code === 'EEXIST' || error.code === 'ENOTEMPTY'); +} + +async function findRotatedLogPath(parameters: FindRotatedLogPathParameters): Promise { + const rotatedLogPath = `${parameters.logFilePath}.${parameters.currentTimeMilliseconds}-${parameters.collisionIndex}.old`; + const rotatedLogPathExists = await parameters.pathExists(rotatedLogPath); + + if (rotatedLogPathExists === false) { + return rotatedLogPath; + } + + return findRotatedLogPath({ + ...parameters, + collisionIndex: parameters.collisionIndex + 1, + }); +} + +async function rotateLogFile(parameters: RotateLogFileParameters): Promise { + const rotatedLogPath = await findRotatedLogPath({ + collisionIndex: parameters.collisionIndex, + currentTimeMilliseconds: parameters.currentTimeMilliseconds, + logFilePath: parameters.logFilePath, + pathExists: parameters.dependencies.pathExists, + }); + + try { + await parameters.dependencies.moveFile(parameters.logFilePath, rotatedLogPath); + } catch (error) { + if (isExistingFileError(error)) { + await rotateLogFile({...parameters, collisionIndex: parameters.collisionIndex + 1}); + + return; + } + + throw error; + } +} + +async function writeLogEntry(parameters: WriteLogEntryParameters): Promise { + const logEntry = `${parameters.writeParameters.message}${os.EOL}`; + const logEntrySizeBytes = Buffer.byteLength(logEntry); + + await parameters.dependencies.ensureDirectory(path.dirname(parameters.writeParameters.logFilePath)); + + const currentFileSizeBytes = await parameters.dependencies.getFileSize(parameters.writeParameters.logFilePath); + const wouldExceedMaximumFileSize = + currentFileSizeBytes > 0 && currentFileSizeBytes + logEntrySizeBytes > parameters.maximumFileSizeBytes; + + if (wouldExceedMaximumFileSize) { + await rotateLogFile({ + collisionIndex: 0, + currentTimeMilliseconds: parameters.dependencies.getCurrentTimeMilliseconds(), + dependencies: parameters.dependencies, + logFilePath: parameters.writeParameters.logFilePath, + }); + await parameters.afterRotation({ + activeFilePaths: parameters.getActiveFilePaths(), + logFilePath: parameters.writeParameters.logFilePath, + }); + } + + await parameters.dependencies.appendFile(parameters.writeParameters.logFilePath, logEntry); +} + +function removePendingWrite(pendingWrites: PendingWrites, logFilePath: string, pendingWrite: Promise): void { + if (pendingWrites.get(logFilePath) === pendingWrite) { + pendingWrites.delete(logFilePath); + } +} + +function ignorePreviousWriteFailure(): void {} + +export function createBoundedLogWriter(parameters: CreateBoundedLogWriterParameters): BoundedLogWriter { + const pendingWrites: PendingWrites = new Map(); + + function writeLogMessage(writeParameters: WriteLogMessageParameters): Promise { + const previousWrite = Maybe.of(pendingWrites.get(writeParameters.logFilePath)).unwrapOr(Promise.resolve()); + const writeOperation = previousWrite.catch(ignorePreviousWriteFailure).then(() => { + return writeLogEntry({ + afterRotation: parameters.afterRotation, + dependencies: parameters.dependencies, + getActiveFilePaths, + maximumFileSizeBytes: parameters.maximumFileSizeBytes, + writeParameters, + }); + }); + + let pendingWrite = Promise.resolve(); + pendingWrite = writeOperation.then( + () => { + return removePendingWrite(pendingWrites, writeParameters.logFilePath, pendingWrite); + }, + error => { + removePendingWrite(pendingWrites, writeParameters.logFilePath, pendingWrite); + + throw error; + }, + ); + pendingWrites.set(writeParameters.logFilePath, pendingWrite); + + return pendingWrite; + } + + function getActiveFilePaths(): ReadonlySet { + return new Set(pendingWrites.keys()); + } + + return {getActiveFilePaths, write: writeLogMessage}; +} diff --git a/electron/src/logging/desktopLogWriter.ts b/electron/src/logging/desktopLogWriter.ts new file mode 100644 index 00000000000..b257fca2d17 --- /dev/null +++ b/electron/src/logging/desktopLogWriter.ts @@ -0,0 +1,87 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'fs-extra'; + +import { + BoundedLogWriter, + BoundedLogWriterDependencies, + createBoundedLogWriter, + LogRotationNotificationParameters, + WriteLogMessageParameters, +} from './boundedLogWriter'; +import {cleanupDesktopLogs, DESKTOP_LOG_RETENTION_POLICY} from './logCleanup'; +import {getLogDirectory} from './logPaths'; + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +} + +function createFileSystemDependencies(): BoundedLogWriterDependencies { + return { + async appendFile(filePath: string, content: string): Promise { + await fs.appendFile(filePath, content); + }, + async ensureDirectory(directoryPath: string): Promise { + await fs.ensureDir(directoryPath); + }, + getCurrentTimeMilliseconds: (): number => { + return Date.now(); + }, + async getFileSize(filePath: string): Promise { + try { + const fileStatistics = await fs.stat(filePath); + + return fileStatistics.isFile() ? fileStatistics.size : 0; + } catch (error) { + if (isMissingFileError(error)) { + return 0; + } + + throw error; + } + }, + async moveFile(sourceFilePath: string, destinationFilePath: string): Promise { + await fs.move(sourceFilePath, destinationFilePath, {overwrite: false}); + }, + async pathExists(filePath: string): Promise { + return fs.pathExists(filePath); + }, + }; +} + +const desktopBoundedLogWriter: BoundedLogWriter = createBoundedLogWriter({ + async afterRotation(rotationParameters: LogRotationNotificationParameters): Promise { + await cleanupDesktopLogs({ + activeFilePaths: rotationParameters.activeFilePaths, + logDirectory: getLogDirectory(), + policy: DESKTOP_LOG_RETENTION_POLICY, + }); + }, + dependencies: createFileSystemDependencies(), + maximumFileSizeBytes: 10 * 1024 * 1024, +}); + +export function writeBoundedLogMessage(parameters: WriteLogMessageParameters): Promise { + return desktopBoundedLogWriter.write(parameters); +} + +export function getActiveLogFilePaths(): ReadonlySet { + return desktopBoundedLogWriter.getActiveFilePaths(); +} diff --git a/electron/src/logging/getLogger.ts b/electron/src/logging/getLogger.ts index e21e07b4322..5d97666bc15 100644 --- a/electron/src/logging/getLogger.ts +++ b/electron/src/logging/getLogger.ts @@ -21,6 +21,7 @@ import * as logdown from 'logdown'; import {LogFactory, LoggerOptions} from '@wireapp/commons'; +import {writeBoundedLogMessage} from './desktopLogWriter'; import {getLogDirectory, getMainProcessLogPath} from './logPaths'; import {config} from '../settings/config'; @@ -32,9 +33,23 @@ const forceLogging = mainProcess.argv.includes('--enable-logging'); export const LOGGER_NAMESPACE = '@wireapp/desktop'; export const ENABLE_LOGGING = isDevelopment || forceLogging; +function writeMainProcessLog(transportOptions: logdown.TransportOptions): void { + const logFilePath = getMainProcessLogPath({date: new Date(), logDirectory: getLogDirectory()}); + const logMessage = `${transportOptions.args[0]} ${transportOptions.msg}`; + + void writeBoundedLogMessage({logFilePath, message: logMessage}); +} + +function configureLogTransports(): void { + if (logdown.transports.length === 0) { + logdown.transports.push(LogFactory.addTimestamp); + logdown.transports.push(writeMainProcessLog); + } +} + export function getLogger(name: string): logdown.Logger { const options: LoggerOptions = { - logFilePath: getMainProcessLogPath({logDirectory: getLogDirectory()}), + logFilePath: getMainProcessLogPath({date: new Date(), logDirectory: getLogDirectory()}), namespace: LOGGER_NAMESPACE, separator: '/', }; @@ -43,5 +58,7 @@ export function getLogger(name: string): logdown.Logger { options.forceEnable = true; } + configureLogTransports(); + return LogFactory.getLogger(name, options); } diff --git a/electron/src/logging/logCleanup.test.main.ts b/electron/src/logging/logCleanup.test.main.ts new file mode 100644 index 00000000000..55a24924e16 --- /dev/null +++ b/electron/src/logging/logCleanup.test.main.ts @@ -0,0 +1,155 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {createLogCleanup, LogCleanupDependencies, RunLogCleanupParameters} from './logCleanup'; +import {LogFileMetadata} from './logRetention'; + +type CleanupTestState = { + failures: string[]; + removedDirectories: string[]; + removedFiles: string[]; +}; + +function createCleanupTestDependencies( + fileMetadata: readonly LogFileMetadata[], + state: CleanupTestState, +): LogCleanupDependencies { + return { + async discoverLogFilePaths(): Promise { + return fileMetadata.map(file => { + return file.filePath; + }); + }, + getCurrentTimeMilliseconds: (): number => { + return 1_000_000; + }, + async getFileMetadata(filePath: string): Promise { + const foundFileMetadata = fileMetadata.find(file => { + return file.filePath === filePath; + }); + + if (foundFileMetadata) { + return foundFileMetadata; + } + + throw new Error(`Missing test metadata for ${filePath}`); + }, + async removeEmptyDirectory(directoryPath: string): Promise { + state.removedDirectories.push(directoryPath); + }, + async removeFile(filePath: string): Promise { + state.removedFiles.push(filePath); + }, + reportFailure: (message: string): void => { + state.failures.push(message); + }, + }; +} + +function createCleanupParameters(activeFilePaths: ReadonlySet): RunLogCleanupParameters { + return { + activeFilePaths, + logDirectory: 'logs', + policy: {maximumAgeMilliseconds: 100, maximumTotalSizeBytes: 100}, + }; +} + +describe('desktop log cleanup', () => { + it('removes planned files and their empty parent directories', async () => { + const state: CleanupTestState = {failures: [], removedDirectories: [], removedFiles: []}; + const fileMetadata: LogFileMetadata[] = [ + {filePath: 'logs/old/console.log', fileSizeBytes: 10, isSymbolicLink: false, modifiedTimeMilliseconds: 1}, + { + filePath: 'logs/recent/console.log', + fileSizeBytes: 10, + isSymbolicLink: false, + modifiedTimeMilliseconds: 999_999, + }, + ]; + const cleanup = createLogCleanup(createCleanupTestDependencies(fileMetadata, state)); + + await cleanup.run(createCleanupParameters(new Set())); + + assert.deepStrictEqual(state.removedFiles, ['logs/old/console.log']); + assert.deepStrictEqual(state.removedDirectories, ['logs/recent', 'logs/old']); + assert.deepStrictEqual(state.failures, []); + }); + + it('never removes active files', async () => { + const state: CleanupTestState = {failures: [], removedDirectories: [], removedFiles: []}; + const fileMetadata: LogFileMetadata[] = [ + {filePath: 'logs/active.log', fileSizeBytes: 200, isSymbolicLink: false, modifiedTimeMilliseconds: 1}, + {filePath: 'logs/eligible.log', fileSizeBytes: 10, isSymbolicLink: false, modifiedTimeMilliseconds: 2}, + ]; + const cleanup = createLogCleanup(createCleanupTestDependencies(fileMetadata, state)); + + await cleanup.run(createCleanupParameters(new Set(['logs/active.log']))); + + assert.deepStrictEqual(state.removedFiles, ['logs/eligible.log']); + }); + + it('continues after an individual removal failure', async () => { + const state: CleanupTestState = {failures: [], removedDirectories: [], removedFiles: []}; + const fileMetadata: LogFileMetadata[] = [ + {filePath: 'logs/first.log', fileSizeBytes: 60, isSymbolicLink: false, modifiedTimeMilliseconds: 1}, + {filePath: 'logs/second.log', fileSizeBytes: 60, isSymbolicLink: false, modifiedTimeMilliseconds: 2}, + ]; + const dependencies = createCleanupTestDependencies(fileMetadata, state); + const cleanup = createLogCleanup({ + ...dependencies, + async removeFile(filePath: string): Promise { + if (filePath === 'logs/first.log') { + throw new Error('test failure'); + } + + state.removedFiles.push(filePath); + }, + }); + + await cleanup.run(createCleanupParameters(new Set())); + + assert.deepStrictEqual(state.removedFiles, ['logs/second.log']); + assert.strictEqual(state.failures.length, 1); + }); + + it('coalesces concurrent cleanup triggers', async () => { + const state: CleanupTestState = {failures: [], removedDirectories: [], removedFiles: []}; + let discoveryCount = 0; + const fileMetadata: LogFileMetadata[] = []; + const dependencies = createCleanupTestDependencies(fileMetadata, state); + const cleanup = createLogCleanup({ + ...dependencies, + async discoverLogFilePaths(): Promise { + discoveryCount += 1; + await new Promise(resolve => { + setTimeout(resolve, 5); + }); + + return []; + }, + }); + const cleanupParameters = createCleanupParameters(new Set()); + + await Promise.all([cleanup.run(cleanupParameters), cleanup.run(cleanupParameters)]); + + assert.strictEqual(discoveryCount, 1); + }); +}); diff --git a/electron/src/logging/logCleanup.ts b/electron/src/logging/logCleanup.ts new file mode 100644 index 00000000000..7840e306bd1 --- /dev/null +++ b/electron/src/logging/logCleanup.ts @@ -0,0 +1,229 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'fs-extra'; +import {Maybe} from 'true-myth'; + +import * as path from 'path'; + +import {getLogFilenames} from './logFiles'; +import {LogFileMetadata, LogRetentionPlanParameters, LogRetentionPolicy, planLogCleanup} from './logRetention'; + +export const DESKTOP_LOG_MAXIMUM_AGE_MILLISECONDS = 7 * 24 * 60 * 60 * 1_000; +export const DESKTOP_LOG_MAXIMUM_TOTAL_SIZE_BYTES = 100 * 1024 * 1024; +export const DESKTOP_LOG_RETENTION_POLICY: LogRetentionPolicy = { + maximumAgeMilliseconds: DESKTOP_LOG_MAXIMUM_AGE_MILLISECONDS, + maximumTotalSizeBytes: DESKTOP_LOG_MAXIMUM_TOTAL_SIZE_BYTES, +}; + +export type LogCleanupDependencies = { + discoverLogFilePaths: (logDirectory: string) => Promise; + getCurrentTimeMilliseconds: () => number; + getFileMetadata: (filePath: string) => Promise; + removeEmptyDirectory: (directoryPath: string) => Promise; + removeFile: (filePath: string) => Promise; + reportFailure: (message: string, error: unknown) => void; +}; + +export type RunLogCleanupParameters = { + activeFilePaths: ReadonlySet; + logDirectory: string; + policy: LogRetentionPolicy; +}; + +export type LogCleanup = { + run: (parameters: RunLogCleanupParameters) => Promise; +}; + +type CleanupFileMetadataParameters = { + filePaths: readonly string[]; + dependencies: LogCleanupDependencies; +}; + +function getParentDirectories(filePaths: readonly string[]): string[] { + const parentDirectories = new Set( + filePaths.map(filePath => { + return path.dirname(filePath); + }), + ); + + return [...parentDirectories].toSorted((firstPath, secondPath) => { + const pathLengthDifference = secondPath.length - firstPath.length; + + if (pathLengthDifference !== 0) { + return pathLengthDifference; + } + + if (firstPath < secondPath) { + return -1; + } + + if (firstPath > secondPath) { + return 1; + } + + return 0; + }); +} + +async function getCleanupFileMetadata(parameters: CleanupFileMetadataParameters): Promise { + const fileMetadata: LogFileMetadata[] = []; + + for (const filePath of parameters.filePaths) { + try { + fileMetadata.push(await parameters.dependencies.getFileMetadata(filePath)); + } catch (error) { + parameters.dependencies.reportFailure(`Failed to inspect log file "${filePath}"`, error); + } + } + + return fileMetadata; +} + +async function removePlannedFiles(filePaths: readonly string[], dependencies: LogCleanupDependencies): Promise { + for (const filePath of filePaths) { + try { + await dependencies.removeFile(filePath); + } catch (error) { + dependencies.reportFailure(`Failed to remove log file "${filePath}"`, error); + } + } +} + +async function removeParentDirectories( + filePaths: readonly string[], + dependencies: LogCleanupDependencies, +): Promise { + for (const directoryPath of getParentDirectories(filePaths)) { + try { + await dependencies.removeEmptyDirectory(directoryPath); + } catch (error) { + dependencies.reportFailure(`Failed to remove empty log directory "${directoryPath}"`, error); + } + } +} + +async function runCleanup(parameters: RunLogCleanupParameters, dependencies: LogCleanupDependencies): Promise { + let logFilePaths: readonly string[]; + + try { + logFilePaths = await dependencies.discoverLogFilePaths(parameters.logDirectory); + } catch (error) { + dependencies.reportFailure(`Failed to discover log files in "${parameters.logDirectory}"`, error); + + return; + } + + const fileMetadata = await getCleanupFileMetadata({dependencies, filePaths: logFilePaths}); + const retentionPlanParameters: LogRetentionPlanParameters = { + activeFilePaths: parameters.activeFilePaths, + currentTimeMilliseconds: dependencies.getCurrentTimeMilliseconds(), + files: fileMetadata, + policy: parameters.policy, + }; + const retentionPlan = planLogCleanup(retentionPlanParameters); + + await removePlannedFiles(retentionPlan.filesToDelete, dependencies); + await removeParentDirectories(logFilePaths, dependencies); +} + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +} + +function isNonEmptyDirectoryError(error: unknown): boolean { + return error instanceof Error && 'code' in error && (error.code === 'ENOTEMPTY' || error.code === 'EEXIST'); +} + +function createFileSystemDependencies( + reportFailure: (message: string, error: unknown) => void, +): LogCleanupDependencies { + return { + async discoverLogFilePaths(logDirectory: string): Promise { + return getLogFilenames({absolute: true, baseDirectory: logDirectory}); + }, + getCurrentTimeMilliseconds: (): number => { + return Date.now(); + }, + async getFileMetadata(filePath: string): Promise { + const fileStatistics = await fs.lstat(filePath); + + return { + filePath, + fileSizeBytes: fileStatistics.isFile() ? fileStatistics.size : 0, + isSymbolicLink: fileStatistics.isSymbolicLink(), + modifiedTimeMilliseconds: fileStatistics.mtimeMs, + }; + }, + async removeEmptyDirectory(directoryPath: string): Promise { + try { + const directoryStatistics = await fs.lstat(directoryPath); + + if (directoryStatistics.isSymbolicLink() || directoryStatistics.isDirectory() === false) { + return; + } + + await fs.rmdir(directoryPath); + } catch (error) { + if (isMissingPathError(error) || isNonEmptyDirectoryError(error)) { + return; + } + + throw error; + } + }, + async removeFile(filePath: string): Promise { + await fs.unlink(filePath); + }, + reportFailure, + }; +} + +export function createLogCleanup(dependencies: LogCleanupDependencies): LogCleanup { + let currentCleanup: Maybe> = Maybe.nothing>(); + + async function run(parameters: RunLogCleanupParameters): Promise { + if (currentCleanup.isJust) { + await currentCleanup.value; + + return; + } + + const cleanupPromise = runCleanup(parameters, dependencies); + currentCleanup = Maybe.just(cleanupPromise); + + try { + await cleanupPromise; + } finally { + currentCleanup = Maybe.nothing>(); + } + } + + return {run}; +} + +const desktopLogCleanup = createLogCleanup( + createFileSystemDependencies((message: string, error: unknown): void => { + console.error(message, error); + }), +); + +export function cleanupDesktopLogs(parameters: RunLogCleanupParameters): Promise { + return desktopLogCleanup.run(parameters); +} diff --git a/electron/src/logging/logCleanupScheduler.test.main.ts b/electron/src/logging/logCleanupScheduler.test.main.ts new file mode 100644 index 00000000000..dfe5db6a9d7 --- /dev/null +++ b/electron/src/logging/logCleanupScheduler.test.main.ts @@ -0,0 +1,58 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {scheduleLogCleanup, UnrefableInterval} from './logCleanupScheduler'; + +describe('desktop log cleanup scheduler', () => { + it('schedules an unrefed hourly cleanup interval', async () => { + let scheduledCallback: () => void = () => {}; + let scheduledIntervalMilliseconds = 0; + let wasUnrefed = false; + let cleanupRunCount = 0; + const interval: UnrefableInterval = { + unref: (): void => { + wasUnrefed = true; + }, + }; + + scheduleLogCleanup({ + intervalMilliseconds: 60_000, + async runCleanup(): Promise { + cleanupRunCount += 1; + }, + setInterval: (callback: () => void, intervalMilliseconds: number): UnrefableInterval => { + scheduledCallback = callback; + scheduledIntervalMilliseconds = intervalMilliseconds; + + return interval; + }, + }); + + scheduledCallback(); + await new Promise(resolve => { + setImmediate(resolve); + }); + + assert.strictEqual(scheduledIntervalMilliseconds, 60_000); + assert.strictEqual(wasUnrefed, true); + assert.strictEqual(cleanupRunCount, 1); + }); +}); diff --git a/electron/src/logging/logCleanupScheduler.ts b/electron/src/logging/logCleanupScheduler.ts new file mode 100644 index 00000000000..923c0cdb02c --- /dev/null +++ b/electron/src/logging/logCleanupScheduler.ts @@ -0,0 +1,36 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export type UnrefableInterval = { + unref: () => void; +}; + +export type ScheduleLogCleanupParameters = { + intervalMilliseconds: number; + runCleanup: () => Promise; + setInterval: (callback: () => void, intervalMilliseconds: number) => UnrefableInterval; +}; + +export function scheduleLogCleanup(parameters: ScheduleLogCleanupParameters): void { + const cleanupInterval = parameters.setInterval(() => { + void parameters.runCleanup(); + }, parameters.intervalMilliseconds); + + cleanupInterval.unref(); +} diff --git a/electron/src/logging/logFiles.test.main.ts b/electron/src/logging/logFiles.test.main.ts index 01c0337e9f7..a2b9aa4330f 100644 --- a/electron/src/logging/logFiles.test.main.ts +++ b/electron/src/logging/logFiles.test.main.ts @@ -39,7 +39,7 @@ describe('desktop log file discovery', () => { const symbolicLinkPath = path.join(temporaryLogDirectory, 'linked-account'); await fs.symlink(symbolicLinkTarget, symbolicLinkPath, 'dir'); - const actualPaths = getLogFilenames({absolute: false, baseDirectory: temporaryLogDirectory}).sort(); + const actualPaths = getLogFilenames({absolute: false, baseDirectory: temporaryLogDirectory}).toSorted(); const expectedPaths = ['account/console.log', 'account/console.old', 'electron.log']; assert.deepStrictEqual(actualPaths, expectedPaths); diff --git a/electron/src/logging/logMigration.test.main.ts b/electron/src/logging/logMigration.test.main.ts deleted file mode 100644 index f0b0faaebae..00000000000 --- a/electron/src/logging/logMigration.test.main.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Wire - * 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 - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * 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 fs from 'fs-extra'; - -import * as assert from 'assert'; -import * as path from 'path'; - -import {renameWebViewLogFiles} from './logMigration'; - -import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; - -describe('desktop log migration', () => { - it( - 'renames every discovered log file to the existing old extension', - withTemporaryDirectory('wire-log-migration-', async (temporaryLogDirectory: string) => { - const currentLogPath = path.join(temporaryLogDirectory, 'account', 'console.log'); - const currentMainLogPath = path.join(temporaryLogDirectory, 'electron.log'); - await fs.outputFile(currentLogPath, 'webview'); - await fs.outputFile(currentMainLogPath, 'main'); - - function ignoreLogMessage(_message: string): void {} - - const logger = { - error: ignoreLogMessage, - log: ignoreLogMessage, - }; - renameWebViewLogFiles(temporaryLogDirectory, logger); - - assert.strictEqual(await fs.pathExists(currentLogPath), false); - assert.strictEqual(await fs.pathExists(currentMainLogPath), false); - assert.strictEqual(await fs.pathExists(currentLogPath.replace('.log', '.old')), true); - assert.strictEqual(await fs.pathExists(currentMainLogPath.replace('.log', '.old')), true); - }), - ); -}); diff --git a/electron/src/logging/logMigration.ts b/electron/src/logging/logMigration.ts deleted file mode 100644 index b9cf2f5324c..00000000000 --- a/electron/src/logging/logMigration.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Wire - * 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 - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * 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 fs from 'fs-extra'; - -import {getLogFilenames} from './logFiles'; - -export type LogMigrationLogger = { - error: (message: string) => void; - log: (message: string) => void; -}; - -export type RenameFileExtensionsParameters = { - files: readonly string[]; - logger: Pick; - newExtension: string; - oldExtension: string; -}; - -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export function renameFileExtensions(parameters: RenameFileExtensionsParameters): void { - const {files, logger, newExtension, oldExtension} = parameters; - for (const file of files) { - try { - const fileStat = fs.statSync(file); - if (fileStat.isFile() && file.endsWith(oldExtension)) { - fs.renameSync(file, file.replace(oldExtension, newExtension)); - } - } catch (error) { - logger.error(`Failed to rename log file: "${getErrorMessage(error)}"`); - } - } -} - -export function renameWebViewLogFiles(logDirectory: string, logger: LogMigrationLogger): void { - try { - const logFiles = getLogFilenames({absolute: true, baseDirectory: logDirectory}); - renameFileExtensions({files: logFiles, logger, newExtension: '.old', oldExtension: '.log'}); - } catch (error) { - logger.log(`Failed to read log directory with error: ${getErrorMessage(error)}`); - } -} diff --git a/electron/src/logging/logPaths.test.main.ts b/electron/src/logging/logPaths.test.main.ts index 21b29beb18f..a04b75882e5 100644 --- a/electron/src/logging/logPaths.test.main.ts +++ b/electron/src/logging/logPaths.test.main.ts @@ -37,32 +37,48 @@ describe('desktop log paths', () => { }); it('preserves the existing main-process log path', () => { - const actualPath = getMainProcessLogPath({logDirectory: path.join('user-data', 'logs')}); - const expectedPath = path.join('user-data', 'logs', 'electron.log'); + const actualPath = getMainProcessLogPath({ + date: new Date('2026-07-10T12:34:56.000Z'), + logDirectory: path.join('user-data', 'logs'), + }); + const expectedPath = path.join('user-data', 'logs', '2026-07-10', 'electron.log'); assert.strictEqual(actualPath, expectedPath); }); - it('preserves the existing webview directory format', () => { + it('uses the account UUID below the UTC daily directory for webview logs', () => { const actualPath = getWebViewLogPath({ - accountIndex: 2, + accountId: 'account-id', date: new Date('2026-07-10T12:34:56.000Z'), logDirectory: path.join('user-data', 'logs'), - webViewId: 'account-webview-id', }); - const expectedPath = path.join('user-data', 'logs', '2_2026_07_10_12_34_56_account-webview-id', 'console.log'); + const expectedPath = path.join('user-data', 'logs', '2026-07-10', 'accounts', 'account-id', 'console.log'); assert.strictEqual(actualPath, expectedPath); }); - it('preserves the existing SSO log path', () => { + it('uses the same account directory for SSO logs', () => { const actualPath = getSsoLogPath({ + accountId: 'account-id', + date: new Date('2026-07-10T12:34:56.000Z'), logDirectory: path.join('user-data', 'logs'), - logFileName: 'console.log', - webViewId: 'sso-webview-id', }); - const expectedPath = path.join('user-data', 'logs', 'sso-webview-id', 'console.log'); + const expectedPath = path.join('user-data', 'logs', '2026-07-10', 'accounts', 'account-id', 'sso.log'); assert.strictEqual(actualPath, expectedPath); }); + + it('switches daily directories at UTC midnight', () => { + const beforeMidnightPath = getMainProcessLogPath({ + date: new Date('2026-07-10T23:59:59.999Z'), + logDirectory: path.join('user-data', 'logs'), + }); + const afterMidnightPath = getMainProcessLogPath({ + date: new Date('2026-07-11T00:00:00.000Z'), + logDirectory: path.join('user-data', 'logs'), + }); + + assert.strictEqual(path.dirname(beforeMidnightPath), path.join('user-data', 'logs', '2026-07-10')); + assert.strictEqual(path.dirname(afterMidnightPath), path.join('user-data', 'logs', '2026-07-11')); + }); }); diff --git a/electron/src/logging/logPaths.ts b/electron/src/logging/logPaths.ts index ce5195a7d55..323db3bd0bd 100644 --- a/electron/src/logging/logPaths.ts +++ b/electron/src/logging/logPaths.ts @@ -21,53 +21,65 @@ import * as Electron from 'electron'; import * as path from 'path'; -import {DateUtil} from '@wireapp/commons'; - import {config} from '../settings/config'; const app = Electron.app || require('@electron/remote').app; export const LOG_DIRECTORY_NAME = 'logs'; export const MAIN_PROCESS_LOG_FILE_NAME = 'electron.log'; +export const ACCOUNT_LOG_DIRECTORY_NAME = 'accounts'; +export const SSO_LOG_FILE_NAME = 'sso.log'; export type WebViewLogPathParameters = { - accountIndex: number; + accountId: string; date: Date; logDirectory: string; - webViewId: string; }; export type MainProcessLogPathParameters = { + date: Date; logDirectory: string; }; export type SsoLogPathParameters = { + accountId: string; + date: Date; + logDirectory: string; +}; + +type DailyLogDirectoryParameters = { + date: Date; logDirectory: string; - logFileName: string; - webViewId: string; }; +function getDailyLogDirectory(parameters: DailyLogDirectoryParameters): string { + const utcDate = parameters.date.toISOString().slice(0, 10); + + return path.join(parameters.logDirectory, utcDate); +} + export function getLogDirectory(): string { return path.join(app.getPath('userData'), LOG_DIRECTORY_NAME); } export function getMainProcessLogPath(parameters: MainProcessLogPathParameters): string { - return path.join(parameters.logDirectory, MAIN_PROCESS_LOG_FILE_NAME); + return path.join(getDailyLogDirectory(parameters), MAIN_PROCESS_LOG_FILE_NAME); } export function getWebViewLogPath(parameters: WebViewLogPathParameters): string { - const {accountIndex, date, logDirectory, webViewId} = parameters; - const {date: formattedDate, time: formattedTime} = DateUtil.isoFormat(date); - const directoryName = `${accountIndex}_${formattedDate.replaceAll('-', '_')}_${formattedTime.replaceAll( - ':', - '_', - )}_${webViewId}`; - - return path.join(logDirectory, directoryName, config.logFileName); + return path.join( + getDailyLogDirectory(parameters), + ACCOUNT_LOG_DIRECTORY_NAME, + parameters.accountId, + config.logFileName, + ); } export function getSsoLogPath(parameters: SsoLogPathParameters): string { - const {logDirectory, logFileName, webViewId} = parameters; - - return path.join(logDirectory, webViewId, logFileName); + return path.join( + getDailyLogDirectory(parameters), + ACCOUNT_LOG_DIRECTORY_NAME, + parameters.accountId, + SSO_LOG_FILE_NAME, + ); } diff --git a/electron/src/logging/logRetention.test.main.ts b/electron/src/logging/logRetention.test.main.ts new file mode 100644 index 00000000000..a45a2712072 --- /dev/null +++ b/electron/src/logging/logRetention.test.main.ts @@ -0,0 +1,104 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {LogFileMetadata, planLogCleanup} from './logRetention'; + +const currentTimeMilliseconds = 1_000_000; +const maximumAgeMilliseconds = 7 * 24 * 60 * 60 * 1_000; +const maximumTotalSizeBytes = 100; + +function createFileMetadata( + filePath: string, + fileSizeBytes: number, + modifiedTimeMilliseconds: number, + isSymbolicLink: boolean, +): LogFileMetadata { + return {filePath, fileSizeBytes, isSymbolicLink, modifiedTimeMilliseconds}; +} + +describe('desktop log retention planner', () => { + it('removes files older than the configured age', () => { + const files = [ + createFileMetadata('expired.log', 10, currentTimeMilliseconds - maximumAgeMilliseconds - 1, false), + createFileMetadata('recent.log', 10, currentTimeMilliseconds - maximumAgeMilliseconds, false), + ]; + + const actualPlan = planLogCleanup({ + activeFilePaths: new Set(), + currentTimeMilliseconds, + files, + policy: {maximumAgeMilliseconds, maximumTotalSizeBytes}, + }); + const expectedFilesToDelete = ['expired.log']; + + assert.deepStrictEqual(actualPlan.filesToDelete, expectedFilesToDelete); + }); + + it('preserves active files and symbolic links', () => { + const files = [ + createFileMetadata('active.log', 200, currentTimeMilliseconds - maximumAgeMilliseconds - 1, false), + createFileMetadata('link.log', 200, currentTimeMilliseconds - maximumAgeMilliseconds - 1, true), + createFileMetadata('eligible.log', 10, currentTimeMilliseconds - maximumAgeMilliseconds - 1, false), + ]; + + const actualPlan = planLogCleanup({ + activeFilePaths: new Set(['active.log']), + currentTimeMilliseconds, + files, + policy: {maximumAgeMilliseconds, maximumTotalSizeBytes}, + }); + const expectedFilesToDelete = ['eligible.log']; + + assert.deepStrictEqual(actualPlan.filesToDelete, expectedFilesToDelete); + }); + + it('removes the oldest eligible files until the total size is within the limit', () => { + const files = [ + createFileMetadata('newest.log', 80, 30, false), + createFileMetadata('oldest.log', 80, 10, false), + createFileMetadata('middle.log', 80, 20, false), + ]; + + const actualPlan = planLogCleanup({ + activeFilePaths: new Set(), + currentTimeMilliseconds, + files, + policy: {maximumAgeMilliseconds, maximumTotalSizeBytes}, + }); + const expectedFilesToDelete = ['oldest.log', 'middle.log']; + + assert.deepStrictEqual(actualPlan.filesToDelete, expectedFilesToDelete); + }); + + it('uses the path as a deterministic tie-breaker', () => { + const files = [createFileMetadata('zeta.log', 60, 10, false), createFileMetadata('alpha.log', 60, 10, false)]; + + const actualPlan = planLogCleanup({ + activeFilePaths: new Set(), + currentTimeMilliseconds, + files, + policy: {maximumAgeMilliseconds, maximumTotalSizeBytes}, + }); + const expectedFilesToDelete = ['alpha.log']; + + assert.deepStrictEqual(actualPlan.filesToDelete, expectedFilesToDelete); + }); +}); diff --git a/electron/src/logging/logRetention.ts b/electron/src/logging/logRetention.ts new file mode 100644 index 00000000000..1055911d2a6 --- /dev/null +++ b/electron/src/logging/logRetention.ts @@ -0,0 +1,135 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export type LogFileMetadata = { + filePath: string; + fileSizeBytes: number; + isSymbolicLink: boolean; + modifiedTimeMilliseconds: number; +}; + +export type LogRetentionPolicy = { + maximumAgeMilliseconds: number; + maximumTotalSizeBytes: number; +}; + +export type LogRetentionPlanParameters = { + activeFilePaths: ReadonlySet; + currentTimeMilliseconds: number; + files: readonly LogFileMetadata[]; + policy: LogRetentionPolicy; +}; + +export type LogRetentionPlan = { + filesToDelete: readonly string[]; +}; + +type GetFilesToDeleteForSizeParameters = { + activeFilePaths: ReadonlySet; + files: readonly LogFileMetadata[]; + maximumTotalSizeBytes: number; +}; + +function compareLogFileAge(firstFile: LogFileMetadata, secondFile: LogFileMetadata): number { + const modificationTimeDifference = firstFile.modifiedTimeMilliseconds - secondFile.modifiedTimeMilliseconds; + + if (modificationTimeDifference !== 0) { + return modificationTimeDifference; + } + + return firstFile.filePath.localeCompare(secondFile.filePath); +} + +function getDeletableFiles(parameters: LogRetentionPlanParameters): LogFileMetadata[] { + return parameters.files.filter(file => { + return file.isSymbolicLink === false && parameters.activeFilePaths.has(file.filePath) === false; + }); +} + +function getExpiredFiles(parameters: LogRetentionPlanParameters): LogFileMetadata[] { + const expirationTimeMilliseconds = parameters.currentTimeMilliseconds - parameters.policy.maximumAgeMilliseconds; + + return getDeletableFiles(parameters) + .filter(file => { + return file.modifiedTimeMilliseconds < expirationTimeMilliseconds; + }) + .toSorted(compareLogFileAge); +} + +function getRemainingFiles( + files: readonly LogFileMetadata[], + filesToDelete: readonly LogFileMetadata[], +): LogFileMetadata[] { + const deletedFilePaths = new Set( + filesToDelete.map(file => { + return file.filePath; + }), + ); + + return files.filter(file => { + return deletedFilePaths.has(file.filePath) === false; + }); +} + +function getTotalFileSizeBytes(files: readonly LogFileMetadata[]): number { + const filesWithoutSymbolicLinks = files.filter(file => { + return file.isSymbolicLink === false; + }); + + return filesWithoutSymbolicLinks.reduce((totalFileSizeBytes, file) => { + return totalFileSizeBytes + file.fileSizeBytes; + }, 0); +} + +function getFilesToDeleteForSize(parameters: GetFilesToDeleteForSizeParameters): LogFileMetadata[] { + const filesEligibleForSizeDeletion = parameters.files + .filter(file => { + return file.isSymbolicLink === false && parameters.activeFilePaths.has(file.filePath) === false; + }) + .toSorted(compareLogFileAge); + const filesToDelete: LogFileMetadata[] = []; + let remainingFileSizeBytes = getTotalFileSizeBytes(parameters.files); + + for (const file of filesEligibleForSizeDeletion) { + if (remainingFileSizeBytes <= parameters.maximumTotalSizeBytes) { + break; + } + + filesToDelete.push(file); + remainingFileSizeBytes -= file.fileSizeBytes; + } + + return filesToDelete; +} + +export function planLogCleanup(parameters: LogRetentionPlanParameters): LogRetentionPlan { + const expiredFiles = getExpiredFiles(parameters); + const filesAfterExpiration = getRemainingFiles(parameters.files, expiredFiles); + const sizeLimitedFiles = getFilesToDeleteForSize({ + activeFilePaths: parameters.activeFilePaths, + files: filesAfterExpiration, + maximumTotalSizeBytes: parameters.policy.maximumTotalSizeBytes, + }); + + return { + filesToDelete: [...expiredFiles, ...sizeLimitedFiles].map(file => { + return file.filePath; + }), + }; +} diff --git a/electron/src/logging/loggerUtils.ts b/electron/src/logging/loggerUtils.ts index 4b4abd0c88f..8335cd78220 100644 --- a/electron/src/logging/loggerUtils.ts +++ b/electron/src/logging/loggerUtils.ts @@ -21,7 +21,9 @@ import * as fs from 'fs-extra'; import * as path from 'path'; +import {getActiveLogFilePaths} from './desktopLogWriter'; import {getLogger} from './getLogger'; +import {cleanupDesktopLogs, DESKTOP_LOG_RETENTION_POLICY} from './logCleanup'; import {getLogFilenames as getLogFilenamesFromRoot, LogFileDiscoveryOptions} from './logFiles'; import {getLogDirectory} from './logPaths'; @@ -35,6 +37,12 @@ export async function gatherLogs(): Promise> { const logFiles: Record = {}; const logDirectory = getLogDirectory(); + await cleanupDesktopLogs({ + activeFilePaths: getActiveLogFilePaths(), + logDirectory, + policy: DESKTOP_LOG_RETENTION_POLICY, + }); + const relativeFilePaths = getLogFilenames({absolute: false, baseDirectory: logDirectory}); for (const relativeFilePath of relativeFilePaths) { diff --git a/electron/src/mainProcess.ts b/electron/src/mainProcess.ts index 17b4bba190c..e0138888816 100644 --- a/electron/src/mainProcess.ts +++ b/electron/src/mainProcess.ts @@ -37,11 +37,11 @@ import fs from 'fs-extra'; import {getProxySettings} from 'get-proxy-settings'; import logdown from 'logdown'; import minimist from 'minimist'; +import {Maybe} from 'true-myth'; import * as path from 'path'; import {URL, pathToFileURL} from 'url'; -import {LogFactory} from '@wireapp/commons'; import {WebAppEvents} from '@wireapp/webapp-events'; import * as ProxyAuth from './auth/ProxyAuth'; @@ -58,8 +58,10 @@ import {deleteAccount} from './lib/LocalAccountDeletion'; import {getOpenGraphDataAsync} from './lib/openGraph'; import {showErrorDialog} from './lib/showDialog'; import * as locale from './locale'; +import {getActiveLogFilePaths, writeBoundedLogMessage} from './logging/desktopLogWriter'; import {ENABLE_LOGGING, getLogger} from './logging/getLogger'; -import {renameWebViewLogFiles} from './logging/logMigration'; +import {cleanupDesktopLogs, DESKTOP_LOG_RETENTION_POLICY} from './logging/logCleanup'; +import {scheduleLogCleanup} from './logging/logCleanupScheduler'; import {getLogDirectory, getMainProcessLogPath, getWebViewLogPath} from './logging/logPaths'; import {getManagedConfig} from './managed/ManagedConfig'; import {developerMenu, openDevTools} from './menu/developer'; @@ -80,9 +82,27 @@ import {WindowManager} from './window/WindowManager'; import * as WindowUtil from './window/WindowUtil'; const MAIN_PROCESS_LOGGER_NAME = 'main.js'; +const LOG_CLEANUP_INTERVAL_MILLISECONDS = 60 * 60 * 1_000; const logger = getLogger(MAIN_PROCESS_LOGGER_NAME); const configuredUserDataPath = getConfiguredPortableUserDataPath(); +function runDesktopLogCleanup(): Promise { + return cleanupDesktopLogs({ + activeFilePaths: getActiveLogFilePaths(), + logDirectory: getLogDirectory(), + policy: DESKTOP_LOG_RETENTION_POLICY, + }); +} + +type OpenLinkInNewWindowParameters = { + accountId: Maybe; + browserWindow: BrowserWindow; + frameName: string; + options: BrowserWindowConstructorOptions; + senderWebContents: WebContents; + url: string; +}; + remoteMain.initialize(); const APP_PATH = path.join(app.getAppPath(), config.electronDirectory); @@ -617,26 +637,29 @@ class ElectronWrapperInit { return {action: 'deny'}; }; - const openLinkInNewWindow = ( - win: BrowserWindow, - url: string, - event: ElectronEvent, - frameName: string, - options: BrowserWindowConstructorOptions, - ): Promise | void => { - if (SingleSignOn.isSingleSignOnLoginWindow(frameName)) { - const singleSignOn = new SingleSignOn(win, event, url, options).init(); + function openLinkInNewWindow( + electronWrapperInitialization: ElectronWrapperInit, + parameters: OpenLinkInNewWindowParameters, + ): Promise | void { + if (SingleSignOn.isSingleSignOnLoginWindow(parameters.frameName)) { + const singleSignOn = new SingleSignOn( + parameters.browserWindow, + parameters.senderWebContents, + parameters.accountId, + parameters.url, + parameters.options, + ).init(); return new Promise(() => { singleSignOn .then(sso => { - this.ssoWindow = sso; - this.ssoWindow.onClose = this.sendSSOWindowCloseEvent; + electronWrapperInitialization.ssoWindow = sso; + electronWrapperInitialization.ssoWindow.onClose = electronWrapperInitialization.sendSSOWindowCloseEvent; }) .catch(error => console.info(error)); }); } - }; + } // Keeping this Function for future use const willNavigateInWebview = (event: ElectronEvent, url: string, baseUrl: string): void => { @@ -651,7 +674,7 @@ class ElectronWrapperInit { const enableSpellChecking = settings.restore(SettingsType.ENABLE_SPELL_CHECKING, true); - app.on('web-contents-created', async (webviewEvent: ElectronEvent, contents: WebContents) => { + app.on('web-contents-created', async (_webviewEvent: ElectronEvent, contents: WebContents) => { remoteMain.enable(contents); // disable new Windows by default on everything contents.setWindowOpenHandler(() => { @@ -682,8 +705,17 @@ class ElectronWrapperInit { } // Open webview links outside of the app contents.setWindowOpenHandler(openLinkInNewWindowHandler); - contents.on('did-create-window', async (win, {url, frameName, options}) => { - await openLinkInNewWindow(win, url, webviewEvent, frameName, options); + contents.on('did-create-window', async (win, windowCreationDetails) => { + const {frameName, options, url} = windowCreationDetails; + + await openLinkInNewWindow(this, { + accountId: lifecycle.getAccountId(contents), + browserWindow: win, + frameName, + options, + senderWebContents: contents, + url, + }); }); contents.on('will-navigate', (event: ElectronEvent, url: string) => { willNavigateInWebview(event, url, contents.getURL()); @@ -692,30 +724,28 @@ class ElectronWrapperInit { const colorCodeRegex = /%c(.+?)%c/gm; const stylingRegex = /(color:#|font-weight:)[^;]+; /gm; const accessTokenRegex = /access_token=[^ &]+/gm; - const logDate = new Date(); contents.on('console-message', async (_event, _level, message) => { - const webViewId = lifecycle.getWebViewId(contents); - /* - * Note: WebContents with ID `1` is the main window, `2` is the - * sidebar and `3` is the first account. - */ - const accountIndex = contents.id - 2; - - if (webViewId) { + const accountId = lifecycle.getAccountId(contents); + + if (accountId.isJust) { const logFilePath = getWebViewLogPath({ - accountIndex, - date: logDate, + accountId: accountId.value, + date: new Date(), logDirectory: getLogDirectory(), - webViewId, }); try { - await LogFactory.writeMessage( - message.replace(colorCodeRegex, '$1').replace(stylingRegex, '').replace(accessTokenRegex, ''), + await writeBoundedLogMessage({ logFilePath, - ); + message: message + .replace(colorCodeRegex, '$1') + .replace(stylingRegex, '') + .replace(accessTokenRegex, ''), + }); } catch (error) { - logger.error(`Cannot write to log file "${logFilePath}": ${(error as any).message}`, error); + const errorMessage = error instanceof Error ? error.message : String(error); + + logger.error(`Cannot write to log file "${logFilePath}": ${errorMessage}`, error); } } }); @@ -778,7 +808,14 @@ if (lifecycle.isFirstInstance) { addLinuxWorkarounds(); bindIpcEvents(); handleAppEvents(); - renameWebViewLogFiles(getLogDirectory(), logger); - fs.ensureFileSync(getMainProcessLogPath({logDirectory: getLogDirectory()})); + fs.ensureFileSync(getMainProcessLogPath({date: new Date(), logDirectory: getLogDirectory()})); + void runDesktopLogCleanup(); + scheduleLogCleanup({ + intervalMilliseconds: LOG_CLEANUP_INTERVAL_MILLISECONDS, + runCleanup: runDesktopLogCleanup, + setInterval: (callback: () => void, intervalMilliseconds: number): NodeJS.Timeout => { + return setInterval(callback, intervalMilliseconds); + }, + }); new ElectronWrapperInit().run().catch(error => logger.error(error)); } diff --git a/electron/src/runtime/lifecycle.ts b/electron/src/runtime/lifecycle.ts index e75fef7f030..033f4d2a1bc 100644 --- a/electron/src/runtime/lifecycle.ts +++ b/electron/src/runtime/lifecycle.ts @@ -18,6 +18,7 @@ */ import {app, session, ipcMain, WebContents} from 'electron'; +import {Maybe} from 'true-myth'; import * as path from 'path'; @@ -61,18 +62,22 @@ export const checkSingleInstance = async () => { } }; -export const getWebViewId = (contents?: WebContents): string | undefined => { - if (!contents) { - return undefined; - } +export function getAccountId(contents: WebContents): Maybe { try { const currentLocation = new URL(contents.getURL()); - const webViewId = currentLocation.searchParams.get('id'); - return webViewId && ValidationUtil.isUUIDv4(webViewId) ? webViewId : undefined; + const accountId = Maybe.of(currentLocation.searchParams.get('id')); + + return accountId.andThen(value => { + if (ValidationUtil.isUUIDv4(value)) { + return Maybe.just(value); + } + + return Maybe.nothing(); + }); } catch (error) { - return undefined; + return Maybe.nothing(); } -}; +} /** * will register a function that will be called in case of a relaunch in MacOS (see https://github.com/electron/electron/issues/13696) diff --git a/electron/src/sso/SingleSignOn.ts b/electron/src/sso/SingleSignOn.ts index 58e6877f0c8..4b1dea75ae4 100644 --- a/electron/src/sso/SingleSignOn.ts +++ b/electron/src/sso/SingleSignOn.ts @@ -27,17 +27,16 @@ import { WebContents, HandlerDetails, } from 'electron'; +import {Maybe} from 'true-myth'; import * as crypto from 'crypto'; import * as path from 'path'; import {URL} from 'url'; -import {LogFactory} from '@wireapp/commons'; - import {executeJavaScriptWithoutResult} from '../lib/ElectronUtil'; +import {writeBoundedLogMessage} from '../logging/desktopLogWriter'; import {ENABLE_LOGGING, getLogger} from '../logging/getLogger'; import {getLogDirectory, getSsoLogPath} from '../logging/logPaths'; -import {getWebViewId} from '../runtime/lifecycle'; import {config} from '../settings/config'; import * as WindowUtil from '../window/WindowUtil'; @@ -67,19 +66,22 @@ export class SingleSignOn { private session: Session | undefined; private ssoWindow: BrowserWindow | undefined; private readonly senderWebContents: WebContents; + private readonly accountId: Maybe; private readonly windowOptions: BrowserWindowConstructorOptions; private readonly windowOriginUrl: URL; public onClose = () => {}; constructor( ssoWindow: BrowserWindow, - senderEvent: ElectronEvent, + senderWebContents: WebContents, + accountId: Maybe, windowOriginURL: string, windowOptions: BrowserWindowConstructorOptions, ) { this.windowOptions = windowOptions; this.ssoWindow = ssoWindow; - this.senderWebContents = (senderEvent as any).sender; + this.senderWebContents = senderWebContents; + this.accountId = accountId; this.windowOriginUrl = new URL(windowOriginURL); } @@ -157,17 +159,18 @@ export class SingleSignOn { if (ENABLE_LOGGING) { ssoWindow.webContents.on('console-message', async (_event, _level, message) => { - const webViewId = getWebViewId(ssoWindow.webContents); - if (webViewId) { + if (this.accountId.isJust) { const logFilePath = getSsoLogPath({ + accountId: this.accountId.value, + date: new Date(), logDirectory: getLogDirectory(), - logFileName: config.logFileName, - webViewId, }); try { - await LogFactory.writeMessage(message, logFilePath); - } catch (error: any) { - console.error(`Cannot write to log file "${logFilePath}": ${error.message}`, error); + await writeBoundedLogMessage({logFilePath, message}); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + console.error(`Cannot write to log file "${logFilePath}": ${errorMessage}`, error); } } }); From 8e4120cb71bb921b5528477c8b34a6d716ea2b2c Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Mon, 24 Aug 2026 16:58:50 +0200 Subject: [PATCH 17/45] Fix unsafe SSO log error formatting --- electron/src/sso/SingleSignOn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/src/sso/SingleSignOn.ts b/electron/src/sso/SingleSignOn.ts index 4b1dea75ae4..88871b4bb2c 100644 --- a/electron/src/sso/SingleSignOn.ts +++ b/electron/src/sso/SingleSignOn.ts @@ -170,7 +170,7 @@ export class SingleSignOn { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`Cannot write to log file "${logFilePath}": ${errorMessage}`, error); + console.error('Cannot write to log file:', logFilePath, errorMessage, error); } } }); From 0e2ce167bacee68ecc0ca6fa130fcccd8d76dafb Mon Sep 17 00:00:00 2001 From: Adam Low Date: Mon, 24 Aug 2026 14:51:39 +0100 Subject: [PATCH 18/45] fix(windows): preserve Squirrel packaging semantics --- bin/build-tools/lib/build-windows-msi.test.ts | 19 +++++++- bin/build-tools/lib/build-windows-msi.ts | 15 ++++-- .../lib/JenkinsSquirrelPackaging.test.ts | 47 +++++++++++++++++++ jenkins/windows.groovy | 9 +++- package.json | 3 +- 5 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 bin/deploy-tools/lib/JenkinsSquirrelPackaging.test.ts diff --git a/bin/build-tools/lib/build-windows-msi.test.ts b/bin/build-tools/lib/build-windows-msi.test.ts index 1c57544608c..a75cf406ddb 100644 --- a/bin/build-tools/lib/build-windows-msi.test.ts +++ b/bin/build-tools/lib/build-windows-msi.test.ts @@ -17,10 +17,13 @@ * */ +import fs from 'fs-extra'; + import assert from 'node:assert'; +import os from 'node:os'; import path from 'node:path'; -import {buildWindowsMsiConfig, customizeMsiProject} from './build-windows-msi'; +import {buildWindowsMsiConfig, customizeMsiProject, validateWindowsMsiAppDirectory} from './build-windows-msi'; const wireJsonPath = path.join(__dirname, '../../../electron/wire.json'); const envFilePath = path.join(__dirname, '../../../.env.defaults'); @@ -157,4 +160,18 @@ describe('build-windows-msi', () => { ); }); }); + + describe('validateWindowsMsiAppDirectory', () => { + it('rejects a directory already mutated by Squirrel packaging', async () => { + const appDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'wire-msi-app-directory-')); + try { + await fs.ensureFile(path.join(appDirectory, 'Wire.exe')); + await fs.ensureFile(path.join(appDirectory, 'Squirrel.exe')); + + await assert.rejects(validateWindowsMsiAppDirectory(appDirectory, 'Wire'), /contains the Squirrel updater/); + } finally { + await fs.remove(appDirectory); + } + }); + }); }); diff --git a/bin/build-tools/lib/build-windows-msi.ts b/bin/build-tools/lib/build-windows-msi.ts index 3694de6655b..55dffd5e7f9 100644 --- a/bin/build-tools/lib/build-windows-msi.ts +++ b/bin/build-tools/lib/build-windows-msi.ts @@ -223,6 +223,17 @@ export async function buildWindowsMsiConfig( return {builderConfig, windowsMsiConfig}; } +export async function validateWindowsMsiAppDirectory(appDirectory: string, executableName: string): Promise { + if (!(await fs.pathExists(path.join(appDirectory, `${executableName}.exe`)))) { + throw new Error(`Packaged Windows application not found in "${appDirectory}". Run the Windows build first.`); + } + if (await fs.pathExists(path.join(appDirectory, 'Squirrel.exe'))) { + throw new Error( + `Packaged Windows application in "${appDirectory}" contains the Squirrel updater. Build the MSI before the Squirrel installer.`, + ); + } +} + export async function buildWindowsMsi( builderConfig: electronBuilder.Configuration, packageJsonPath: string, @@ -240,9 +251,7 @@ export async function buildWindowsMsi( logger.info(`Building ${commonConfig.name} ${commonConfig.version} MSI for Windows ...`); - if (!(await fs.pathExists(path.join(appDirectory, `${commonConfig.name}.exe`)))) { - throw new Error(`Packaged Windows application not found in "${appDirectory}". Run the Windows build first.`); - } + await validateWindowsMsiAppDirectory(appDirectory, commonConfig.name); const backup = await backupFiles([packageJsonResolved, wireJsonResolved]); const packageJsonContent = await fs.readJson(packageJsonResolved); diff --git a/bin/deploy-tools/lib/JenkinsSquirrelPackaging.test.ts b/bin/deploy-tools/lib/JenkinsSquirrelPackaging.test.ts new file mode 100644 index 00000000000..f22c1db4e89 --- /dev/null +++ b/bin/deploy-tools/lib/JenkinsSquirrelPackaging.test.ts @@ -0,0 +1,47 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'node:fs'; +import path from 'node:path'; + +import assert from 'node:assert'; + +const repositoryRoot = path.resolve(process.cwd()); +const packageJson = JSON.parse(fs.readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8')); +const windowsPipeline = fs.readFileSync(path.join(repositoryRoot, 'jenkins/windows.groovy'), 'utf8'); + +describe('Jenkins Squirrel packaging', () => { + it('builds a clean MSI before Squirrel mutates the application directory', () => { + assert.strictEqual( + packageJson.scripts['build:win:installers'], + 'rimraf wrap/dist && yarn build:win:msi:package -m && yarn build:win:installer:package', + ); + assert.strictEqual( + packageJson.scripts['build:win:installers:manual'], + 'rimraf wrap/dist && yarn build:win:msi:package -m && yarn build:win:installer:package -m', + ); + const signApplicationStage = windowsPipeline.indexOf("stage('Sign application')"); + const buildInstallersStage = windowsPipeline.indexOf("stage('Build installers')"); + assert.ok(signApplicationStage >= 0 && signApplicationStage < buildInstallersStage); + assert.match( + windowsPipeline, + /if \(production \|\| custom\) \{\s+bat 'yarn build:win:installers'\s+\} else \{\s+bat 'yarn build:win:installers:manual'/, + ); + }); +}); diff --git a/jenkins/windows.groovy b/jenkins/windows.groovy index c38add0e937..a02c4003006 100644 --- a/jenkins/windows.groovy +++ b/jenkins/windows.groovy @@ -81,8 +81,13 @@ node('windows') { stage('Build installers') { try { withEnv(["PATH+NODE=${NODE}", 'npm_config_target_arch=x64']) { - // Build both installer families from the same signed application without cleaning between them. - bat 'yarn build:win:installers' + // Build the MSI before Squirrel injects its updater into the application directory. Squirrel keeps its + // established signing path for production and custom builds. + if (production || custom) { + bat 'yarn build:win:installers' + } else { + bat 'yarn build:win:installers:manual' + } bat 'if not exist "wrap\\dist\\*-Setup.exe" (echo Missing Squirrel Setup executable & exit /b 1)' bat 'if not exist "wrap\\dist\\*-full.nupkg" (echo Missing Squirrel package & exit /b 1)' bat 'if not exist "wrap\\dist\\RELEASES" (echo Missing Squirrel RELEASES file & exit /b 1)' diff --git a/package.json b/package.json index d4144cac04c..39a590f7d50 100644 --- a/package.json +++ b/package.json @@ -167,7 +167,8 @@ "build:win:installer": "rimraf wrap/dist && yarn build:win:installer:package", "build:win:installer:package": "ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows-installer", "build:win:installer:internal": "cross-env-shell APP_ENV=internal \"yarn build:win:installer -m\"", - "build:win:installers": "rimraf wrap/dist && yarn build:win:installer:package -m && yarn build:win:msi:package -m", + "build:win:installers": "rimraf wrap/dist && yarn build:win:msi:package -m && yarn build:win:installer:package", + "build:win:installers:manual": "rimraf wrap/dist && yarn build:win:msi:package -m && yarn build:win:installer:package -m", "build:win:msi": "rimraf wrap/dist && yarn build:win:msi:package", "build:win:msi:package": "ts-node -P tsconfig.bin.json ./bin/build-tools/build-cli.ts windows-msi", "build:win:msi:internal": "cross-env-shell APP_ENV=internal \"yarn build:win:msi -m\"", From 255bdd54f03ffe1886b006dc81848ea44309fd8f Mon Sep 17 00:00:00 2001 From: Adam Low Date: Mon, 24 Aug 2026 14:52:17 +0100 Subject: [PATCH 19/45] fix(windows): require Squirrel before MSI deployment --- bin/deploy-tools/lib/JenkinsWindowsDeployment.test.ts | 7 +++++++ jenkins/deployment.groovy | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bin/deploy-tools/lib/JenkinsWindowsDeployment.test.ts b/bin/deploy-tools/lib/JenkinsWindowsDeployment.test.ts index 2600c6b9e54..1aee9950971 100644 --- a/bin/deploy-tools/lib/JenkinsWindowsDeployment.test.ts +++ b/bin/deploy-tools/lib/JenkinsWindowsDeployment.test.ts @@ -26,6 +26,13 @@ const repositoryRoot = path.resolve(process.cwd()); const deploymentPipeline = fs.readFileSync(path.join(repositoryRoot, 'jenkins/deployment.groovy'), 'utf8'); describe('Jenkins Windows deployment', () => { + it('requires the primary Squirrel artifact set before deploying Windows artifacts', () => { + assert.match( + deploymentPipeline, + /if \(!hasWindowsSquirrel\) \{\s+error\('No complete Windows Squirrel artifact set found'\)/, + ); + }); + it('updates the Squirrel feed whenever a complete Squirrel artifact set is present', () => { assert.doesNotMatch(deploymentPipeline, /\bisWindowsMsi\b/); assert.match(deploymentPipeline, /if \(hasWindowsSquirrel\) \{\s+stage\('Update RELEASES file'\)/); diff --git a/jenkins/deployment.groovy b/jenkins/deployment.groovy index 00540d195b7..5ec8402ccdb 100644 --- a/jenkins/deployment.groovy +++ b/jenkins/deployment.groovy @@ -131,8 +131,8 @@ node('built-in') { AWS_SECRET_CREDENTIALS_ID = params.AWS_CUSTOM_SECRET_ACCESS_KEY } - if (!hasWindowsSquirrel && !hasWindowsMsi) { - error('No complete Windows Squirrel or MSI artifact set found') + if (!hasWindowsSquirrel) { + error('No complete Windows Squirrel artifact set found') } try { From dce4c285f8b963c8eaec5c7fc6c8751dc57c5746 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Wed, 26 Aug 2026 16:18:51 +0200 Subject: [PATCH 20/45] Prune desktop logs and validate account directories --- electron/src/lib/LocalAccountDeletion.ts | 31 +++++- .../src/lib/accountLogDeletion.test.main.ts | 80 +++++++++++++- electron/src/lib/accountLogDeletion.ts | 43 +++++++- electron/src/logging/logCleanup.test.main.ts | 65 ++++++++++- electron/src/logging/logCleanup.ts | 103 ++++++++++-------- .../logDirectoryAncestors.test.main.ts | 56 ++++++++++ electron/src/logging/logDirectoryAncestors.ts | 66 +++++++++++ .../logging/logDirectoryCleanup.test.main.ts | 44 ++++++++ electron/src/logging/logDirectoryCleanup.ts | 89 +++++++++++++++ 9 files changed, 519 insertions(+), 58 deletions(-) create mode 100644 electron/src/logging/logDirectoryAncestors.test.main.ts create mode 100644 electron/src/logging/logDirectoryAncestors.ts create mode 100644 electron/src/logging/logDirectoryCleanup.test.main.ts create mode 100644 electron/src/logging/logDirectoryCleanup.ts diff --git a/electron/src/lib/LocalAccountDeletion.ts b/electron/src/lib/LocalAccountDeletion.ts index 85f196f0ec2..a73099bfcf7 100644 --- a/electron/src/lib/LocalAccountDeletion.ts +++ b/electron/src/lib/LocalAccountDeletion.ts @@ -28,6 +28,11 @@ import {ValidationUtil} from '@wireapp/commons'; import {deleteAccountLogDirectories} from './accountLogDeletion'; import {getLogger} from '../logging/getLogger'; +import { + LogDirectoryCleanupDependencies, + LogDirectoryCleanupResult, + removeEmptyLogDirectoryAncestors, +} from '../logging/logDirectoryCleanup'; import {getLogFilenames} from '../logging/logFiles'; import {getLogDirectory} from '../logging/logPaths'; @@ -94,14 +99,28 @@ export async function deleteAccount(id: number, accountId: string, partitionId?: } const logDirectory = getLogDirectory(); const logFilePaths = getLogFilenames({absolute: true, baseDirectory: logDirectory}); + const directoryCleanupDependencies: LogDirectoryCleanupDependencies = { + async getDirectoryMetadata(directoryPath: string) { + const directoryStatistics = await fs.lstat(directoryPath); + + return { + isDirectory: directoryStatistics.isDirectory(), + isSymbolicLink: directoryStatistics.isSymbolicLink(), + }; + }, + async removeDirectory(directoryPath: string): Promise { + await fs.rmdir(directoryPath); + }, + }; + await deleteAccountLogDirectories({ accountId, dependencies: { async isSafeDirectory(directoryPath: string): Promise { try { - const directoryStatistics = await fs.lstat(directoryPath); + const directoryMetadata = await directoryCleanupDependencies.getDirectoryMetadata(directoryPath); - return directoryStatistics.isDirectory() && directoryStatistics.isSymbolicLink() === false; + return directoryMetadata.isDirectory && directoryMetadata.isSymbolicLink === false; } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return false; @@ -110,6 +129,14 @@ export async function deleteAccount(id: number, accountId: string, partitionId?: throw error; } }, + async removeEmptyDirectoryAncestors(directoryPath: string): Promise { + return removeEmptyLogDirectoryAncestors({ + dependencies: directoryCleanupDependencies, + logDirectory, + pathToRemove: directoryPath, + pathType: 'directory', + }); + }, async removeDirectory(directoryPath: string): Promise { await fs.remove(directoryPath); }, diff --git a/electron/src/lib/accountLogDeletion.test.main.ts b/electron/src/lib/accountLogDeletion.test.main.ts index 83e93bbf4be..a595ad1f995 100644 --- a/electron/src/lib/accountLogDeletion.test.main.ts +++ b/electron/src/lib/accountLogDeletion.test.main.ts @@ -17,10 +17,18 @@ * */ +import {Result} from 'true-myth'; + import * as assert from 'assert'; import * as path from 'path'; -import {deleteAccountLogDirectories, getAccountLogDirectories} from './accountLogDeletion'; +import { + deleteAccountLogDirectories, + getAccountLogDirectories, + parseLegacyAccountLogDirectory, +} from './accountLogDeletion'; + +import {LogDirectoryCleanupResult} from '../logging/logDirectoryCleanup'; const logDirectory = path.join('user-data', 'logs'); const accountId = '11111111-1111-4111-8111-111111111111'; @@ -34,6 +42,9 @@ describe('account log deletion', () => { path.join(logDirectory, `2_2026_07_10_12_34_56_${accountId}`, 'console.log'), path.join(logDirectory, `2_2026_07_10_12_34_56_${otherAccountId}`, 'console.log'), path.join(logDirectory, '2026-07-10', 'accounts', `${accountId}-suffix`, 'console.log'), + path.join(logDirectory, `foo_${accountId}`, 'console.log'), + path.join(logDirectory, `backup_2_2026_07_10_12_34_56_${accountId}`, 'console.log'), + path.join(logDirectory, `2_invalid_${accountId}`, 'console.log'), ]; const actualDirectories = getAccountLogDirectories({accountId, filePaths, logDirectory}); @@ -46,6 +57,27 @@ describe('account log deletion', () => { assert.deepStrictEqual(actualDirectories, expectedDirectories); }); + it('parses only the historic timestamped account directory format', () => { + const parsedDirectory = parseLegacyAccountLogDirectory(`2_2026_07_10_12_34_56_${accountId}`); + const rejectedDirectoryNames = [ + `foo_${accountId}`, + `backup_2_2026_07_10_12_34_56_${accountId}`, + `2_invalid_${accountId}`, + `-1_2026_07_10_12_34_56_${accountId}`, + ]; + + assert.strictEqual(parsedDirectory.isJust, true); + + if (parsedDirectory.isJust) { + assert.strictEqual(parsedDirectory.value.accountId, accountId); + assert.strictEqual(parsedDirectory.value.accountIndex, 2); + } + + for (const directoryName of rejectedDirectoryNames) { + assert.strictEqual(parseLegacyAccountLogDirectory(directoryName).isNothing, true); + } + }); + it('skips unsafe directories and continues after deletion failures', async () => { const removedDirectories: string[] = []; const failures: string[] = []; @@ -60,6 +92,11 @@ describe('account log deletion', () => { async isSafeDirectory(directoryPath: string): Promise { return directoryPath === path.join(logDirectory, accountId); }, + async removeEmptyDirectoryAncestors(): Promise { + // Account directory pruning is covered by the filesystem boundary tests. + + return Result.ok(); + }, async removeDirectory(directoryPath: string): Promise { if (directoryPath === path.join(logDirectory, accountId)) { throw new Error('test failure'); @@ -78,4 +115,45 @@ describe('account log deletion', () => { assert.deepStrictEqual(removedDirectories, []); assert.strictEqual(failures.length, 1); }); + + it('deletes new and valid legacy account directories and prunes their ancestors', async () => { + const removedDirectories: string[] = []; + const prunedDirectories: string[] = []; + const filePaths = [ + path.join(logDirectory, '2026-07-10', 'accounts', accountId, 'console.log'), + path.join(logDirectory, `2_2026_07_10_12_34_56_${accountId}`, 'console.log'), + ]; + + await deleteAccountLogDirectories({ + accountId, + dependencies: { + async isSafeDirectory(): Promise { + return true; + }, + async removeEmptyDirectoryAncestors(directoryPath: string): Promise { + prunedDirectories.push(directoryPath); + + return Result.ok(); + }, + async removeDirectory(directoryPath: string): Promise { + removedDirectories.push(directoryPath); + }, + reportFailure: (): void => { + // The valid account layouts should not report failures. + }, + }, + filePaths, + logDirectory, + }); + + assert.deepStrictEqual( + removedDirectories, + [ + path.join(logDirectory, accountId), + path.join(logDirectory, '2026-07-10', 'accounts', accountId), + path.join(logDirectory, `2_2026_07_10_12_34_56_${accountId}`), + ].toSorted(), + ); + assert.deepStrictEqual(prunedDirectories, removedDirectories); + }); }); diff --git a/electron/src/lib/accountLogDeletion.ts b/electron/src/lib/accountLogDeletion.ts index 1880e00de36..0d4658d1f90 100644 --- a/electron/src/lib/accountLogDeletion.ts +++ b/electron/src/lib/accountLogDeletion.ts @@ -17,8 +17,14 @@ * */ +import {Maybe} from 'true-myth'; + import * as path from 'path'; +import {ValidationUtil} from '@wireapp/commons'; + +import {LogDirectoryCleanupResult} from '../logging/logDirectoryCleanup'; + export type AccountLogDirectoryParameters = { accountId: string; filePaths: readonly string[]; @@ -27,6 +33,7 @@ export type AccountLogDirectoryParameters = { export type AccountLogDeletionDependencies = { isSafeDirectory: (directoryPath: string) => Promise; + removeEmptyDirectoryAncestors: (directoryPath: string) => Promise; removeDirectory: (directoryPath: string) => Promise; reportFailure: (message: string, error: unknown) => void; }; @@ -35,6 +42,14 @@ export type DeleteAccountLogDirectoriesParameters = AccountLogDirectoryParameter dependencies: AccountLogDeletionDependencies; }; +export type LegacyAccountLogDirectory = { + accountId: string; + accountIndex: number; +}; + +const LEGACY_ACCOUNT_LOG_DIRECTORY_PATTERN = + /^(\d+)_(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i; + function getRelativePathSegments(logDirectory: string, filePath: string): string[] { return path.relative(logDirectory, filePath).split(path.sep); } @@ -43,14 +58,32 @@ function isNewAccountLogPath(pathSegments: readonly string[], accountId: string) return pathSegments.length >= 4 && pathSegments[1] === 'accounts' && pathSegments[2] === accountId; } +export function parseLegacyAccountLogDirectory(directoryName: string): Maybe { + const patternMatch = Maybe.of(LEGACY_ACCOUNT_LOG_DIRECTORY_PATTERN.exec(directoryName)); + + if (patternMatch.isNothing) { + return Maybe.nothing(); + } + + const accountIndex = Number(patternMatch.value[1]); + const accountId = patternMatch.value[8]; + + if (Number.isSafeInteger(accountIndex) === false || ValidationUtil.isUUIDv4(accountId) === false) { + return Maybe.nothing(); + } + + return Maybe.just({accountId, accountIndex}); +} + function isLegacyTimestampedAccountLogPath(pathSegments: readonly string[], accountId: string): boolean { if (pathSegments.length < 2) { return false; } const directoryName = pathSegments[pathSegments.length - 2]; + const parsedDirectory = parseLegacyAccountLogDirectory(directoryName); - return directoryName.endsWith(`_${accountId}`); + return parsedDirectory.isJust && parsedDirectory.value.accountId === accountId; } export function getAccountLogDirectories(parameters: AccountLogDirectoryParameters): readonly string[] { @@ -80,6 +113,14 @@ export async function deleteAccountLogDirectories(parameters: DeleteAccountLogDi if (isSafeDirectory) { await parameters.dependencies.removeDirectory(directoryPath); + const cleanupResult = await parameters.dependencies.removeEmptyDirectoryAncestors(directoryPath); + + if (cleanupResult.isErr) { + parameters.dependencies.reportFailure( + `Failed to delete account log directory "${directoryPath}"`, + cleanupResult.error, + ); + } } } catch (error) { parameters.dependencies.reportFailure(`Failed to delete account log directory "${directoryPath}"`, error); diff --git a/electron/src/logging/logCleanup.test.main.ts b/electron/src/logging/logCleanup.test.main.ts index 55a24924e16..a0ca5e9eedc 100644 --- a/electron/src/logging/logCleanup.test.main.ts +++ b/electron/src/logging/logCleanup.test.main.ts @@ -17,11 +17,23 @@ * */ -import * as assert from 'assert'; +import * as fs from 'fs-extra'; +import {Result} from 'true-myth'; -import {createLogCleanup, LogCleanupDependencies, RunLogCleanupParameters} from './logCleanup'; +import * as assert from 'assert'; +import * as path from 'path'; + +import { + createLogCleanup, + createLogCleanupFileSystemDependencies, + LogCleanupDependencies, + RunLogCleanupParameters, +} from './logCleanup'; +import {LogDirectoryCleanupResult} from './logDirectoryCleanup'; import {LogFileMetadata} from './logRetention'; +import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; + type CleanupTestState = { failures: string[]; removedDirectories: string[]; @@ -52,8 +64,10 @@ function createCleanupTestDependencies( throw new Error(`Missing test metadata for ${filePath}`); }, - async removeEmptyDirectory(directoryPath: string): Promise { + async removeEmptyDirectory(directoryPath: string): Promise { state.removedDirectories.push(directoryPath); + + return Result.ok(); }, async removeFile(filePath: string): Promise { state.removedFiles.push(filePath); @@ -76,7 +90,12 @@ describe('desktop log cleanup', () => { it('removes planned files and their empty parent directories', async () => { const state: CleanupTestState = {failures: [], removedDirectories: [], removedFiles: []}; const fileMetadata: LogFileMetadata[] = [ - {filePath: 'logs/old/console.log', fileSizeBytes: 10, isSymbolicLink: false, modifiedTimeMilliseconds: 1}, + { + filePath: 'logs/2026-08-20/accounts/account/console.log', + fileSizeBytes: 10, + isSymbolicLink: false, + modifiedTimeMilliseconds: 1, + }, { filePath: 'logs/recent/console.log', fileSizeBytes: 10, @@ -88,8 +107,12 @@ describe('desktop log cleanup', () => { await cleanup.run(createCleanupParameters(new Set())); - assert.deepStrictEqual(state.removedFiles, ['logs/old/console.log']); - assert.deepStrictEqual(state.removedDirectories, ['logs/recent', 'logs/old']); + assert.deepStrictEqual(state.removedFiles, ['logs/2026-08-20/accounts/account/console.log']); + assert.deepStrictEqual(state.removedDirectories, [ + 'logs/2026-08-20/accounts/account', + 'logs/2026-08-20/accounts', + 'logs/2026-08-20', + ]); assert.deepStrictEqual(state.failures, []); }); @@ -152,4 +175,34 @@ describe('desktop log cleanup', () => { assert.strictEqual(discoveryCount, 1); }); + + it( + 'removes empty ancestors without removing the log root or following symbolic links', + withTemporaryDirectory('wire-log-cleanup-directories-', async (temporaryLogDirectory: string) => { + const reportFailure = (): void => { + // The test expects no filesystem failures. + }; + const dependencies = createLogCleanupFileSystemDependencies(reportFailure); + const dateDirectory = path.join(temporaryLogDirectory, '2026-08-20'); + const accountsDirectory = path.join(dateDirectory, 'accounts'); + const accountDirectory = path.join(accountsDirectory, 'account'); + const symbolicLinkTarget = path.join(temporaryLogDirectory, 'symbolic-link-target'); + const symbolicLinkPath = path.join(accountsDirectory, 'symbolic-link'); + + await fs.ensureDir(accountDirectory); + await fs.ensureDir(symbolicLinkTarget); + await fs.symlink(symbolicLinkTarget, symbolicLinkPath, 'dir'); + + await dependencies.removeEmptyDirectory(accountDirectory); + await dependencies.removeEmptyDirectory(accountsDirectory); + await dependencies.removeEmptyDirectory(dateDirectory); + await dependencies.removeEmptyDirectory(symbolicLinkPath); + + assert.strictEqual(await fs.pathExists(accountDirectory), false); + assert.strictEqual(await fs.pathExists(accountsDirectory), true); + assert.strictEqual(await fs.pathExists(dateDirectory), true); + assert.strictEqual(await fs.pathExists(temporaryLogDirectory), true); + assert.strictEqual((await fs.lstat(symbolicLinkPath)).isSymbolicLink(), true); + }), + ); }); diff --git a/electron/src/logging/logCleanup.ts b/electron/src/logging/logCleanup.ts index 7840e306bd1..8cd278f5d72 100644 --- a/electron/src/logging/logCleanup.ts +++ b/electron/src/logging/logCleanup.ts @@ -20,8 +20,12 @@ import * as fs from 'fs-extra'; import {Maybe} from 'true-myth'; -import * as path from 'path'; - +import {getLogDirectoryAncestors} from './logDirectoryAncestors'; +import { + LogDirectoryCleanupDependencies, + LogDirectoryCleanupResult, + removeEmptyLogDirectory, +} from './logDirectoryCleanup'; import {getLogFilenames} from './logFiles'; import {LogFileMetadata, LogRetentionPlanParameters, LogRetentionPolicy, planLogCleanup} from './logRetention'; @@ -36,7 +40,7 @@ export type LogCleanupDependencies = { discoverLogFilePaths: (logDirectory: string) => Promise; getCurrentTimeMilliseconds: () => number; getFileMetadata: (filePath: string) => Promise; - removeEmptyDirectory: (directoryPath: string) => Promise; + removeEmptyDirectory: (directoryPath: string) => Promise; removeFile: (filePath: string) => Promise; reportFailure: (message: string, error: unknown) => void; }; @@ -56,30 +60,32 @@ type CleanupFileMetadataParameters = { dependencies: LogCleanupDependencies; }; -function getParentDirectories(filePaths: readonly string[]): string[] { - const parentDirectories = new Set( - filePaths.map(filePath => { - return path.dirname(filePath); - }), - ); +function compareDirectoryDepth(firstPath: string, secondPath: string): number { + const pathLengthDifference = secondPath.length - firstPath.length; - return [...parentDirectories].toSorted((firstPath, secondPath) => { - const pathLengthDifference = secondPath.length - firstPath.length; + if (pathLengthDifference !== 0) { + return pathLengthDifference; + } - if (pathLengthDifference !== 0) { - return pathLengthDifference; - } + if (firstPath < secondPath) { + return -1; + } - if (firstPath < secondPath) { - return -1; - } + if (firstPath > secondPath) { + return 1; + } - if (firstPath > secondPath) { - return 1; - } + return 0; +} + +function getParentDirectories(filePaths: readonly string[], logDirectory: string): string[] { + const parentDirectories = new Set( + filePaths.flatMap(filePath => { + return getLogDirectoryAncestors({logDirectory, pathToRemove: filePath, pathType: 'file'}); + }), + ); - return 0; - }); + return [...parentDirectories].toSorted(compareDirectoryDepth); } async function getCleanupFileMetadata(parameters: CleanupFileMetadataParameters): Promise { @@ -108,11 +114,16 @@ async function removePlannedFiles(filePaths: readonly string[], dependencies: Lo async function removeParentDirectories( filePaths: readonly string[], + logDirectory: string, dependencies: LogCleanupDependencies, ): Promise { - for (const directoryPath of getParentDirectories(filePaths)) { + for (const directoryPath of getParentDirectories(filePaths, logDirectory)) { try { - await dependencies.removeEmptyDirectory(directoryPath); + const cleanupResult = await dependencies.removeEmptyDirectory(directoryPath); + + if (cleanupResult.isErr) { + dependencies.reportFailure(`Failed to remove empty log directory "${directoryPath}"`, cleanupResult.error); + } } catch (error) { dependencies.reportFailure(`Failed to remove empty log directory "${directoryPath}"`, error); } @@ -140,20 +151,30 @@ async function runCleanup(parameters: RunLogCleanupParameters, dependencies: Log const retentionPlan = planLogCleanup(retentionPlanParameters); await removePlannedFiles(retentionPlan.filesToDelete, dependencies); - await removeParentDirectories(logFilePaths, dependencies); + await removeParentDirectories(retentionPlan.filesToDelete, parameters.logDirectory, dependencies); } -function isMissingPathError(error: unknown): boolean { - return error instanceof Error && 'code' in error && error.code === 'ENOENT'; -} +function createLogDirectoryCleanupDependencies(): LogDirectoryCleanupDependencies { + return { + async getDirectoryMetadata(directoryPath: string) { + const directoryStatistics = await fs.lstat(directoryPath); -function isNonEmptyDirectoryError(error: unknown): boolean { - return error instanceof Error && 'code' in error && (error.code === 'ENOTEMPTY' || error.code === 'EEXIST'); + return { + isDirectory: directoryStatistics.isDirectory(), + isSymbolicLink: directoryStatistics.isSymbolicLink(), + }; + }, + async removeDirectory(directoryPath: string): Promise { + await fs.rmdir(directoryPath); + }, + }; } -function createFileSystemDependencies( +export function createLogCleanupFileSystemDependencies( reportFailure: (message: string, error: unknown) => void, ): LogCleanupDependencies { + const directoryCleanupDependencies = createLogDirectoryCleanupDependencies(); + return { async discoverLogFilePaths(logDirectory: string): Promise { return getLogFilenames({absolute: true, baseDirectory: logDirectory}); @@ -171,22 +192,8 @@ function createFileSystemDependencies( modifiedTimeMilliseconds: fileStatistics.mtimeMs, }; }, - async removeEmptyDirectory(directoryPath: string): Promise { - try { - const directoryStatistics = await fs.lstat(directoryPath); - - if (directoryStatistics.isSymbolicLink() || directoryStatistics.isDirectory() === false) { - return; - } - - await fs.rmdir(directoryPath); - } catch (error) { - if (isMissingPathError(error) || isNonEmptyDirectoryError(error)) { - return; - } - - throw error; - } + async removeEmptyDirectory(directoryPath: string): Promise { + return removeEmptyLogDirectory({directoryPath, dependencies: directoryCleanupDependencies}); }, async removeFile(filePath: string): Promise { await fs.unlink(filePath); @@ -219,7 +226,7 @@ export function createLogCleanup(dependencies: LogCleanupDependencies): LogClean } const desktopLogCleanup = createLogCleanup( - createFileSystemDependencies((message: string, error: unknown): void => { + createLogCleanupFileSystemDependencies((message: string, error: unknown): void => { console.error(message, error); }), ); diff --git a/electron/src/logging/logDirectoryAncestors.test.main.ts b/electron/src/logging/logDirectoryAncestors.test.main.ts new file mode 100644 index 00000000000..d2a1b5a3b9e --- /dev/null +++ b/electron/src/logging/logDirectoryAncestors.test.main.ts @@ -0,0 +1,56 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {getLogDirectoryAncestors} from './logDirectoryAncestors'; + +describe('desktop log directory ancestors', () => { + it('returns nested ancestors deepest-first without the log root', () => { + const actualAncestors = getLogDirectoryAncestors({ + logDirectory: path.join('logs'), + pathToRemove: path.join('logs', '2026-08-20', 'accounts', 'account', 'console.log'), + pathType: 'file', + }); + const expectedAncestors = [ + path.join('logs', '2026-08-20', 'accounts', 'account'), + path.join('logs', '2026-08-20', 'accounts'), + path.join('logs', '2026-08-20'), + ]; + + assert.deepStrictEqual(actualAncestors, expectedAncestors); + }); + + it('returns no ancestors for the root or a path outside the root', () => { + const actualRootAncestors = getLogDirectoryAncestors({ + logDirectory: path.join('logs'), + pathToRemove: path.join('logs'), + pathType: 'directory', + }); + const actualOutsideAncestors = getLogDirectoryAncestors({ + logDirectory: path.join('logs'), + pathToRemove: path.join('other', 'account', 'console.log'), + pathType: 'file', + }); + + assert.deepStrictEqual(actualRootAncestors, []); + assert.deepStrictEqual(actualOutsideAncestors, []); + }); +}); diff --git a/electron/src/logging/logDirectoryAncestors.ts b/electron/src/logging/logDirectoryAncestors.ts new file mode 100644 index 00000000000..5ab40b4eb8a --- /dev/null +++ b/electron/src/logging/logDirectoryAncestors.ts @@ -0,0 +1,66 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 path from 'path'; + +export type LogDirectoryPathType = 'directory' | 'file'; + +export type GetLogDirectoryAncestorsParameters = { + logDirectory: string; + pathToRemove: string; + pathType: LogDirectoryPathType; +}; + +function isPathInsideLogDirectory(logDirectory: string, candidatePath: string): boolean { + const relativePath = path.relative(logDirectory, candidatePath); + + return ( + relativePath !== '' && + relativePath !== '..' && + relativePath.startsWith(`..${path.sep}`) === false && + path.isAbsolute(relativePath) === false + ); +} + +function getStartingDirectory(parameters: GetLogDirectoryAncestorsParameters): string { + const resolvedPath = path.resolve(parameters.pathToRemove); + + if (parameters.pathType === 'file') { + return path.dirname(resolvedPath); + } + + return resolvedPath; +} + +export function getLogDirectoryAncestors(parameters: GetLogDirectoryAncestorsParameters): readonly string[] { + const resolvedLogDirectory = path.resolve(parameters.logDirectory); + let currentDirectory = getStartingDirectory(parameters); + const ancestorDirectories: string[] = []; + + while ( + currentDirectory !== resolvedLogDirectory && + isPathInsideLogDirectory(resolvedLogDirectory, currentDirectory) + ) { + const relativeAncestorPath = path.relative(resolvedLogDirectory, currentDirectory); + ancestorDirectories.push(path.join(parameters.logDirectory, relativeAncestorPath)); + currentDirectory = path.dirname(currentDirectory); + } + + return ancestorDirectories; +} diff --git a/electron/src/logging/logDirectoryCleanup.test.main.ts b/electron/src/logging/logDirectoryCleanup.test.main.ts new file mode 100644 index 00000000000..016ccda7043 --- /dev/null +++ b/electron/src/logging/logDirectoryCleanup.test.main.ts @@ -0,0 +1,44 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {LogDirectoryCleanupDependencies, LogDirectoryMetadata, removeEmptyLogDirectory} from './logDirectoryCleanup'; + +describe('log directory cleanup', () => { + it('returns unexpected filesystem failures as an Err result', async () => { + const expectedFailure = new Error('directory removal failed'); + const dependencies: LogDirectoryCleanupDependencies = { + async getDirectoryMetadata(): Promise { + return {isDirectory: true, isSymbolicLink: false}; + }, + async removeDirectory(): Promise { + return Promise.reject(expectedFailure); + }, + }; + + const cleanupResult = await removeEmptyLogDirectory({dependencies, directoryPath: 'logs/2026-08-20'}); + + assert.strictEqual(cleanupResult.isErr, true); + + if (cleanupResult.isErr) { + assert.strictEqual(cleanupResult.error, expectedFailure); + } + }); +}); diff --git a/electron/src/logging/logDirectoryCleanup.ts b/electron/src/logging/logDirectoryCleanup.ts new file mode 100644 index 00000000000..7092f85f7e6 --- /dev/null +++ b/electron/src/logging/logDirectoryCleanup.ts @@ -0,0 +1,89 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {Result, Unit} from 'true-myth'; + +import {getLogDirectoryAncestors, GetLogDirectoryAncestorsParameters} from './logDirectoryAncestors'; + +export type LogDirectoryMetadata = { + isDirectory: boolean; + isSymbolicLink: boolean; +}; + +export type LogDirectoryCleanupResult = Result; + +export type LogDirectoryCleanupDependencies = { + getDirectoryMetadata: (directoryPath: string) => Promise; + removeDirectory: (directoryPath: string) => Promise; +}; + +export type RemoveEmptyLogDirectoryParameters = { + dependencies: LogDirectoryCleanupDependencies; + directoryPath: string; +}; + +export type RemoveEmptyLogDirectoryAncestorsParameters = GetLogDirectoryAncestorsParameters & { + dependencies: LogDirectoryCleanupDependencies; +}; + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +} + +function isNonEmptyDirectoryError(error: unknown): boolean { + return error instanceof Error && 'code' in error && (error.code === 'ENOTEMPTY' || error.code === 'EEXIST'); +} + +export async function removeEmptyLogDirectory( + parameters: RemoveEmptyLogDirectoryParameters, +): Promise { + try { + const directoryMetadata = await parameters.dependencies.getDirectoryMetadata(parameters.directoryPath); + + if (directoryMetadata.isSymbolicLink || directoryMetadata.isDirectory === false) { + return Result.ok(); + } + + await parameters.dependencies.removeDirectory(parameters.directoryPath); + + return Result.ok(); + } catch (error) { + if (isMissingPathError(error) || isNonEmptyDirectoryError(error)) { + return Result.ok(); + } + + return Result.err(error); + } +} + +export async function removeEmptyLogDirectoryAncestors( + parameters: RemoveEmptyLogDirectoryAncestorsParameters, +): Promise { + const ancestorDirectories = getLogDirectoryAncestors(parameters); + + for (const directoryPath of ancestorDirectories) { + const cleanupResult = await removeEmptyLogDirectory({dependencies: parameters.dependencies, directoryPath}); + + if (cleanupResult.isErr) { + return cleanupResult; + } + } + + return Result.ok(); +} From 1781c337347481832f7f49b0ad8ef33d45e87d1f Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Wed, 26 Aug 2026 16:19:56 +0200 Subject: [PATCH 21/45] Coordinate asynchronous log maintenance --- .../src/lib/fireAndForgetInvoker.test.main.ts | 122 ++++++++++++++ electron/src/lib/fireAndForgetInvoker.ts | 69 ++++++++ .../src/logging/logMaintenance.test.main.ts | 156 ++++++++++++++++++ electron/src/logging/logMaintenance.ts | 142 ++++++++++++++++ 4 files changed, 489 insertions(+) create mode 100644 electron/src/lib/fireAndForgetInvoker.test.main.ts create mode 100644 electron/src/lib/fireAndForgetInvoker.ts create mode 100644 electron/src/logging/logMaintenance.test.main.ts create mode 100644 electron/src/logging/logMaintenance.ts diff --git a/electron/src/lib/fireAndForgetInvoker.test.main.ts b/electron/src/lib/fireAndForgetInvoker.test.main.ts new file mode 100644 index 00000000000..cd37b681542 --- /dev/null +++ b/electron/src/lib/fireAndForgetInvoker.test.main.ts @@ -0,0 +1,122 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {createFireAndForgetInvoker} from './fireAndForgetInvoker'; + +describe('fire-and-forget invoker', () => { + it('invokes an asynchronous action without waiting for it', () => { + let actionInvocationCount = 0; + const invoker = createFireAndForgetInvoker({ + reportFailure(): void { + // The action is expected to resolve. + }, + }); + + invoker.fireAndForget(async (): Promise => { + actionInvocationCount += 1; + }); + + assert.strictEqual(actionInvocationCount, 1); + }); + + it('reports synchronous action failures', async () => { + const failures: unknown[] = []; + const expectedFailure = new Error('synchronous failure'); + const invoker = createFireAndForgetInvoker({ + reportFailure(error: unknown): void { + failures.push(error); + }, + }); + + invoker.fireAndForget(() => { + throw expectedFailure; + }); + await invoker.waitUntilAllSettled(); + + assert.deepStrictEqual(failures, [expectedFailure]); + }); + + it('reports rejected asynchronous actions', async () => { + const failures: unknown[] = []; + const expectedFailure = new Error('asynchronous failure'); + const invoker = createFireAndForgetInvoker({ + reportFailure(error: unknown): void { + failures.push(error); + }, + }); + + invoker.fireAndForget(async (): Promise => { + throw expectedFailure; + }); + await invoker.waitUntilAllSettled(); + + assert.deepStrictEqual(failures, [expectedFailure]); + }); + + it('waits for active actions to settle', async () => { + let resolveFirstAction: () => void = () => { + // The resolver is replaced by the Promise constructor. + }; + let resolveSecondAction: () => void = () => { + // The resolver is replaced by the Promise constructor. + }; + const invoker = createFireAndForgetInvoker({ + reportFailure(): void { + // The actions are expected to resolve. + }, + }); + const firstAction = new Promise(resolve => { + resolveFirstAction = resolve; + }); + const secondAction = new Promise(resolve => { + resolveSecondAction = resolve; + }); + let hasWaitFinished = false; + + invoker.fireAndForget(async (): Promise => { + await firstAction; + }); + invoker.fireAndForget(async (): Promise => { + await secondAction; + }); + + async function waitForActionsToSettle(): Promise { + await invoker.waitUntilAllSettled(); + + hasWaitFinished = true; + } + + const waitPromise = waitForActionsToSettle(); + await Promise.resolve(); + + assert.strictEqual(hasWaitFinished, false); + + resolveFirstAction(); + await Promise.resolve(); + + assert.strictEqual(hasWaitFinished, false); + + resolveSecondAction(); + await waitPromise; + + assert.strictEqual(hasWaitFinished, true); + }); +}); diff --git a/electron/src/lib/fireAndForgetInvoker.ts b/electron/src/lib/fireAndForgetInvoker.ts new file mode 100644 index 00000000000..4f85357e345 --- /dev/null +++ b/electron/src/lib/fireAndForgetInvoker.ts @@ -0,0 +1,69 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export type FireAndForgetInvokerDependencies = { + reportFailure: (error: unknown) => void; +}; + +export type FireAndForgetInvoker = { + fireAndForget: (asyncAction: () => Promise) => void; + waitUntilAllSettled: () => Promise; +}; + +export function createFireAndForgetInvoker(dependencies: FireAndForgetInvokerDependencies): FireAndForgetInvoker { + const activePromises = new Set>(); + + async function observePromise(trackedPromise: Promise): Promise { + try { + await trackedPromise; + } catch (error) { + dependencies.reportFailure(error); + } finally { + activePromises.delete(trackedPromise); + } + } + + function reportUnexpectedObservationFailure(error: unknown): void { + dependencies.reportFailure(error); + } + + function trackPromise(trackedPromise: Promise): void { + activePromises.add(trackedPromise); + observePromise(trackedPromise).catch(reportUnexpectedObservationFailure); + } + + function fireAndForget(asyncAction: () => Promise): void { + try { + const trackedPromise = asyncAction(); + + trackPromise(trackedPromise); + } catch (error) { + dependencies.reportFailure(error); + } + } + + async function waitUntilAllSettled(): Promise { + await Promise.allSettled(Array.from(activePromises)); + } + + return { + fireAndForget, + waitUntilAllSettled, + }; +} diff --git a/electron/src/logging/logMaintenance.test.main.ts b/electron/src/logging/logMaintenance.test.main.ts new file mode 100644 index 00000000000..fcaea74debc --- /dev/null +++ b/electron/src/logging/logMaintenance.test.main.ts @@ -0,0 +1,156 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {createLogMaintenanceCoordinator} from './logMaintenance'; + +import {createFireAndForgetInvoker} from '../lib/fireAndForgetInvoker'; + +function createDeferredCompletion(): {promise: Promise; resolve: () => void} { + let resolvePromise: () => void = () => { + // The resolver is replaced by the Promise constructor. + }; + const promise = new Promise(resolve => { + resolvePromise = resolve; + }); + + return {promise, resolve: resolvePromise}; +} + +function createTestMaintenanceCoordinator() { + const invoker = createFireAndForgetInvoker({ + reportFailure(): void { + // The coordinator's public promises report expected operation failures. + }, + }); + + return createLogMaintenanceCoordinator({fireAndForget: invoker.fireAndForget}); +} + +describe('desktop log maintenance coordination', () => { + it('waits for active writes and blocks writes requested after maintenance', async () => { + const coordinator = createTestMaintenanceCoordinator(); + const firstWriteCompletion = createDeferredCompletion(); + let writeCount = 0; + let maintenanceCount = 0; + + const firstWrite = coordinator.runWrite(async (): Promise => { + writeCount += 1; + await firstWriteCompletion.promise; + }); + const maintenance = coordinator.runMaintenance(async (): Promise => { + maintenanceCount += 1; + }); + const queuedWrite = coordinator.runWrite(async (): Promise => { + writeCount += 1; + }); + + await Promise.resolve(); + await Promise.resolve(); + + assert.strictEqual(writeCount, 1); + assert.strictEqual(maintenanceCount, 0); + + firstWriteCompletion.resolve(); + await firstWrite; + await maintenance; + await queuedWrite; + + assert.strictEqual(writeCount, 2); + assert.strictEqual(maintenanceCount, 1); + }); + + it('keeps normal writes to different files concurrent', async () => { + const coordinator = createTestMaintenanceCoordinator(); + const firstWriteCompletion = createDeferredCompletion(); + const secondWriteCompletion = createDeferredCompletion(); + let activeWriteCount = 0; + let maximumActiveWriteCount = 0; + + const writeFirstFile = coordinator.runWrite(async (): Promise => { + activeWriteCount += 1; + maximumActiveWriteCount = Math.max(maximumActiveWriteCount, activeWriteCount); + await firstWriteCompletion.promise; + activeWriteCount -= 1; + }); + const writeSecondFile = coordinator.runWrite(async (): Promise => { + activeWriteCount += 1; + maximumActiveWriteCount = Math.max(maximumActiveWriteCount, activeWriteCount); + await secondWriteCompletion.promise; + activeWriteCount -= 1; + }); + + await Promise.resolve(); + + assert.strictEqual(maximumActiveWriteCount, 2); + + firstWriteCompletion.resolve(); + secondWriteCompletion.resolve(); + await Promise.all([writeFirstFile, writeSecondFile]); + }); + + it('releases queued writes when maintenance fails', async () => { + const coordinator = createTestMaintenanceCoordinator(); + const maintenance = coordinator.runMaintenance(async (): Promise => { + throw new Error('maintenance failed'); + }); + const queuedWrite = coordinator.runWrite(async (): Promise => { + // The test only verifies that the queued write resumes. + }); + let maintenanceFailure: unknown; + + try { + await maintenance; + } catch (error) { + maintenanceFailure = error; + } + + await queuedWrite; + + assert.strictEqual(maintenanceFailure instanceof Error, true); + }); + + it('serializes concurrent maintenance operations', async () => { + const coordinator = createTestMaintenanceCoordinator(); + const firstMaintenanceCompletion = createDeferredCompletion(); + let activeMaintenanceCount = 0; + let maximumActiveMaintenanceCount = 0; + + const firstMaintenance = coordinator.runMaintenance(async (): Promise => { + activeMaintenanceCount += 1; + maximumActiveMaintenanceCount = Math.max(maximumActiveMaintenanceCount, activeMaintenanceCount); + await firstMaintenanceCompletion.promise; + activeMaintenanceCount -= 1; + }); + const secondMaintenance = coordinator.runMaintenance(async (): Promise => { + activeMaintenanceCount += 1; + maximumActiveMaintenanceCount = Math.max(maximumActiveMaintenanceCount, activeMaintenanceCount); + activeMaintenanceCount -= 1; + }); + + await Promise.resolve(); + await Promise.resolve(); + + assert.strictEqual(maximumActiveMaintenanceCount, 1); + + firstMaintenanceCompletion.resolve(); + await Promise.all([firstMaintenance, secondMaintenance]); + }); +}); diff --git a/electron/src/logging/logMaintenance.ts b/electron/src/logging/logMaintenance.ts new file mode 100644 index 00000000000..bd8fc9cf93e --- /dev/null +++ b/electron/src/logging/logMaintenance.ts @@ -0,0 +1,142 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export type LogMaintenanceCoordinator = { + runMaintenance(operation: () => Promise): Promise; + runWrite(operation: () => Promise): Promise; +}; + +export type CreateLogMaintenanceCoordinatorDependencies = { + fireAndForget: (asyncAction: () => Promise) => void; +}; + +type QueuedMaintenance = { + operation: () => Promise; +}; + +type QueuedWrite = () => void; + +export function createLogMaintenanceCoordinator( + dependencies: CreateLogMaintenanceCoordinatorDependencies, +): LogMaintenanceCoordinator { + let activeWriteCount = 0; + let maintenanceRequested = false; + let maintenanceRunning = false; + let queuedMaintenances: QueuedMaintenance[] = []; + let queuedWrites: QueuedWrite[] = []; + + function startQueuedWrites(): void { + const writesToStart = queuedWrites; + queuedWrites = []; + + for (const startWrite of writesToStart) { + startWrite(); + } + } + + function finishMaintenance(): void { + maintenanceRunning = false; + + if (queuedMaintenances.length > 0) { + dependencies.fireAndForget(startNextMaintenance); + + return; + } + + maintenanceRequested = false; + startQueuedWrites(); + } + + function releaseWrite(): void { + activeWriteCount -= 1; + dependencies.fireAndForget(startNextMaintenance); + } + + async function startWrite( + operation: () => Promise, + resolve: (result: Result) => void, + reject: (error: unknown) => void, + ): Promise { + activeWriteCount += 1; + + try { + const result = await operation(); + + resolve(result); + } catch (error) { + reject(error); + } finally { + releaseWrite(); + } + } + + async function startNextMaintenance(): Promise { + if (maintenanceRunning || activeWriteCount !== 0 || queuedMaintenances.length === 0) { + return; + } + + maintenanceRunning = true; + const nextMaintenance = queuedMaintenances[0]; + queuedMaintenances = queuedMaintenances.toSpliced(0, 1); + + try { + await nextMaintenance.operation(); + } catch { + // The queued operation rejects the public maintenance promise itself. + } finally { + finishMaintenance(); + } + } + + function runWrite(operation: () => Promise): Promise { + return new Promise((resolve, reject) => { + const startCurrentWrite = (): void => { + dependencies.fireAndForget(() => startWrite(operation, resolve, reject)); + }; + + if (maintenanceRequested === false && maintenanceRunning === false) { + startCurrentWrite(); + + return; + } + + queuedWrites = [...queuedWrites, startCurrentWrite]; + }); + } + + function runMaintenance(operation: () => Promise): Promise { + return new Promise((resolve, reject) => { + const queuedOperation = async (): Promise => { + try { + const result = await operation(); + + resolve(result); + } catch (error) { + reject(error); + } + }; + + maintenanceRequested = true; + queuedMaintenances = [...queuedMaintenances, {operation: queuedOperation}]; + dependencies.fireAndForget(startNextMaintenance); + }); + } + + return {runMaintenance, runWrite}; +} From 8872a5b31a6f94914fa9bb5e025b3a1b2f23de6e Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Wed, 26 Aug 2026 16:20:18 +0200 Subject: [PATCH 22/45] Coordinate bounded desktop log writes --- .../src/logging/boundedLogWriter.test.main.ts | 100 ++++++++++++++++-- electron/src/logging/boundedLogWriter.ts | 95 +++++++++++------ electron/src/logging/desktopLogWriter.ts | 64 +++++++++-- electron/src/logging/getLogger.ts | 6 +- 4 files changed, 216 insertions(+), 49 deletions(-) diff --git a/electron/src/logging/boundedLogWriter.test.main.ts b/electron/src/logging/boundedLogWriter.test.main.ts index a0cf91292ca..ee88a060ed5 100644 --- a/electron/src/logging/boundedLogWriter.test.main.ts +++ b/electron/src/logging/boundedLogWriter.test.main.ts @@ -23,8 +23,10 @@ import * as assert from 'assert'; import * as path from 'path'; import {BoundedLogWriter, BoundedLogWriterDependencies, createBoundedLogWriter} from './boundedLogWriter'; +import {createLogMaintenanceCoordinator} from './logMaintenance'; import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; +import {createFireAndForgetInvoker} from '../lib/fireAndForgetInvoker'; function createTestFileSystemDependencies(currentTimeMilliseconds: number): BoundedLogWriterDependencies { return { @@ -73,18 +75,29 @@ function waitMilliseconds(milliseconds: number): Promise { }); } -function ignoreLogRotation(): Promise { +function ignorePostWriteCleanup(): Promise { return Promise.resolve(); } +function createTestMaintenanceCoordinator() { + const invoker = createFireAndForgetInvoker({ + reportFailure(): void { + // The coordinator's public promises report expected operation failures. + }, + }); + + return createLogMaintenanceCoordinator({fireAndForget: invoker.fireAndForget}); +} + describe('bounded desktop log writer', () => { it( 'preserves the order of concurrent writes to one file', withTemporaryDirectory('wire-bounded-log-writer-', async (temporaryLogDirectory: string) => { const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); const boundedLogWriter = createBoundedLogWriter({ - afterRotation: ignoreLogRotation, + afterWrite: ignorePostWriteCleanup, dependencies: createTestFileSystemDependencies(1), + maintenanceCoordinator: createTestMaintenanceCoordinator(), maximumFileSizeBytes: 1024, }); @@ -116,8 +129,9 @@ describe('bounded desktop log writer', () => { }, }; const boundedLogWriter = createBoundedLogWriter({ - afterRotation: ignoreLogRotation, + afterWrite: ignorePostWriteCleanup, dependencies, + maintenanceCoordinator: createTestMaintenanceCoordinator(), maximumFileSizeBytes: 1024, }); @@ -130,16 +144,43 @@ describe('bounded desktop log writer', () => { }), ); + it( + 'appends to a pre-existing current file with a fresh writer instance', + withTemporaryDirectory('wire-bounded-log-restart-', async (temporaryLogDirectory: string) => { + const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); + const firstWriter = createBoundedLogWriter({ + afterWrite: ignorePostWriteCleanup, + dependencies: createTestFileSystemDependencies(7), + maintenanceCoordinator: createTestMaintenanceCoordinator(), + maximumFileSizeBytes: 1024, + }); + const secondWriter = createBoundedLogWriter({ + afterWrite: ignorePostWriteCleanup, + dependencies: createTestFileSystemDependencies(7), + maintenanceCoordinator: createTestMaintenanceCoordinator(), + maximumFileSizeBytes: 1024, + }); + + await firstWriter.write({logFilePath, message: 'before restart'}); + await secondWriter.write({logFilePath, message: 'after restart'}); + + const actualLogContent = await fs.readFile(logFilePath, 'utf8'); + + assert.strictEqual(actualLogContent, 'before restart\nafter restart\n'); + }), + ); + it( 'rotates before an entry exceeds the configured file size', withTemporaryDirectory('wire-bounded-log-rotation-', async (temporaryLogDirectory: string) => { const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); let rotationCount = 0; const boundedLogWriter = createBoundedLogWriter({ - async afterRotation(): Promise { + async afterWrite(): Promise { rotationCount += 1; }, dependencies: createTestFileSystemDependencies(2), + maintenanceCoordinator: createTestMaintenanceCoordinator(), maximumFileSizeBytes: 10, }); @@ -161,8 +202,9 @@ describe('bounded desktop log writer', () => { const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); const existingRotatedLogPath = `${logFilePath}.3-0.old`; const boundedLogWriter = createBoundedLogWriter({ - afterRotation: ignoreLogRotation, + afterWrite: ignorePostWriteCleanup, dependencies: createTestFileSystemDependencies(3), + maintenanceCoordinator: createTestMaintenanceCoordinator(), maximumFileSizeBytes: 10, }); @@ -182,8 +224,9 @@ describe('bounded desktop log writer', () => { withTemporaryDirectory('wire-bounded-log-large-entry-', async (temporaryLogDirectory: string) => { const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); const boundedLogWriter = createBoundedLogWriter({ - afterRotation: ignoreLogRotation, + afterWrite: ignorePostWriteCleanup, dependencies: createTestFileSystemDependencies(4), + maintenanceCoordinator: createTestMaintenanceCoordinator(), maximumFileSizeBytes: 5, }); @@ -197,4 +240,49 @@ describe('bounded desktop log writer', () => { assert.strictEqual(actualRotatedLogContent, '123456\n'); }), ); + + it( + 'runs post-write cleanup after an oversized entry', + withTemporaryDirectory('wire-bounded-log-large-entry-cleanup-', async (temporaryLogDirectory: string) => { + const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); + let cleanupCount = 0; + const boundedLogWriter = createBoundedLogWriter({ + async afterWrite(): Promise { + cleanupCount += 1; + }, + dependencies: createTestFileSystemDependencies(5), + maintenanceCoordinator: createTestMaintenanceCoordinator(), + maximumFileSizeBytes: 5, + }); + + await boundedLogWriter.write({logFilePath, message: '123456'}); + + assert.strictEqual(cleanupCount, 1); + }), + ); + + it( + 'does not deadlock when rotation cleanup requests maintenance', + withTemporaryDirectory('wire-bounded-log-rotation-cleanup-', async (temporaryLogDirectory: string) => { + const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); + const maintenanceCoordinator = createTestMaintenanceCoordinator(); + let cleanupCount = 0; + const boundedLogWriter = createBoundedLogWriter({ + async afterWrite(): Promise { + cleanupCount += 1; + await maintenanceCoordinator.runMaintenance(async (): Promise => { + // The test only verifies that the maintenance request completes. + }); + }, + dependencies: createTestFileSystemDependencies(6), + maintenanceCoordinator, + maximumFileSizeBytes: 10, + }); + + await boundedLogWriter.write({logFilePath, message: '12345'}); + await boundedLogWriter.write({logFilePath, message: '6789'}); + + assert.strictEqual(cleanupCount, 1); + }), + ); }); diff --git a/electron/src/logging/boundedLogWriter.ts b/electron/src/logging/boundedLogWriter.ts index 762f1a2bdf6..036e1b7c96a 100644 --- a/electron/src/logging/boundedLogWriter.ts +++ b/electron/src/logging/boundedLogWriter.ts @@ -22,6 +22,8 @@ import {Maybe} from 'true-myth'; import * as os from 'os'; import * as path from 'path'; +import {LogMaintenanceCoordinator} from './logMaintenance'; + export type BoundedLogWriterDependencies = { appendFile: (filePath: string, content: string) => Promise; ensureDirectory: (directoryPath: string) => Promise; @@ -32,8 +34,9 @@ export type BoundedLogWriterDependencies = { }; export type CreateBoundedLogWriterParameters = { - afterRotation: (parameters: LogRotationNotificationParameters) => Promise; + afterWrite: () => Promise; dependencies: BoundedLogWriterDependencies; + maintenanceCoordinator: LogMaintenanceCoordinator; maximumFileSizeBytes: number; }; @@ -42,13 +45,9 @@ export type WriteLogMessageParameters = { message: string; }; -export type LogRotationNotificationParameters = { - activeFilePaths: ReadonlySet; - logFilePath: string; -}; - export type BoundedLogWriter = { getActiveFilePaths: () => ReadonlySet; + runMaintenance(operation: () => Promise): Promise; write: (parameters: WriteLogMessageParameters) => Promise; }; @@ -69,13 +68,15 @@ type RotateLogFileParameters = { }; type WriteLogEntryParameters = { - afterRotation: (parameters: LogRotationNotificationParameters) => Promise; dependencies: BoundedLogWriterDependencies; - getActiveFilePaths: () => ReadonlySet; maximumFileSizeBytes: number; writeParameters: WriteLogMessageParameters; }; +type WriteLogEntryResult = { + requiresPostWriteCleanup: boolean; +}; + function isExistingFileError(error: unknown): boolean { return error instanceof Error && 'code' in error && (error.code === 'EEXIST' || error.code === 'ENOTEMPTY'); } @@ -115,7 +116,7 @@ async function rotateLogFile(parameters: RotateLogFileParameters): Promise } } -async function writeLogEntry(parameters: WriteLogEntryParameters): Promise { +async function writeLogEntry(parameters: WriteLogEntryParameters): Promise { const logEntry = `${parameters.writeParameters.message}${os.EOL}`; const logEntrySizeBytes = Buffer.byteLength(logEntry); @@ -124,6 +125,7 @@ async function writeLogEntry(parameters: WriteLogEntryParameters): Promise const currentFileSizeBytes = await parameters.dependencies.getFileSize(parameters.writeParameters.logFilePath); const wouldExceedMaximumFileSize = currentFileSizeBytes > 0 && currentFileSizeBytes + logEntrySizeBytes > parameters.maximumFileSizeBytes; + let didRotate = false; if (wouldExceedMaximumFileSize) { await rotateLogFile({ @@ -132,13 +134,12 @@ async function writeLogEntry(parameters: WriteLogEntryParameters): Promise dependencies: parameters.dependencies, logFilePath: parameters.writeParameters.logFilePath, }); - await parameters.afterRotation({ - activeFilePaths: parameters.getActiveFilePaths(), - logFilePath: parameters.writeParameters.logFilePath, - }); + didRotate = true; } await parameters.dependencies.appendFile(parameters.writeParameters.logFilePath, logEntry); + + return {requiresPostWriteCleanup: didRotate || logEntrySizeBytes > parameters.maximumFileSizeBytes}; } function removePendingWrite(pendingWrites: PendingWrites, logFilePath: string, pendingWrite: Promise): void { @@ -147,34 +148,60 @@ function removePendingWrite(pendingWrites: PendingWrites, logFilePath: string, p } } -function ignorePreviousWriteFailure(): void {} +type RunQueuedWriteParameters = { + afterWrite: () => Promise; + maintenanceCoordinator: LogMaintenanceCoordinator; + maximumFileSizeBytes: number; + previousWrite: Promise; + writeParameters: WriteLogMessageParameters; + dependencies: BoundedLogWriterDependencies; +}; + +async function runQueuedWrite(parameters: RunQueuedWriteParameters): Promise { + try { + await parameters.previousWrite; + } catch { + // A failed previous write must not prevent the next queued write. + } + + const writeResult = await parameters.maintenanceCoordinator.runWrite(() => { + return writeLogEntry({ + dependencies: parameters.dependencies, + maximumFileSizeBytes: parameters.maximumFileSizeBytes, + writeParameters: parameters.writeParameters, + }); + }); + + if (writeResult.requiresPostWriteCleanup) { + await parameters.afterWrite(); + } +} export function createBoundedLogWriter(parameters: CreateBoundedLogWriterParameters): BoundedLogWriter { const pendingWrites: PendingWrites = new Map(); function writeLogMessage(writeParameters: WriteLogMessageParameters): Promise { const previousWrite = Maybe.of(pendingWrites.get(writeParameters.logFilePath)).unwrapOr(Promise.resolve()); - const writeOperation = previousWrite.catch(ignorePreviousWriteFailure).then(() => { - return writeLogEntry({ - afterRotation: parameters.afterRotation, - dependencies: parameters.dependencies, - getActiveFilePaths, - maximumFileSizeBytes: parameters.maximumFileSizeBytes, - writeParameters, - }); + const writeOperation = runQueuedWrite({ + afterWrite: parameters.afterWrite, + dependencies: parameters.dependencies, + maintenanceCoordinator: parameters.maintenanceCoordinator, + maximumFileSizeBytes: parameters.maximumFileSizeBytes, + previousWrite, + writeParameters, }); - let pendingWrite = Promise.resolve(); - pendingWrite = writeOperation.then( - () => { - return removePendingWrite(pendingWrites, writeParameters.logFilePath, pendingWrite); - }, - error => { + let pendingWrite: Promise = Promise.resolve(); + + async function removePendingWriteAfterCompletion(): Promise { + try { + await writeOperation; + } finally { removePendingWrite(pendingWrites, writeParameters.logFilePath, pendingWrite); + } + } - throw error; - }, - ); + pendingWrite = removePendingWriteAfterCompletion(); pendingWrites.set(writeParameters.logFilePath, pendingWrite); return pendingWrite; @@ -184,5 +211,9 @@ export function createBoundedLogWriter(parameters: CreateBoundedLogWriterParamet return new Set(pendingWrites.keys()); } - return {getActiveFilePaths, write: writeLogMessage}; + return { + getActiveFilePaths, + runMaintenance: parameters.maintenanceCoordinator.runMaintenance, + write: writeLogMessage, + }; } diff --git a/electron/src/logging/desktopLogWriter.ts b/electron/src/logging/desktopLogWriter.ts index b257fca2d17..34f1ecd0a0e 100644 --- a/electron/src/logging/desktopLogWriter.ts +++ b/electron/src/logging/desktopLogWriter.ts @@ -18,17 +18,20 @@ */ import * as fs from 'fs-extra'; +import {Maybe} from 'true-myth'; import { BoundedLogWriter, BoundedLogWriterDependencies, createBoundedLogWriter, - LogRotationNotificationParameters, WriteLogMessageParameters, } from './boundedLogWriter'; import {cleanupDesktopLogs, DESKTOP_LOG_RETENTION_POLICY} from './logCleanup'; +import {createLogMaintenanceCoordinator, LogMaintenanceCoordinator} from './logMaintenance'; import {getLogDirectory} from './logPaths'; +import {createFireAndForgetInvoker} from '../lib/fireAndForgetInvoker'; + function isMissingFileError(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT'; } @@ -66,22 +69,65 @@ function createFileSystemDependencies(): BoundedLogWriterDependencies { }; } +const desktopLogFireAndForgetInvoker = createFireAndForgetInvoker({ + reportFailure(error: unknown): void { + console.error('Failed to execute a desktop log background operation.', error); + }, +}); + +const desktopLogMaintenanceCoordinator: LogMaintenanceCoordinator = createLogMaintenanceCoordinator({ + fireAndForget: desktopLogFireAndForgetInvoker.fireAndForget, +}); + const desktopBoundedLogWriter: BoundedLogWriter = createBoundedLogWriter({ - async afterRotation(rotationParameters: LogRotationNotificationParameters): Promise { - await cleanupDesktopLogs({ - activeFilePaths: rotationParameters.activeFilePaths, - logDirectory: getLogDirectory(), - policy: DESKTOP_LOG_RETENTION_POLICY, - }); + async afterWrite(): Promise { + await runDesktopLogCleanup(); }, dependencies: createFileSystemDependencies(), + maintenanceCoordinator: desktopLogMaintenanceCoordinator, maximumFileSizeBytes: 10 * 1024 * 1024, }); +let currentDesktopLogCleanup: Maybe> = Maybe.nothing>(); + +export function runDesktopLogCleanupWithinMaintenance(): Promise { + return cleanupDesktopLogs({ + activeFilePaths: desktopBoundedLogWriter.getActiveFilePaths(), + logDirectory: getLogDirectory(), + policy: DESKTOP_LOG_RETENTION_POLICY, + }); +} + +async function resetCleanupAfterCompletion(cleanupPromise: Promise): Promise { + try { + await cleanupPromise; + } finally { + currentDesktopLogCleanup = Maybe.nothing>(); + } +} + +export async function runDesktopLogCleanup(): Promise { + if (currentDesktopLogCleanup.isJust) { + await currentDesktopLogCleanup.value; + + return; + } + + const cleanupPromise = desktopLogMaintenanceCoordinator.runMaintenance(runDesktopLogCleanupWithinMaintenance); + const cleanupPromiseWithReset = resetCleanupAfterCompletion(cleanupPromise); + currentDesktopLogCleanup = Maybe.just(cleanupPromiseWithReset); + + await cleanupPromiseWithReset; +} + +export function runDesktopLogMaintenance(operation: () => Promise): Promise { + return desktopLogMaintenanceCoordinator.runMaintenance(operation); +} + export function writeBoundedLogMessage(parameters: WriteLogMessageParameters): Promise { return desktopBoundedLogWriter.write(parameters); } -export function getActiveLogFilePaths(): ReadonlySet { - return desktopBoundedLogWriter.getActiveFilePaths(); +export function fireAndForgetDesktopLogOperation(asyncAction: () => Promise): void { + desktopLogFireAndForgetInvoker.fireAndForget(asyncAction); } diff --git a/electron/src/logging/getLogger.ts b/electron/src/logging/getLogger.ts index 5d97666bc15..2aa957489f4 100644 --- a/electron/src/logging/getLogger.ts +++ b/electron/src/logging/getLogger.ts @@ -21,7 +21,7 @@ import * as logdown from 'logdown'; import {LogFactory, LoggerOptions} from '@wireapp/commons'; -import {writeBoundedLogMessage} from './desktopLogWriter'; +import {fireAndForgetDesktopLogOperation, writeBoundedLogMessage} from './desktopLogWriter'; import {getLogDirectory, getMainProcessLogPath} from './logPaths'; import {config} from '../settings/config'; @@ -37,7 +37,9 @@ function writeMainProcessLog(transportOptions: logdown.TransportOptions): void { const logFilePath = getMainProcessLogPath({date: new Date(), logDirectory: getLogDirectory()}); const logMessage = `${transportOptions.args[0]} ${transportOptions.msg}`; - void writeBoundedLogMessage({logFilePath, message: logMessage}); + fireAndForgetDesktopLogOperation(async (): Promise => { + await writeBoundedLogMessage({logFilePath, message: logMessage}); + }); } function configureLogTransports(): void { From 302d9274d217c5aa7e943bf939a3df76434c759a Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Wed, 26 Aug 2026 16:20:35 +0200 Subject: [PATCH 23/45] Coordinate desktop log export --- electron/src/logging/logExport.test.main.ts | 96 +++++++++++++++++++++ electron/src/logging/logExport.ts | 50 +++++++++++ electron/src/logging/loggerUtils.ts | 34 +++----- 3 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 electron/src/logging/logExport.test.main.ts create mode 100644 electron/src/logging/logExport.ts diff --git a/electron/src/logging/logExport.test.main.ts b/electron/src/logging/logExport.test.main.ts new file mode 100644 index 00000000000..071afad4a12 --- /dev/null +++ b/electron/src/logging/logExport.test.main.ts @@ -0,0 +1,96 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 fs from 'fs-extra'; + +import * as assert from 'assert'; +import * as path from 'path'; + +import {gatherLogFiles} from './logExport'; +import {createLogMaintenanceCoordinator} from './logMaintenance'; + +import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; +import {createFireAndForgetInvoker} from '../lib/fireAndForgetInvoker'; + +function createDeferredCompletion(): {promise: Promise; resolve: () => void} { + let resolvePromise: () => void = () => { + // The resolver is replaced by the Promise constructor. + }; + const promise = new Promise(resolve => { + resolvePromise = resolve; + }); + + return {promise, resolve: resolvePromise}; +} + +function createTestMaintenanceCoordinator() { + const invoker = createFireAndForgetInvoker({ + reportFailure(): void { + // The coordinator's public promises report expected operation failures. + }, + }); + + return createLogMaintenanceCoordinator({fireAndForget: invoker.fireAndForget}); +} + +describe('desktop log export', () => { + it( + 'waits for pending writes before cleanup and gathering files', + withTemporaryDirectory('wire-log-export-', async (temporaryLogDirectory: string) => { + const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); + const writeCompletion = createDeferredCompletion(); + const maintenanceCoordinator = createTestMaintenanceCoordinator(); + const writePromise = maintenanceCoordinator.runWrite(async (): Promise => { + await writeCompletion.promise; + await fs.outputFile(logFilePath, 'complete entry\n'); + }); + const events: string[] = []; + const exportPromise = gatherLogFiles({ + async cleanup(): Promise { + events.push('cleanup'); + }, + discoverLogFilePaths(): readonly string[] { + events.push('discover'); + + return ['electron.log']; + }, + logDirectory: temporaryLogDirectory, + async readFile(filePath: string): Promise { + events.push('read'); + + return fs.readFile(filePath); + }, + reportReadFailure(): void { + events.push('read-failed'); + }, + runMaintenance: maintenanceCoordinator.runMaintenance, + }); + + await Promise.resolve(); + assert.deepStrictEqual(events, []); + + writeCompletion.resolve(); + await writePromise; + const actualLogFiles = await exportPromise; + + assert.deepStrictEqual(events, ['cleanup', 'discover', 'read']); + assert.strictEqual(Buffer.from(actualLogFiles['electron.log']).toString(), 'complete entry\n'); + }), + ); +}); diff --git a/electron/src/logging/logExport.ts b/electron/src/logging/logExport.ts new file mode 100644 index 00000000000..829b969ae89 --- /dev/null +++ b/electron/src/logging/logExport.ts @@ -0,0 +1,50 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 path from 'path'; + +export type GatherLogFilesParameters = { + cleanup: () => Promise; + discoverLogFilePaths: () => readonly string[]; + logDirectory: string; + readFile: (filePath: string) => Promise; + reportReadFailure: (filePath: string, error: unknown) => void; + runMaintenance(operation: () => Promise): Promise; +}; + +export async function gatherLogFiles(parameters: GatherLogFilesParameters): Promise> { + return parameters.runMaintenance(async () => { + await parameters.cleanup(); + + const logFiles: Record = {}; + const relativeFilePaths = parameters.discoverLogFilePaths(); + + for (const relativeFilePath of relativeFilePaths) { + const resolvedPath = path.join(parameters.logDirectory, relativeFilePath); + + try { + logFiles[relativeFilePath] = await parameters.readFile(resolvedPath); + } catch (error) { + parameters.reportReadFailure(relativeFilePath, error); + } + } + + return logFiles; + }); +} diff --git a/electron/src/logging/loggerUtils.ts b/electron/src/logging/loggerUtils.ts index 8335cd78220..7d9e91cebe0 100644 --- a/electron/src/logging/loggerUtils.ts +++ b/electron/src/logging/loggerUtils.ts @@ -21,9 +21,9 @@ import * as fs from 'fs-extra'; import * as path from 'path'; -import {getActiveLogFilePaths} from './desktopLogWriter'; +import {runDesktopLogCleanupWithinMaintenance, runDesktopLogMaintenance} from './desktopLogWriter'; import {getLogger} from './getLogger'; -import {cleanupDesktopLogs, DESKTOP_LOG_RETENTION_POLICY} from './logCleanup'; +import {gatherLogFiles} from './logExport'; import {getLogFilenames as getLogFilenamesFromRoot, LogFileDiscoveryOptions} from './logFiles'; import {getLogDirectory} from './logPaths'; @@ -34,26 +34,20 @@ export function getLogFilenames(parameters: LogFileDiscoveryOptions): string[] { } export async function gatherLogs(): Promise> { - const logFiles: Record = {}; const logDirectory = getLogDirectory(); - await cleanupDesktopLogs({ - activeFilePaths: getActiveLogFilePaths(), + return gatherLogFiles({ + cleanup: runDesktopLogCleanupWithinMaintenance, + discoverLogFilePaths: (): readonly string[] => { + return getLogFilenames({absolute: false, baseDirectory: logDirectory}); + }, logDirectory, - policy: DESKTOP_LOG_RETENTION_POLICY, - }); - - const relativeFilePaths = getLogFilenames({absolute: false, baseDirectory: logDirectory}); - - for (const relativeFilePath of relativeFilePaths) { - const resolvedPath = path.join(logDirectory, relativeFilePath); - try { - const fileContent = await fs.readFile(resolvedPath); - logFiles[relativeFilePath] = fileContent; - } catch (error) { + readFile: (filePath: string): Promise => { + return fs.readFile(filePath); + }, + reportReadFailure: (_filePath: string, error: unknown): void => { logger.error(error); - } - } - - return logFiles; + }, + runMaintenance: runDesktopLogMaintenance, + }); } From 9244eff4a5a131cd7787530fc1e7a1d67f3a0917 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Wed, 26 Aug 2026 16:20:57 +0200 Subject: [PATCH 24/45] Sequence desktop log startup maintenance --- .../logging/logCleanupScheduler.test.main.ts | 8 ++ electron/src/logging/logCleanupScheduler.ts | 3 +- electron/src/logging/logStartup.test.main.ts | 93 +++++++++++++++++++ electron/src/logging/logStartup.ts | 38 ++++++++ electron/src/mainProcess.ts | 48 ++++++---- 5 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 electron/src/logging/logStartup.test.main.ts create mode 100644 electron/src/logging/logStartup.ts diff --git a/electron/src/logging/logCleanupScheduler.test.main.ts b/electron/src/logging/logCleanupScheduler.test.main.ts index dfe5db6a9d7..cd8093f336f 100644 --- a/electron/src/logging/logCleanupScheduler.test.main.ts +++ b/electron/src/logging/logCleanupScheduler.test.main.ts @@ -21,6 +21,8 @@ import * as assert from 'assert'; import {scheduleLogCleanup, UnrefableInterval} from './logCleanupScheduler'; +import {createFireAndForgetInvoker} from '../lib/fireAndForgetInvoker'; + describe('desktop log cleanup scheduler', () => { it('schedules an unrefed hourly cleanup interval', async () => { let scheduledCallback: () => void = () => {}; @@ -32,8 +34,14 @@ describe('desktop log cleanup scheduler', () => { wasUnrefed = true; }, }; + const invoker = createFireAndForgetInvoker({ + reportFailure(error: unknown): void { + throw error; + }, + }); scheduleLogCleanup({ + fireAndForget: invoker.fireAndForget, intervalMilliseconds: 60_000, async runCleanup(): Promise { cleanupRunCount += 1; diff --git a/electron/src/logging/logCleanupScheduler.ts b/electron/src/logging/logCleanupScheduler.ts index 923c0cdb02c..fc193fb8379 100644 --- a/electron/src/logging/logCleanupScheduler.ts +++ b/electron/src/logging/logCleanupScheduler.ts @@ -22,6 +22,7 @@ export type UnrefableInterval = { }; export type ScheduleLogCleanupParameters = { + fireAndForget: (asyncAction: () => Promise) => void; intervalMilliseconds: number; runCleanup: () => Promise; setInterval: (callback: () => void, intervalMilliseconds: number) => UnrefableInterval; @@ -29,7 +30,7 @@ export type ScheduleLogCleanupParameters = { export function scheduleLogCleanup(parameters: ScheduleLogCleanupParameters): void { const cleanupInterval = parameters.setInterval(() => { - void parameters.runCleanup(); + parameters.fireAndForget(parameters.runCleanup); }, parameters.intervalMilliseconds); cleanupInterval.unref(); diff --git a/electron/src/logging/logStartup.test.main.ts b/electron/src/logging/logStartup.test.main.ts new file mode 100644 index 00000000000..3cc455338ec --- /dev/null +++ b/electron/src/logging/logStartup.test.main.ts @@ -0,0 +1,93 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {initializeDesktopLogLifecycle} from './logStartup'; + +function createDeferredCompletion(): {promise: Promise; resolve: () => void} { + let resolvePromise: () => void = () => { + // The resolver is replaced by the Promise constructor. + }; + const promise = new Promise(resolve => { + resolvePromise = resolve; + }); + + return {promise, resolve: resolvePromise}; +} + +describe('desktop log startup', () => { + it('completes cleanup before scheduling and initializing webview logging', async () => { + const events: string[] = []; + const cleanupCompletion = createDeferredCompletion(); + const startup = initializeDesktopLogLifecycle({ + async initializeWebviewLogging(): Promise { + events.push('initialize-webview-logging'); + }, + reportCleanupFailure(): void { + events.push('cleanup-failed'); + }, + async runInitialCleanup(): Promise { + events.push('cleanup-started'); + await cleanupCompletion.promise; + events.push('cleanup-completed'); + }, + schedulePeriodicCleanup(): void { + events.push('schedule-cleanup'); + }, + }); + + await Promise.resolve(); + + assert.deepStrictEqual(events, ['cleanup-started']); + + cleanupCompletion.resolve(); + await startup; + + assert.deepStrictEqual(events, [ + 'cleanup-started', + 'cleanup-completed', + 'schedule-cleanup', + 'initialize-webview-logging', + ]); + }); + + it('reports cleanup failure and continues startup', async () => { + const events: string[] = []; + const cleanupFailure = new Error('cleanup failed'); + + await initializeDesktopLogLifecycle({ + async initializeWebviewLogging(): Promise { + events.push('initialize-webview-logging'); + }, + reportCleanupFailure(error: unknown): void { + assert.strictEqual(error, cleanupFailure); + events.push('cleanup-failed'); + }, + async runInitialCleanup(): Promise { + throw cleanupFailure; + }, + schedulePeriodicCleanup(): void { + events.push('schedule-cleanup'); + }, + }); + + assert.deepStrictEqual(events, ['cleanup-failed', 'schedule-cleanup', 'initialize-webview-logging']); + }); +}); diff --git a/electron/src/logging/logStartup.ts b/electron/src/logging/logStartup.ts new file mode 100644 index 00000000000..ba1429eb1e5 --- /dev/null +++ b/electron/src/logging/logStartup.ts @@ -0,0 +1,38 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export type InitializeDesktopLogLifecycleParameters = { + initializeWebviewLogging: () => Promise; + reportCleanupFailure: (error: unknown) => void; + runInitialCleanup: () => Promise; + schedulePeriodicCleanup: () => void; +}; + +export async function initializeDesktopLogLifecycle( + parameters: InitializeDesktopLogLifecycleParameters, +): Promise { + try { + await parameters.runInitialCleanup(); + } catch (error) { + parameters.reportCleanupFailure(error); + } + + parameters.schedulePeriodicCleanup(); + await parameters.initializeWebviewLogging(); +} diff --git a/electron/src/mainProcess.ts b/electron/src/mainProcess.ts index e0138888816..6effe11222d 100644 --- a/electron/src/mainProcess.ts +++ b/electron/src/mainProcess.ts @@ -53,16 +53,17 @@ import { import {CustomProtocolHandler} from './lib/CoreProtocol'; import {downloadImage} from './lib/download'; import {EVENT_TYPE} from './lib/eventType'; +import {createFireAndForgetInvoker} from './lib/fireAndForgetInvoker'; import {forwardWrapperReloadRequest} from './lib/forwardWrapperReloadRequest'; import {deleteAccount} from './lib/LocalAccountDeletion'; import {getOpenGraphDataAsync} from './lib/openGraph'; import {showErrorDialog} from './lib/showDialog'; import * as locale from './locale'; -import {getActiveLogFilePaths, writeBoundedLogMessage} from './logging/desktopLogWriter'; +import {runDesktopLogCleanup, writeBoundedLogMessage} from './logging/desktopLogWriter'; import {ENABLE_LOGGING, getLogger} from './logging/getLogger'; -import {cleanupDesktopLogs, DESKTOP_LOG_RETENTION_POLICY} from './logging/logCleanup'; import {scheduleLogCleanup} from './logging/logCleanupScheduler'; import {getLogDirectory, getMainProcessLogPath, getWebViewLogPath} from './logging/logPaths'; +import {initializeDesktopLogLifecycle} from './logging/logStartup'; import {getManagedConfig} from './managed/ManagedConfig'; import {developerMenu, openDevTools} from './menu/developer'; import * as systemMenu from './menu/system'; @@ -84,16 +85,13 @@ import * as WindowUtil from './window/WindowUtil'; const MAIN_PROCESS_LOGGER_NAME = 'main.js'; const LOG_CLEANUP_INTERVAL_MILLISECONDS = 60 * 60 * 1_000; const logger = getLogger(MAIN_PROCESS_LOGGER_NAME); +const mainProcessFireAndForgetInvoker = createFireAndForgetInvoker({ + reportFailure(error: unknown): void { + logger.error('Failed to execute a main-process background operation.', error); + }, +}); const configuredUserDataPath = getConfiguredPortableUserDataPath(); -function runDesktopLogCleanup(): Promise { - return cleanupDesktopLogs({ - activeFilePaths: getActiveLogFilePaths(), - logDirectory: getLogDirectory(), - policy: DESKTOP_LOG_RETENTION_POLICY, - }); -} - type OpenLinkInNewWindowParameters = { accountId: Maybe; browserWindow: BrowserWindow; @@ -632,7 +630,7 @@ class ElectronWrapperInit { } this.logger.log('Opening an external window from a webview.'); - void WindowUtil.openExternal(details.url); + mainProcessFireAndForgetInvoker.fireAndForget(() => WindowUtil.openExternal(details.url)); return {action: 'deny'}; }; @@ -809,13 +807,25 @@ if (lifecycle.isFirstInstance) { bindIpcEvents(); handleAppEvents(); fs.ensureFileSync(getMainProcessLogPath({date: new Date(), logDirectory: getLogDirectory()})); - void runDesktopLogCleanup(); - scheduleLogCleanup({ - intervalMilliseconds: LOG_CLEANUP_INTERVAL_MILLISECONDS, - runCleanup: runDesktopLogCleanup, - setInterval: (callback: () => void, intervalMilliseconds: number): NodeJS.Timeout => { - return setInterval(callback, intervalMilliseconds); - }, + mainProcessFireAndForgetInvoker.fireAndForget(async (): Promise => { + await initializeDesktopLogLifecycle({ + async initializeWebviewLogging(): Promise { + await new ElectronWrapperInit().run(); + }, + reportCleanupFailure(error: unknown): void { + logger.error('Failed to complete initial desktop log cleanup.', error); + }, + runInitialCleanup: runDesktopLogCleanup, + schedulePeriodicCleanup(): void { + scheduleLogCleanup({ + fireAndForget: mainProcessFireAndForgetInvoker.fireAndForget, + intervalMilliseconds: LOG_CLEANUP_INTERVAL_MILLISECONDS, + runCleanup: runDesktopLogCleanup, + setInterval(callback: () => void, intervalMilliseconds: number): NodeJS.Timeout { + return setInterval(callback, intervalMilliseconds); + }, + }); + }, + }); }); - new ElectronWrapperInit().run().catch(error => logger.error(error)); } From 9f0ecab75440a6699c6dcc513504c351f778a334 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Wed, 26 Aug 2026 16:32:04 +0200 Subject: [PATCH 25/45] Remove stale active-file tracking from log cleanup --- .../src/logging/boundedLogWriter.test.main.ts | 62 +++++++++++++++++++ electron/src/logging/boundedLogWriter.ts | 6 -- electron/src/logging/desktopLogWriter.ts | 2 +- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/electron/src/logging/boundedLogWriter.test.main.ts b/electron/src/logging/boundedLogWriter.test.main.ts index ee88a060ed5..e1d6388a3e4 100644 --- a/electron/src/logging/boundedLogWriter.test.main.ts +++ b/electron/src/logging/boundedLogWriter.test.main.ts @@ -23,7 +23,9 @@ import * as assert from 'assert'; import * as path from 'path'; import {BoundedLogWriter, BoundedLogWriterDependencies, createBoundedLogWriter} from './boundedLogWriter'; +import {createLogCleanup, createLogCleanupFileSystemDependencies} from './logCleanup'; import {createLogMaintenanceCoordinator} from './logMaintenance'; +import {LogRetentionPolicy} from './logRetention'; import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; import {createFireAndForgetInvoker} from '../lib/fireAndForgetInvoker'; @@ -261,6 +263,66 @@ describe('bounded desktop log writer', () => { }), ); + it( + 'removes an oversized entry during post-write cleanup', + withTemporaryDirectory('wire-bounded-log-large-entry-retention-', async (temporaryLogDirectory: string) => { + const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); + const maintenanceCoordinator = createTestMaintenanceCoordinator(); + const retentionPolicy: LogRetentionPolicy = { + maximumAgeMilliseconds: 7 * 24 * 60 * 60 * 1_000, + maximumTotalSizeBytes: 10, + }; + const cleanupFailureMessages: string[] = []; + const cleanup = createLogCleanup( + createLogCleanupFileSystemDependencies((message: string): void => { + cleanupFailureMessages.push(message); + }), + ); + let isWriteCriticalSectionActive = false; + let writtenLogSizeBytes = 0; + let cleanupCount = 0; + let cleanupStartedAfterWriteCriticalSection = false; + const baseDependencies = createTestFileSystemDependencies(9); + const dependencies: BoundedLogWriterDependencies = { + ...baseDependencies, + async appendFile(filePath: string, content: string): Promise { + isWriteCriticalSectionActive = true; + + try { + await fs.appendFile(filePath, content); + writtenLogSizeBytes = Buffer.byteLength(content); + } finally { + isWriteCriticalSectionActive = false; + } + }, + }; + const boundedLogWriter = createBoundedLogWriter({ + async afterWrite(): Promise { + cleanupCount += 1; + await maintenanceCoordinator.runMaintenance(async (): Promise => { + cleanupStartedAfterWriteCriticalSection = isWriteCriticalSectionActive === false; + await cleanup.run({ + activeFilePaths: new Set(), + logDirectory: temporaryLogDirectory, + policy: retentionPolicy, + }); + }); + }, + dependencies, + maintenanceCoordinator, + maximumFileSizeBytes: 5, + }); + + await boundedLogWriter.write({logFilePath, message: '1234567890'}); + + assert.strictEqual(writtenLogSizeBytes, 11); + assert.strictEqual(cleanupCount, 1); + assert.strictEqual(cleanupStartedAfterWriteCriticalSection, true); + assert.strictEqual(cleanupFailureMessages.length, 0); + assert.strictEqual(await fs.pathExists(logFilePath), false); + }), + ); + it( 'does not deadlock when rotation cleanup requests maintenance', withTemporaryDirectory('wire-bounded-log-rotation-cleanup-', async (temporaryLogDirectory: string) => { diff --git a/electron/src/logging/boundedLogWriter.ts b/electron/src/logging/boundedLogWriter.ts index 036e1b7c96a..bcaf7b3d800 100644 --- a/electron/src/logging/boundedLogWriter.ts +++ b/electron/src/logging/boundedLogWriter.ts @@ -46,7 +46,6 @@ export type WriteLogMessageParameters = { }; export type BoundedLogWriter = { - getActiveFilePaths: () => ReadonlySet; runMaintenance(operation: () => Promise): Promise; write: (parameters: WriteLogMessageParameters) => Promise; }; @@ -207,12 +206,7 @@ export function createBoundedLogWriter(parameters: CreateBoundedLogWriterParamet return pendingWrite; } - function getActiveFilePaths(): ReadonlySet { - return new Set(pendingWrites.keys()); - } - return { - getActiveFilePaths, runMaintenance: parameters.maintenanceCoordinator.runMaintenance, write: writeLogMessage, }; diff --git a/electron/src/logging/desktopLogWriter.ts b/electron/src/logging/desktopLogWriter.ts index 34f1ecd0a0e..c6e9cb37b1b 100644 --- a/electron/src/logging/desktopLogWriter.ts +++ b/electron/src/logging/desktopLogWriter.ts @@ -92,7 +92,7 @@ let currentDesktopLogCleanup: Maybe> = Maybe.nothing export function runDesktopLogCleanupWithinMaintenance(): Promise { return cleanupDesktopLogs({ - activeFilePaths: desktopBoundedLogWriter.getActiveFilePaths(), + activeFilePaths: new Set(), logDirectory: getLogDirectory(), policy: DESKTOP_LOG_RETENTION_POLICY, }); From 8e4aa52ba8f10f26e97df50862ff02117216faad Mon Sep 17 00:00:00 2001 From: Adam Low Date: Thu, 27 Aug 2026 11:02:03 +0100 Subject: [PATCH 26/45] fix(windows): detect supported OS from registry --- bin/build-tools/lib/build-windows-msi.test.ts | 23 +++++++++++++++++-- bin/build-tools/lib/build-windows-msi.ts | 7 +++++- docs/windows-msi.md | 2 ++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/bin/build-tools/lib/build-windows-msi.test.ts b/bin/build-tools/lib/build-windows-msi.test.ts index a75cf406ddb..86b742321bc 100644 --- a/bin/build-tools/lib/build-windows-msi.test.ts +++ b/bin/build-tools/lib/build-windows-msi.test.ts @@ -110,6 +110,27 @@ describe('build-windows-msi', () => { }); describe('customizeMsiProject', () => { + it('regression: detects Windows 10 and later from the actual registry build number', () => { + const project = ` + = 601]]> + + + + + + `; + + const result = customizeMsiProject(project, 'wire', 'com.squirrel.wire.wire', 'Wire', 'msi-banner.bmp'); + + assert.match(result, /Property Id="WIRE_WINDOWS_BUILD" Secure="yes"/); + assert.match( + result, + /RegistrySearch Id="WireWindowsBuild" Root="HKLM" Key="SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" Name="CurrentBuildNumber" Type="raw" Win64="yes"/, + ); + assert.match(result, /Installed OR WIRE_WINDOWS_BUILD >= 10240/); + assert.doesNotMatch(result, /VersionNT >= 603 AND WindowsBuild >= 10240/); + }); + it('brands the assisted UI and makes the URL protocol and desktop shortcut identity MSI-owned', () => { const project = ` @@ -125,8 +146,6 @@ describe('build-windows-msi', () => { assert.match(result, /WixVariable Id="WixUIBannerBmp" Value="msi-banner\.bmp"/); assert.match(result, /Windows 10 or above is required/); - assert.match(result, /VersionNT >= 603 AND WindowsBuild >= 10240/); - assert.doesNotMatch(result, /VersionNT >= 1000/); assert.match(result, /ShortcutProperty Key="System\.AppUserModel\.ID" Value="com\.squirrel\.wire\.wire"/); assert.match(result, /RegistryKey Root="HKLM" Key="Software\\Classes\\wire"/); assert.match(result, /Value=""\[#mainExecutable\]" "%1""/); diff --git a/bin/build-tools/lib/build-windows-msi.ts b/bin/build-tools/lib/build-windows-msi.ts index 55dffd5e7f9..b6f4c84304c 100644 --- a/bin/build-tools/lib/build-windows-msi.ts +++ b/bin/build-tools/lib/build-windows-msi.ts @@ -113,6 +113,9 @@ export function customizeMsiProject( const banner = escapeXmlAttribute(bannerFileName); const msiProperties = [ ` `, + ' ', + ' ', + ' ', ' ', ' ', ' ', @@ -129,7 +132,9 @@ export function customizeMsiProject( } customizedProject = customizedProject.replace( electronBuilderOsCondition, - '= 603 AND WindowsBuild >= 10240)]]>', + // Windows Installer can expose compatibility values for its built-in OS properties on modern Windows. Read the + // actual build from the registry instead; 10240 is the first public Windows 10 build. + '= 10240]]>', ); const escapedAppId = escapeXmlAttribute(appId); diff --git a/docs/windows-msi.md b/docs/windows-msi.md index a331178857e..a1063621e0b 100644 --- a/docs/windows-msi.md +++ b/docs/windows-msi.md @@ -15,6 +15,8 @@ The Windows Jenkins job uses `yarn build:win:installers` to create the Squirrel `-m` disables electron-builder's automatic signing. Jenkins uses this mode because it signs the packaged Windows executables before creating the MSI, and signs the resulting MSI separately with the managed signing service. +The Windows 10 minimum-version check reads `CurrentBuildNumber` from the 64-bit Windows registry. Do not replace it with Windows Installer's built-in version properties, which can report compatibility values on modern Windows. + Interactive installation uses the standard assisted Windows Installer flow with a Wire-branded banner, including welcome, installation location, readiness, progress, and completion pages. The managed deployment defaults create both desktop and Start Menu shortcuts; they are not optional features in the installer UI. The standard Wire environments have permanent MSI upgrade codes in `build-windows-msi.ts`. Never change an upgrade code after its first release. A custom-branded build must set `WIN_MSI_UPGRADE_CODE` to its own permanent GUID so that it cannot collide with a standard Wire installation. The MSI manufacturer defaults to `Wire Swiss GmbH`; an OEM build can override it with `WIN_MSI_MANUFACTURER`. From f5a2ce3a3f0b7199151cf797e8631803c0be08a4 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 28 Aug 2026 09:07:28 +0200 Subject: [PATCH 27/45] Add streaming export dependencies --- .yarnrc.yml | 2 + package.json | 3 + yarn.lock | 344 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 345 insertions(+), 4 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index f0440a87036..576ef3c2a87 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,5 +1,7 @@ nodeLinker: node-modules +defaultSemverRangePrefix: "" + npmRegistryServer: 'https://registry.npmjs.org' plugins: diff --git a/package.json b/package.json index f4c1b183d3a..17fe90684de 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,14 @@ "author": "Wire Swiss ", "dependencies": { "@electron/remote": "2.1.3", + "@enormora/wall-clock": "0.0.3", "@hapi/joi": "17.1.1", "@wireapp/certificate-check": "0.7.27", "@wireapp/commons": "5.4.13", "@wireapp/protocol-messaging": "1.56.0", "@wireapp/react-ui-kit": "9.59.1", "@wireapp/webapp-events": "0.29.2", + "archiver": "8.0.0", "auto-launch": "5.0.6", "axios": "0.21.2", "content-type": "1.0.5", @@ -50,6 +52,7 @@ "@playwright/test": "1.60.0", "@types/adm-zip": "0.5.8", "@types/amplify": "1.1.28", + "@types/archiver": "8.0.0", "@types/auto-launch": "5.0.5", "@types/content-type": "1.1.9", "@types/eslint": "^8.56.7", diff --git a/yarn.lock b/yarn.lock index de57b0c4999..09c1e39aaaa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2677,6 +2677,13 @@ __metadata: languageName: node linkType: hard +"@enormora/wall-clock@npm:0.0.3": + version: 0.0.3 + resolution: "@enormora/wall-clock@npm:0.0.3" + checksum: 7563b317c6a2fa40dddfd244ff5d25095bbef246b193936a748ee70c73488e64e6f487a1342d45607e457f6150723ccdd850220b74716c99539ca8d832eecf9f + languageName: node + linkType: hard + "@es-joy/jsdoccomment@npm:~0.46.0": version: 0.46.0 resolution: "@es-joy/jsdoccomment@npm:0.46.0" @@ -4370,6 +4377,16 @@ __metadata: languageName: node linkType: hard +"@types/archiver@npm:8.0.0": + version: 8.0.0 + resolution: "@types/archiver@npm:8.0.0" + dependencies: + "@types/node": "*" + "@types/readdir-glob": "*" + checksum: e2e93b45e6ce7638bb62fca6e5d6bd9177d5b7e2b2c5509679ced6da05b6a0280ae6727f1b810c351e0b4359516a5e5104fc2c5b3c70834eb06b391271598c56 + languageName: node + linkType: hard + "@types/auto-launch@npm:5.0.5": version: 5.0.5 resolution: "@types/auto-launch@npm:5.0.5" @@ -4878,6 +4895,15 @@ __metadata: languageName: node linkType: hard +"@types/readdir-glob@npm:*": + version: 1.1.5 + resolution: "@types/readdir-glob@npm:1.1.5" + dependencies: + "@types/node": "*" + checksum: 58625586589a2cbcf19d7bff5a9b61e961b42e438e5a4f537633785efdcacfad15d3b32df3d27926696b36b43eb86eeb790fce9373fa357ab87720c64f86618b + languageName: node + linkType: hard + "@types/redux-logger@npm:^3.0.12": version: 3.0.12 resolution: "@types/redux-logger@npm:3.0.12" @@ -5935,6 +5961,15 @@ __metadata: languageName: node linkType: hard +"abort-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: ^5.0.0 + checksum: 170bdba9b47b7e65906a28c8ce4f38a7a369d78e2271706f020849c1bfe0ee2067d4261df8bbb66eb84f79208fd5b710df759d64191db58cfba7ce8ef9c54b75 + languageName: node + linkType: hard + "acorn-globals@npm:^7.0.0": version: 7.0.1 resolution: "acorn-globals@npm:7.0.1" @@ -6323,6 +6358,23 @@ __metadata: languageName: node linkType: hard +"archiver@npm:8.0.0": + version: 8.0.0 + resolution: "archiver@npm:8.0.0" + dependencies: + async: ^3.2.4 + buffer-crc32: ^1.0.0 + is-stream: ^4.0.0 + lazystream: ^1.0.0 + normalize-path: ^3.0.0 + readable-stream: ^4.0.0 + readdir-glob: ^3.0.0 + tar-stream: ^3.0.0 + zip-stream: ^7.0.2 + checksum: 88f14806d49e9a6e011ea87bc14430ecadad3edc80a3cf2f88f1226f558026f6902186306d715af72a0637de2fe48aeea5635cb801d342ddd6b0dd3d6d934850 + languageName: node + linkType: hard + "archy@npm:^1.0.0": version: 1.0.0 resolution: "archy@npm:1.0.0" @@ -6766,6 +6818,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^3.2.4": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: ee6eb8cd8a0ab1b58bd2a3ed6c415e93e773573a91d31df9d5ef559baafa9dab37d3b096fa7993e84585cac3697b2af6ddb9086f45d3ac8cae821bb2aab65682 + languageName: node + linkType: hard + "asynciterator.prototype@npm:^1.0.0": version: 1.0.0 resolution: "asynciterator.prototype@npm:1.0.0" @@ -6907,6 +6966,18 @@ __metadata: languageName: node linkType: hard +"b4a@npm:^1.6.4, b4a@npm:^1.8.1": + version: 1.8.1 + resolution: "b4a@npm:1.8.1" + peerDependencies: + react-native-b4a: "*" + peerDependenciesMeta: + react-native-b4a: + optional: true + checksum: a34131bba0eb004e5537f5ec0b0d74cd2c075832f65b8dc1a2666f895a34ce47eb553b47b4949bbb312cfdf47760da8cde3537c691b5133b0d6b132af8e3b16f + languageName: node + linkType: hard + "babel-core@npm:7.0.0-bridge.0": version: 7.0.0-bridge.0 resolution: "babel-core@npm:7.0.0-bridge.0" @@ -7075,6 +7146,81 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0 + languageName: node + linkType: hard + +"bare-events@npm:^2.5.4, bare-events@npm:^2.7.0": + version: 2.9.2 + resolution: "bare-events@npm:2.9.2" + peerDependencies: + bare-abort-controller: "*" + peerDependenciesMeta: + bare-abort-controller: + optional: true + checksum: b0c40525bcefc0524d6b21b1ecca6ad5e67a8dbdde38a5ab152373f377d78453181fec242ff282f062cf843f27da933f7560cfafe4fb362038a677e7f7c5984e + languageName: node + linkType: hard + +"bare-fs@npm:^4.5.5": + version: 4.8.1 + resolution: "bare-fs@npm:4.8.1" + dependencies: + bare-events: ^2.5.4 + bare-path: ^3.0.0 + bare-stream: ^2.6.4 + bare-url: ^2.2.2 + fast-fifo: ^1.3.2 + peerDependencies: + bare-buffer: "*" + peerDependenciesMeta: + bare-buffer: + optional: true + checksum: 64546d04f75a16590f65cfd3d5be2957ac5ad39f622057961e6a152431e68b753855d84b7654f20b21868d32245c7bbe71d3d4cbe5799e2f04ae4a6d4e8b6088 + languageName: node + linkType: hard + +"bare-path@npm:^3.0.0": + version: 3.1.1 + resolution: "bare-path@npm:3.1.1" + checksum: 0760d1eface8841b1a25f743197ae278577762590ad18de4455a467031cfe2f5ac2b534d53bf9701f59404bd299dc2e66bce95b898c6a6af1686b8acaeca403f + languageName: node + linkType: hard + +"bare-stream@npm:^2.6.4": + version: 2.13.4 + resolution: "bare-stream@npm:2.13.4" + dependencies: + b4a: ^1.8.1 + streamx: ^2.25.0 + teex: ^1.0.1 + peerDependencies: + bare-abort-controller: "*" + bare-buffer: "*" + bare-events: "*" + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + checksum: 0d7fb158ea8cc78eaf132d556164b1392609fe15eab89673147c2bfe33e15fd62f5900eef20c17e26e30df2a15f536e7044b5ac37ce68c9117bc5c57a799c548 + languageName: node + linkType: hard + +"bare-url@npm:^2.2.2": + version: 2.5.2 + resolution: "bare-url@npm:2.5.2" + dependencies: + bare-path: ^3.0.0 + checksum: 0d6f71e71bf90009811a2b05762db572e573117904c7698bb1b64f5ab74f4a3ab6ce1f2f741a3dc724ceb63317ada4fe722bfe19b413ddbec9a8725b8b4b38e6 + languageName: node + linkType: hard + "base64-js@npm:^1.0.2, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -7183,6 +7329,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.8": + version: 5.0.9 + resolution: "brace-expansion@npm:5.0.9" + dependencies: + balanced-match: ^4.0.2 + checksum: 81d14bf63c4683fa03163320a0686ee34e67c4fba8525c26949cae42aae6020369a3263f01345ec456a8bc572ab762f85216d6700997ae9ef3eeecb7f3d8b28a + languageName: node + linkType: hard + "braces@npm:^3.0.3, braces@npm:~3.0.2": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -7251,6 +7406,13 @@ __metadata: languageName: node linkType: hard +"buffer-crc32@npm:^1.0.0": + version: 1.0.0 + resolution: "buffer-crc32@npm:1.0.0" + checksum: bc114c0e02fe621249e0b5093c70e6f12d4c2b1d8ddaf3b1b7bbe3333466700100e6b1ebdc12c050d0db845bc582c4fce8c293da487cc483f97eea027c480b23 + languageName: node + linkType: hard + "buffer-crc32@npm:~0.2.3": version: 0.2.13 resolution: "buffer-crc32@npm:0.2.13" @@ -7293,6 +7455,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: ^1.3.1 + ieee754: ^1.2.1 + checksum: 5ad23293d9a731e4318e420025800b42bf0d264004c0286c8cc010af7a270c7a0f6522e84f54b9ad65cbd6db20b8badbfd8d2ebf4f80fa03dab093b89e68c3f9 + languageName: node + linkType: hard + "builder-util-runtime@npm:9.2.10": version: 9.2.10 resolution: "builder-util-runtime@npm:9.2.10" @@ -7932,6 +8104,19 @@ __metadata: languageName: node linkType: hard +"compress-commons@npm:^7.0.0": + version: 7.0.1 + resolution: "compress-commons@npm:7.0.1" + dependencies: + crc-32: ^1.2.0 + crc32-stream: ^7.0.1 + is-stream: ^4.0.0 + normalize-path: ^3.0.0 + readable-stream: ^4.0.0 + checksum: 0ab5a84e3967e8b314961900f64aa0c96c9311c8e7a481b4d84b46c8cc142f0c8d2a0103db211b5b89f59e8b48e8a53bb8c03acf28db52553183e94c359e6271 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -8072,6 +8257,25 @@ __metadata: languageName: node linkType: hard +"crc-32@npm:^1.2.0": + version: 1.2.2 + resolution: "crc-32@npm:1.2.2" + bin: + crc32: bin/crc32.njs + checksum: ad2d0ad0cbd465b75dcaeeff0600f8195b686816ab5f3ba4c6e052a07f728c3e70df2e3ca9fd3d4484dc4ba70586e161ca5a2334ec8bf5a41bf022a6103ff243 + languageName: node + linkType: hard + +"crc32-stream@npm:^7.0.1": + version: 7.0.1 + resolution: "crc32-stream@npm:7.0.1" + dependencies: + crc-32: ^1.2.0 + readable-stream: ^4.0.0 + checksum: 49417e33e366f5c0241ec2ac91fa7888e18d4fa1c9690cfca0181905c707a924d88191143a43f01de4fa5c3b14568fbe96d0c9dfe3ecb01f9e74962d30d8b7d7 + languageName: node + linkType: hard + "crc@npm:^3.8.0": version: 3.8.0 resolution: "crc@npm:3.8.0" @@ -10740,6 +10944,22 @@ __metadata: languageName: node linkType: hard +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 1ffe3bb22a6d51bdeb6bf6f7cf97d2ff4a74b017ad12284cc9e6a279e727dc30a5de6bb613e5596ff4dc3e517841339ad09a7eec44266eccb1aa201a30448166 + languageName: node + linkType: hard + +"events-universal@npm:^1.0.0": + version: 1.0.1 + resolution: "events-universal@npm:1.0.1" + dependencies: + bare-events: ^2.7.0 + checksum: fb8451c98535bde30585004303a368d55c38e5bc3ed6aa9b5d29fecaabaf8ec276a33ff77dcc1d1c05eecf83b8161f184cabc9a03b76a06c10e9a4ce827a6abc + languageName: node + linkType: hard + "events@npm:1.1.1": version: 1.1.1 resolution: "events@npm:1.1.1" @@ -10747,7 +10967,7 @@ __metadata: languageName: node linkType: hard -"events@npm:^3.2.0": +"events@npm:^3.2.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" checksum: f6f487ad2198aa41d878fa31452f1a3c00958f46e9019286ff4787c84aac329332ab45c9cdc8c445928fc6d7ded294b9e005a7fce9426488518017831b272780 @@ -10924,6 +11144,13 @@ __metadata: languageName: node linkType: hard +"fast-fifo@npm:^1.2.0, fast-fifo@npm:^1.3.2": + version: 1.3.2 + resolution: "fast-fifo@npm:1.3.2" + checksum: 6bfcba3e4df5af7be3332703b69a7898a8ed7020837ec4395bb341bd96cc3a6d86c3f6071dd98da289618cf2234c70d84b2a6f09a33dd6f988b1ff60d8e54275 + languageName: node + linkType: hard + "fast-glob@npm:^3.2.9": version: 3.2.12 resolution: "fast-glob@npm:3.2.12" @@ -12457,7 +12684,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.1.13, ieee754@npm:^1.1.4": +"ieee754@npm:^1.1.13, ieee754@npm:^1.1.4, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e @@ -13184,6 +13411,13 @@ __metadata: languageName: node linkType: hard +"is-stream@npm:^4.0.0": + version: 4.0.1 + resolution: "is-stream@npm:4.0.1" + checksum: cbea3f1fc271b21ceb228819d0c12a0965a02b57f39423925f99530b4eb86935235f258f06310b67cd02b2d10b49e9a0998f5ececf110ab7d3760bae4055ad23 + languageName: node + linkType: hard + "is-string@npm:^1.0.5, is-string@npm:^1.0.7": version: 1.0.7 resolution: "is-string@npm:1.0.7" @@ -14573,6 +14807,15 @@ __metadata: languageName: node linkType: hard +"lazystream@npm:^1.0.0": + version: 1.0.1 + resolution: "lazystream@npm:1.0.1" + dependencies: + readable-stream: ^2.0.5 + checksum: 822c54c6b87701a6491c70d4fabc4cafcf0f87d6b656af168ee7bb3c45de9128a801cb612e6eeeefc64d298a7524a698dd49b13b0121ae50c2ae305f0dcc5310 + languageName: node + linkType: hard + "leven@npm:^3.1.0": version: 3.1.0 resolution: "leven@npm:3.1.0" @@ -15206,6 +15449,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.2": + version: 10.2.6 + resolution: "minimatch@npm:10.2.6" + dependencies: + brace-expansion: ^5.0.8 + checksum: 6f3d0ba7654a6d667dc1787a3684192d65e130cbcf02c1b0cf0d0b2f7f69ab41703d4d255dc171632dc25994527a35fb6ea7d44cc6132e4d93de5a2efb210bd1 + languageName: node + linkType: hard + "minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -16834,6 +17086,13 @@ __metadata: languageName: node linkType: hard +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: bfcce49814f7d172a6e6a14d5fa3ac92cc3d0c3b9feb1279774708a719e19acd673995226351a082a9ae99978254e320ccda4240ddc474ba31a76c79491ca7c3 + languageName: node + linkType: hard + "progress@npm:^2.0.3": version: 2.0.3 resolution: "progress@npm:2.0.3" @@ -17242,7 +17501,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.0.6, readable-stream@npm:~2.3.6": +"readable-stream@npm:^2.0.5, readable-stream@npm:^2.0.6, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -17268,6 +17527,28 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^4.0.0": + version: 4.7.0 + resolution: "readable-stream@npm:4.7.0" + dependencies: + abort-controller: ^3.0.0 + buffer: ^6.0.3 + events: ^3.3.0 + process: ^0.11.10 + string_decoder: ^1.3.0 + checksum: 03ec762faed8e149dc6452798b60394a8650861a1bb4bf936fa07b94044826bc25abe73696f5f45372abc404eec01876c560f64b479eba108b56397312dbe2ae + languageName: node + linkType: hard + +"readdir-glob@npm:^3.0.0": + version: 3.0.0 + resolution: "readdir-glob@npm:3.0.0" + dependencies: + minimatch: ^10.2.2 + checksum: 0cd76f0c2db33595efcab2422a0cce82442e03ca569fa15500909f4e4ae532680d73ec4efdb753b73833658defba365ed0fc3efde0fdbd2146180cdb6019e68e + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -18785,6 +19066,17 @@ __metadata: languageName: node linkType: hard +"streamx@npm:^2.12.5, streamx@npm:^2.15.0, streamx@npm:^2.25.0": + version: 2.28.1 + resolution: "streamx@npm:2.28.1" + dependencies: + events-universal: ^1.0.0 + fast-fifo: ^1.3.2 + text-decoder: ^1.1.0 + checksum: 4726dbb84cb32be9469fa7bcec4c91856d6ce73a9f2d83a021bf1033daf722a5032c1feb608c49cb0598794a1008b00d6906a03e0b2b3b6dc8000afe36be1cf2 + languageName: node + linkType: hard + "string-length@npm:^4.0.1": version: 4.0.2 resolution: "string-length@npm:4.0.2" @@ -19013,7 +19305,7 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:^1.1.1": +"string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: @@ -19258,6 +19550,18 @@ __metadata: languageName: node linkType: hard +"tar-stream@npm:^3.0.0": + version: 3.2.1 + resolution: "tar-stream@npm:3.2.1" + dependencies: + b4a: ^1.6.4 + bare-fs: ^4.5.5 + fast-fifo: ^1.2.0 + streamx: ^2.15.0 + checksum: 399735634f9a6e267b1f5b8067ce5d4a1d9a0606773322825a004dd27c53c8f0b9d7b889a94aabb59cb06481e4e65a6af3b0457e321977354c7e1763bd76adbf + languageName: node + linkType: hard + "tar@npm:^6.0.5, tar@npm:^6.1.11, tar@npm:^6.1.12, tar@npm:^6.1.2": version: 6.2.1 resolution: "tar@npm:6.2.1" @@ -19272,6 +19576,15 @@ __metadata: languageName: node linkType: hard +"teex@npm:^1.0.1": + version: 1.0.1 + resolution: "teex@npm:1.0.1" + dependencies: + streamx: ^2.12.5 + checksum: 36bf7ce8bb5eb428ad7b14b695ee7fb0a02f09c1a9d8181cc42531208543a920b299d711bf78dad4ff9bcf36ac437ae8e138053734746076e3e0e7d6d76eef64 + languageName: node + linkType: hard + "temp-file@npm:^3.4.0": version: 3.4.0 resolution: "temp-file@npm:3.4.0" @@ -19338,6 +19651,15 @@ __metadata: languageName: node linkType: hard +"text-decoder@npm:^1.1.0": + version: 1.2.7 + resolution: "text-decoder@npm:1.2.7" + dependencies: + b4a: ^1.6.4 + checksum: a544f8490806675986e9703cda2d0809d7bd010adf0cc19ac9975791912ea9e6998cd4696d7d7e9392c5648e660111a865c983e8adfeb29699fab471548f82a3 + languageName: node + linkType: hard + "text-table@npm:^0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" @@ -20870,12 +21192,14 @@ __metadata: "@electron/fuses": 1.8.0 "@electron/osx-sign": 1.3.3 "@electron/remote": 2.1.3 + "@enormora/wall-clock": 0.0.3 "@faker-js/faker": 10.4.0 "@hapi/joi": 17.1.1 "@oazapfts/runtime": 1.2.0 "@playwright/test": 1.60.0 "@types/adm-zip": 0.5.8 "@types/amplify": 1.1.28 + "@types/archiver": 8.0.0 "@types/auto-launch": 5.0.5 "@types/content-type": 1.1.9 "@types/eslint": ^8.56.7 @@ -20907,6 +21231,7 @@ __metadata: "@wireapp/react-ui-kit": 9.59.1 "@wireapp/webapp-events": 0.29.2 adm-zip: 0.5.17 + archiver: 8.0.0 auto-launch: 5.0.6 aws-sdk: 2.1693.0 axios: 0.21.2 @@ -21297,3 +21622,14 @@ __metadata: checksum: 2cac84540f65c64ccc1683c267edce396b26b1e931aa429660aefac8fbe0188167b7aee815a3c22fa59a28a58d898d1a2b1825048f834d8d629f4c2a5d443801 languageName: node linkType: hard + +"zip-stream@npm:^7.0.2": + version: 7.0.5 + resolution: "zip-stream@npm:7.0.5" + dependencies: + compress-commons: ^7.0.0 + normalize-path: ^3.0.0 + readable-stream: ^4.0.0 + checksum: 6c9d870fca394fb17e2f4197569757e940c2c03c01922abf496024f11e0b9c1c3dd08ce75dc33f56fd3a12c071e78a6128d4d674cde680d2daf55a1a01b018ac + languageName: node + linkType: hard From e83d6f11160a41404f10f6875ea52af15b49727d Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 28 Aug 2026 09:08:39 +0200 Subject: [PATCH 28/45] Separate download destination selection --- electron/src/lib/download.test.main.ts | 39 +++++++++++++++++- electron/src/lib/download.ts | 56 ++++++++++++++++++-------- electron/src/mainProcess.ts | 7 +++- 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/electron/src/lib/download.test.main.ts b/electron/src/lib/download.test.main.ts index 9ab9f94ec4f..dc006da0162 100644 --- a/electron/src/lib/download.test.main.ts +++ b/electron/src/lib/download.test.main.ts @@ -17,15 +17,50 @@ * */ +import {Maybe} from 'true-myth'; + import * as assert from 'assert'; -import {suggestFileName} from './download'; +import {downloadLogArchive, suggestFileName} from './download'; describe('download', () => { it('converts colons to dashes because colons cannot be used in filenames on Windows', async () => { // May 4th 2020, 13:42:00 - const actual = suggestFileName('1588599720000'); + const actual = suggestFileName(Maybe.just('1588599720000')); const expected = `Wire 2020-05-04 at 13-42-00`; + assert.equal(actual, expected); }); + + it('does no export work when the save dialog is cancelled', async () => { + let exportWorkCount = 0; + + await downloadLogArchive({ + async chooseDestinationPath(): Promise> { + return Maybe.nothing(); + }, + async writeArchive(): Promise { + exportWorkCount += 1; + }, + }); + + assert.strictEqual(exportWorkCount, 0); + }); + + it('writes the archive only after a destination has been selected', async () => { + const events: string[] = []; + + await downloadLogArchive({ + async chooseDestinationPath(): Promise> { + events.push('choose-destination'); + + return Maybe.just('logs.zip'); + }, + async writeArchive(): Promise { + events.push('write-archive'); + }, + }); + + assert.deepStrictEqual(events, ['choose-destination', 'write-archive']); + }); }); diff --git a/electron/src/lib/download.ts b/electron/src/lib/download.ts index cf4b6e1eee9..25a05271ba0 100644 --- a/electron/src/lib/download.ts +++ b/electron/src/lib/download.ts @@ -20,6 +20,7 @@ import {dialog, SaveDialogOptions} from 'electron'; import * as fs from 'fs-extra'; import imageType from 'image-type'; +import {Maybe} from 'true-myth'; import * as path from 'path'; @@ -29,7 +30,7 @@ import {getLogger} from '../logging/getLogger'; const logger = getLogger(path.basename(__filename)); -export const downloadLogs = async (bytes: Uint8Array, timestamp: Date = new Date()) => { +export async function chooseLogDownloadPath(timestamp: Date): Promise> { const options: SaveDialogOptions = { filters: [{extensions: ['zip'], name: 'Archives (*.zip)'}], }; @@ -39,41 +40,64 @@ export const downloadLogs = async (bytes: Uint8Array, timestamp: Date = new Date const formattedTimeShort = formattedTime.replace(/:/g, '-').substr(0, 5); const filename = `wire-logs-${formattedDate}-${formattedTimeShort}.zip`; - return downloadFile(bytes, filename, options); + try { + const {filePath: chosenPath} = await dialog.showSaveDialog({defaultPath: filename, ...options}); + + return Maybe.of(chosenPath); + } catch (error) { + logger.error(error); + + return Maybe.nothing(); + } +} + +export type DownloadLogArchiveParameters = { + chooseDestinationPath: () => Promise>; + writeArchive: (destinationPath: string) => Promise; }; -export const downloadImage = async (bytes: Uint8Array, timestamp?: string): Promise => { - const type = imageType(bytes); +export async function downloadLogArchive(parameters: DownloadLogArchiveParameters): Promise { + const destinationPath = await parameters.chooseDestinationPath(); + + if (destinationPath.isJust) { + await parameters.writeArchive(destinationPath.value); + } +} + +export async function downloadImage(bytes: Uint8Array, timestamp: Maybe): Promise { + const detectedImageType = Maybe.of(imageType(bytes)); const options: SaveDialogOptions = {}; let filename = suggestFileName(timestamp); - if (type?.ext) { + if (detectedImageType.isJust && detectedImageType.value.ext) { options.filters = [ { - extensions: [type.ext], + extensions: [detectedImageType.value.ext], name: 'Images', }, ]; - filename += `.${type.ext}`; + filename += `.${detectedImageType.value.ext}`; } return downloadFile(bytes, filename, options); -}; +} -export const downloadFile = async (bytes: Uint8Array, filename: string, options?: SaveDialogOptions): Promise => { +export async function downloadFile(bytes: Uint8Array, filename: string, options: SaveDialogOptions): Promise { try { - const {filePath: chosenPath} = await dialog.showSaveDialog({defaultPath: filename, ...options}); - if (chosenPath) { - await fs.writeFile(chosenPath, bytes); + const saveDialogResult = await dialog.showSaveDialog({defaultPath: filename, ...options}); + const chosenPath = Maybe.of(saveDialogResult.filePath); + + if (chosenPath.isJust) { + await fs.writeFile(chosenPath.value, bytes); } } catch (error) { logger.error(error); } -}; +} -export const suggestFileName = (timestamp?: string): string => { - const imageDate = timestamp ? new Date(Number(timestamp)) : new Date(); +export function suggestFileName(timestamp: Maybe): string { + const imageDate = timestamp.isJust ? new Date(Number(timestamp.value)) : new Date(); const {date: formattedDate, time: formattedTime} = DateUtil.isoFormat(imageDate); return `Wire ${formattedDate} at ${formattedTime}`.replace(/:/g, '-'); -}; +} diff --git a/electron/src/mainProcess.ts b/electron/src/mainProcess.ts index 6effe11222d..55a0f253cbc 100644 --- a/electron/src/mainProcess.ts +++ b/electron/src/mainProcess.ts @@ -202,8 +202,11 @@ app.commandLine.appendSwitch('force-webrtc-ip-handling-policy', 'default_public_ // IPC events const bindIpcEvents = (): void => { - ipcMain.on(EVENT_TYPE.ACTION.SAVE_PICTURE, (_event, bytes: Uint8Array, timestamp?: string) => { - return downloadImage(bytes, timestamp); + ipcMain.on(EVENT_TYPE.ACTION.SAVE_PICTURE, (_event, bytes: Uint8Array, timestamp: unknown) => { + const imageTimestamp = + typeof timestamp === 'string' && timestamp.length > 0 ? Maybe.just(timestamp) : Maybe.nothing(); + + return downloadImage(bytes, imageTimestamp); }); ipcMain.on(EVENT_TYPE.ACTION.NOTIFICATION_CLICK, () => WindowManager.showPrimaryWindow()); From 035aae92a8e7d08d877561628dc0286d2e686310 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 28 Aug 2026 09:09:23 +0200 Subject: [PATCH 29/45] Stream desktop log exports from snapshots --- electron/src/lib/zip.ts | 44 --- electron/src/logging/logExport.ts | 379 ++++++++++++++++++++++- electron/src/logging/loggerUtils.ts | 62 ++-- electron/src/mainProcess.ts | 10 +- electron/src/menu/system.ts | 89 +++--- electron/src/runtime/wallClockLoader.mts | 25 ++ 6 files changed, 487 insertions(+), 122 deletions(-) delete mode 100644 electron/src/lib/zip.ts create mode 100644 electron/src/runtime/wallClockLoader.mts diff --git a/electron/src/lib/zip.ts b/electron/src/lib/zip.ts deleted file mode 100644 index 5937c0326b7..00000000000 --- a/electron/src/lib/zip.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Wire - * Copyright (C) 2020 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 - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * 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 JSZip from 'jszip'; - -import * as path from 'path'; - -import {getLogger} from '../logging/getLogger'; - -const logger = getLogger(path.basename(__filename)); - -export const zipFiles = async (files: Record): Promise => { - const zip = new JSZip(); - - try { - for (const filename in files) { - zip.file(filename, files[filename], {binary: true}); - } - } catch (error) { - logger.error(error); - } - - return zip; -}; - -export const createFile = (zip: JSZip): Promise => { - return zip.generateAsync({compression: 'DEFLATE', type: 'uint8array'}); -}; diff --git a/electron/src/logging/logExport.ts b/electron/src/logging/logExport.ts index 829b969ae89..e7a68e15493 100644 --- a/electron/src/logging/logExport.ts +++ b/electron/src/logging/logExport.ts @@ -17,34 +17,383 @@ * */ +import {ZipArchive} from 'archiver'; +import * as fs from 'fs-extra'; +import noop from 'lodash/noop'; +import {Maybe} from 'true-myth'; + +import * as os from 'os'; import * as path from 'path'; +import {Writable} from 'stream'; +import {finished} from 'stream/promises'; + +export type LogSnapshotFile = { + absolutePath: string; + relativePath: string; +}; + +export type LogSnapshot = { + directoryPath: string; + files: readonly LogSnapshotFile[]; +}; + +export type LogSnapshotFileMetadata = { + isFile: boolean; + isSymbolicLink: boolean; +}; + +export type LogSnapshotFileSystemDependencies = { + copyFile: (sourceFilePath: string, destinationFilePath: string) => Promise; + createTemporaryDirectory: (temporaryDirectoryPrefix: string) => Promise; + ensureDirectory: (directoryPath: string) => Promise; + getFileMetadata: (filePath: string) => Promise; + removeDirectory: (directoryPath: string) => Promise; +}; -export type GatherLogFilesParameters = { +export type CreateLogSnapshotParameters = { cleanup: () => Promise; discoverLogFilePaths: () => readonly string[]; logDirectory: string; - readFile: (filePath: string) => Promise; - reportReadFailure: (filePath: string, error: unknown) => void; + reportFailure: (message: string, error: unknown) => void; runMaintenance(operation: () => Promise): Promise; + dependencies: LogSnapshotFileSystemDependencies; }; -export async function gatherLogFiles(parameters: GatherLogFilesParameters): Promise> { - return parameters.runMaintenance(async () => { - await parameters.cleanup(); +export type StreamLogFilesToZipParameters = { + destinationPath: string; + snapshotFiles: readonly LogSnapshotFile[]; + dependencies: LogArchiveDependencies; +}; + +export type LogArchiveDependencies = { + createArchive: () => ZipArchive; + createOutputStream: (destinationPath: string) => Writable; + pathExists: (filePath: string) => Promise; + removeFile: (filePath: string) => Promise; + reportFailure: (message: string, error: unknown) => void; +}; + +export type StreamSnapshotParameters = { + destinationPath: string; + snapshotFiles: readonly LogSnapshotFile[]; +}; + +export type ExportLogFilesParameters = { + createSnapshot: () => Promise; + destinationPath: string; + removeSnapshotDirectory: (directoryPath: string) => Promise; + reportFailure: (message: string, error: unknown) => void; + streamSnapshot: (parameters: StreamSnapshotParameters) => Promise; +}; + +type ResolvePathWithinDirectoryParameters = { + directoryPath: string; + relativePath: string; +}; + +type CopyLogFileToSnapshotParameters = { + logDirectory: string; + relativePath: string; + sourceFilePath: string; + snapshotFilePath: string; + dependencies: LogSnapshotFileSystemDependencies; +}; + +type GetSafeSourceFileMetadataParameters = { + logDirectory: string; + sourceFilePath: string; + dependencies: LogSnapshotFileSystemDependencies; +}; + +type RemoveIncompleteDestinationFileParameters = { + destinationPath: string; + destinationAlreadyExisted: boolean; + outputStreamCreated: boolean; + exportParameters: StreamLogFilesToZipParameters; +}; + +const logSnapshotDirectoryPrefix = path.join(os.tmpdir(), 'wire-log-snapshot-'); + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +} + +export function resolvePathWithinDirectory(parameters: ResolvePathWithinDirectoryParameters): string { + const resolvedDirectoryPath = path.resolve(parameters.directoryPath); + + if (parameters.relativePath.length === 0 || path.isAbsolute(parameters.relativePath)) { + throw new Error(`Log path must be a non-empty relative path: ${parameters.relativePath}`); + } + + const resolvedPath = path.resolve(resolvedDirectoryPath, parameters.relativePath); + const relativeResolvedPath = path.relative(resolvedDirectoryPath, resolvedPath); + const isOutsideDirectory = + relativeResolvedPath === '' || + relativeResolvedPath === '..' || + relativeResolvedPath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeResolvedPath); + + if (isOutsideDirectory) { + throw new Error(`Log path escapes its directory: ${parameters.relativePath}`); + } + + return resolvedPath; +} + +function getArchiveRelativePath(relativePath: string): string { + return path.normalize(relativePath).split(path.sep).join('/'); +} + +async function getSafeSourceFileMetadata( + parameters: GetSafeSourceFileMetadataParameters, +): Promise> { + const resolvedLogDirectory = path.resolve(parameters.logDirectory); + const sourceRelativePath = path.relative(resolvedLogDirectory, parameters.sourceFilePath); + let currentPath = resolvedLogDirectory; + let sourceFileMetadata: Maybe = Maybe.nothing(); + + for (const pathPart of sourceRelativePath.split(path.sep)) { + currentPath = path.join(currentPath, pathPart); + const currentFileMetadata = await parameters.dependencies.getFileMetadata(currentPath); + sourceFileMetadata = Maybe.just(currentFileMetadata); + + if (currentFileMetadata.isSymbolicLink === true) { + return Maybe.nothing(); + } + } + + return sourceFileMetadata; +} + +async function copyLogFileToSnapshot(parameters: CopyLogFileToSnapshotParameters): Promise> { + const sourceFileMetadata = await getSafeSourceFileMetadata({ + dependencies: parameters.dependencies, + logDirectory: parameters.logDirectory, + sourceFilePath: parameters.sourceFilePath, + }); + + if (sourceFileMetadata.isJust === false) { + return Maybe.nothing(); + } + + if (sourceFileMetadata.value.isFile === false) { + return Maybe.nothing(); + } + + await parameters.dependencies.ensureDirectory(path.dirname(parameters.snapshotFilePath)); + await parameters.dependencies.copyFile(parameters.sourceFilePath, parameters.snapshotFilePath); + + return Maybe.just({ + absolutePath: parameters.snapshotFilePath, + relativePath: getArchiveRelativePath(parameters.relativePath), + }); +} + +async function removeSnapshotDirectoryAfterFailure( + snapshotDirectoryPath: Maybe, + parameters: CreateLogSnapshotParameters, +): Promise { + if (snapshotDirectoryPath.isJust === false) { + return; + } + + try { + await parameters.dependencies.removeDirectory(snapshotDirectoryPath.value); + } catch (error) { + parameters.reportFailure(`Failed to remove temporary log snapshot "${snapshotDirectoryPath.value}"`, error); + } +} + +export async function createLogSnapshot(parameters: CreateLogSnapshotParameters): Promise { + let snapshotDirectoryPath: Maybe = Maybe.nothing(); + + try { + return await parameters.runMaintenance(async () => { + await parameters.cleanup(); + + const relativeLogFilePaths = parameters.discoverLogFilePaths(); + const createdSnapshotDirectoryPath = await parameters.dependencies.createTemporaryDirectory( + logSnapshotDirectoryPrefix, + ); + snapshotDirectoryPath = Maybe.just(createdSnapshotDirectoryPath); + const snapshotFiles: LogSnapshotFile[] = []; - const logFiles: Record = {}; - const relativeFilePaths = parameters.discoverLogFilePaths(); + for (const relativePath of relativeLogFilePaths) { + const sourceFilePath = resolvePathWithinDirectory({directoryPath: parameters.logDirectory, relativePath}); + const snapshotFilePath = resolvePathWithinDirectory({ + directoryPath: createdSnapshotDirectoryPath, + relativePath, + }); - for (const relativeFilePath of relativeFilePaths) { - const resolvedPath = path.join(parameters.logDirectory, relativeFilePath); + const snapshotFile = await copyLogFileToSnapshot({ + dependencies: parameters.dependencies, + logDirectory: parameters.logDirectory, + relativePath, + sourceFilePath, + snapshotFilePath, + }).catch(error => { + parameters.reportFailure(`Failed to copy log file "${relativePath}" to the temporary snapshot`, error); - try { - logFiles[relativeFilePath] = await parameters.readFile(resolvedPath); - } catch (error) { - parameters.reportReadFailure(relativeFilePath, error); + return Maybe.nothing(); + }); + + if (snapshotFile.isJust) { + snapshotFiles.push(snapshotFile.value); + } } + + return {directoryPath: createdSnapshotDirectoryPath, files: snapshotFiles}; + }); + } catch (error) { + await removeSnapshotDirectoryAfterFailure(snapshotDirectoryPath, parameters); + throw error; + } +} + +async function removeIncompleteDestinationFile(parameters: RemoveIncompleteDestinationFileParameters): Promise { + if (parameters.destinationAlreadyExisted || parameters.outputStreamCreated === false) { + return; + } + + try { + await parameters.exportParameters.dependencies.removeFile(parameters.destinationPath); + } catch (error) { + if (isMissingFileError(error)) { + return; } - return logFiles; + parameters.exportParameters.dependencies.reportFailure( + `Failed to remove incomplete log archive "${parameters.destinationPath}"`, + error, + ); + } +} + +async function waitForArchiveFailure(archive: ZipArchive): Promise { + return new Promise((_resolve, reject) => { + archive.once('error', reject); + archive.once('warning', reject); }); } + +async function waitForPromiseToSettle(promise: Promise): Promise { + try { + await promise; + } catch (error) { + noop(error); + } +} + +export async function streamLogFilesToZip(parameters: StreamLogFilesToZipParameters): Promise { + const destinationAlreadyExisted = await parameters.dependencies.pathExists(parameters.destinationPath); + let outputStream: Maybe = Maybe.nothing(); + let outputStreamCreated = false; + let archive: Maybe = Maybe.nothing(); + let archiveFinalization: Maybe> = Maybe.nothing>(); + let outputCompletion: Maybe> = Maybe.nothing>(); + + try { + const createdOutputStream = parameters.dependencies.createOutputStream(parameters.destinationPath); + outputStream = Maybe.just(createdOutputStream); + outputStreamCreated = true; + const createdOutputCompletion = finished(createdOutputStream); + outputCompletion = Maybe.just(createdOutputCompletion); + void waitForPromiseToSettle(createdOutputCompletion); + const createdArchive = parameters.dependencies.createArchive(); + archive = Maybe.just(createdArchive); + createdArchive.pipe(createdOutputStream); + + for (const snapshotFile of parameters.snapshotFiles) { + createdArchive.file(snapshotFile.absolutePath, {name: snapshotFile.relativePath}); + } + + const createdArchiveFinalization = createdArchive.finalize(); + archiveFinalization = Maybe.just(createdArchiveFinalization); + await Promise.race([createdArchiveFinalization, waitForArchiveFailure(createdArchive)]); + await createdOutputCompletion; + } catch (error) { + if (archive.isJust) { + archive.value.abort(); + } + + if (outputStream.isJust) { + outputStream.value.destroy(error instanceof Error ? error : new Error('Log archive output failed')); + } + + if (archiveFinalization.isJust) { + await waitForPromiseToSettle(archiveFinalization.value); + } + + if (outputCompletion.isJust) { + await waitForPromiseToSettle(outputCompletion.value); + } + + await removeIncompleteDestinationFile({ + destinationPath: parameters.destinationPath, + destinationAlreadyExisted, + outputStreamCreated, + exportParameters: parameters, + }); + + throw error; + } +} + +export async function exportLogFiles(parameters: ExportLogFilesParameters): Promise { + const snapshot = await parameters.createSnapshot(); + + try { + await parameters.streamSnapshot({destinationPath: parameters.destinationPath, snapshotFiles: snapshot.files}); + } finally { + try { + await parameters.removeSnapshotDirectory(snapshot.directoryPath); + } catch (error) { + parameters.reportFailure(`Failed to remove temporary log snapshot "${snapshot.directoryPath}"`, error); + } + } +} + +export function createLogSnapshotFileSystemDependencies(): LogSnapshotFileSystemDependencies { + return { + async copyFile(sourceFilePath: string, destinationFilePath: string): Promise { + await fs.copyFile(sourceFilePath, destinationFilePath); + }, + async createTemporaryDirectory(temporaryDirectoryPrefix: string): Promise { + return fs.mkdtemp(temporaryDirectoryPrefix); + }, + async ensureDirectory(directoryPath: string): Promise { + await fs.ensureDir(directoryPath); + }, + async getFileMetadata(filePath: string): Promise { + const fileStatistics = await fs.lstat(filePath); + + return { + isFile: fileStatistics.isFile(), + isSymbolicLink: fileStatistics.isSymbolicLink(), + }; + }, + async removeDirectory(directoryPath: string): Promise { + await fs.remove(directoryPath); + }, + }; +} + +export function createLogArchiveDependencies( + reportFailure: (message: string, error: unknown) => void, +): LogArchiveDependencies { + return { + createArchive() { + return new ZipArchive({zlib: {level: 6}}); + }, + createOutputStream(destinationPath: string) { + return fs.createWriteStream(destinationPath); + }, + pathExists(filePath: string) { + return fs.pathExists(filePath); + }, + removeFile(filePath: string) { + return fs.unlink(filePath); + }, + reportFailure, + }; +} diff --git a/electron/src/logging/loggerUtils.ts b/electron/src/logging/loggerUtils.ts index 7d9e91cebe0..ebbd4065012 100644 --- a/electron/src/logging/loggerUtils.ts +++ b/electron/src/logging/loggerUtils.ts @@ -17,13 +17,17 @@ * */ -import * as fs from 'fs-extra'; - import * as path from 'path'; import {runDesktopLogCleanupWithinMaintenance, runDesktopLogMaintenance} from './desktopLogWriter'; import {getLogger} from './getLogger'; -import {gatherLogFiles} from './logExport'; +import { + createLogArchiveDependencies, + createLogSnapshot, + createLogSnapshotFileSystemDependencies, + exportLogFiles, + streamLogFilesToZip, +} from './logExport'; import {getLogFilenames as getLogFilenamesFromRoot, LogFileDiscoveryOptions} from './logFiles'; import {getLogDirectory} from './logPaths'; @@ -33,21 +37,43 @@ export function getLogFilenames(parameters: LogFileDiscoveryOptions): string[] { return getLogFilenamesFromRoot(parameters); } -export async function gatherLogs(): Promise> { +export async function exportLogs(destinationPath: string): Promise { const logDirectory = getLogDirectory(); - - return gatherLogFiles({ - cleanup: runDesktopLogCleanupWithinMaintenance, - discoverLogFilePaths: (): readonly string[] => { - return getLogFilenames({absolute: false, baseDirectory: logDirectory}); - }, - logDirectory, - readFile: (filePath: string): Promise => { - return fs.readFile(filePath); - }, - reportReadFailure: (_filePath: string, error: unknown): void => { - logger.error(error); - }, - runMaintenance: runDesktopLogMaintenance, + const snapshotFileSystemDependencies = createLogSnapshotFileSystemDependencies(); + const archiveDependencies = createLogArchiveDependencies((message: string, error: unknown): void => { + logger.error(message, error); }); + + try { + await exportLogFiles({ + createSnapshot() { + return createLogSnapshot({ + cleanup: runDesktopLogCleanupWithinMaintenance, + discoverLogFilePaths(): readonly string[] { + return getLogFilenames({absolute: false, baseDirectory: logDirectory}); + }, + logDirectory, + reportFailure(message: string, error: unknown): void { + logger.error(message, error); + }, + runMaintenance: runDesktopLogMaintenance, + dependencies: snapshotFileSystemDependencies, + }); + }, + destinationPath, + removeSnapshotDirectory: snapshotFileSystemDependencies.removeDirectory, + reportFailure(message: string, error: unknown): void { + logger.error(message, error); + }, + streamSnapshot({destinationPath: snapshotDestinationPath, snapshotFiles}) { + return streamLogFilesToZip({ + destinationPath: snapshotDestinationPath, + snapshotFiles, + dependencies: archiveDependencies, + }); + }, + }); + } catch (error) { + logger.error('Failed to export desktop logs.', error); + } } diff --git a/electron/src/mainProcess.ts b/electron/src/mainProcess.ts index 55a0f253cbc..d317b612a6b 100644 --- a/electron/src/mainProcess.ts +++ b/electron/src/mainProcess.ts @@ -18,6 +18,7 @@ */ import * as remoteMain from '@electron/remote/main'; +import type {WallClock} from '@enormora/wall-clock/wall-clock'; import { app, dialog, @@ -85,6 +86,11 @@ import * as WindowUtil from './window/WindowUtil'; const MAIN_PROCESS_LOGGER_NAME = 'main.js'; const LOG_CLEANUP_INTERVAL_MILLISECONDS = 60 * 60 * 1_000; const logger = getLogger(MAIN_PROCESS_LOGGER_NAME); +type WallClockModule = { + readonly createDesktopWallClock: () => WallClock; +}; +const wallClockModule = require('./runtime/wallClockLoader.mjs') as WallClockModule; +const wallClock = wallClockModule.createDesktopWallClock(); const mainProcessFireAndForgetInvoker = createFireAndForgetInvoker({ reportFailure(error: unknown): void { logger.error('Failed to execute a main-process background operation.', error); @@ -504,7 +510,7 @@ const handleAppEvents = (): void => { // System Menu, Tray Icon & Show window app.on('ready', async () => { const mainWindowState = initWindowStateKeeper(); - const appMenu = systemMenu.createMenu(isFullScreen); + const appMenu = systemMenu.createMenu(isFullScreen, wallClock); if (EnvironmentUtil.app.IS_DEVELOPMENT) { app.commandLine.appendSwitch('enable-webrtc-internals'); appMenu.append(developerMenu); @@ -519,7 +525,7 @@ const handleAppEvents = (): void => { app.on('ready', async () => { const mainWindowState = initWindowStateKeeper(); - const appMenu = systemMenu.createMenu(isFullScreen); + const appMenu = systemMenu.createMenu(isFullScreen, wallClock); if (EnvironmentUtil.app.IS_DEVELOPMENT) { appMenu.append(developerMenu); } diff --git a/electron/src/menu/system.ts b/electron/src/menu/system.ts index 043fdc39c1e..13c16e0fabb 100644 --- a/electron/src/menu/system.ts +++ b/electron/src/menu/system.ts @@ -17,17 +17,17 @@ * */ +import type {WallClock} from '@enormora/wall-clock/wall-clock'; import autoLaunch from 'auto-launch'; import {dialog, globalShortcut, ipcMain, Menu, MenuItemConstructorOptions} from 'electron'; import * as path from 'path'; -import {downloadLogs} from '../lib/download'; +import {chooseLogDownloadPath, downloadLogArchive} from '../lib/download'; import {EVENT_TYPE} from '../lib/eventType'; -import {zipFiles, createFile} from '../lib/zip'; import * as locale from '../locale'; import {getLogger} from '../logging/getLogger'; -import {gatherLogs} from '../logging/loggerUtils'; +import {exportLogs} from '../logging/loggerUtils'; import * as EnvironmentUtil from '../runtime/EnvironmentUtil'; import * as lifecycle from '../runtime/lifecycle'; import {config} from '../settings/config'; @@ -249,45 +249,47 @@ const windowTemplate: MenuItemConstructorOptions = { ], }; -const downloadLogsTemplate: MenuItemConstructorOptions = { - click: async () => { - const logFiles = await gatherLogs(); - const archive = await zipFiles(logFiles); - const archiveFile = await createFile(archive); - await downloadLogs(archiveFile); - }, - label: locale.getText('menuDownloadDebugLogs'), -}; - -const helpTemplate: MenuItemConstructorOptions = { - label: `&${locale.getText('menuHelp')}`, - role: 'help', - submenu: [ - { - click: () => openExternal(config.legalUrl, true), - label: locale.getText('menuLegal'), - }, - // TODO: removing these temporarily until such a time as the website is fixed. - // See https://wearezeta.atlassian.net/browse/SQCORE-1271 for more information. - // { - // click: () => openExternal(config.privacyUrl, true), - // label: locale.getText('menuPrivacy'), - // }, - // { - // click: () => openExternal(config.licensesUrl, true), - // label: locale.getText('menuLicense'), - // }, - { - click: () => openExternal(config.supportUrl, true), - label: locale.getText('menuSupport'), - }, - { - click: () => openExternal(EnvironmentUtil.web.getWebsiteUrl(), true), - label: locale.getText('menuAppURL'), +function createDownloadLogsTemplate(wallClock: WallClock): MenuItemConstructorOptions { + return { + click() { + return downloadLogArchive({ + chooseDestinationPath() { + return chooseLogDownloadPath(wallClock.currentDate); + }, + writeArchive: exportLogs, + }); }, - downloadLogsTemplate, - ], -}; + label: locale.getText('menuDownloadDebugLogs'), + }; +} + +function createHelpTemplate(wallClock: WallClock): MenuItemConstructorOptions { + return { + label: `&${locale.getText('menuHelp')}`, + role: 'help', + submenu: [ + { + click() { + return openExternal(config.legalUrl, true); + }, + label: locale.getText('menuLegal'), + }, + { + click() { + return openExternal(config.supportUrl, true); + }, + label: locale.getText('menuSupport'), + }, + { + click() { + return openExternal(EnvironmentUtil.web.getWebsiteUrl(), true); + }, + label: locale.getText('menuAppURL'), + }, + createDownloadLogsTemplate(wallClock), + ], + }; +} const darwinTemplate: MenuItemConstructorOptions = { label: `&${config.name}`, @@ -405,7 +407,8 @@ const changeLocale = async (language: locale.SupportedI18nLanguage): Promise { +export function createMenu(isFullScreen: boolean, wallClock: WallClock): Menu { + const helpTemplate = createHelpTemplate(wallClock); const menuTemplate = [conversationTemplate, editTemplate, windowTemplate, helpTemplate]; if (EnvironmentUtil.platform.IS_MAC_OS) { @@ -474,7 +477,7 @@ export const createMenu = (isFullScreen: boolean): Menu => { processMenu(menuTemplate, locale.getCurrent()); return Menu.buildFromTemplate(menuTemplate); -}; +} export const toggleMenuBar = (): void => { const mainBrowserWindow = WindowManager.getPrimaryWindow(); diff --git a/electron/src/runtime/wallClockLoader.mts b/electron/src/runtime/wallClockLoader.mts new file mode 100644 index 00000000000..087cffbec89 --- /dev/null +++ b/electron/src/runtime/wallClockLoader.mts @@ -0,0 +1,25 @@ +/* + * Wire + * 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 + * the Free Software Foundation: either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {createWallClock} from '@enormora/wall-clock/wall-clock'; +import type {WallClock} from '@enormora/wall-clock/wall-clock'; + +export function createDesktopWallClock(): WallClock { + return createWallClock(); +} From 465f9dade7e872ca0d87d715d6c8877df83d9997 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 28 Aug 2026 09:09:55 +0200 Subject: [PATCH 30/45] Cover streaming desktop log exports --- electron/src/logging/logExport.test.main.ts | 435 ++++++++++++++++++-- 1 file changed, 410 insertions(+), 25 deletions(-) diff --git a/electron/src/logging/logExport.test.main.ts b/electron/src/logging/logExport.test.main.ts index 071afad4a12..7ecefb17284 100644 --- a/electron/src/logging/logExport.test.main.ts +++ b/electron/src/logging/logExport.test.main.ts @@ -17,21 +17,45 @@ * */ +import AdmZip from 'adm-zip'; import * as fs from 'fs-extra'; +import noop from 'lodash/noop'; +import {Maybe} from 'true-myth'; import * as assert from 'assert'; import * as path from 'path'; +import {Writable} from 'stream'; -import {gatherLogFiles} from './logExport'; +import { + createLogArchiveDependencies, + createLogSnapshot, + createLogSnapshotFileSystemDependencies, + exportLogFiles, + LogSnapshot, + LogSnapshotFileSystemDependencies, + streamLogFilesToZip, +} from './logExport'; +import {getLogFilenames} from './logFiles'; import {createLogMaintenanceCoordinator} from './logMaintenance'; import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; import {createFireAndForgetInvoker} from '../lib/fireAndForgetInvoker'; +type CreateSnapshotTestParameters = { + cleanup: () => Promise; + dependencies: LogSnapshotFileSystemDependencies; + discoverLogFilePaths: () => readonly string[]; + logDirectory: string; + maintenanceCoordinator: ReturnType; + reportFailure: (message: string, error: unknown) => void; +}; + +function runNoopCleanup(): Promise { + return Promise.resolve(); +} + function createDeferredCompletion(): {promise: Promise; resolve: () => void} { - let resolvePromise: () => void = () => { - // The resolver is replaced by the Promise constructor. - }; + let resolvePromise: () => void = noop; const promise = new Promise(resolve => { resolvePromise = resolve; }); @@ -49,10 +73,31 @@ function createTestMaintenanceCoordinator() { return createLogMaintenanceCoordinator({fireAndForget: invoker.fireAndForget}); } +function createSnapshotTestParameters(parameters: CreateSnapshotTestParameters) { + return { + cleanup: parameters.cleanup, + dependencies: parameters.dependencies, + discoverLogFilePaths: parameters.discoverLogFilePaths, + logDirectory: parameters.logDirectory, + reportFailure: parameters.reportFailure, + runMaintenance: parameters.maintenanceCoordinator.runMaintenance, + }; +} + +function getArchiveEntryContents(archive: AdmZip, relativePath: string): string { + const archiveEntry = Maybe.of(archive.getEntry(relativePath)); + + if (archiveEntry.isJust === false) { + throw new Error(`Archive entry was not found: ${relativePath}`); + } + + return archiveEntry.value.getData().toString(); +} + describe('desktop log export', () => { it( - 'waits for pending writes before cleanup and gathering files', - withTemporaryDirectory('wire-log-export-', async (temporaryLogDirectory: string) => { + 'waits for pending writes before cleanup and snapshotting files', + withTemporaryDirectory('wire-log-export-maintenance-', async (temporaryLogDirectory: string) => { const logFilePath = path.join(temporaryLogDirectory, 'electron.log'); const writeCompletion = createDeferredCompletion(); const maintenanceCoordinator = createTestMaintenanceCoordinator(); @@ -61,36 +106,376 @@ describe('desktop log export', () => { await fs.outputFile(logFilePath, 'complete entry\n'); }); const events: string[] = []; - const exportPromise = gatherLogFiles({ - async cleanup(): Promise { - events.push('cleanup'); + const snapshotPromise = createLogSnapshot( + createSnapshotTestParameters({ + async cleanup(): Promise { + events.push('cleanup'); + }, + dependencies: createLogSnapshotFileSystemDependencies(), + discoverLogFilePaths(): readonly string[] { + events.push('discover'); + + return ['electron.log']; + }, + logDirectory: temporaryLogDirectory, + maintenanceCoordinator, + reportFailure: noop, + }), + ); + + await Promise.resolve(); + assert.deepStrictEqual(events, []); + + writeCompletion.resolve(); + await writePromise; + const snapshot = await snapshotPromise; + + try { + assert.deepStrictEqual(events, ['cleanup', 'discover']); + assert.deepStrictEqual( + snapshot.files.map(file => { + return file.relativePath; + }), + ['electron.log'], + ); + const actualSnapshotContents = await fs.readFile(snapshot.files[0].absolutePath, 'utf8'); + + assert.strictEqual(actualSnapshotContents, 'complete entry\n'); + } finally { + await fs.remove(snapshot.directoryPath); + } + }), + ); + + it( + 'copies retained files asynchronously without reading their complete contents into memory', + withTemporaryDirectory('wire-log-export-copy-', async (temporaryLogDirectory: string) => { + const sourceFilePath = path.join(temporaryLogDirectory, 'electron.log'); + await fs.outputFile(sourceFilePath, 'log entry\n'); + const copiedFiles: Array<{sourceFilePath: string; destinationFilePath: string}> = []; + const defaultDependencies = createLogSnapshotFileSystemDependencies(); + const dependencies: LogSnapshotFileSystemDependencies = { + ...defaultDependencies, + async copyFile(sourceFilePath: string, destinationFilePath: string): Promise { + copiedFiles.push({sourceFilePath, destinationFilePath}); + await defaultDependencies.copyFile(sourceFilePath, destinationFilePath); }, - discoverLogFilePaths(): readonly string[] { - events.push('discover'); + }; + const snapshot = await createLogSnapshot( + createSnapshotTestParameters({ + cleanup: runNoopCleanup, + dependencies, + discoverLogFilePaths(): readonly string[] { + return ['electron.log']; + }, + logDirectory: temporaryLogDirectory, + maintenanceCoordinator: createTestMaintenanceCoordinator(), + reportFailure: noop, + }), + ); + + try { + assert.deepStrictEqual(copiedFiles, [ + { + sourceFilePath, + destinationFilePath: path.join(snapshot.directoryPath, 'electron.log'), + }, + ]); + assert.deepStrictEqual(Object.keys(snapshot), ['directoryPath', 'files']); + } finally { + await fs.remove(snapshot.directoryPath); + } + }), + ); - return ['electron.log']; + it( + 'preserves nested relative paths and skips symbolic links', + withTemporaryDirectory('wire-log-export-paths-', async (temporaryLogDirectory: string) => { + const currentLogRelativePath = '2026-08-27/accounts/account-id/console.log'; + const rotatedLogRelativePath = '2026-08-27/accounts/account-id/console.log.123-0.old'; + const legacyLogRelativePath = 'legacy.old'; + await fs.outputFile(path.join(temporaryLogDirectory, currentLogRelativePath), 'current\n'); + await fs.outputFile(path.join(temporaryLogDirectory, rotatedLogRelativePath), 'rotated\n'); + await fs.outputFile(path.join(temporaryLogDirectory, legacyLogRelativePath), 'legacy\n'); + const symbolicLinkTargetPath = path.join(temporaryLogDirectory, 'outside.log'); + const symbolicLinkPath = path.join(temporaryLogDirectory, 'linked.log'); + await fs.outputFile(symbolicLinkTargetPath, 'must not be copied\n'); + await fs.symlink(symbolicLinkTargetPath, symbolicLinkPath, 'file'); + const symbolicLinkDirectoryTargetPath = path.join(temporaryLogDirectory, 'outside-account'); + const symbolicLinkDirectoryPath = path.join(temporaryLogDirectory, 'linked-account'); + await fs.outputFile(path.join(symbolicLinkDirectoryTargetPath, 'console.log'), 'must not be copied\n'); + await fs.symlink(symbolicLinkDirectoryTargetPath, symbolicLinkDirectoryPath, 'dir'); + + const snapshot = await createLogSnapshot( + createSnapshotTestParameters({ + cleanup: runNoopCleanup, + dependencies: createLogSnapshotFileSystemDependencies(), + discoverLogFilePaths(): readonly string[] { + return [ + currentLogRelativePath, + rotatedLogRelativePath, + legacyLogRelativePath, + 'linked.log', + 'linked-account/console.log', + ]; + }, + logDirectory: temporaryLogDirectory, + maintenanceCoordinator: createTestMaintenanceCoordinator(), + reportFailure: noop, + }), + ); + + try { + const actualRelativePaths = snapshot.files + .map(file => { + return file.relativePath; + }) + .toSorted(); + const expectedRelativePaths = [ + currentLogRelativePath, + legacyLogRelativePath, + rotatedLogRelativePath, + ].toSorted(); + + assert.deepStrictEqual(actualRelativePaths, expectedRelativePaths); + assert.strictEqual(await fs.pathExists(path.join(snapshot.directoryPath, 'linked.log')), false); + assert.strictEqual(await fs.pathExists(path.join(snapshot.directoryPath, 'linked-account')), false); + } finally { + await fs.remove(snapshot.directoryPath); + } + }), + ); + + it( + 'rejects paths that escape the log and snapshot directories and removes the partial snapshot', + withTemporaryDirectory('wire-log-export-safety-', async (temporaryLogDirectory: string) => { + const outsideFileName = `${path.basename(temporaryLogDirectory)}-outside.log`; + const outsideFilePath = path.join(path.dirname(temporaryLogDirectory), outsideFileName); + const relativeEscapePath = path.join('..', outsideFileName); + await fs.outputFile(outsideFilePath, 'must remain unchanged\n'); + const removedSnapshotDirectories: string[] = []; + const defaultDependencies = createLogSnapshotFileSystemDependencies(); + const dependencies: LogSnapshotFileSystemDependencies = { + ...defaultDependencies, + async removeDirectory(directoryPath: string): Promise { + removedSnapshotDirectories.push(directoryPath); + await defaultDependencies.removeDirectory(directoryPath); }, - logDirectory: temporaryLogDirectory, - async readFile(filePath: string): Promise { - events.push('read'); + }; + + try { + await assert.rejects( + createLogSnapshot( + createSnapshotTestParameters({ + cleanup: runNoopCleanup, + dependencies, + discoverLogFilePaths(): readonly string[] { + return [relativeEscapePath]; + }, + logDirectory: temporaryLogDirectory, + maintenanceCoordinator: createTestMaintenanceCoordinator(), + reportFailure: noop, + }), + ), + ); - return fs.readFile(filePath); + assert.strictEqual(await fs.readFile(outsideFilePath, 'utf8'), 'must remain unchanged\n'); + assert.strictEqual(removedSnapshotDirectories.length, 1); + assert.strictEqual(await fs.pathExists(removedSnapshotDirectories[0]), false); + } finally { + await fs.remove(outsideFilePath); + } + }), + ); + + it( + 'allows normal writes to resume while ZIP streaming is still running', + withTemporaryDirectory('wire-log-export-resume-', async (temporaryLogDirectory: string) => { + await fs.outputFile(path.join(temporaryLogDirectory, 'electron.log'), 'before archive\n'); + const maintenanceCoordinator = createTestMaintenanceCoordinator(); + const snapshotPromise = createLogSnapshot( + createSnapshotTestParameters({ + cleanup: runNoopCleanup, + dependencies: createLogSnapshotFileSystemDependencies(), + discoverLogFilePaths(): readonly string[] { + return ['electron.log']; + }, + logDirectory: temporaryLogDirectory, + maintenanceCoordinator, + reportFailure: noop, + }), + ); + const zipStarted = createDeferredCompletion(); + const zipCompletion = createDeferredCompletion(); + let exportCompleted = false; + const exportPromise = exportLogFiles({ + async createSnapshot(): Promise { + return snapshotPromise; }, - reportReadFailure(): void { - events.push('read-failed'); + destinationPath: path.join(temporaryLogDirectory, 'logs.zip'), + async removeSnapshotDirectory(directoryPath: string): Promise { + await fs.remove(directoryPath); }, - runMaintenance: maintenanceCoordinator.runMaintenance, + reportFailure: noop, + async streamSnapshot(): Promise { + zipStarted.resolve(); + await zipCompletion.promise; + }, + }).then(() => { + exportCompleted = true; }); - await Promise.resolve(); - assert.deepStrictEqual(events, []); + await zipStarted.promise; + const writePromise = maintenanceCoordinator.runWrite(async (): Promise => { + await fs.outputFile(path.join(temporaryLogDirectory, 'electron.log'), 'during archive\n'); + }); - writeCompletion.resolve(); await writePromise; - const actualLogFiles = await exportPromise; + assert.strictEqual(exportCompleted, false); + + zipCompletion.resolve(); + await exportPromise; + assert.strictEqual(exportCompleted, true); + }), + ); + + it( + 'streams current, rotated, and legacy logs into a valid archive', + withTemporaryDirectory('wire-log-export-archive-', async (temporaryLogDirectory: string) => { + const currentLogRelativePath = '2026-08-27/electron.log'; + const nestedLogRelativePath = '2026-08-27/accounts/account-id/console.log'; + const rotatedLogRelativePath = '2026-08-27/accounts/account-id/console.log.123-0.old'; + const legacyLogRelativePath = 'legacy.old'; + await fs.outputFile(path.join(temporaryLogDirectory, currentLogRelativePath), 'current\n'); + await fs.outputFile(path.join(temporaryLogDirectory, nestedLogRelativePath), 'nested\n'); + await fs.outputFile(path.join(temporaryLogDirectory, rotatedLogRelativePath), 'rotated\n'); + await fs.outputFile(path.join(temporaryLogDirectory, legacyLogRelativePath), 'legacy\n'); + const archivePath = path.join(temporaryLogDirectory, 'logs.zip'); + let createdSnapshot: Maybe = Maybe.nothing(); + + await exportLogFiles({ + async createSnapshot(): Promise { + const snapshot = await createLogSnapshot( + createSnapshotTestParameters({ + cleanup: runNoopCleanup, + dependencies: createLogSnapshotFileSystemDependencies(), + discoverLogFilePaths(): readonly string[] { + return getLogFilenames({absolute: false, baseDirectory: temporaryLogDirectory}); + }, + logDirectory: temporaryLogDirectory, + maintenanceCoordinator: createTestMaintenanceCoordinator(), + reportFailure: noop, + }), + ); + createdSnapshot = Maybe.just(snapshot); + + return snapshot; + }, + destinationPath: archivePath, + removeSnapshotDirectory: createLogSnapshotFileSystemDependencies().removeDirectory, + reportFailure: noop, + async streamSnapshot({destinationPath, snapshotFiles}): Promise { + await streamLogFilesToZip({ + destinationPath, + snapshotFiles, + dependencies: createLogArchiveDependencies(noop), + }); + }, + }); + + const createdSnapshotDirectoryPath = createdSnapshot.match({ + Just(snapshot) { + return snapshot.directoryPath; + }, + Nothing() { + throw new Error('The export did not create a snapshot'); + }, + }); + + assert.strictEqual(await fs.pathExists(createdSnapshotDirectoryPath), false); + + const archive = new AdmZip(archivePath); + const actualEntryPaths = archive + .getEntries() + .map(archiveEntry => { + return archiveEntry.entryName; + }) + .toSorted(); + const expectedEntryPaths = [ + currentLogRelativePath, + legacyLogRelativePath, + nestedLogRelativePath, + rotatedLogRelativePath, + ].toSorted(); + + assert.deepStrictEqual(actualEntryPaths, expectedEntryPaths); + assert.strictEqual(getArchiveEntryContents(archive, currentLogRelativePath), 'current\n'); + assert.strictEqual(getArchiveEntryContents(archive, nestedLogRelativePath), 'nested\n'); + assert.strictEqual(getArchiveEntryContents(archive, rotatedLogRelativePath), 'rotated\n'); + assert.strictEqual(getArchiveEntryContents(archive, legacyLogRelativePath), 'legacy\n'); + }), + ); + + it( + 'removes the snapshot and partial destination after ZIP output failure', + withTemporaryDirectory('wire-log-export-failure-', async (temporaryLogDirectory: string) => { + await fs.outputFile(path.join(temporaryLogDirectory, 'electron.log'), 'archive failure\n'); + const snapshotFileSystemDependencies = createLogSnapshotFileSystemDependencies(); + const archivePath = path.join(temporaryLogDirectory, 'logs.zip'); + const outputFailure = new Error('output failed'); + const snapshotPromise = createLogSnapshot( + createSnapshotTestParameters({ + cleanup: runNoopCleanup, + dependencies: snapshotFileSystemDependencies, + discoverLogFilePaths(): readonly string[] { + return ['electron.log']; + }, + logDirectory: temporaryLogDirectory, + maintenanceCoordinator: createTestMaintenanceCoordinator(), + reportFailure: noop, + }), + ); + let wrotePartialArchive = false; + const archiveDependencies = createLogArchiveDependencies(noop); + const failingArchiveDependencies = { + ...archiveDependencies, + createOutputStream(): Writable { + return new Writable({ + write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error) => void): void { + if (wrotePartialArchive === false) { + wrotePartialArchive = true; + fs.writeFileSync(archivePath, chunk); + } + + callback(outputFailure); + }, + }); + }, + }; + + await assert.rejects( + exportLogFiles({ + async createSnapshot(): Promise { + return snapshotPromise; + }, + destinationPath: archivePath, + removeSnapshotDirectory: snapshotFileSystemDependencies.removeDirectory, + reportFailure: noop, + async streamSnapshot({destinationPath, snapshotFiles}) { + await streamLogFilesToZip({ + destinationPath, + snapshotFiles, + dependencies: failingArchiveDependencies, + }); + }, + }), + outputFailure, + ); - assert.deepStrictEqual(events, ['cleanup', 'discover', 'read']); - assert.strictEqual(Buffer.from(actualLogFiles['electron.log']).toString(), 'complete entry\n'); + const snapshot = await snapshotPromise; + assert.strictEqual(wrotePartialArchive, true); + assert.strictEqual(await fs.pathExists(archivePath), false); + assert.strictEqual(await fs.pathExists(snapshot.directoryPath), false); }), ); }); From 3c78dd58c89a7171b9c0a1cd4388cddc8e78059e Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 28 Aug 2026 09:19:36 +0200 Subject: [PATCH 31/45] Rename export arguments to options --- electron/src/lib/download.ts | 8 +- electron/src/logging/logExport.test.main.ts | 33 +++--- electron/src/logging/logExport.ts | 116 ++++++++++---------- 3 files changed, 79 insertions(+), 78 deletions(-) diff --git a/electron/src/lib/download.ts b/electron/src/lib/download.ts index 25a05271ba0..145a2c8e716 100644 --- a/electron/src/lib/download.ts +++ b/electron/src/lib/download.ts @@ -51,16 +51,16 @@ export async function chooseLogDownloadPath(timestamp: Date): Promise Promise>; writeArchive: (destinationPath: string) => Promise; }; -export async function downloadLogArchive(parameters: DownloadLogArchiveParameters): Promise { - const destinationPath = await parameters.chooseDestinationPath(); +export async function downloadLogArchive(options: DownloadLogArchiveOptions): Promise { + const destinationPath = await options.chooseDestinationPath(); if (destinationPath.isJust) { - await parameters.writeArchive(destinationPath.value); + await options.writeArchive(destinationPath.value); } } diff --git a/electron/src/logging/logExport.test.main.ts b/electron/src/logging/logExport.test.main.ts index 7ecefb17284..b46e89dba2d 100644 --- a/electron/src/logging/logExport.test.main.ts +++ b/electron/src/logging/logExport.test.main.ts @@ -30,6 +30,7 @@ import { createLogArchiveDependencies, createLogSnapshot, createLogSnapshotFileSystemDependencies, + CreateLogSnapshotOptions, exportLogFiles, LogSnapshot, LogSnapshotFileSystemDependencies, @@ -41,7 +42,7 @@ import {createLogMaintenanceCoordinator} from './logMaintenance'; import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; import {createFireAndForgetInvoker} from '../lib/fireAndForgetInvoker'; -type CreateSnapshotTestParameters = { +type CreateSnapshotTestOptions = { cleanup: () => Promise; dependencies: LogSnapshotFileSystemDependencies; discoverLogFilePaths: () => readonly string[]; @@ -63,7 +64,7 @@ function createDeferredCompletion(): {promise: Promise; resolve: () => voi return {promise, resolve: resolvePromise}; } -function createTestMaintenanceCoordinator() { +function createTestMaintenanceCoordinator(): ReturnType { const invoker = createFireAndForgetInvoker({ reportFailure(): void { // The coordinator's public promises report expected operation failures. @@ -73,14 +74,14 @@ function createTestMaintenanceCoordinator() { return createLogMaintenanceCoordinator({fireAndForget: invoker.fireAndForget}); } -function createSnapshotTestParameters(parameters: CreateSnapshotTestParameters) { +function createSnapshotTestOptions(options: CreateSnapshotTestOptions): CreateLogSnapshotOptions { return { - cleanup: parameters.cleanup, - dependencies: parameters.dependencies, - discoverLogFilePaths: parameters.discoverLogFilePaths, - logDirectory: parameters.logDirectory, - reportFailure: parameters.reportFailure, - runMaintenance: parameters.maintenanceCoordinator.runMaintenance, + cleanup: options.cleanup, + dependencies: options.dependencies, + discoverLogFilePaths: options.discoverLogFilePaths, + logDirectory: options.logDirectory, + reportFailure: options.reportFailure, + runMaintenance: options.maintenanceCoordinator.runMaintenance, }; } @@ -107,7 +108,7 @@ describe('desktop log export', () => { }); const events: string[] = []; const snapshotPromise = createLogSnapshot( - createSnapshotTestParameters({ + createSnapshotTestOptions({ async cleanup(): Promise { events.push('cleanup'); }, @@ -162,7 +163,7 @@ describe('desktop log export', () => { }, }; const snapshot = await createLogSnapshot( - createSnapshotTestParameters({ + createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies, discoverLogFilePaths(): readonly string[] { @@ -207,7 +208,7 @@ describe('desktop log export', () => { await fs.symlink(symbolicLinkDirectoryTargetPath, symbolicLinkDirectoryPath, 'dir'); const snapshot = await createLogSnapshot( - createSnapshotTestParameters({ + createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies: createLogSnapshotFileSystemDependencies(), discoverLogFilePaths(): readonly string[] { @@ -266,7 +267,7 @@ describe('desktop log export', () => { try { await assert.rejects( createLogSnapshot( - createSnapshotTestParameters({ + createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies, discoverLogFilePaths(): readonly string[] { @@ -294,7 +295,7 @@ describe('desktop log export', () => { await fs.outputFile(path.join(temporaryLogDirectory, 'electron.log'), 'before archive\n'); const maintenanceCoordinator = createTestMaintenanceCoordinator(); const snapshotPromise = createLogSnapshot( - createSnapshotTestParameters({ + createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies: createLogSnapshotFileSystemDependencies(), discoverLogFilePaths(): readonly string[] { @@ -356,7 +357,7 @@ describe('desktop log export', () => { await exportLogFiles({ async createSnapshot(): Promise { const snapshot = await createLogSnapshot( - createSnapshotTestParameters({ + createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies: createLogSnapshotFileSystemDependencies(), discoverLogFilePaths(): readonly string[] { @@ -424,7 +425,7 @@ describe('desktop log export', () => { const archivePath = path.join(temporaryLogDirectory, 'logs.zip'); const outputFailure = new Error('output failed'); const snapshotPromise = createLogSnapshot( - createSnapshotTestParameters({ + createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies: snapshotFileSystemDependencies, discoverLogFilePaths(): readonly string[] { diff --git a/electron/src/logging/logExport.ts b/electron/src/logging/logExport.ts index e7a68e15493..4ea830d97b7 100644 --- a/electron/src/logging/logExport.ts +++ b/electron/src/logging/logExport.ts @@ -50,7 +50,7 @@ export type LogSnapshotFileSystemDependencies = { removeDirectory: (directoryPath: string) => Promise; }; -export type CreateLogSnapshotParameters = { +export type CreateLogSnapshotOptions = { cleanup: () => Promise; discoverLogFilePaths: () => readonly string[]; logDirectory: string; @@ -59,7 +59,7 @@ export type CreateLogSnapshotParameters = { dependencies: LogSnapshotFileSystemDependencies; }; -export type StreamLogFilesToZipParameters = { +export type StreamLogFilesToZipOptions = { destinationPath: string; snapshotFiles: readonly LogSnapshotFile[]; dependencies: LogArchiveDependencies; @@ -73,25 +73,25 @@ export type LogArchiveDependencies = { reportFailure: (message: string, error: unknown) => void; }; -export type StreamSnapshotParameters = { +export type StreamSnapshotOptions = { destinationPath: string; snapshotFiles: readonly LogSnapshotFile[]; }; -export type ExportLogFilesParameters = { +export type ExportLogFilesOptions = { createSnapshot: () => Promise; destinationPath: string; removeSnapshotDirectory: (directoryPath: string) => Promise; reportFailure: (message: string, error: unknown) => void; - streamSnapshot: (parameters: StreamSnapshotParameters) => Promise; + streamSnapshot: (options: StreamSnapshotOptions) => Promise; }; -type ResolvePathWithinDirectoryParameters = { +type ResolvePathWithinDirectoryOptions = { directoryPath: string; relativePath: string; }; -type CopyLogFileToSnapshotParameters = { +type CopyLogFileToSnapshotOptions = { logDirectory: string; relativePath: string; sourceFilePath: string; @@ -99,17 +99,17 @@ type CopyLogFileToSnapshotParameters = { dependencies: LogSnapshotFileSystemDependencies; }; -type GetSafeSourceFileMetadataParameters = { +type GetSafeSourceFileMetadataOptions = { logDirectory: string; sourceFilePath: string; dependencies: LogSnapshotFileSystemDependencies; }; -type RemoveIncompleteDestinationFileParameters = { +type RemoveIncompleteDestinationFileOptions = { destinationPath: string; destinationAlreadyExisted: boolean; outputStreamCreated: boolean; - exportParameters: StreamLogFilesToZipParameters; + exportOptions: StreamLogFilesToZipOptions; }; const logSnapshotDirectoryPrefix = path.join(os.tmpdir(), 'wire-log-snapshot-'); @@ -118,14 +118,14 @@ function isMissingFileError(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT'; } -export function resolvePathWithinDirectory(parameters: ResolvePathWithinDirectoryParameters): string { - const resolvedDirectoryPath = path.resolve(parameters.directoryPath); +export function resolvePathWithinDirectory(options: ResolvePathWithinDirectoryOptions): string { + const resolvedDirectoryPath = path.resolve(options.directoryPath); - if (parameters.relativePath.length === 0 || path.isAbsolute(parameters.relativePath)) { - throw new Error(`Log path must be a non-empty relative path: ${parameters.relativePath}`); + if (options.relativePath.length === 0 || path.isAbsolute(options.relativePath)) { + throw new Error(`Log path must be a non-empty relative path: ${options.relativePath}`); } - const resolvedPath = path.resolve(resolvedDirectoryPath, parameters.relativePath); + const resolvedPath = path.resolve(resolvedDirectoryPath, options.relativePath); const relativeResolvedPath = path.relative(resolvedDirectoryPath, resolvedPath); const isOutsideDirectory = relativeResolvedPath === '' || @@ -134,7 +134,7 @@ export function resolvePathWithinDirectory(parameters: ResolvePathWithinDirector path.isAbsolute(relativeResolvedPath); if (isOutsideDirectory) { - throw new Error(`Log path escapes its directory: ${parameters.relativePath}`); + throw new Error(`Log path escapes its directory: ${options.relativePath}`); } return resolvedPath; @@ -145,16 +145,16 @@ function getArchiveRelativePath(relativePath: string): string { } async function getSafeSourceFileMetadata( - parameters: GetSafeSourceFileMetadataParameters, + options: GetSafeSourceFileMetadataOptions, ): Promise> { - const resolvedLogDirectory = path.resolve(parameters.logDirectory); - const sourceRelativePath = path.relative(resolvedLogDirectory, parameters.sourceFilePath); + const resolvedLogDirectory = path.resolve(options.logDirectory); + const sourceRelativePath = path.relative(resolvedLogDirectory, options.sourceFilePath); let currentPath = resolvedLogDirectory; let sourceFileMetadata: Maybe = Maybe.nothing(); for (const pathPart of sourceRelativePath.split(path.sep)) { currentPath = path.join(currentPath, pathPart); - const currentFileMetadata = await parameters.dependencies.getFileMetadata(currentPath); + const currentFileMetadata = await options.dependencies.getFileMetadata(currentPath); sourceFileMetadata = Maybe.just(currentFileMetadata); if (currentFileMetadata.isSymbolicLink === true) { @@ -165,11 +165,11 @@ async function getSafeSourceFileMetadata( return sourceFileMetadata; } -async function copyLogFileToSnapshot(parameters: CopyLogFileToSnapshotParameters): Promise> { +async function copyLogFileToSnapshot(options: CopyLogFileToSnapshotOptions): Promise> { const sourceFileMetadata = await getSafeSourceFileMetadata({ - dependencies: parameters.dependencies, - logDirectory: parameters.logDirectory, - sourceFilePath: parameters.sourceFilePath, + dependencies: options.dependencies, + logDirectory: options.logDirectory, + sourceFilePath: options.sourceFilePath, }); if (sourceFileMetadata.isJust === false) { @@ -180,59 +180,59 @@ async function copyLogFileToSnapshot(parameters: CopyLogFileToSnapshotParameters return Maybe.nothing(); } - await parameters.dependencies.ensureDirectory(path.dirname(parameters.snapshotFilePath)); - await parameters.dependencies.copyFile(parameters.sourceFilePath, parameters.snapshotFilePath); + await options.dependencies.ensureDirectory(path.dirname(options.snapshotFilePath)); + await options.dependencies.copyFile(options.sourceFilePath, options.snapshotFilePath); return Maybe.just({ - absolutePath: parameters.snapshotFilePath, - relativePath: getArchiveRelativePath(parameters.relativePath), + absolutePath: options.snapshotFilePath, + relativePath: getArchiveRelativePath(options.relativePath), }); } async function removeSnapshotDirectoryAfterFailure( snapshotDirectoryPath: Maybe, - parameters: CreateLogSnapshotParameters, + options: CreateLogSnapshotOptions, ): Promise { if (snapshotDirectoryPath.isJust === false) { return; } try { - await parameters.dependencies.removeDirectory(snapshotDirectoryPath.value); + await options.dependencies.removeDirectory(snapshotDirectoryPath.value); } catch (error) { - parameters.reportFailure(`Failed to remove temporary log snapshot "${snapshotDirectoryPath.value}"`, error); + options.reportFailure(`Failed to remove temporary log snapshot "${snapshotDirectoryPath.value}"`, error); } } -export async function createLogSnapshot(parameters: CreateLogSnapshotParameters): Promise { +export async function createLogSnapshot(options: CreateLogSnapshotOptions): Promise { let snapshotDirectoryPath: Maybe = Maybe.nothing(); try { - return await parameters.runMaintenance(async () => { - await parameters.cleanup(); + return await options.runMaintenance(async () => { + await options.cleanup(); - const relativeLogFilePaths = parameters.discoverLogFilePaths(); - const createdSnapshotDirectoryPath = await parameters.dependencies.createTemporaryDirectory( + const relativeLogFilePaths = options.discoverLogFilePaths(); + const createdSnapshotDirectoryPath = await options.dependencies.createTemporaryDirectory( logSnapshotDirectoryPrefix, ); snapshotDirectoryPath = Maybe.just(createdSnapshotDirectoryPath); const snapshotFiles: LogSnapshotFile[] = []; for (const relativePath of relativeLogFilePaths) { - const sourceFilePath = resolvePathWithinDirectory({directoryPath: parameters.logDirectory, relativePath}); + const sourceFilePath = resolvePathWithinDirectory({directoryPath: options.logDirectory, relativePath}); const snapshotFilePath = resolvePathWithinDirectory({ directoryPath: createdSnapshotDirectoryPath, relativePath, }); const snapshotFile = await copyLogFileToSnapshot({ - dependencies: parameters.dependencies, - logDirectory: parameters.logDirectory, + dependencies: options.dependencies, + logDirectory: options.logDirectory, relativePath, sourceFilePath, snapshotFilePath, }).catch(error => { - parameters.reportFailure(`Failed to copy log file "${relativePath}" to the temporary snapshot`, error); + options.reportFailure(`Failed to copy log file "${relativePath}" to the temporary snapshot`, error); return Maybe.nothing(); }); @@ -245,25 +245,25 @@ export async function createLogSnapshot(parameters: CreateLogSnapshotParameters) return {directoryPath: createdSnapshotDirectoryPath, files: snapshotFiles}; }); } catch (error) { - await removeSnapshotDirectoryAfterFailure(snapshotDirectoryPath, parameters); + await removeSnapshotDirectoryAfterFailure(snapshotDirectoryPath, options); throw error; } } -async function removeIncompleteDestinationFile(parameters: RemoveIncompleteDestinationFileParameters): Promise { - if (parameters.destinationAlreadyExisted || parameters.outputStreamCreated === false) { +async function removeIncompleteDestinationFile(options: RemoveIncompleteDestinationFileOptions): Promise { + if (options.destinationAlreadyExisted || options.outputStreamCreated === false) { return; } try { - await parameters.exportParameters.dependencies.removeFile(parameters.destinationPath); + await options.exportOptions.dependencies.removeFile(options.destinationPath); } catch (error) { if (isMissingFileError(error)) { return; } - parameters.exportParameters.dependencies.reportFailure( - `Failed to remove incomplete log archive "${parameters.destinationPath}"`, + options.exportOptions.dependencies.reportFailure( + `Failed to remove incomplete log archive "${options.destinationPath}"`, error, ); } @@ -284,8 +284,8 @@ async function waitForPromiseToSettle(promise: Promise): Promise { } } -export async function streamLogFilesToZip(parameters: StreamLogFilesToZipParameters): Promise { - const destinationAlreadyExisted = await parameters.dependencies.pathExists(parameters.destinationPath); +export async function streamLogFilesToZip(options: StreamLogFilesToZipOptions): Promise { + const destinationAlreadyExisted = await options.dependencies.pathExists(options.destinationPath); let outputStream: Maybe = Maybe.nothing(); let outputStreamCreated = false; let archive: Maybe = Maybe.nothing(); @@ -293,17 +293,17 @@ export async function streamLogFilesToZip(parameters: StreamLogFilesToZipParamet let outputCompletion: Maybe> = Maybe.nothing>(); try { - const createdOutputStream = parameters.dependencies.createOutputStream(parameters.destinationPath); + const createdOutputStream = options.dependencies.createOutputStream(options.destinationPath); outputStream = Maybe.just(createdOutputStream); outputStreamCreated = true; const createdOutputCompletion = finished(createdOutputStream); outputCompletion = Maybe.just(createdOutputCompletion); void waitForPromiseToSettle(createdOutputCompletion); - const createdArchive = parameters.dependencies.createArchive(); + const createdArchive = options.dependencies.createArchive(); archive = Maybe.just(createdArchive); createdArchive.pipe(createdOutputStream); - for (const snapshotFile of parameters.snapshotFiles) { + for (const snapshotFile of options.snapshotFiles) { createdArchive.file(snapshotFile.absolutePath, {name: snapshotFile.relativePath}); } @@ -329,26 +329,26 @@ export async function streamLogFilesToZip(parameters: StreamLogFilesToZipParamet } await removeIncompleteDestinationFile({ - destinationPath: parameters.destinationPath, + destinationPath: options.destinationPath, destinationAlreadyExisted, outputStreamCreated, - exportParameters: parameters, + exportOptions: options, }); throw error; } } -export async function exportLogFiles(parameters: ExportLogFilesParameters): Promise { - const snapshot = await parameters.createSnapshot(); +export async function exportLogFiles(options: ExportLogFilesOptions): Promise { + const snapshot = await options.createSnapshot(); try { - await parameters.streamSnapshot({destinationPath: parameters.destinationPath, snapshotFiles: snapshot.files}); + await options.streamSnapshot({destinationPath: options.destinationPath, snapshotFiles: snapshot.files}); } finally { try { - await parameters.removeSnapshotDirectory(snapshot.directoryPath); + await options.removeSnapshotDirectory(snapshot.directoryPath); } catch (error) { - parameters.reportFailure(`Failed to remove temporary log snapshot "${snapshot.directoryPath}"`, error); + options.reportFailure(`Failed to remove temporary log snapshot "${snapshot.directoryPath}"`, error); } } } From 2906d28240debb9f71fda962511c53458ab7c2f4 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 28 Aug 2026 09:25:02 +0200 Subject: [PATCH 32/45] Infer object literal method returns --- electron/src/lib/download.test.main.ts | 8 ++--- electron/src/logging/logExport.test.main.ts | 36 ++++++++++----------- electron/src/logging/logExport.ts | 10 +++--- electron/src/logging/loggerUtils.ts | 6 ++-- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/electron/src/lib/download.test.main.ts b/electron/src/lib/download.test.main.ts index dc006da0162..39e21f40037 100644 --- a/electron/src/lib/download.test.main.ts +++ b/electron/src/lib/download.test.main.ts @@ -36,10 +36,10 @@ describe('download', () => { let exportWorkCount = 0; await downloadLogArchive({ - async chooseDestinationPath(): Promise> { + async chooseDestinationPath() { return Maybe.nothing(); }, - async writeArchive(): Promise { + async writeArchive() { exportWorkCount += 1; }, }); @@ -51,12 +51,12 @@ describe('download', () => { const events: string[] = []; await downloadLogArchive({ - async chooseDestinationPath(): Promise> { + async chooseDestinationPath() { events.push('choose-destination'); return Maybe.just('logs.zip'); }, - async writeArchive(): Promise { + async writeArchive() { events.push('write-archive'); }, }); diff --git a/electron/src/logging/logExport.test.main.ts b/electron/src/logging/logExport.test.main.ts index b46e89dba2d..a3e3ffb8d3b 100644 --- a/electron/src/logging/logExport.test.main.ts +++ b/electron/src/logging/logExport.test.main.ts @@ -66,7 +66,7 @@ function createDeferredCompletion(): {promise: Promise; resolve: () => voi function createTestMaintenanceCoordinator(): ReturnType { const invoker = createFireAndForgetInvoker({ - reportFailure(): void { + reportFailure() { // The coordinator's public promises report expected operation failures. }, }); @@ -109,11 +109,11 @@ describe('desktop log export', () => { const events: string[] = []; const snapshotPromise = createLogSnapshot( createSnapshotTestOptions({ - async cleanup(): Promise { + async cleanup() { events.push('cleanup'); }, dependencies: createLogSnapshotFileSystemDependencies(), - discoverLogFilePaths(): readonly string[] { + discoverLogFilePaths() { events.push('discover'); return ['electron.log']; @@ -157,7 +157,7 @@ describe('desktop log export', () => { const defaultDependencies = createLogSnapshotFileSystemDependencies(); const dependencies: LogSnapshotFileSystemDependencies = { ...defaultDependencies, - async copyFile(sourceFilePath: string, destinationFilePath: string): Promise { + async copyFile(sourceFilePath: string, destinationFilePath: string) { copiedFiles.push({sourceFilePath, destinationFilePath}); await defaultDependencies.copyFile(sourceFilePath, destinationFilePath); }, @@ -166,7 +166,7 @@ describe('desktop log export', () => { createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies, - discoverLogFilePaths(): readonly string[] { + discoverLogFilePaths() { return ['electron.log']; }, logDirectory: temporaryLogDirectory, @@ -211,7 +211,7 @@ describe('desktop log export', () => { createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies: createLogSnapshotFileSystemDependencies(), - discoverLogFilePaths(): readonly string[] { + discoverLogFilePaths() { return [ currentLogRelativePath, rotatedLogRelativePath, @@ -258,7 +258,7 @@ describe('desktop log export', () => { const defaultDependencies = createLogSnapshotFileSystemDependencies(); const dependencies: LogSnapshotFileSystemDependencies = { ...defaultDependencies, - async removeDirectory(directoryPath: string): Promise { + async removeDirectory(directoryPath: string) { removedSnapshotDirectories.push(directoryPath); await defaultDependencies.removeDirectory(directoryPath); }, @@ -270,7 +270,7 @@ describe('desktop log export', () => { createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies, - discoverLogFilePaths(): readonly string[] { + discoverLogFilePaths() { return [relativeEscapePath]; }, logDirectory: temporaryLogDirectory, @@ -298,7 +298,7 @@ describe('desktop log export', () => { createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies: createLogSnapshotFileSystemDependencies(), - discoverLogFilePaths(): readonly string[] { + discoverLogFilePaths() { return ['electron.log']; }, logDirectory: temporaryLogDirectory, @@ -310,15 +310,15 @@ describe('desktop log export', () => { const zipCompletion = createDeferredCompletion(); let exportCompleted = false; const exportPromise = exportLogFiles({ - async createSnapshot(): Promise { + async createSnapshot() { return snapshotPromise; }, destinationPath: path.join(temporaryLogDirectory, 'logs.zip'), - async removeSnapshotDirectory(directoryPath: string): Promise { + async removeSnapshotDirectory(directoryPath: string) { await fs.remove(directoryPath); }, reportFailure: noop, - async streamSnapshot(): Promise { + async streamSnapshot() { zipStarted.resolve(); await zipCompletion.promise; }, @@ -355,12 +355,12 @@ describe('desktop log export', () => { let createdSnapshot: Maybe = Maybe.nothing(); await exportLogFiles({ - async createSnapshot(): Promise { + async createSnapshot() { const snapshot = await createLogSnapshot( createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies: createLogSnapshotFileSystemDependencies(), - discoverLogFilePaths(): readonly string[] { + discoverLogFilePaths() { return getLogFilenames({absolute: false, baseDirectory: temporaryLogDirectory}); }, logDirectory: temporaryLogDirectory, @@ -375,7 +375,7 @@ describe('desktop log export', () => { destinationPath: archivePath, removeSnapshotDirectory: createLogSnapshotFileSystemDependencies().removeDirectory, reportFailure: noop, - async streamSnapshot({destinationPath, snapshotFiles}): Promise { + async streamSnapshot({destinationPath, snapshotFiles}) { await streamLogFilesToZip({ destinationPath, snapshotFiles, @@ -428,7 +428,7 @@ describe('desktop log export', () => { createSnapshotTestOptions({ cleanup: runNoopCleanup, dependencies: snapshotFileSystemDependencies, - discoverLogFilePaths(): readonly string[] { + discoverLogFilePaths() { return ['electron.log']; }, logDirectory: temporaryLogDirectory, @@ -440,7 +440,7 @@ describe('desktop log export', () => { const archiveDependencies = createLogArchiveDependencies(noop); const failingArchiveDependencies = { ...archiveDependencies, - createOutputStream(): Writable { + createOutputStream() { return new Writable({ write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error) => void): void { if (wrotePartialArchive === false) { @@ -456,7 +456,7 @@ describe('desktop log export', () => { await assert.rejects( exportLogFiles({ - async createSnapshot(): Promise { + async createSnapshot() { return snapshotPromise; }, destinationPath: archivePath, diff --git a/electron/src/logging/logExport.ts b/electron/src/logging/logExport.ts index 4ea830d97b7..83db590411e 100644 --- a/electron/src/logging/logExport.ts +++ b/electron/src/logging/logExport.ts @@ -355,16 +355,16 @@ export async function exportLogFiles(options: ExportLogFilesOptions): Promise { + async copyFile(sourceFilePath: string, destinationFilePath: string) { await fs.copyFile(sourceFilePath, destinationFilePath); }, - async createTemporaryDirectory(temporaryDirectoryPrefix: string): Promise { + async createTemporaryDirectory(temporaryDirectoryPrefix: string) { return fs.mkdtemp(temporaryDirectoryPrefix); }, - async ensureDirectory(directoryPath: string): Promise { + async ensureDirectory(directoryPath: string) { await fs.ensureDir(directoryPath); }, - async getFileMetadata(filePath: string): Promise { + async getFileMetadata(filePath: string) { const fileStatistics = await fs.lstat(filePath); return { @@ -372,7 +372,7 @@ export function createLogSnapshotFileSystemDependencies(): LogSnapshotFileSystem isSymbolicLink: fileStatistics.isSymbolicLink(), }; }, - async removeDirectory(directoryPath: string): Promise { + async removeDirectory(directoryPath: string) { await fs.remove(directoryPath); }, }; diff --git a/electron/src/logging/loggerUtils.ts b/electron/src/logging/loggerUtils.ts index ebbd4065012..c5d8704ab4a 100644 --- a/electron/src/logging/loggerUtils.ts +++ b/electron/src/logging/loggerUtils.ts @@ -49,11 +49,11 @@ export async function exportLogs(destinationPath: string): Promise { createSnapshot() { return createLogSnapshot({ cleanup: runDesktopLogCleanupWithinMaintenance, - discoverLogFilePaths(): readonly string[] { + discoverLogFilePaths() { return getLogFilenames({absolute: false, baseDirectory: logDirectory}); }, logDirectory, - reportFailure(message: string, error: unknown): void { + reportFailure(message: string, error: unknown) { logger.error(message, error); }, runMaintenance: runDesktopLogMaintenance, @@ -62,7 +62,7 @@ export async function exportLogs(destinationPath: string): Promise { }, destinationPath, removeSnapshotDirectory: snapshotFileSystemDependencies.removeDirectory, - reportFailure(message: string, error: unknown): void { + reportFailure(message: string, error: unknown) { logger.error(message, error); }, streamSnapshot({destinationPath: snapshotDestinationPath, snapshotFiles}) { From f2fb21b19a9bfc604e58606f8895becc5c4a4dbd Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 28 Aug 2026 09:37:01 +0200 Subject: [PATCH 33/45] Inject log export dependencies --- electron/src/logging/logExport.test.main.ts | 12 +++- electron/src/logging/logExport.ts | 54 +-------------- electron/src/logging/logExportDependencies.ts | 68 +++++++++++++++++++ electron/src/logging/loggerUtils.ts | 11 ++- 4 files changed, 85 insertions(+), 60 deletions(-) create mode 100644 electron/src/logging/logExportDependencies.ts diff --git a/electron/src/logging/logExport.test.main.ts b/electron/src/logging/logExport.test.main.ts index a3e3ffb8d3b..7dcdc93ecb4 100644 --- a/electron/src/logging/logExport.test.main.ts +++ b/electron/src/logging/logExport.test.main.ts @@ -27,15 +27,14 @@ import * as path from 'path'; import {Writable} from 'stream'; import { - createLogArchiveDependencies, createLogSnapshot, - createLogSnapshotFileSystemDependencies, CreateLogSnapshotOptions, exportLogFiles, LogSnapshot, LogSnapshotFileSystemDependencies, streamLogFilesToZip, } from './logExport'; +import {createLogArchiveDependencies, createLogSnapshotFileSystemDependencies} from './logExportDependencies'; import {getLogFilenames} from './logFiles'; import {createLogMaintenanceCoordinator} from './logMaintenance'; @@ -49,6 +48,7 @@ type CreateSnapshotTestOptions = { logDirectory: string; maintenanceCoordinator: ReturnType; reportFailure: (message: string, error: unknown) => void; + temporaryDirectoryPrefix: string; }; function runNoopCleanup(): Promise { @@ -82,6 +82,7 @@ function createSnapshotTestOptions(options: CreateSnapshotTestOptions): CreateLo logDirectory: options.logDirectory, reportFailure: options.reportFailure, runMaintenance: options.maintenanceCoordinator.runMaintenance, + temporaryDirectoryPrefix: options.temporaryDirectoryPrefix, }; } @@ -121,6 +122,7 @@ describe('desktop log export', () => { logDirectory: temporaryLogDirectory, maintenanceCoordinator, reportFailure: noop, + temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); @@ -172,6 +174,7 @@ describe('desktop log export', () => { logDirectory: temporaryLogDirectory, maintenanceCoordinator: createTestMaintenanceCoordinator(), reportFailure: noop, + temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); @@ -223,6 +226,7 @@ describe('desktop log export', () => { logDirectory: temporaryLogDirectory, maintenanceCoordinator: createTestMaintenanceCoordinator(), reportFailure: noop, + temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); @@ -276,6 +280,7 @@ describe('desktop log export', () => { logDirectory: temporaryLogDirectory, maintenanceCoordinator: createTestMaintenanceCoordinator(), reportFailure: noop, + temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ), ); @@ -304,6 +309,7 @@ describe('desktop log export', () => { logDirectory: temporaryLogDirectory, maintenanceCoordinator, reportFailure: noop, + temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); const zipStarted = createDeferredCompletion(); @@ -366,6 +372,7 @@ describe('desktop log export', () => { logDirectory: temporaryLogDirectory, maintenanceCoordinator: createTestMaintenanceCoordinator(), reportFailure: noop, + temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); createdSnapshot = Maybe.just(snapshot); @@ -434,6 +441,7 @@ describe('desktop log export', () => { logDirectory: temporaryLogDirectory, maintenanceCoordinator: createTestMaintenanceCoordinator(), reportFailure: noop, + temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); let wrotePartialArchive = false; diff --git a/electron/src/logging/logExport.ts b/electron/src/logging/logExport.ts index 83db590411e..fed3fc37649 100644 --- a/electron/src/logging/logExport.ts +++ b/electron/src/logging/logExport.ts @@ -17,12 +17,10 @@ * */ -import {ZipArchive} from 'archiver'; -import * as fs from 'fs-extra'; +import type {ZipArchive} from 'archiver'; import noop from 'lodash/noop'; import {Maybe} from 'true-myth'; -import * as os from 'os'; import * as path from 'path'; import {Writable} from 'stream'; import {finished} from 'stream/promises'; @@ -56,6 +54,7 @@ export type CreateLogSnapshotOptions = { logDirectory: string; reportFailure: (message: string, error: unknown) => void; runMaintenance(operation: () => Promise): Promise; + temporaryDirectoryPrefix: string; dependencies: LogSnapshotFileSystemDependencies; }; @@ -112,8 +111,6 @@ type RemoveIncompleteDestinationFileOptions = { exportOptions: StreamLogFilesToZipOptions; }; -const logSnapshotDirectoryPrefix = path.join(os.tmpdir(), 'wire-log-snapshot-'); - function isMissingFileError(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT'; } @@ -213,7 +210,7 @@ export async function createLogSnapshot(options: CreateLogSnapshotOptions): Prom const relativeLogFilePaths = options.discoverLogFilePaths(); const createdSnapshotDirectoryPath = await options.dependencies.createTemporaryDirectory( - logSnapshotDirectoryPrefix, + options.temporaryDirectoryPrefix, ); snapshotDirectoryPath = Maybe.just(createdSnapshotDirectoryPath); const snapshotFiles: LogSnapshotFile[] = []; @@ -352,48 +349,3 @@ export async function exportLogFiles(options: ExportLogFilesOptions): Promise void, -): LogArchiveDependencies { - return { - createArchive() { - return new ZipArchive({zlib: {level: 6}}); - }, - createOutputStream(destinationPath: string) { - return fs.createWriteStream(destinationPath); - }, - pathExists(filePath: string) { - return fs.pathExists(filePath); - }, - removeFile(filePath: string) { - return fs.unlink(filePath); - }, - reportFailure, - }; -} diff --git a/electron/src/logging/logExportDependencies.ts b/electron/src/logging/logExportDependencies.ts new file mode 100644 index 00000000000..78a49dd9f1a --- /dev/null +++ b/electron/src/logging/logExportDependencies.ts @@ -0,0 +1,68 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {ZipArchive} from 'archiver'; +import * as fs from 'fs-extra'; + +import type {LogArchiveDependencies, LogSnapshotFileSystemDependencies} from './logExport'; + +export function createLogSnapshotFileSystemDependencies(): LogSnapshotFileSystemDependencies { + return { + async copyFile(sourceFilePath: string, destinationFilePath: string) { + await fs.copyFile(sourceFilePath, destinationFilePath); + }, + async createTemporaryDirectory(temporaryDirectoryPrefix: string) { + return fs.mkdtemp(temporaryDirectoryPrefix); + }, + async ensureDirectory(directoryPath: string) { + await fs.ensureDir(directoryPath); + }, + async getFileMetadata(filePath: string) { + const fileStatistics = await fs.lstat(filePath); + + return { + isFile: fileStatistics.isFile(), + isSymbolicLink: fileStatistics.isSymbolicLink(), + }; + }, + async removeDirectory(directoryPath: string) { + await fs.remove(directoryPath); + }, + }; +} + +export function createLogArchiveDependencies( + reportFailure: (message: string, error: unknown) => void, +): LogArchiveDependencies { + return { + createArchive() { + return new ZipArchive({zlib: {level: 6}}); + }, + createOutputStream(destinationPath: string) { + return fs.createWriteStream(destinationPath); + }, + pathExists(filePath: string) { + return fs.pathExists(filePath); + }, + removeFile(filePath: string) { + return fs.unlink(filePath); + }, + reportFailure, + }; +} diff --git a/electron/src/logging/loggerUtils.ts b/electron/src/logging/loggerUtils.ts index c5d8704ab4a..6ead2a2bc1a 100644 --- a/electron/src/logging/loggerUtils.ts +++ b/electron/src/logging/loggerUtils.ts @@ -17,17 +17,13 @@ * */ +import * as os from 'os'; import * as path from 'path'; import {runDesktopLogCleanupWithinMaintenance, runDesktopLogMaintenance} from './desktopLogWriter'; import {getLogger} from './getLogger'; -import { - createLogArchiveDependencies, - createLogSnapshot, - createLogSnapshotFileSystemDependencies, - exportLogFiles, - streamLogFilesToZip, -} from './logExport'; +import {createLogSnapshot, exportLogFiles, streamLogFilesToZip} from './logExport'; +import {createLogArchiveDependencies, createLogSnapshotFileSystemDependencies} from './logExportDependencies'; import {getLogFilenames as getLogFilenamesFromRoot, LogFileDiscoveryOptions} from './logFiles'; import {getLogDirectory} from './logPaths'; @@ -57,6 +53,7 @@ export async function exportLogs(destinationPath: string): Promise { logger.error(message, error); }, runMaintenance: runDesktopLogMaintenance, + temporaryDirectoryPrefix: path.join(os.tmpdir(), 'wire-log-snapshot-'), dependencies: snapshotFileSystemDependencies, }); }, From c8ac8635ca5b3c0b50bb2f780c5be2b3d7b2e1fc Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 28 Aug 2026 12:52:25 +0200 Subject: [PATCH 34/45] Observe log export failures safely --- .yarnrc.yml | 2 - electron/src/logging/logExport.test.main.ts | 108 ++++++++++++++++++++ electron/src/logging/logExport.ts | 11 +- electron/src/logging/loggerUtils.ts | 7 +- 4 files changed, 122 insertions(+), 6 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index 576ef3c2a87..f0440a87036 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,7 +1,5 @@ nodeLinker: node-modules -defaultSemverRangePrefix: "" - npmRegistryServer: 'https://registry.npmjs.org' plugins: diff --git a/electron/src/logging/logExport.test.main.ts b/electron/src/logging/logExport.test.main.ts index 7dcdc93ecb4..fb362b72e20 100644 --- a/electron/src/logging/logExport.test.main.ts +++ b/electron/src/logging/logExport.test.main.ts @@ -74,6 +74,12 @@ function createTestMaintenanceCoordinator(): ReturnType Promise) => void { + const invoker = createFireAndForgetInvoker({reportFailure: noop}); + + return invoker.fireAndForget; +} + function createSnapshotTestOptions(options: CreateSnapshotTestOptions): CreateLogSnapshotOptions { return { cleanup: options.cleanup, @@ -387,6 +393,7 @@ describe('desktop log export', () => { destinationPath, snapshotFiles, dependencies: createLogArchiveDependencies(noop), + fireAndForget: createTestFireAndForget(), }); }, }); @@ -424,6 +431,106 @@ describe('desktop log export', () => { }), ); + it( + 'catches an archive error emitted immediately during finalization', + withTemporaryDirectory('wire-log-export-archive-error-', async (temporaryLogDirectory: string) => { + const archivePath = path.join(temporaryLogDirectory, 'logs.zip'); + const archiveFailure = new Error('archive failed during finalization'); + const archiveDependencies = createLogArchiveDependencies(noop); + let outputStream: Maybe = Maybe.nothing(); + const failingArchiveDependencies = { + ...archiveDependencies, + createOutputStream(destinationPath: string) { + const createdOutputStream = archiveDependencies.createOutputStream(destinationPath); + outputStream = Maybe.just(createdOutputStream); + + return createdOutputStream; + }, + createArchive() { + const createdArchive = archiveDependencies.createArchive(); + createdArchive.on('error', noop); + createdArchive.finalize = function finalize(): Promise { + createdArchive.emit('error', archiveFailure); + outputStream.match({ + Just(createdOutputStream) { + createdOutputStream.end(); + }, + Nothing() { + throw new Error('The archive output stream was not created'); + }, + }); + + return new Promise(resolve => { + setImmediate(resolve); + }); + }; + + return createdArchive; + }, + }; + + await assert.rejects( + streamLogFilesToZip({ + destinationPath: archivePath, + snapshotFiles: [], + dependencies: failingArchiveDependencies, + fireAndForget: createTestFireAndForget(), + }), + archiveFailure, + ); + assert.strictEqual(await fs.pathExists(archivePath), false); + }), + ); + + it( + 'propagates output failure before archive finalization completes', + withTemporaryDirectory('wire-log-export-output-error-', async (temporaryLogDirectory: string) => { + const archivePath = path.join(temporaryLogDirectory, 'logs.zip'); + const outputFailure = new Error('output failed before archive finalization'); + const archiveFinalization = createDeferredCompletion(); + const archiveDependencies = createLogArchiveDependencies(noop); + let outputStream: Maybe = Maybe.nothing(); + const failingArchiveDependencies = { + ...archiveDependencies, + createOutputStream(destinationPath: string) { + const createdOutputStream = archiveDependencies.createOutputStream(destinationPath); + outputStream = Maybe.just(createdOutputStream); + + return createdOutputStream; + }, + createArchive() { + const createdArchive = archiveDependencies.createArchive(); + createdArchive.finalize = function finalize(): Promise { + outputStream.match({ + Just(createdOutputStream) { + createdOutputStream.destroy(outputFailure); + }, + Nothing() { + throw new Error('The archive output stream was not created'); + }, + }); + setImmediate(archiveFinalization.resolve); + + return archiveFinalization.promise; + }; + + return createdArchive; + }, + }; + + await assert.rejects( + streamLogFilesToZip({ + destinationPath: archivePath, + snapshotFiles: [], + dependencies: failingArchiveDependencies, + fireAndForget: createTestFireAndForget(), + }), + outputFailure, + ); + assert.strictEqual(await fs.pathExists(archivePath), false); + }), + ); + it( 'removes the snapshot and partial destination after ZIP output failure', withTemporaryDirectory('wire-log-export-failure-', async (temporaryLogDirectory: string) => { @@ -475,6 +582,7 @@ describe('desktop log export', () => { destinationPath, snapshotFiles, dependencies: failingArchiveDependencies, + fireAndForget: createTestFireAndForget(), }); }, }), diff --git a/electron/src/logging/logExport.ts b/electron/src/logging/logExport.ts index fed3fc37649..bb3cc332b27 100644 --- a/electron/src/logging/logExport.ts +++ b/electron/src/logging/logExport.ts @@ -62,6 +62,7 @@ export type StreamLogFilesToZipOptions = { destinationPath: string; snapshotFiles: readonly LogSnapshotFile[]; dependencies: LogArchiveDependencies; + fireAndForget: (asyncAction: () => Promise) => void; }; export type LogArchiveDependencies = { @@ -295,7 +296,7 @@ export async function streamLogFilesToZip(options: StreamLogFilesToZipOptions): outputStreamCreated = true; const createdOutputCompletion = finished(createdOutputStream); outputCompletion = Maybe.just(createdOutputCompletion); - void waitForPromiseToSettle(createdOutputCompletion); + options.fireAndForget(() => waitForPromiseToSettle(createdOutputCompletion)); const createdArchive = options.dependencies.createArchive(); archive = Maybe.just(createdArchive); createdArchive.pipe(createdOutputStream); @@ -304,9 +305,12 @@ export async function streamLogFilesToZip(options: StreamLogFilesToZipOptions): createdArchive.file(snapshotFile.absolutePath, {name: snapshotFile.relativePath}); } + const archiveFailure = waitForArchiveFailure(createdArchive); + options.fireAndForget(() => waitForPromiseToSettle(archiveFailure)); const createdArchiveFinalization = createdArchive.finalize(); archiveFinalization = Maybe.just(createdArchiveFinalization); - await Promise.race([createdArchiveFinalization, waitForArchiveFailure(createdArchive)]); + await Promise.race([createdArchiveFinalization, archiveFailure, createdOutputCompletion]); + await createdArchiveFinalization; await createdOutputCompletion; } catch (error) { if (archive.isJust) { @@ -318,7 +322,8 @@ export async function streamLogFilesToZip(options: StreamLogFilesToZipOptions): } if (archiveFinalization.isJust) { - await waitForPromiseToSettle(archiveFinalization.value); + const completedArchiveFinalization = archiveFinalization.value; + options.fireAndForget(() => waitForPromiseToSettle(completedArchiveFinalization)); } if (outputCompletion.isJust) { diff --git a/electron/src/logging/loggerUtils.ts b/electron/src/logging/loggerUtils.ts index 6ead2a2bc1a..b49b69a8672 100644 --- a/electron/src/logging/loggerUtils.ts +++ b/electron/src/logging/loggerUtils.ts @@ -20,7 +20,11 @@ import * as os from 'os'; import * as path from 'path'; -import {runDesktopLogCleanupWithinMaintenance, runDesktopLogMaintenance} from './desktopLogWriter'; +import { + fireAndForgetDesktopLogOperation, + runDesktopLogCleanupWithinMaintenance, + runDesktopLogMaintenance, +} from './desktopLogWriter'; import {getLogger} from './getLogger'; import {createLogSnapshot, exportLogFiles, streamLogFilesToZip} from './logExport'; import {createLogArchiveDependencies, createLogSnapshotFileSystemDependencies} from './logExportDependencies'; @@ -67,6 +71,7 @@ export async function exportLogs(destinationPath: string): Promise { destinationPath: snapshotDestinationPath, snapshotFiles, dependencies: archiveDependencies, + fireAndForget: fireAndForgetDesktopLogOperation, }); }, }); From 8d4d461063e737a495edfe2258c47618a8968571 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 28 Aug 2026 13:13:50 +0200 Subject: [PATCH 35/45] Fix log export lifecycle and upgrade adm-zip --- electron/src/logging/logExport.test.main.ts | 18 +++++++----------- electron/src/logging/logExport.ts | 10 ---------- electron/src/logging/loggerUtils.ts | 7 +------ package.json | 2 +- yarn.lock | 10 +++++----- 5 files changed, 14 insertions(+), 33 deletions(-) diff --git a/electron/src/logging/logExport.test.main.ts b/electron/src/logging/logExport.test.main.ts index fb362b72e20..52531999e56 100644 --- a/electron/src/logging/logExport.test.main.ts +++ b/electron/src/logging/logExport.test.main.ts @@ -74,12 +74,6 @@ function createTestMaintenanceCoordinator(): ReturnType Promise) => void { - const invoker = createFireAndForgetInvoker({reportFailure: noop}); - - return invoker.fireAndForget; -} - function createSnapshotTestOptions(options: CreateSnapshotTestOptions): CreateLogSnapshotOptions { return { cleanup: options.cleanup, @@ -364,9 +358,10 @@ describe('desktop log export', () => { await fs.outputFile(path.join(temporaryLogDirectory, rotatedLogRelativePath), 'rotated\n'); await fs.outputFile(path.join(temporaryLogDirectory, legacyLogRelativePath), 'legacy\n'); const archivePath = path.join(temporaryLogDirectory, 'logs.zip'); + const exportFireAndForgetInvoker = createFireAndForgetInvoker({reportFailure: noop}); let createdSnapshot: Maybe = Maybe.nothing(); - await exportLogFiles({ + const exportPromise = exportLogFiles({ async createSnapshot() { const snapshot = await createLogSnapshot( createSnapshotTestOptions({ @@ -393,10 +388,14 @@ describe('desktop log export', () => { destinationPath, snapshotFiles, dependencies: createLogArchiveDependencies(noop), - fireAndForget: createTestFireAndForget(), }); }, }); + exportFireAndForgetInvoker.fireAndForget(() => { + return exportPromise; + }); + await exportPromise; + await exportFireAndForgetInvoker.waitUntilAllSettled(); const createdSnapshotDirectoryPath = createdSnapshot.match({ Just(snapshot) { @@ -474,7 +473,6 @@ describe('desktop log export', () => { destinationPath: archivePath, snapshotFiles: [], dependencies: failingArchiveDependencies, - fireAndForget: createTestFireAndForget(), }), archiveFailure, ); @@ -523,7 +521,6 @@ describe('desktop log export', () => { destinationPath: archivePath, snapshotFiles: [], dependencies: failingArchiveDependencies, - fireAndForget: createTestFireAndForget(), }), outputFailure, ); @@ -582,7 +579,6 @@ describe('desktop log export', () => { destinationPath, snapshotFiles, dependencies: failingArchiveDependencies, - fireAndForget: createTestFireAndForget(), }); }, }), diff --git a/electron/src/logging/logExport.ts b/electron/src/logging/logExport.ts index bb3cc332b27..d3f4a7540d7 100644 --- a/electron/src/logging/logExport.ts +++ b/electron/src/logging/logExport.ts @@ -62,7 +62,6 @@ export type StreamLogFilesToZipOptions = { destinationPath: string; snapshotFiles: readonly LogSnapshotFile[]; dependencies: LogArchiveDependencies; - fireAndForget: (asyncAction: () => Promise) => void; }; export type LogArchiveDependencies = { @@ -287,7 +286,6 @@ export async function streamLogFilesToZip(options: StreamLogFilesToZipOptions): let outputStream: Maybe = Maybe.nothing(); let outputStreamCreated = false; let archive: Maybe = Maybe.nothing(); - let archiveFinalization: Maybe> = Maybe.nothing>(); let outputCompletion: Maybe> = Maybe.nothing>(); try { @@ -296,7 +294,6 @@ export async function streamLogFilesToZip(options: StreamLogFilesToZipOptions): outputStreamCreated = true; const createdOutputCompletion = finished(createdOutputStream); outputCompletion = Maybe.just(createdOutputCompletion); - options.fireAndForget(() => waitForPromiseToSettle(createdOutputCompletion)); const createdArchive = options.dependencies.createArchive(); archive = Maybe.just(createdArchive); createdArchive.pipe(createdOutputStream); @@ -306,9 +303,7 @@ export async function streamLogFilesToZip(options: StreamLogFilesToZipOptions): } const archiveFailure = waitForArchiveFailure(createdArchive); - options.fireAndForget(() => waitForPromiseToSettle(archiveFailure)); const createdArchiveFinalization = createdArchive.finalize(); - archiveFinalization = Maybe.just(createdArchiveFinalization); await Promise.race([createdArchiveFinalization, archiveFailure, createdOutputCompletion]); await createdArchiveFinalization; await createdOutputCompletion; @@ -321,11 +316,6 @@ export async function streamLogFilesToZip(options: StreamLogFilesToZipOptions): outputStream.value.destroy(error instanceof Error ? error : new Error('Log archive output failed')); } - if (archiveFinalization.isJust) { - const completedArchiveFinalization = archiveFinalization.value; - options.fireAndForget(() => waitForPromiseToSettle(completedArchiveFinalization)); - } - if (outputCompletion.isJust) { await waitForPromiseToSettle(outputCompletion.value); } diff --git a/electron/src/logging/loggerUtils.ts b/electron/src/logging/loggerUtils.ts index b49b69a8672..6ead2a2bc1a 100644 --- a/electron/src/logging/loggerUtils.ts +++ b/electron/src/logging/loggerUtils.ts @@ -20,11 +20,7 @@ import * as os from 'os'; import * as path from 'path'; -import { - fireAndForgetDesktopLogOperation, - runDesktopLogCleanupWithinMaintenance, - runDesktopLogMaintenance, -} from './desktopLogWriter'; +import {runDesktopLogCleanupWithinMaintenance, runDesktopLogMaintenance} from './desktopLogWriter'; import {getLogger} from './getLogger'; import {createLogSnapshot, exportLogFiles, streamLogFilesToZip} from './logExport'; import {createLogArchiveDependencies, createLogSnapshotFileSystemDependencies} from './logExportDependencies'; @@ -71,7 +67,6 @@ export async function exportLogs(destinationPath: string): Promise { destinationPath: snapshotDestinationPath, snapshotFiles, dependencies: archiveDependencies, - fireAndForget: fireAndForgetDesktopLogOperation, }); }, }); diff --git a/package.json b/package.json index 17fe90684de..78b31a86e58 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "@wireapp/copy-config": "2.3.13", "@wireapp/eslint-config": "3.1.9", "@wireapp/prettier-config": "0.6.11", - "adm-zip": "0.5.17", + "adm-zip": "0.6.0", "aws-sdk": "2.1693.0", "babel-core": "7.0.0-bridge.0", "babel-eslint": "10.1.0", diff --git a/yarn.lock b/yarn.lock index 09c1e39aaaa..ab0810aeaa9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6050,10 +6050,10 @@ __metadata: languageName: node linkType: hard -"adm-zip@npm:0.5.17": - version: 0.5.17 - resolution: "adm-zip@npm:0.5.17" - checksum: 1b05b8b76538cdf7d34369961866602859941ffbdacf20e10459394f99a60e161ee3b937f15bf485f514745f2376531d42553a6667a67841cb1585a5d7611319 +"adm-zip@npm:0.6.0": + version: 0.6.0 + resolution: "adm-zip@npm:0.6.0" + checksum: b564eb6dd877c1c93c52acef5f18f092a6286e3c6392c7d186ede6765e5c7d1b5006cb870284ec04a01aedc18c1e931cdc294f77ee33488d1e93b29a5c74a5ca languageName: node linkType: hard @@ -21230,7 +21230,7 @@ __metadata: "@wireapp/protocol-messaging": 1.56.0 "@wireapp/react-ui-kit": 9.59.1 "@wireapp/webapp-events": 0.29.2 - adm-zip: 0.5.17 + adm-zip: 0.6.0 archiver: 8.0.0 auto-launch: 5.0.6 aws-sdk: 2.1693.0 From 753e8335c0799d3b048c0c37bafce27076713211 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 28 Aug 2026 13:33:37 +0200 Subject: [PATCH 36/45] Make log export dependencies explicit --- electron/src/logging/logExport.test.main.ts | 131 +++++++++++++++----- electron/src/logging/logExport.ts | 56 +++++---- electron/src/logging/loggerUtils.ts | 12 +- 3 files changed, 138 insertions(+), 61 deletions(-) diff --git a/electron/src/logging/logExport.test.main.ts b/electron/src/logging/logExport.test.main.ts index 52531999e56..b11e051240b 100644 --- a/electron/src/logging/logExport.test.main.ts +++ b/electron/src/logging/logExport.test.main.ts @@ -43,13 +43,12 @@ import {createFireAndForgetInvoker} from '../lib/fireAndForgetInvoker'; type CreateSnapshotTestOptions = { cleanup: () => Promise; - dependencies: LogSnapshotFileSystemDependencies; discoverLogFilePaths: () => readonly string[]; logDirectory: string; maintenanceCoordinator: ReturnType; reportFailure: (message: string, error: unknown) => void; temporaryDirectoryPrefix: string; -}; +} & LogSnapshotFileSystemDependencies; function runNoopCleanup(): Promise { return Promise.resolve(); @@ -75,12 +74,18 @@ function createTestMaintenanceCoordinator(): ReturnType { await fs.outputFile(logFilePath, 'complete entry\n'); }); const events: string[] = []; + const snapshotFileSystemDependencies = createLogSnapshotFileSystemDependencies(); const snapshotPromise = createLogSnapshot( createSnapshotTestOptions({ async cleanup() { events.push('cleanup'); }, - dependencies: createLogSnapshotFileSystemDependencies(), + copyFile: snapshotFileSystemDependencies.copyFile, + createTemporaryDirectory: snapshotFileSystemDependencies.createTemporaryDirectory, discoverLogFilePaths() { events.push('discover'); return ['electron.log']; }, + ensureDirectory: snapshotFileSystemDependencies.ensureDirectory, + getFileMetadata: snapshotFileSystemDependencies.getFileMetadata, logDirectory: temporaryLogDirectory, maintenanceCoordinator, reportFailure: noop, + removeDirectory: snapshotFileSystemDependencies.removeDirectory, temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); @@ -158,22 +168,29 @@ describe('desktop log export', () => { const copiedFiles: Array<{sourceFilePath: string; destinationFilePath: string}> = []; const defaultDependencies = createLogSnapshotFileSystemDependencies(); const dependencies: LogSnapshotFileSystemDependencies = { - ...defaultDependencies, async copyFile(sourceFilePath: string, destinationFilePath: string) { copiedFiles.push({sourceFilePath, destinationFilePath}); await defaultDependencies.copyFile(sourceFilePath, destinationFilePath); }, + createTemporaryDirectory: defaultDependencies.createTemporaryDirectory, + ensureDirectory: defaultDependencies.ensureDirectory, + getFileMetadata: defaultDependencies.getFileMetadata, + removeDirectory: defaultDependencies.removeDirectory, }; const snapshot = await createLogSnapshot( createSnapshotTestOptions({ cleanup: runNoopCleanup, - dependencies, + copyFile: dependencies.copyFile, + createTemporaryDirectory: dependencies.createTemporaryDirectory, discoverLogFilePaths() { return ['electron.log']; }, + ensureDirectory: dependencies.ensureDirectory, + getFileMetadata: dependencies.getFileMetadata, logDirectory: temporaryLogDirectory, maintenanceCoordinator: createTestMaintenanceCoordinator(), reportFailure: noop, + removeDirectory: dependencies.removeDirectory, temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); @@ -210,10 +227,12 @@ describe('desktop log export', () => { await fs.outputFile(path.join(symbolicLinkDirectoryTargetPath, 'console.log'), 'must not be copied\n'); await fs.symlink(symbolicLinkDirectoryTargetPath, symbolicLinkDirectoryPath, 'dir'); + const snapshotFileSystemDependencies = createLogSnapshotFileSystemDependencies(); const snapshot = await createLogSnapshot( createSnapshotTestOptions({ cleanup: runNoopCleanup, - dependencies: createLogSnapshotFileSystemDependencies(), + copyFile: snapshotFileSystemDependencies.copyFile, + createTemporaryDirectory: snapshotFileSystemDependencies.createTemporaryDirectory, discoverLogFilePaths() { return [ currentLogRelativePath, @@ -223,9 +242,12 @@ describe('desktop log export', () => { 'linked-account/console.log', ]; }, + ensureDirectory: snapshotFileSystemDependencies.ensureDirectory, + getFileMetadata: snapshotFileSystemDependencies.getFileMetadata, logDirectory: temporaryLogDirectory, maintenanceCoordinator: createTestMaintenanceCoordinator(), reportFailure: noop, + removeDirectory: snapshotFileSystemDependencies.removeDirectory, temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); @@ -261,11 +283,14 @@ describe('desktop log export', () => { const removedSnapshotDirectories: string[] = []; const defaultDependencies = createLogSnapshotFileSystemDependencies(); const dependencies: LogSnapshotFileSystemDependencies = { - ...defaultDependencies, async removeDirectory(directoryPath: string) { removedSnapshotDirectories.push(directoryPath); await defaultDependencies.removeDirectory(directoryPath); }, + copyFile: defaultDependencies.copyFile, + createTemporaryDirectory: defaultDependencies.createTemporaryDirectory, + ensureDirectory: defaultDependencies.ensureDirectory, + getFileMetadata: defaultDependencies.getFileMetadata, }; try { @@ -273,13 +298,17 @@ describe('desktop log export', () => { createLogSnapshot( createSnapshotTestOptions({ cleanup: runNoopCleanup, - dependencies, + copyFile: dependencies.copyFile, + createTemporaryDirectory: dependencies.createTemporaryDirectory, discoverLogFilePaths() { return [relativeEscapePath]; }, + ensureDirectory: dependencies.ensureDirectory, + getFileMetadata: dependencies.getFileMetadata, logDirectory: temporaryLogDirectory, maintenanceCoordinator: createTestMaintenanceCoordinator(), reportFailure: noop, + removeDirectory: dependencies.removeDirectory, temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ), @@ -299,16 +328,21 @@ describe('desktop log export', () => { withTemporaryDirectory('wire-log-export-resume-', async (temporaryLogDirectory: string) => { await fs.outputFile(path.join(temporaryLogDirectory, 'electron.log'), 'before archive\n'); const maintenanceCoordinator = createTestMaintenanceCoordinator(); + const snapshotFileSystemDependencies = createLogSnapshotFileSystemDependencies(); const snapshotPromise = createLogSnapshot( createSnapshotTestOptions({ cleanup: runNoopCleanup, - dependencies: createLogSnapshotFileSystemDependencies(), + copyFile: snapshotFileSystemDependencies.copyFile, + createTemporaryDirectory: snapshotFileSystemDependencies.createTemporaryDirectory, discoverLogFilePaths() { return ['electron.log']; }, + ensureDirectory: snapshotFileSystemDependencies.ensureDirectory, + getFileMetadata: snapshotFileSystemDependencies.getFileMetadata, logDirectory: temporaryLogDirectory, maintenanceCoordinator, reportFailure: noop, + removeDirectory: snapshotFileSystemDependencies.removeDirectory, temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); @@ -358,6 +392,8 @@ describe('desktop log export', () => { await fs.outputFile(path.join(temporaryLogDirectory, rotatedLogRelativePath), 'rotated\n'); await fs.outputFile(path.join(temporaryLogDirectory, legacyLogRelativePath), 'legacy\n'); const archivePath = path.join(temporaryLogDirectory, 'logs.zip'); + const snapshotFileSystemDependencies = createLogSnapshotFileSystemDependencies(); + const archiveDependencies = createLogArchiveDependencies(noop); const exportFireAndForgetInvoker = createFireAndForgetInvoker({reportFailure: noop}); let createdSnapshot: Maybe = Maybe.nothing(); @@ -366,13 +402,17 @@ describe('desktop log export', () => { const snapshot = await createLogSnapshot( createSnapshotTestOptions({ cleanup: runNoopCleanup, - dependencies: createLogSnapshotFileSystemDependencies(), + copyFile: snapshotFileSystemDependencies.copyFile, + createTemporaryDirectory: snapshotFileSystemDependencies.createTemporaryDirectory, discoverLogFilePaths() { return getLogFilenames({absolute: false, baseDirectory: temporaryLogDirectory}); }, + ensureDirectory: snapshotFileSystemDependencies.ensureDirectory, + getFileMetadata: snapshotFileSystemDependencies.getFileMetadata, logDirectory: temporaryLogDirectory, maintenanceCoordinator: createTestMaintenanceCoordinator(), reportFailure: noop, + removeDirectory: snapshotFileSystemDependencies.removeDirectory, temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); @@ -381,13 +421,17 @@ describe('desktop log export', () => { return snapshot; }, destinationPath: archivePath, - removeSnapshotDirectory: createLogSnapshotFileSystemDependencies().removeDirectory, + removeSnapshotDirectory: snapshotFileSystemDependencies.removeDirectory, reportFailure: noop, async streamSnapshot({destinationPath, snapshotFiles}) { await streamLogFilesToZip({ + createArchive: archiveDependencies.createArchive, + createOutputStream: archiveDependencies.createOutputStream, destinationPath, + pathExists: archiveDependencies.pathExists, + removeFile: archiveDependencies.removeFile, + reportFailure: archiveDependencies.reportFailure, snapshotFiles, - dependencies: createLogArchiveDependencies(noop), }); }, }); @@ -438,13 +482,6 @@ describe('desktop log export', () => { const archiveDependencies = createLogArchiveDependencies(noop); let outputStream: Maybe = Maybe.nothing(); const failingArchiveDependencies = { - ...archiveDependencies, - createOutputStream(destinationPath: string) { - const createdOutputStream = archiveDependencies.createOutputStream(destinationPath); - outputStream = Maybe.just(createdOutputStream); - - return createdOutputStream; - }, createArchive() { const createdArchive = archiveDependencies.createArchive(); createdArchive.on('error', noop); @@ -466,13 +503,26 @@ describe('desktop log export', () => { return createdArchive; }, + createOutputStream(destinationPath: string) { + const createdOutputStream = archiveDependencies.createOutputStream(destinationPath); + outputStream = Maybe.just(createdOutputStream); + + return createdOutputStream; + }, + pathExists: archiveDependencies.pathExists, + removeFile: archiveDependencies.removeFile, + reportFailure: archiveDependencies.reportFailure, }; await assert.rejects( streamLogFilesToZip({ + createArchive: failingArchiveDependencies.createArchive, + createOutputStream: failingArchiveDependencies.createOutputStream, destinationPath: archivePath, + pathExists: failingArchiveDependencies.pathExists, + removeFile: failingArchiveDependencies.removeFile, + reportFailure: failingArchiveDependencies.reportFailure, snapshotFiles: [], - dependencies: failingArchiveDependencies, }), archiveFailure, ); @@ -489,13 +539,6 @@ describe('desktop log export', () => { const archiveDependencies = createLogArchiveDependencies(noop); let outputStream: Maybe = Maybe.nothing(); const failingArchiveDependencies = { - ...archiveDependencies, - createOutputStream(destinationPath: string) { - const createdOutputStream = archiveDependencies.createOutputStream(destinationPath); - outputStream = Maybe.just(createdOutputStream); - - return createdOutputStream; - }, createArchive() { const createdArchive = archiveDependencies.createArchive(); createdArchive.finalize = function finalize(): Promise { @@ -514,13 +557,26 @@ describe('desktop log export', () => { return createdArchive; }, + createOutputStream(destinationPath: string) { + const createdOutputStream = archiveDependencies.createOutputStream(destinationPath); + outputStream = Maybe.just(createdOutputStream); + + return createdOutputStream; + }, + pathExists: archiveDependencies.pathExists, + removeFile: archiveDependencies.removeFile, + reportFailure: archiveDependencies.reportFailure, }; await assert.rejects( streamLogFilesToZip({ + createArchive: failingArchiveDependencies.createArchive, + createOutputStream: failingArchiveDependencies.createOutputStream, destinationPath: archivePath, + pathExists: failingArchiveDependencies.pathExists, + removeFile: failingArchiveDependencies.removeFile, + reportFailure: failingArchiveDependencies.reportFailure, snapshotFiles: [], - dependencies: failingArchiveDependencies, }), outputFailure, ); @@ -538,20 +594,24 @@ describe('desktop log export', () => { const snapshotPromise = createLogSnapshot( createSnapshotTestOptions({ cleanup: runNoopCleanup, - dependencies: snapshotFileSystemDependencies, + copyFile: snapshotFileSystemDependencies.copyFile, + createTemporaryDirectory: snapshotFileSystemDependencies.createTemporaryDirectory, discoverLogFilePaths() { return ['electron.log']; }, + ensureDirectory: snapshotFileSystemDependencies.ensureDirectory, + getFileMetadata: snapshotFileSystemDependencies.getFileMetadata, logDirectory: temporaryLogDirectory, maintenanceCoordinator: createTestMaintenanceCoordinator(), reportFailure: noop, + removeDirectory: snapshotFileSystemDependencies.removeDirectory, temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), }), ); let wrotePartialArchive = false; const archiveDependencies = createLogArchiveDependencies(noop); const failingArchiveDependencies = { - ...archiveDependencies, + createArchive: archiveDependencies.createArchive, createOutputStream() { return new Writable({ write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error) => void): void { @@ -564,6 +624,9 @@ describe('desktop log export', () => { }, }); }, + pathExists: archiveDependencies.pathExists, + removeFile: archiveDependencies.removeFile, + reportFailure: archiveDependencies.reportFailure, }; await assert.rejects( @@ -576,9 +639,13 @@ describe('desktop log export', () => { reportFailure: noop, async streamSnapshot({destinationPath, snapshotFiles}) { await streamLogFilesToZip({ + createArchive: failingArchiveDependencies.createArchive, + createOutputStream: failingArchiveDependencies.createOutputStream, destinationPath, + pathExists: failingArchiveDependencies.pathExists, + removeFile: failingArchiveDependencies.removeFile, + reportFailure: failingArchiveDependencies.reportFailure, snapshotFiles, - dependencies: failingArchiveDependencies, }); }, }), diff --git a/electron/src/logging/logExport.ts b/electron/src/logging/logExport.ts index d3f4a7540d7..2661da6d296 100644 --- a/electron/src/logging/logExport.ts +++ b/electron/src/logging/logExport.ts @@ -55,14 +55,12 @@ export type CreateLogSnapshotOptions = { reportFailure: (message: string, error: unknown) => void; runMaintenance(operation: () => Promise): Promise; temporaryDirectoryPrefix: string; - dependencies: LogSnapshotFileSystemDependencies; -}; +} & LogSnapshotFileSystemDependencies; export type StreamLogFilesToZipOptions = { destinationPath: string; snapshotFiles: readonly LogSnapshotFile[]; - dependencies: LogArchiveDependencies; -}; +} & LogArchiveDependencies; export type LogArchiveDependencies = { createArchive: () => ZipArchive; @@ -95,21 +93,19 @@ type CopyLogFileToSnapshotOptions = { relativePath: string; sourceFilePath: string; snapshotFilePath: string; - dependencies: LogSnapshotFileSystemDependencies; -}; +} & Pick; type GetSafeSourceFileMetadataOptions = { logDirectory: string; sourceFilePath: string; - dependencies: LogSnapshotFileSystemDependencies; + getFileMetadata: LogSnapshotFileSystemDependencies['getFileMetadata']; }; type RemoveIncompleteDestinationFileOptions = { destinationPath: string; destinationAlreadyExisted: boolean; outputStreamCreated: boolean; - exportOptions: StreamLogFilesToZipOptions; -}; +} & Pick; function isMissingFileError(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT'; @@ -144,6 +140,7 @@ function getArchiveRelativePath(relativePath: string): string { async function getSafeSourceFileMetadata( options: GetSafeSourceFileMetadataOptions, ): Promise> { + const {getFileMetadata} = options; const resolvedLogDirectory = path.resolve(options.logDirectory); const sourceRelativePath = path.relative(resolvedLogDirectory, options.sourceFilePath); let currentPath = resolvedLogDirectory; @@ -151,7 +148,7 @@ async function getSafeSourceFileMetadata( for (const pathPart of sourceRelativePath.split(path.sep)) { currentPath = path.join(currentPath, pathPart); - const currentFileMetadata = await options.dependencies.getFileMetadata(currentPath); + const currentFileMetadata = await getFileMetadata(currentPath); sourceFileMetadata = Maybe.just(currentFileMetadata); if (currentFileMetadata.isSymbolicLink === true) { @@ -163,8 +160,9 @@ async function getSafeSourceFileMetadata( } async function copyLogFileToSnapshot(options: CopyLogFileToSnapshotOptions): Promise> { + const {copyFile, ensureDirectory, getFileMetadata} = options; const sourceFileMetadata = await getSafeSourceFileMetadata({ - dependencies: options.dependencies, + getFileMetadata, logDirectory: options.logDirectory, sourceFilePath: options.sourceFilePath, }); @@ -177,8 +175,8 @@ async function copyLogFileToSnapshot(options: CopyLogFileToSnapshotOptions): Pro return Maybe.nothing(); } - await options.dependencies.ensureDirectory(path.dirname(options.snapshotFilePath)); - await options.dependencies.copyFile(options.sourceFilePath, options.snapshotFilePath); + await ensureDirectory(path.dirname(options.snapshotFilePath)); + await copyFile(options.sourceFilePath, options.snapshotFilePath); return Maybe.just({ absolutePath: options.snapshotFilePath, @@ -190,18 +188,21 @@ async function removeSnapshotDirectoryAfterFailure( snapshotDirectoryPath: Maybe, options: CreateLogSnapshotOptions, ): Promise { + const {removeDirectory} = options; + if (snapshotDirectoryPath.isJust === false) { return; } try { - await options.dependencies.removeDirectory(snapshotDirectoryPath.value); + await removeDirectory(snapshotDirectoryPath.value); } catch (error) { options.reportFailure(`Failed to remove temporary log snapshot "${snapshotDirectoryPath.value}"`, error); } } export async function createLogSnapshot(options: CreateLogSnapshotOptions): Promise { + const {copyFile, createTemporaryDirectory, ensureDirectory, getFileMetadata} = options; let snapshotDirectoryPath: Maybe = Maybe.nothing(); try { @@ -209,9 +210,7 @@ export async function createLogSnapshot(options: CreateLogSnapshotOptions): Prom await options.cleanup(); const relativeLogFilePaths = options.discoverLogFilePaths(); - const createdSnapshotDirectoryPath = await options.dependencies.createTemporaryDirectory( - options.temporaryDirectoryPrefix, - ); + const createdSnapshotDirectoryPath = await createTemporaryDirectory(options.temporaryDirectoryPrefix); snapshotDirectoryPath = Maybe.just(createdSnapshotDirectoryPath); const snapshotFiles: LogSnapshotFile[] = []; @@ -223,7 +222,9 @@ export async function createLogSnapshot(options: CreateLogSnapshotOptions): Prom }); const snapshotFile = await copyLogFileToSnapshot({ - dependencies: options.dependencies, + copyFile, + ensureDirectory, + getFileMetadata, logDirectory: options.logDirectory, relativePath, sourceFilePath, @@ -248,21 +249,20 @@ export async function createLogSnapshot(options: CreateLogSnapshotOptions): Prom } async function removeIncompleteDestinationFile(options: RemoveIncompleteDestinationFileOptions): Promise { + const {removeFile, reportFailure} = options; + if (options.destinationAlreadyExisted || options.outputStreamCreated === false) { return; } try { - await options.exportOptions.dependencies.removeFile(options.destinationPath); + await removeFile(options.destinationPath); } catch (error) { if (isMissingFileError(error)) { return; } - options.exportOptions.dependencies.reportFailure( - `Failed to remove incomplete log archive "${options.destinationPath}"`, - error, - ); + reportFailure(`Failed to remove incomplete log archive "${options.destinationPath}"`, error); } } @@ -282,19 +282,20 @@ async function waitForPromiseToSettle(promise: Promise): Promise { } export async function streamLogFilesToZip(options: StreamLogFilesToZipOptions): Promise { - const destinationAlreadyExisted = await options.dependencies.pathExists(options.destinationPath); + const {createArchive, createOutputStream, pathExists, removeFile, reportFailure} = options; + const destinationAlreadyExisted = await pathExists(options.destinationPath); let outputStream: Maybe = Maybe.nothing(); let outputStreamCreated = false; let archive: Maybe = Maybe.nothing(); let outputCompletion: Maybe> = Maybe.nothing>(); try { - const createdOutputStream = options.dependencies.createOutputStream(options.destinationPath); + const createdOutputStream = createOutputStream(options.destinationPath); outputStream = Maybe.just(createdOutputStream); outputStreamCreated = true; const createdOutputCompletion = finished(createdOutputStream); outputCompletion = Maybe.just(createdOutputCompletion); - const createdArchive = options.dependencies.createArchive(); + const createdArchive = createArchive(); archive = Maybe.just(createdArchive); createdArchive.pipe(createdOutputStream); @@ -324,7 +325,8 @@ export async function streamLogFilesToZip(options: StreamLogFilesToZipOptions): destinationPath: options.destinationPath, destinationAlreadyExisted, outputStreamCreated, - exportOptions: options, + removeFile, + reportFailure, }); throw error; diff --git a/electron/src/logging/loggerUtils.ts b/electron/src/logging/loggerUtils.ts index 6ead2a2bc1a..9f7ac100171 100644 --- a/electron/src/logging/loggerUtils.ts +++ b/electron/src/logging/loggerUtils.ts @@ -54,7 +54,11 @@ export async function exportLogs(destinationPath: string): Promise { }, runMaintenance: runDesktopLogMaintenance, temporaryDirectoryPrefix: path.join(os.tmpdir(), 'wire-log-snapshot-'), - dependencies: snapshotFileSystemDependencies, + copyFile: snapshotFileSystemDependencies.copyFile, + createTemporaryDirectory: snapshotFileSystemDependencies.createTemporaryDirectory, + ensureDirectory: snapshotFileSystemDependencies.ensureDirectory, + getFileMetadata: snapshotFileSystemDependencies.getFileMetadata, + removeDirectory: snapshotFileSystemDependencies.removeDirectory, }); }, destinationPath, @@ -64,9 +68,13 @@ export async function exportLogs(destinationPath: string): Promise { }, streamSnapshot({destinationPath: snapshotDestinationPath, snapshotFiles}) { return streamLogFilesToZip({ + createArchive: archiveDependencies.createArchive, + createOutputStream: archiveDependencies.createOutputStream, destinationPath: snapshotDestinationPath, + pathExists: archiveDependencies.pathExists, + removeFile: archiveDependencies.removeFile, + reportFailure: archiveDependencies.reportFailure, snapshotFiles, - dependencies: archiveDependencies, }); }, }); From 30c1c2e29a06c0e6d3034185ea0a65912fd97b07 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Wed, 2 Sep 2026 08:56:52 +0200 Subject: [PATCH 37/45] Register webview handlers before log cleanup Register the Electron web contents handlers synchronously during startup before the application can create BrowserWindows or WebViews. Keep initial log cleanup asynchronous and rely on the existing log maintenance coordinator to serialize cleanup and log writes. --- .../src/lib/applicationBootstrap.test.main.ts | 53 ++++++++++++++++++ electron/src/lib/applicationBootstrap.ts | 35 ++++++++++++ electron/src/logging/logStartup.test.main.ts | 29 +++------- electron/src/logging/logStartup.ts | 14 ++--- electron/src/mainProcess.ts | 55 +++++++++++-------- 5 files changed, 135 insertions(+), 51 deletions(-) create mode 100644 electron/src/lib/applicationBootstrap.test.main.ts create mode 100644 electron/src/lib/applicationBootstrap.ts diff --git a/electron/src/lib/applicationBootstrap.test.main.ts b/electron/src/lib/applicationBootstrap.test.main.ts new file mode 100644 index 00000000000..42bf600eb6f --- /dev/null +++ b/electron/src/lib/applicationBootstrap.test.main.ts @@ -0,0 +1,53 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * 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 {initializeFirstInstance} from './applicationBootstrap'; + +describe('first-instance application bootstrap', () => { + it('initializes Electron handlers before app events and log maintenance', () => { + const events: string[] = []; + + initializeFirstInstance({ + bindIpcEvents() { + events.push('bind-ipc-events'); + }, + ensureMainProcessLogFile() { + events.push('ensure-main-process-log-file'); + }, + handleAppEvents() { + events.push('handle-app-events'); + }, + initializeElectronWrapper() { + events.push('initialize-electron-wrapper'); + }, + startDesktopLogLifecycle() { + events.push('start-desktop-log-lifecycle'); + }, + }); + + const actualCriticalEvents = events.filter(event => + ['initialize-electron-wrapper', 'handle-app-events', 'start-desktop-log-lifecycle'].includes(event), + ); + const expectedCriticalEvents = ['initialize-electron-wrapper', 'handle-app-events', 'start-desktop-log-lifecycle']; + + assert.deepStrictEqual(actualCriticalEvents, expectedCriticalEvents); + }); +}); diff --git a/electron/src/lib/applicationBootstrap.ts b/electron/src/lib/applicationBootstrap.ts new file mode 100644 index 00000000000..0dd98a82e3d --- /dev/null +++ b/electron/src/lib/applicationBootstrap.ts @@ -0,0 +1,35 @@ +/* + * Wire + * 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export type InitializeFirstInstanceOptions = { + bindIpcEvents: () => void; + ensureMainProcessLogFile: () => void; + handleAppEvents: () => void; + initializeElectronWrapper: () => void; + startDesktopLogLifecycle: () => void; +}; + +export function initializeFirstInstance(options: InitializeFirstInstanceOptions): void { + options.bindIpcEvents(); + options.ensureMainProcessLogFile(); + // Electron does not replay web-contents-created, so handlers must be registered before app events can create windows. + options.initializeElectronWrapper(); + options.handleAppEvents(); + options.startDesktopLogLifecycle(); +} diff --git a/electron/src/logging/logStartup.test.main.ts b/electron/src/logging/logStartup.test.main.ts index 3cc455338ec..3e991b1b1a7 100644 --- a/electron/src/logging/logStartup.test.main.ts +++ b/electron/src/logging/logStartup.test.main.ts @@ -33,22 +33,19 @@ function createDeferredCompletion(): {promise: Promise; resolve: () => voi } describe('desktop log startup', () => { - it('completes cleanup before scheduling and initializing webview logging', async () => { + it('completes initial cleanup before scheduling periodic cleanup', async () => { const events: string[] = []; const cleanupCompletion = createDeferredCompletion(); const startup = initializeDesktopLogLifecycle({ - async initializeWebviewLogging(): Promise { - events.push('initialize-webview-logging'); - }, - reportCleanupFailure(): void { + reportCleanupFailure() { events.push('cleanup-failed'); }, - async runInitialCleanup(): Promise { + async runInitialCleanup() { events.push('cleanup-started'); await cleanupCompletion.promise; events.push('cleanup-completed'); }, - schedulePeriodicCleanup(): void { + schedulePeriodicCleanup() { events.push('schedule-cleanup'); }, }); @@ -60,12 +57,7 @@ describe('desktop log startup', () => { cleanupCompletion.resolve(); await startup; - assert.deepStrictEqual(events, [ - 'cleanup-started', - 'cleanup-completed', - 'schedule-cleanup', - 'initialize-webview-logging', - ]); + assert.deepStrictEqual(events, ['cleanup-started', 'cleanup-completed', 'schedule-cleanup']); }); it('reports cleanup failure and continues startup', async () => { @@ -73,21 +65,18 @@ describe('desktop log startup', () => { const cleanupFailure = new Error('cleanup failed'); await initializeDesktopLogLifecycle({ - async initializeWebviewLogging(): Promise { - events.push('initialize-webview-logging'); - }, - reportCleanupFailure(error: unknown): void { + reportCleanupFailure(error: unknown) { assert.strictEqual(error, cleanupFailure); events.push('cleanup-failed'); }, - async runInitialCleanup(): Promise { + async runInitialCleanup() { throw cleanupFailure; }, - schedulePeriodicCleanup(): void { + schedulePeriodicCleanup() { events.push('schedule-cleanup'); }, }); - assert.deepStrictEqual(events, ['cleanup-failed', 'schedule-cleanup', 'initialize-webview-logging']); + assert.deepStrictEqual(events, ['cleanup-failed', 'schedule-cleanup']); }); }); diff --git a/electron/src/logging/logStartup.ts b/electron/src/logging/logStartup.ts index ba1429eb1e5..1f84acf0576 100644 --- a/electron/src/logging/logStartup.ts +++ b/electron/src/logging/logStartup.ts @@ -17,22 +17,18 @@ * */ -export type InitializeDesktopLogLifecycleParameters = { - initializeWebviewLogging: () => Promise; +export type InitializeDesktopLogLifecycleOptions = { reportCleanupFailure: (error: unknown) => void; runInitialCleanup: () => Promise; schedulePeriodicCleanup: () => void; }; -export async function initializeDesktopLogLifecycle( - parameters: InitializeDesktopLogLifecycleParameters, -): Promise { +export async function initializeDesktopLogLifecycle(options: InitializeDesktopLogLifecycleOptions): Promise { try { - await parameters.runInitialCleanup(); + await options.runInitialCleanup(); } catch (error) { - parameters.reportCleanupFailure(error); + options.reportCleanupFailure(error); } - parameters.schedulePeriodicCleanup(); - await parameters.initializeWebviewLogging(); + options.schedulePeriodicCleanup(); } diff --git a/electron/src/mainProcess.ts b/electron/src/mainProcess.ts index d317b612a6b..23a9b7150e1 100644 --- a/electron/src/mainProcess.ts +++ b/electron/src/mainProcess.ts @@ -47,6 +47,7 @@ import {WebAppEvents} from '@wireapp/webapp-events'; import * as ProxyAuth from './auth/ProxyAuth'; import {getPictureInPictureCallWindowOptions, isPictureInPictureCallWindow} from './calling/PictureInPictureCall'; +import {initializeFirstInstance} from './lib/applicationBootstrap'; import { attachTo as attachCertificateVerifyProcManagerTo, setCertificateVerifyProc, @@ -595,7 +596,7 @@ class ElectronWrapperInit { ipcMain.on(WebAppEvents.LIFECYCLE.SSO_WINDOW_FOCUS, this.focusSSOWindow); } - async run(): Promise { + run(): void { this.logger.log('webviewProtection init'); this.webviewProtection(); } @@ -813,28 +814,38 @@ lifecycle.addRelaunchListeners(async () => { // Stop further execution on update to prevent second tray icon if (lifecycle.isFirstInstance) { addLinuxWorkarounds(); - bindIpcEvents(); - handleAppEvents(); - fs.ensureFileSync(getMainProcessLogPath({date: new Date(), logDirectory: getLogDirectory()})); - mainProcessFireAndForgetInvoker.fireAndForget(async (): Promise => { - await initializeDesktopLogLifecycle({ - async initializeWebviewLogging(): Promise { - await new ElectronWrapperInit().run(); - }, - reportCleanupFailure(error: unknown): void { - logger.error('Failed to complete initial desktop log cleanup.', error); - }, - runInitialCleanup: runDesktopLogCleanup, - schedulePeriodicCleanup(): void { - scheduleLogCleanup({ - fireAndForget: mainProcessFireAndForgetInvoker.fireAndForget, - intervalMilliseconds: LOG_CLEANUP_INTERVAL_MILLISECONDS, - runCleanup: runDesktopLogCleanup, - setInterval(callback: () => void, intervalMilliseconds: number): NodeJS.Timeout { - return setInterval(callback, intervalMilliseconds); + initializeFirstInstance({ + bindIpcEvents, + ensureMainProcessLogFile() { + fs.ensureFileSync(getMainProcessLogPath({date: new Date(), logDirectory: getLogDirectory()})); + }, + handleAppEvents, + initializeElectronWrapper() { + try { + new ElectronWrapperInit().run(); + } catch (error) { + logger.error(error); + } + }, + startDesktopLogLifecycle() { + mainProcessFireAndForgetInvoker.fireAndForget(async () => { + await initializeDesktopLogLifecycle({ + reportCleanupFailure(error: unknown) { + logger.error('Failed to complete initial desktop log cleanup.', error); + }, + runInitialCleanup: runDesktopLogCleanup, + schedulePeriodicCleanup() { + scheduleLogCleanup({ + fireAndForget: mainProcessFireAndForgetInvoker.fireAndForget, + intervalMilliseconds: LOG_CLEANUP_INTERVAL_MILLISECONDS, + runCleanup: runDesktopLogCleanup, + setInterval(callback: () => void, intervalMilliseconds: number): NodeJS.Timeout { + return setInterval(callback, intervalMilliseconds); + }, + }); }, }); - }, - }); + }); + }, }); } From 4bbd55727d7f3ff715cf1590466d4e9cb6f802c9 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Thu, 3 Sep 2026 13:30:08 +0200 Subject: [PATCH 38/45] Expose the system regional locale to the WebApp Read Electron's system locale after app readiness and include it in DesktopAppConfig so the WebApp can format numeric dates according to the operating system's regional settings. Replace the managed-config-specific IPC boundary with a cohesive Desktop configuration boundary. Keep the regional locale optional so older WebApp versions and IPC fallback paths continue to work. --- .../src/lib/desktopAppConfig.test.main.ts | 29 +++++++++++++++++-- electron/src/lib/desktopAppConfig.ts | 12 +++++++- electron/src/lib/eventType.ts | 6 ++-- electron/src/mainProcess.ts | 22 ++++++++++++-- electron/src/preload/preload-webview.ts | 13 +++++---- 5 files changed, 68 insertions(+), 14 deletions(-) diff --git a/electron/src/lib/desktopAppConfig.test.main.ts b/electron/src/lib/desktopAppConfig.test.main.ts index 705ac02ac00..92378d1906c 100644 --- a/electron/src/lib/desktopAppConfig.test.main.ts +++ b/electron/src/lib/desktopAppConfig.test.main.ts @@ -22,9 +22,34 @@ import assert from 'node:assert'; import {createDesktopAppConfig} from './desktopAppConfig'; describe('createDesktopAppConfig', () => { - it('exposes support for refreshing existing webviews', () => { - const desktopAppConfig = createDesktopAppConfig('3.42.0', {applockOverride: false}); + it('exposes the supplied regional locale unchanged', () => { + const desktopAppConfig = createDesktopAppConfig({ + managedConfig: {applockOverride: false}, + regionalLocale: 'de-DE', + version: '3.42.0', + }); + assert.strictEqual(desktopAppConfig.regionalLocale, 'de-DE'); + }); + + it('preserves the existing version, managed config and capability flags', () => { + const desktopAppConfig = createDesktopAppConfig({ + managedConfig: {applockOverride: true}, + version: '3.42.0', + }); + + assert.strictEqual(desktopAppConfig.version, '3.42.0'); + assert.deepStrictEqual(desktopAppConfig.managedConfig, {applockOverride: true}); + assert.strictEqual(desktopAppConfig.supportsCallingPopoutWindow, true); assert.strictEqual(desktopAppConfig.supportsWebViewRefresh, true); }); + + it('leaves the regional locale absent when it is not supplied', () => { + const desktopAppConfig = createDesktopAppConfig({ + managedConfig: {applockOverride: false}, + version: '3.42.0', + }); + + assert.strictEqual(desktopAppConfig.regionalLocale, undefined); + }); }); diff --git a/electron/src/lib/desktopAppConfig.ts b/electron/src/lib/desktopAppConfig.ts index db3326b701b..1e19a9090ed 100644 --- a/electron/src/lib/desktopAppConfig.ts +++ b/electron/src/lib/desktopAppConfig.ts @@ -21,14 +21,24 @@ import type {ManagedConfig} from '../managed/ManagedConfig'; export type DesktopAppConfig = { readonly version: string; + readonly regionalLocale?: string; readonly supportsCallingPopoutWindow?: boolean; readonly supportsWebViewRefresh?: boolean; readonly managedConfig?: ManagedConfig; }; -export function createDesktopAppConfig(version: string, managedConfig: ManagedConfig): DesktopAppConfig { +type CreateDesktopAppConfigOptions = { + readonly managedConfig: ManagedConfig; + readonly regionalLocale?: string; + readonly version: string; +}; + +export function createDesktopAppConfig(options: CreateDesktopAppConfigOptions): DesktopAppConfig { + const {managedConfig, regionalLocale, version} = options; + return { version, + regionalLocale, supportsCallingPopoutWindow: true, supportsWebViewRefresh: true, managedConfig, diff --git a/electron/src/lib/eventType.ts b/electron/src/lib/eventType.ts index 980282c3b4f..33c639300e3 100644 --- a/electron/src/lib/eventType.ts +++ b/electron/src/lib/eventType.ts @@ -61,6 +61,9 @@ export const EVENT_TYPE = { TOGGLE_MUTE: 'EVENT_TYPE.CONVERSATION.TOGGLE_MUTE', VIDEO_CALL: 'EVENT_TYPE.CONVERSATION.VIDEO_CALL', }, + DESKTOP: { + GET_CONFIG: 'EVENT_TYPE.DESKTOP.GET_CONFIG', + }, EDIT: { COPY: 'EVENT_TYPE.EDIT.COPY', CUT: 'EVENT_TYPE.EDIT.CUT', @@ -75,9 +78,6 @@ export const EVENT_TYPE = { SIGN_OUT: 'EVENT_TYPE.LIFECYCLE.SIGN_OUT', UNREAD_COUNT: 'EVENT_TYPE.LIFECYCLE.UNREAD_COUNT', }, - MANAGED: { - GET_CONFIG: 'EVENT_TYPE.MANAGED.GET_CONFIG', - }, PREFERENCES: { SHOW: 'EVENT_TYPE.PREFERENCES.SHOW', }, diff --git a/electron/src/mainProcess.ts b/electron/src/mainProcess.ts index 23a9b7150e1..cb3a10a6c06 100644 --- a/electron/src/mainProcess.ts +++ b/electron/src/mainProcess.ts @@ -53,6 +53,8 @@ import { setCertificateVerifyProc, } from './lib/CertificateVerifyProcManager'; import {CustomProtocolHandler} from './lib/CoreProtocol'; +import {createDesktopAppConfig} from './lib/desktopAppConfig'; +import type {DesktopAppConfig} from './lib/desktopAppConfig'; import {downloadImage} from './lib/download'; import {EVENT_TYPE} from './lib/eventType'; import {createFireAndForgetInvoker} from './lib/fireAndForgetInvoker'; @@ -191,6 +193,7 @@ let tray: TrayHandler; let isFullScreen = false; let isQuitting = false; let main: BrowserWindow; +let desktopAppConfig: DesktopAppConfig | undefined; Object.entries(config).forEach(([key, value]) => { if (typeof value === 'undefined' || (typeof value === 'number' && isNaN(value))) { @@ -232,9 +235,10 @@ const bindIpcEvents = (): void => { ipcMain.on(EVENT_TYPE.ABOUT.SHOW, () => AboutWindow.showWindow()); // Answered synchronously: the webview preload reads this via `ipcRenderer.sendSync` while it builds - // `window.desktopAppConfig`. The value is pre-read and memoized, so the handler does no I/O here. - ipcMain.on(EVENT_TYPE.MANAGED.GET_CONFIG, event => { - event.returnValue = getManagedConfig(); + // `window.desktopAppConfig`. The config is resolved after app readiness and the handler only returns + // the cached serializable value. + ipcMain.on(EVENT_TYPE.DESKTOP.GET_CONFIG, event => { + event.returnValue = desktopAppConfig; }); ipcMain.handle(EVENT_TYPE.ACTION.GET_OG_DATA, (_event, url) => getOpenGraphDataAsync(url)); @@ -510,6 +514,18 @@ const handleAppEvents = (): void => { // System Menu, Tray Icon & Show window app.on('ready', async () => { + let regionalLocale: string | undefined; + try { + regionalLocale = app.getSystemLocale(); + } catch (error) { + logger.warn('Failed to read the system regional locale, omitting it from the desktop config:', error); + } + desktopAppConfig = createDesktopAppConfig({ + managedConfig: getManagedConfig(), + regionalLocale, + version: EnvironmentUtil.app.DESKTOP_VERSION, + }); + const mainWindowState = initWindowStateKeeper(); const appMenu = systemMenu.createMenu(isFullScreen, wallClock); if (EnvironmentUtil.app.IS_DEVELOPMENT) { diff --git a/electron/src/preload/preload-webview.ts b/electron/src/preload/preload-webview.ts index 2bbbfb85dcd..42c3dc6ef62 100644 --- a/electron/src/preload/preload-webview.ts +++ b/electron/src/preload/preload-webview.ts @@ -289,14 +289,17 @@ process.once('loaded', () => { }; global.environment = EnvironmentUtil; // Read synchronously so the value is present at the exact point `desktopAppConfig` is assigned. - // The main-process handler returns a pre-read, memoized value, so the blocking call is negligible. - let managedConfig = {applockOverride: false}; + const fallbackDesktopAppConfig = createDesktopAppConfig({ + managedConfig: {applockOverride: false}, + version: EnvironmentUtil.app.DESKTOP_VERSION, + }); + let resolvedDesktopAppConfig = fallbackDesktopAppConfig; try { - managedConfig = ipcRenderer.sendSync(EVENT_TYPE.MANAGED.GET_CONFIG) ?? {applockOverride: false}; + resolvedDesktopAppConfig = ipcRenderer.sendSync(EVENT_TYPE.DESKTOP.GET_CONFIG) ?? fallbackDesktopAppConfig; } catch (error) { - logger.warn('Failed to read managed config from the main process, treating the device as unmanaged:', error); + logger.warn('Failed to read desktop config from the main process, using the unmanaged fallback:', error); } - global.desktopAppConfig = createDesktopAppConfig(EnvironmentUtil.app.DESKTOP_VERSION, managedConfig); + global.desktopAppConfig = resolvedDesktopAppConfig; global.openGraphAsync = getOpenGraphDataViaChannel; global.setImmediate = _setImmediate; }); From 4ee56f09cc79262191543166804134fe0a5d10b7 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Thu, 3 Sep 2026 17:07:55 +0200 Subject: [PATCH 39/45] feat: remove device enrollment check [WPB-28485] --- electron/src/managed/backends/windows.ts | 32 ++---------------------- electron/src/managed/constants.ts | 6 ----- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/electron/src/managed/backends/windows.ts b/electron/src/managed/backends/windows.ts index bbfa33f9a23..e9f652efd65 100644 --- a/electron/src/managed/backends/windows.ts +++ b/electron/src/managed/backends/windows.ts @@ -18,12 +18,7 @@ */ import {getLogger} from '../../logging/getLogger'; -import { - APPLOCK_OVERRIDE_KEY, - WINDOWS_CLOUD_DOMAIN_JOIN_KEY, - WINDOWS_ENROLLMENTS_KEY, - WINDOWS_POLICY_KEY, -} from '../constants'; +import {APPLOCK_OVERRIDE_KEY, WINDOWS_POLICY_KEY} from '../constants'; const logger = getLogger('ManagedConfig/windows'); @@ -62,14 +57,6 @@ function valuesOf(registry: RegistryJs, hive: number, subkey: string): RegistryV } } -function subkeysOf(registry: RegistryJs, hive: number, subkey: string): string[] { - try { - return registry.enumerateKeys(hive, subkey) ?? []; - } catch { - return []; - } -} - // Treats `1` (REG_DWORD) and `"1"`/`"true"` (REG_SZ) as the enabled override flag. function isOverrideEnabled(value: RegistryValue): boolean { if (value.name !== APPLOCK_OVERRIDE_KEY) { @@ -87,25 +74,10 @@ function hasWirePolicyPayload(registry: RegistryJs): boolean { ); } -// True when the device is MDM-enrolled or Azure AD / Entra joined. -function isDeviceEnrolled(registry: RegistryJs): boolean { - const {HKEY} = registry; - - const isMdmEnrolled = subkeysOf(registry, HKEY.HKEY_LOCAL_MACHINE, WINDOWS_ENROLLMENTS_KEY).some(enrollmentKey => { - const values = valuesOf(registry, HKEY.HKEY_LOCAL_MACHINE, `${WINDOWS_ENROLLMENTS_KEY}\\${enrollmentKey}`); - return values.some(({name}) => name === 'UPN' || name === 'ProviderID'); - }); - if (isMdmEnrolled) { - return true; - } - - return subkeysOf(registry, HKEY.HKEY_LOCAL_MACHINE, WINDOWS_CLOUD_DOMAIN_JOIN_KEY).length > 0; -} - export function isDeviceManagedWindows(): boolean { const registry = loadRegistry(); if (!registry) { return false; } - return hasWirePolicyPayload(registry) || isDeviceEnrolled(registry); + return hasWirePolicyPayload(registry); } diff --git a/electron/src/managed/constants.ts b/electron/src/managed/constants.ts index 681fd530c12..9c470d65089 100644 --- a/electron/src/managed/constants.ts +++ b/electron/src/managed/constants.ts @@ -34,12 +34,6 @@ export const APPLOCK_OVERRIDE_KEY = 'applockOverride'; /** Windows: machine-wide Group Policy key. The `APPLOCK_OVERRIDE_KEY` value under it drives the override. */ export const WINDOWS_POLICY_KEY = 'SOFTWARE\\Policies\\Wire'; -/** Windows: MDM enrollment registry. A subkey carrying a `UPN`/`ProviderID` means the device is enrolled. */ -export const WINDOWS_ENROLLMENTS_KEY = 'SOFTWARE\\Microsoft\\Enrollments'; - -/** Windows: Azure AD / Entra device join. A subkey under JoinInfo means the device is joined. */ -export const WINDOWS_CLOUD_DOMAIN_JOIN_KEY = 'SYSTEM\\CurrentControlSet\\Control\\CloudDomainJoin\\JoinInfo'; - // macOS reads `APPLOCK_OVERRIDE_KEY` from the app's defaults domain (pushed via an MDM AppConfig profile). /** Linux: machine-wide managed config file holding the `APPLOCK_OVERRIDE_KEY` flag. */ From d72ec14622aeeb627017c57d9e17a13d45e3ad7a Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 4 Sep 2026 08:11:57 +0200 Subject: [PATCH 40/45] ci: remove Linux Jenkins build pipeline Delete the Linux Jenkins build, Docker, and source-signing jobs. Remove Linux job discovery and artifact deployment from Jenkins. Keep local Linux build tooling unchanged. --- jenkins/deployment.groovy | 48 +--------------- jenkins/linux.Dockerfile | 13 ----- jenkins/linux.groovy | 86 ----------------------------- jenkins/linux_source_signing.groovy | 17 ------ 4 files changed, 3 insertions(+), 161 deletions(-) delete mode 100644 jenkins/linux.Dockerfile delete mode 100644 jenkins/linux.groovy delete mode 100644 jenkins/linux_source_signing.groovy diff --git a/jenkins/deployment.groovy b/jenkins/deployment.groovy index 5ec8402ccdb..ffe502c4fd8 100644 --- a/jenkins/deployment.groovy +++ b/jenkins/deployment.groovy @@ -5,7 +5,7 @@ def list = [] jobs = jenkins.getAllItems() jobs.each { job -> name = job.fullName - if (name.contains('Windows Internal') || name.contains('Wrapper_Linux') || name.contains('Wrapper_macOS')) { + if (name.contains('Windows Internal') || name.contains('Wrapper_macOS')) { builds = job.builds for (i = 0; i <5; i++) { lastbuild = job.builds[i] @@ -298,50 +298,8 @@ node('built-in') { throw e } - } else if (projectName.contains('Linux')) { - // ----------------------------- - // 3) Linux S3 Upload - // ----------------------------- - try { - if (params.Release == 'Custom') { - error('Please set S3_NAME for custom Linux') - } - - if (params.Release == 'Custom') { - error('Please set S3_NAME for custom Linux') - } - - if (params.Release == 'Production') { - S3_NAME = 'linux' - } else if (params.Release == 'Internal') { - S3_NAME = 'linux-internal' - } else if (params.Release == 'Wire-Gov') { - S3_NAME = 'linux-wire-gov' - } - - withAWS(region:'eu-west-1', credentials: 'wire-taco') { - echo('Upload repository files') - s3Upload acl: 'PublicRead', - bucket: S3_BUCKET, - workingDir: 'wrap/dist/', - includePathPattern: 'debian/**', - path: S3_NAME + '/' - - echo('Upload files for download page') - files = findFiles(glob: 'wrap/dist/*.deb,wrap/dist/*.AppImage') - files.each { - s3Upload acl: 'PublicRead', - bucket: S3_BUCKET, - file: it.path, - path: S3_NAME + '/' + it.name - } - } - } catch(e) { - currentBuild.result = 'FAILED' - wireSend secret: "$jenkinsbot_secret", - message: "**Deploying to S3 failed for ${version}** see: ${JOB_URL}" - throw e - } + } else { + error("Unsupported wrapper build project: ${projectName}") } } } diff --git a/jenkins/linux.Dockerfile b/jenkins/linux.Dockerfile deleted file mode 100644 index 082793354fb..00000000000 --- a/jenkins/linux.Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM node:23-bullseye - -# Needed to workaround JENKINS-38438 -RUN mkdir /.yarn -RUN mkdir /.cache -RUN mkdir /.gnupg -RUN chmod 777 /.yarn -RUN chmod 777 /.cache -RUN chmod 777 /.gnupg - -RUN set -ex; \ - apt-get update; \ - apt-get install -y --no-install-recommends alien apt-utils g++-multilib gnupg2 psmisc diff --git a/jenkins/linux.groovy b/jenkins/linux.groovy deleted file mode 100644 index 72c177e297f..00000000000 --- a/jenkins/linux.groovy +++ /dev/null @@ -1,86 +0,0 @@ -@NonCPS -def parseJson(def text) { - new groovy.json.JsonSlurperClassic().parseText(text) -} - -node('linux') { - checkout scm - - def production = params.PRODUCTION - def custom = params.CUSTOM - def wireGov = params.WIRE_GOV - - def jenkinsbot_secret = '' - withCredentials([string(credentialsId: "${params.JENKINSBOT_SECRET}", variable: 'JENKINSBOT_SECRET')]) { - jenkinsbot_secret = env.JENKINSBOT_SECRET - } - - if (wireGov) { - env.APP_ENV = 'wire-gov' - } else if (!production && !custom) { - env.APP_ENV = 'internal' - } - - def environment = docker.build('node', '-f jenkins/linux.Dockerfile .') - def version - def electronVersion - - environment.inside { - - stage('Checkout & Clean') { - git branch: "${GIT_BRANCH}", url: 'https://github.com/wireapp/wire-desktop.git' - sh returnStatus: true, script: 'rm -rf $WORKSPACE/node_modules/ $WORKSPACE/*.sig' - } - - def wireJson = readFile('electron/wire.json') - def (major, minor) = parseJson(wireJson).version.tokenize('.') - version = "${major}.${minor}.${env.BUILD_NUMBER}" - currentBuild.displayName = version - - def packageJson = readFile('package.json') - electronVersion = parseJson(packageJson).devDependencies.electron - - stage('Build') { - try { - sh 'node -v' - sh 'npm -v' - sh 'yarn' - if (production) { - sh 'yarn build:linux' - } else if (wireGov) { - sh 'yarn build:linux:wire-gov' - } else { - sh 'yarn build:linux:internal' - } - } catch(e) { - currentBuild.result = 'FAILED' - wireSend secret: "${jenkinsbot_secret}", message: "🐧 **${JOB_NAME} ${version} build failed**\n${BUILD_URL}" - throw e - } - } - - stage('Generate repository') { - withCredentials([file(credentialsId: 'D599C1AA126762B1.asc', variable: 'PGP_PRIVATE_KEY_FILE'), string(credentialsId: 'PGP_PASSPHRASE', variable: 'PGP_PASSPHRASE')]) { - sh 'cd wrap/dist/ && ../../bin/repo/linux-prod-repo.sh' - } - } - - stage('Create SHA256 checksums') { - withCredentials([file(credentialsId: 'D599C1AA126762B1.asc', variable: 'PGP_PRIVATE_KEY_FILE'), string(credentialsId: 'PGP_PASSPHRASE', variable: 'PGP_PASSPHRASE')]) { - sh "cd wrap/dist/ && ../../bin/linux-checksums.sh ${version}" - } - } - - stage('Test packaging') { - sh 'dpkg-deb --info wrap/dist/debian/pool/main/*amd64.deb' - sh 'dpkg-deb --info wrap/dist/*amd64.deb' - } - - stage('Save .deb, AppImage and repo files') { - archiveArtifacts 'package.json,wrap/dist/*.deb,wrap/dist/*.AppImage,wrap/dist/*.rpm,wrap/dist/*.asc,wrap/dist/debian/**' - } - - } - - wireSend secret: "${jenkinsbot_secret}", message: "🐧 **New build of ${JOB_NAME} ${version}**\n- Download: [Jenkins](${BUILD_URL})\n- Electron version: ${electronVersion}\n- Branch: [${GIT_BRANCH}](https://github.com/wireapp/wire-desktop/commits/${GIT_BRANCH})" -} diff --git a/jenkins/linux_source_signing.groovy b/jenkins/linux_source_signing.groovy deleted file mode 100644 index 2a8848d1606..00000000000 --- a/jenkins/linux_source_signing.groovy +++ /dev/null @@ -1,17 +0,0 @@ -node('linux') { - - stage('Checkout & Clean') { - git branch: "${GIT_BRANCH}", url: 'https://github.com/wireapp/wire-desktop.git' - } - - stage('Create SHA256 checksums') { - withCredentials([file(credentialsId: 'D599C1AA126762B1.asc', variable: 'PGP_PRIVATE_KEY_FILE'), string(credentialsId: 'PGP_PASSPHRASE', variable: 'PGP_PASSPHRASE')]) { - sh "bin/linux-source-signing.sh ${version}" - } - } - - stage('Save signature') { - archiveArtifacts '*.tar.gz.sig' - } - -} From 0f59bdc4320fe29af3e5b6ad053403cee1b0fa8d Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 4 Sep 2026 08:15:43 +0200 Subject: [PATCH 41/45] chore: disable Dependabot updates in favor of Renovate Add a deny-all Dependabot configuration for npm, GitHub Actions, and Docker dependencies. Setting the pull request limit to zero disables regular version update pull requests, while the wildcard ignore rules also suppress security update pull requests. Dependabot alerts remain enabled so Renovate can continue using them for vulnerability remediation. --- .github/dependabot.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..8d826757467 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +# Renovate is the only dependency-update automation used by this repository. +# +# `open-pull-requests-limit: 0` disables Dependabot version-update pull +# requests. The wildcard ignore rule is also required because security-update +# pull requests are not subject to that limit. + +version: 2 + +updates: + - package-ecosystem: 'npm' + directories: + - '**/*' + schedule: + interval: 'monthly' + open-pull-requests-limit: 0 + ignore: + - dependency-name: '*' + + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'monthly' + open-pull-requests-limit: 0 + ignore: + - dependency-name: '*' + + - package-ecosystem: 'docker' + directories: + - '**/*' + schedule: + interval: 'monthly' + open-pull-requests-limit: 0 + ignore: + - dependency-name: '*' From 083fe35e9b0f218a7a40ee131719545020426d9f Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 4 Sep 2026 08:29:08 +0200 Subject: [PATCH 42/45] Adjust desktop log retention size limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increase the total desktop log retention limit from 100 MiB to 500 MiB. Observed WireInternal usage produces roughly 40–60 MiB of logs per day, which would make the previous 100 MiB limit shorten the intended seven-day retention period to around two days. Keep the seven-day retention period and 10 MiB per-file rotation unchanged. --- electron/src/logging/logCleanup.test.main.ts | 95 ++++++++++++++++++-- electron/src/logging/logCleanup.ts | 2 +- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/electron/src/logging/logCleanup.test.main.ts b/electron/src/logging/logCleanup.test.main.ts index a0ca5e9eedc..45d345c9ae3 100644 --- a/electron/src/logging/logCleanup.test.main.ts +++ b/electron/src/logging/logCleanup.test.main.ts @@ -26,11 +26,14 @@ import * as path from 'path'; import { createLogCleanup, createLogCleanupFileSystemDependencies, + DESKTOP_LOG_MAXIMUM_AGE_MILLISECONDS, + DESKTOP_LOG_MAXIMUM_TOTAL_SIZE_BYTES, + DESKTOP_LOG_RETENTION_POLICY, LogCleanupDependencies, RunLogCleanupParameters, } from './logCleanup'; import {LogDirectoryCleanupResult} from './logDirectoryCleanup'; -import {LogFileMetadata} from './logRetention'; +import {LogFileMetadata, LogRetentionPolicy} from './logRetention'; import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; @@ -40,6 +43,16 @@ type CleanupTestState = { removedFiles: string[]; }; +const testLogRetentionPolicy: LogRetentionPolicy = { + maximumAgeMilliseconds: 100, + maximumTotalSizeBytes: 100, +}; + +type CleanupTestOptions = { + activeFilePaths: ReadonlySet; + policy?: LogRetentionPolicy; +}; + function createCleanupTestDependencies( fileMetadata: readonly LogFileMetadata[], state: CleanupTestState, @@ -78,15 +91,79 @@ function createCleanupTestDependencies( }; } -function createCleanupParameters(activeFilePaths: ReadonlySet): RunLogCleanupParameters { +function createCleanupOptions(options: CleanupTestOptions): RunLogCleanupParameters { + const retentionPolicy = options.policy ?? testLogRetentionPolicy; + return { - activeFilePaths, + activeFilePaths: options.activeFilePaths, logDirectory: 'logs', - policy: {maximumAgeMilliseconds: 100, maximumTotalSizeBytes: 100}, + policy: retentionPolicy, }; } describe('desktop log cleanup', () => { + it('uses a seven-day age limit and a 500 MiB total size limit', () => { + assert.strictEqual(DESKTOP_LOG_MAXIMUM_AGE_MILLISECONDS, 7 * 24 * 60 * 60 * 1_000); + assert.strictEqual(DESKTOP_LOG_MAXIMUM_TOTAL_SIZE_BYTES, 500 * 1024 * 1024); + assert.deepStrictEqual(DESKTOP_LOG_RETENTION_POLICY, { + maximumAgeMilliseconds: DESKTOP_LOG_MAXIMUM_AGE_MILLISECONDS, + maximumTotalSizeBytes: DESKTOP_LOG_MAXIMUM_TOTAL_SIZE_BYTES, + }); + }); + + it('retains recent files when total usage is below the desktop total size limit', async () => { + const state: CleanupTestState = {failures: [], removedDirectories: [], removedFiles: []}; + const fileMetadata: LogFileMetadata[] = [ + { + filePath: 'logs/newest.log', + fileSizeBytes: DESKTOP_LOG_MAXIMUM_TOTAL_SIZE_BYTES - 2, + isSymbolicLink: false, + modifiedTimeMilliseconds: 999_999, + }, + { + filePath: 'logs/second-newest.log', + fileSizeBytes: 1, + isSymbolicLink: false, + modifiedTimeMilliseconds: 999_998, + }, + ]; + const cleanup = createLogCleanup(createCleanupTestDependencies(fileMetadata, state)); + + await cleanup.run(createCleanupOptions({activeFilePaths: new Set(), policy: DESKTOP_LOG_RETENTION_POLICY})); + + assert.deepStrictEqual(state.removedFiles, []); + }); + + it('removes the oldest eligible files when desktop total usage exceeds the limit', async () => { + const state: CleanupTestState = {failures: [], removedDirectories: [], removedFiles: []}; + const fileSizeBytes = Math.floor(DESKTOP_LOG_MAXIMUM_TOTAL_SIZE_BYTES / 2) + 1; + const fileMetadata: LogFileMetadata[] = [ + { + filePath: 'logs/newest.log', + fileSizeBytes, + isSymbolicLink: false, + modifiedTimeMilliseconds: 3, + }, + { + filePath: 'logs/oldest.log', + fileSizeBytes, + isSymbolicLink: false, + modifiedTimeMilliseconds: 1, + }, + { + filePath: 'logs/middle.log', + fileSizeBytes, + isSymbolicLink: false, + modifiedTimeMilliseconds: 2, + }, + ]; + const cleanup = createLogCleanup(createCleanupTestDependencies(fileMetadata, state)); + + await cleanup.run(createCleanupOptions({activeFilePaths: new Set(), policy: DESKTOP_LOG_RETENTION_POLICY})); + + assert.deepStrictEqual(state.removedFiles, ['logs/oldest.log', 'logs/middle.log']); + }); + it('removes planned files and their empty parent directories', async () => { const state: CleanupTestState = {failures: [], removedDirectories: [], removedFiles: []}; const fileMetadata: LogFileMetadata[] = [ @@ -105,7 +182,7 @@ describe('desktop log cleanup', () => { ]; const cleanup = createLogCleanup(createCleanupTestDependencies(fileMetadata, state)); - await cleanup.run(createCleanupParameters(new Set())); + await cleanup.run(createCleanupOptions({activeFilePaths: new Set()})); assert.deepStrictEqual(state.removedFiles, ['logs/2026-08-20/accounts/account/console.log']); assert.deepStrictEqual(state.removedDirectories, [ @@ -124,7 +201,7 @@ describe('desktop log cleanup', () => { ]; const cleanup = createLogCleanup(createCleanupTestDependencies(fileMetadata, state)); - await cleanup.run(createCleanupParameters(new Set(['logs/active.log']))); + await cleanup.run(createCleanupOptions({activeFilePaths: new Set(['logs/active.log'])})); assert.deepStrictEqual(state.removedFiles, ['logs/eligible.log']); }); @@ -147,7 +224,7 @@ describe('desktop log cleanup', () => { }, }); - await cleanup.run(createCleanupParameters(new Set())); + await cleanup.run(createCleanupOptions({activeFilePaths: new Set()})); assert.deepStrictEqual(state.removedFiles, ['logs/second.log']); assert.strictEqual(state.failures.length, 1); @@ -169,9 +246,9 @@ describe('desktop log cleanup', () => { return []; }, }); - const cleanupParameters = createCleanupParameters(new Set()); + const cleanupOptions = createCleanupOptions({activeFilePaths: new Set()}); - await Promise.all([cleanup.run(cleanupParameters), cleanup.run(cleanupParameters)]); + await Promise.all([cleanup.run(cleanupOptions), cleanup.run(cleanupOptions)]); assert.strictEqual(discoveryCount, 1); }); diff --git a/electron/src/logging/logCleanup.ts b/electron/src/logging/logCleanup.ts index 8cd278f5d72..ee28f4b9f57 100644 --- a/electron/src/logging/logCleanup.ts +++ b/electron/src/logging/logCleanup.ts @@ -30,7 +30,7 @@ import {getLogFilenames} from './logFiles'; import {LogFileMetadata, LogRetentionPlanParameters, LogRetentionPolicy, planLogCleanup} from './logRetention'; export const DESKTOP_LOG_MAXIMUM_AGE_MILLISECONDS = 7 * 24 * 60 * 60 * 1_000; -export const DESKTOP_LOG_MAXIMUM_TOTAL_SIZE_BYTES = 100 * 1024 * 1024; +export const DESKTOP_LOG_MAXIMUM_TOTAL_SIZE_BYTES = 500 * 1024 * 1024; export const DESKTOP_LOG_RETENTION_POLICY: LogRetentionPolicy = { maximumAgeMilliseconds: DESKTOP_LOG_MAXIMUM_AGE_MILLISECONDS, maximumTotalSizeBytes: DESKTOP_LOG_MAXIMUM_TOTAL_SIZE_BYTES, From 4cfa295268ca2ca295fb57fa5574b7faa100c3d3 Mon Sep 17 00:00:00 2001 From: Christian Rackerseder Date: Fri, 4 Sep 2026 09:21:28 +0200 Subject: [PATCH 43/45] Use Hosted Dev for desktop smoke tests --- jenkins/macOS.groovy | 2 +- jenkins/windows.groovy | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jenkins/macOS.groovy b/jenkins/macOS.groovy index 0fc71ff75e6..4a03bf05996 100644 --- a/jenkins/macOS.groovy +++ b/jenkins/macOS.groovy @@ -185,7 +185,7 @@ node("macos") { stage('Trigger smoke tests') { if (production) { try { - build job: 'Wrapper_macOS_Smoke_Tests', parameters: [run(description: '', name: 'WRAPPER_BUILD', runId: "Wrapper_macOS_Production#${BUILD_ID}"), string(name: 'WEBAPP_ENV', value: 'https://wire-webapp-master.zinfra.io/')], wait: false + build job: 'Wrapper_macOS_Smoke_Tests', parameters: [run(description: '', name: 'WRAPPER_BUILD', runId: "Wrapper_macOS_Production#${BUILD_ID}"), string(name: 'WEBAPP_ENV', value: 'https://wire-webapp-dev.zinfra.io/')], wait: false } catch(e) { wireSend secret: "${jenkinsbot_secret}", message: "🍏 **${JOB_NAME} Unable to trigger smoke tests for ${version}**\n${BUILD_URL}" print e diff --git a/jenkins/windows.groovy b/jenkins/windows.groovy index b5e849386f7..6ba202d1144 100644 --- a/jenkins/windows.groovy +++ b/jenkins/windows.groovy @@ -161,7 +161,7 @@ node('windows') { build job: 'Wrapper_Windows_Smoke_Tests', parameters: [ run (description: '', name: 'WRAPPER_BUILD', runId: "Wrapper_Windows_Production#${BUILD_ID}"), - string(name: 'WEBAPP_ENV', value: 'https://wire-webapp-master.zinfra.io/') + string(name: 'WEBAPP_ENV', value: 'https://wire-webapp-dev.zinfra.io/') ], wait: false } catch (e) { From 62b56038fd9df40fdc841c2805d4a9719322f53a Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Thu, 3 Sep 2026 17:07:55 +0200 Subject: [PATCH 44/45] feat: remove device enrollment check [WPB-28485] --- electron/src/managed/backends/windows.ts | 32 ++---------------------- electron/src/managed/constants.ts | 6 ----- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/electron/src/managed/backends/windows.ts b/electron/src/managed/backends/windows.ts index bbfa33f9a23..e9f652efd65 100644 --- a/electron/src/managed/backends/windows.ts +++ b/electron/src/managed/backends/windows.ts @@ -18,12 +18,7 @@ */ import {getLogger} from '../../logging/getLogger'; -import { - APPLOCK_OVERRIDE_KEY, - WINDOWS_CLOUD_DOMAIN_JOIN_KEY, - WINDOWS_ENROLLMENTS_KEY, - WINDOWS_POLICY_KEY, -} from '../constants'; +import {APPLOCK_OVERRIDE_KEY, WINDOWS_POLICY_KEY} from '../constants'; const logger = getLogger('ManagedConfig/windows'); @@ -62,14 +57,6 @@ function valuesOf(registry: RegistryJs, hive: number, subkey: string): RegistryV } } -function subkeysOf(registry: RegistryJs, hive: number, subkey: string): string[] { - try { - return registry.enumerateKeys(hive, subkey) ?? []; - } catch { - return []; - } -} - // Treats `1` (REG_DWORD) and `"1"`/`"true"` (REG_SZ) as the enabled override flag. function isOverrideEnabled(value: RegistryValue): boolean { if (value.name !== APPLOCK_OVERRIDE_KEY) { @@ -87,25 +74,10 @@ function hasWirePolicyPayload(registry: RegistryJs): boolean { ); } -// True when the device is MDM-enrolled or Azure AD / Entra joined. -function isDeviceEnrolled(registry: RegistryJs): boolean { - const {HKEY} = registry; - - const isMdmEnrolled = subkeysOf(registry, HKEY.HKEY_LOCAL_MACHINE, WINDOWS_ENROLLMENTS_KEY).some(enrollmentKey => { - const values = valuesOf(registry, HKEY.HKEY_LOCAL_MACHINE, `${WINDOWS_ENROLLMENTS_KEY}\\${enrollmentKey}`); - return values.some(({name}) => name === 'UPN' || name === 'ProviderID'); - }); - if (isMdmEnrolled) { - return true; - } - - return subkeysOf(registry, HKEY.HKEY_LOCAL_MACHINE, WINDOWS_CLOUD_DOMAIN_JOIN_KEY).length > 0; -} - export function isDeviceManagedWindows(): boolean { const registry = loadRegistry(); if (!registry) { return false; } - return hasWirePolicyPayload(registry) || isDeviceEnrolled(registry); + return hasWirePolicyPayload(registry); } diff --git a/electron/src/managed/constants.ts b/electron/src/managed/constants.ts index 681fd530c12..9c470d65089 100644 --- a/electron/src/managed/constants.ts +++ b/electron/src/managed/constants.ts @@ -34,12 +34,6 @@ export const APPLOCK_OVERRIDE_KEY = 'applockOverride'; /** Windows: machine-wide Group Policy key. The `APPLOCK_OVERRIDE_KEY` value under it drives the override. */ export const WINDOWS_POLICY_KEY = 'SOFTWARE\\Policies\\Wire'; -/** Windows: MDM enrollment registry. A subkey carrying a `UPN`/`ProviderID` means the device is enrolled. */ -export const WINDOWS_ENROLLMENTS_KEY = 'SOFTWARE\\Microsoft\\Enrollments'; - -/** Windows: Azure AD / Entra device join. A subkey under JoinInfo means the device is joined. */ -export const WINDOWS_CLOUD_DOMAIN_JOIN_KEY = 'SYSTEM\\CurrentControlSet\\Control\\CloudDomainJoin\\JoinInfo'; - // macOS reads `APPLOCK_OVERRIDE_KEY` from the app's defaults domain (pushed via an MDM AppConfig profile). /** Linux: machine-wide managed config file holding the `APPLOCK_OVERRIDE_KEY` flag. */ From 305882a56d8ef84d55b99cc2d5e0b2de6ec75cd7 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 8 Sep 2026 08:17:49 +0000 Subject: [PATCH 45/45] chore: Update translations --- electron/src/locale/ar-SA.json | 6 +++--- electron/src/locale/bg-BG.json | 6 +++--- electron/src/locale/bn-BD.json | 6 +++--- electron/src/locale/bs-BA.json | 6 +++--- electron/src/locale/ca-ES.json | 6 +++--- electron/src/locale/cs-CZ.json | 6 +++--- electron/src/locale/da-DK.json | 6 +++--- electron/src/locale/de-DE.json | 20 ++++++++++---------- electron/src/locale/el-CY.json | 6 +++--- electron/src/locale/el-GR.json | 6 +++--- electron/src/locale/es-ES.json | 6 +++--- electron/src/locale/et-EE.json | 6 +++--- electron/src/locale/fa-IR.json | 6 +++--- electron/src/locale/fi-FI.json | 6 +++--- electron/src/locale/fr-FR.json | 6 +++--- electron/src/locale/ga-IE.json | 6 +++--- electron/src/locale/he-IL.json | 6 +++--- electron/src/locale/hi-IN.json | 6 +++--- electron/src/locale/hr-HR.json | 6 +++--- electron/src/locale/hu-HU.json | 6 +++--- electron/src/locale/id-ID.json | 6 +++--- electron/src/locale/is-IS.json | 6 +++--- electron/src/locale/it-IT.json | 6 +++--- electron/src/locale/ja-JP.json | 6 +++--- electron/src/locale/lb-LU.json | 6 +++--- electron/src/locale/lt-LT.json | 6 +++--- electron/src/locale/lv-LV.json | 6 +++--- electron/src/locale/me-ME.json | 6 +++--- electron/src/locale/mk-MK.json | 6 +++--- electron/src/locale/ms-MY.json | 6 +++--- electron/src/locale/mt-MT.json | 6 +++--- electron/src/locale/nl-NL.json | 6 +++--- electron/src/locale/no-NO.json | 6 +++--- electron/src/locale/pl-PL.json | 6 +++--- electron/src/locale/pt-BR.json | 6 +++--- electron/src/locale/pt-PT.json | 6 +++--- electron/src/locale/ro-RO.json | 6 +++--- electron/src/locale/ru-RU.json | 6 +++--- electron/src/locale/si-LK.json | 6 +++--- electron/src/locale/sk-SK.json | 6 +++--- electron/src/locale/sl-SI.json | 6 +++--- electron/src/locale/sq-AL.json | 6 +++--- electron/src/locale/sr-Cyrl-ME.json | 6 +++--- electron/src/locale/sr-SP.json | 6 +++--- electron/src/locale/sv-SE.json | 20 ++++++++++---------- electron/src/locale/th-TH.json | 6 +++--- electron/src/locale/tr-CY.json | 6 +++--- electron/src/locale/tr-TR.json | 6 +++--- electron/src/locale/uk-UA.json | 6 +++--- electron/src/locale/uz-UZ.json | 6 +++--- electron/src/locale/vi-VN.json | 6 +++--- electron/src/locale/zh-CN.json | 6 +++--- electron/src/locale/zh-HK.json | 6 +++--- electron/src/locale/zh-TW.json | 6 +++--- 54 files changed, 176 insertions(+), 176 deletions(-) diff --git a/electron/src/locale/ar-SA.json b/electron/src/locale/ar-SA.json index efdaf15942d..9f1992d8614 100644 --- a/electron/src/locale/ar-SA.json +++ b/electron/src/locale/ar-SA.json @@ -73,6 +73,8 @@ "menuView": "عرض", "menuWindow": "نافذة", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "الوكيل يحتاج مصادقة. يرجى إدخال اسم المستخدم وكلمة المرور.", "proxyPromptPassword": "كلمة السر", "proxyPromptTitle": "مصادقة الوكيل (بروكسي)", @@ -110,7 +112,5 @@ "promptCancel": "الغاء", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/bg-BG.json b/electron/src/locale/bg-BG.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/bg-BG.json +++ b/electron/src/locale/bg-BG.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/bn-BD.json b/electron/src/locale/bn-BD.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/bn-BD.json +++ b/electron/src/locale/bn-BD.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/bs-BA.json b/electron/src/locale/bs-BA.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/bs-BA.json +++ b/electron/src/locale/bs-BA.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/ca-ES.json b/electron/src/locale/ca-ES.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/ca-ES.json +++ b/electron/src/locale/ca-ES.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/cs-CZ.json b/electron/src/locale/cs-CZ.json index 0163860471d..da68c0ade7a 100644 --- a/electron/src/locale/cs-CZ.json +++ b/electron/src/locale/cs-CZ.json @@ -73,6 +73,8 @@ "menuView": "Zobrazení", "menuWindow": "Okno", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Heslo", "proxyPromptTitle": "Autentizace na proxy serveru", @@ -110,7 +112,5 @@ "promptCancel": "Zrušit", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/da-DK.json b/electron/src/locale/da-DK.json index 6069a283511..39baf1e7bc2 100644 --- a/electron/src/locale/da-DK.json +++ b/electron/src/locale/da-DK.json @@ -73,6 +73,8 @@ "menuView": "Vis", "menuWindow": "Vindue", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Adgangskode", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Annuller", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/de-DE.json b/electron/src/locale/de-DE.json index 958ee058d56..ee1bab5d7e7 100644 --- a/electron/src/locale/de-DE.json +++ b/electron/src/locale/de-DE.json @@ -73,6 +73,8 @@ "menuView": "Ansicht", "menuWindow": "Fenster", "noInternet": "Kein Internet", + "noUrlConfiguredDescription": "Um die App zu verwenden, bitten Sie Ihren Administrator, Sie mit dem richtigen Backend zu verbinden.", + "noUrlConfiguredTitle": "Willkommen bei Wire Gov", "proxyPromptHeadline": "Der Proxy erfordert eine Authentifizierung mit Benutzername und Passwort.", "proxyPromptPassword": "Passwort", "proxyPromptTitle": "Proxy-Authentifizierung", @@ -84,18 +86,18 @@ "trayOpen": "Öffnen", "trayQuit": "Beenden", "unreadMessages": "Ungelesene Nachricht", - "sidebarAccountNotification": "New message or missed call", - "sidebarAccountTypeTeam": "Team account", - "sidebarAccountTypePersonal": "Personal account", - "sidebarAccountSelected": "selected", - "sidebarAccountUnselected": "unselected", + "sidebarAccountNotification": "Neue Nachricht oder verpasster Anruf", + "sidebarAccountTypeTeam": "Team-Konto", + "sidebarAccountTypePersonal": "Privates Konto", + "sidebarAccountSelected": "ausgewählt", + "sidebarAccountUnselected": "nicht ausgewählt", "sidebarAccountLabel": "{accountType}, {accountName}, {selectionState}", - "sidebarAccountNameFallback": "Account", + "sidebarAccountNameFallback": "Benutzerkonto", "webviewErrorDescription": "Wir können keine Verbindung zum Server auf \"{url}\" herstellen", "webviewErrorDescriptionSub": "Bitte informieren Sie Ihren Server-Administrator über dieses Problem.", "webviewErrorRetryAction": "Wiederholen", "webviewErrorTitle": "Dieser Server kann nicht erreicht werden", - "wrapperAddAccount": "Add Another Account", + "wrapperAddAccount": "Weiteres Konto hinzufügen", "wrapperCreateTeam": "Team erstellen", "wrapperLogOut": "Abmelden", "wrapperManageTeam": "Team verwalten", @@ -110,7 +112,5 @@ "promptCancel": "Abbrechen", "promptWarning": "Warnung", "promptError": "Fehler", - "urlBlockedPromptText": "Eine potenziell unsichere URL wurde gesperrt. Wenn Sie diese trotzdem öffnen möchten, kopieren Sie die URL und öffnen Sie die entsprechende Anwendung.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "Eine potenziell unsichere URL wurde gesperrt. Wenn Sie diese trotzdem öffnen möchten, kopieren Sie die URL und öffnen Sie die entsprechende Anwendung." } diff --git a/electron/src/locale/el-CY.json b/electron/src/locale/el-CY.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/el-CY.json +++ b/electron/src/locale/el-CY.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/el-GR.json b/electron/src/locale/el-GR.json index 4e3c2df7c1c..aded57b985b 100644 --- a/electron/src/locale/el-GR.json +++ b/electron/src/locale/el-GR.json @@ -73,6 +73,8 @@ "menuView": "Προβολή", "menuWindow": "Παράθυρο", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/es-ES.json b/electron/src/locale/es-ES.json index 2ef3f9ac188..0d1af284315 100644 --- a/electron/src/locale/es-ES.json +++ b/electron/src/locale/es-ES.json @@ -73,6 +73,8 @@ "menuView": "Vista", "menuWindow": "Ventana", "noInternet": "Sin conexión a Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "El proxy requiere autenticación con nombre de usuario y contraseña.", "proxyPromptPassword": "Contraseña", "proxyPromptTitle": "Autenticación de proxy", @@ -110,7 +112,5 @@ "promptCancel": "Cancelar", "promptWarning": "Advertencia", "promptError": "Error", - "urlBlockedPromptText": "Una URL potencialmente insegura ha sido bloqueada. Si aún desea abrirla, copie la URL y abra la aplicación apropiada.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "Una URL potencialmente insegura ha sido bloqueada. Si aún desea abrirla, copie la URL y abra la aplicación apropiada." } diff --git a/electron/src/locale/et-EE.json b/electron/src/locale/et-EE.json index fc664af17f7..123022c0fe4 100644 --- a/electron/src/locale/et-EE.json +++ b/electron/src/locale/et-EE.json @@ -73,6 +73,8 @@ "menuView": "Vaade", "menuWindow": "Aken", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Parool", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Tühista", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/fa-IR.json b/electron/src/locale/fa-IR.json index 3776ff8b00a..ceccbc1153a 100644 --- a/electron/src/locale/fa-IR.json +++ b/electron/src/locale/fa-IR.json @@ -73,6 +73,8 @@ "menuView": "نمایش", "menuWindow": "پنجره", "noInternet": "اینترنت متصل نیست", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "پروکسی به نام کاربری و رمز عبور احتیاج دارد.", "proxyPromptPassword": "گذرواژه", "proxyPromptTitle": "احراز هویت پروکسی", @@ -110,7 +112,5 @@ "promptCancel": "لغو", "promptWarning": "هشدار", "promptError": "خطا", - "urlBlockedPromptText": "یک لینک نا امن مسدود شد. اگر همچنان مایل به باز کردن آن هستید، آن را کپی کرده و در نرم افزار دیگری باز کنید.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "یک لینک نا امن مسدود شد. اگر همچنان مایل به باز کردن آن هستید، آن را کپی کرده و در نرم افزار دیگری باز کنید." } diff --git a/electron/src/locale/fi-FI.json b/electron/src/locale/fi-FI.json index cc42f5f736f..db1578deff8 100644 --- a/electron/src/locale/fi-FI.json +++ b/electron/src/locale/fi-FI.json @@ -73,6 +73,8 @@ "menuView": "Näytä", "menuWindow": "Ikkuna", "noInternet": "Ei internet-yhteyttä", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Välityspalvelin vaatii tunnistautumisen käyttäjätunnuksella sekä salasanalla.", "proxyPromptPassword": "Salasana", "proxyPromptTitle": "Proxy tunnistus", @@ -110,7 +112,5 @@ "promptCancel": "Peruuta", "promptWarning": "Varoitus", "promptError": "Virhe", - "urlBlockedPromptText": "Mahdollisesti vaarallinen URL-osoite on estetty. Jos silti haluat avata kyseisen URL-osoitteen, kopioi osoite leikepöydälle ja avaa se sopivalla sovelluksella.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "Mahdollisesti vaarallinen URL-osoite on estetty. Jos silti haluat avata kyseisen URL-osoitteen, kopioi osoite leikepöydälle ja avaa se sopivalla sovelluksella." } diff --git a/electron/src/locale/fr-FR.json b/electron/src/locale/fr-FR.json index e0574984cf2..9c3fdaebdde 100644 --- a/electron/src/locale/fr-FR.json +++ b/electron/src/locale/fr-FR.json @@ -73,6 +73,8 @@ "menuView": "Affichage", "menuWindow": "Fenêtre", "noInternet": "Pas de connexion internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Le proxy a besoin d’une authentification. Veuillez fournir un nom d’utilisateur et un mot de passe.", "proxyPromptPassword": "Mot de passe", "proxyPromptTitle": "Authentification proxy", @@ -110,7 +112,5 @@ "promptCancel": "Annuler", "promptWarning": "Avertissement", "promptError": "Erreur", - "urlBlockedPromptText": "Une URL potentiellement dangereuse a été bloquée. Si vous souhaitez quand même l'ouvrir, copiez l'URL et ouvrez l'application appropriée.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "Une URL potentiellement dangereuse a été bloquée. Si vous souhaitez quand même l'ouvrir, copiez l'URL et ouvrez l'application appropriée." } diff --git a/electron/src/locale/ga-IE.json b/electron/src/locale/ga-IE.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/ga-IE.json +++ b/electron/src/locale/ga-IE.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/he-IL.json b/electron/src/locale/he-IL.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/he-IL.json +++ b/electron/src/locale/he-IL.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/hi-IN.json b/electron/src/locale/hi-IN.json index 3ac752965dc..841477ff1cb 100644 --- a/electron/src/locale/hi-IN.json +++ b/electron/src/locale/hi-IN.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/hr-HR.json b/electron/src/locale/hr-HR.json index 2f0b2977050..fed6950c027 100644 --- a/electron/src/locale/hr-HR.json +++ b/electron/src/locale/hr-HR.json @@ -73,6 +73,8 @@ "menuView": "Pregled", "menuWindow": "Prozor", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/hu-HU.json b/electron/src/locale/hu-HU.json index 4549cf25382..441b57c425b 100644 --- a/electron/src/locale/hu-HU.json +++ b/electron/src/locale/hu-HU.json @@ -73,6 +73,8 @@ "menuView": "Nézet", "menuWindow": "Ablak", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "A proxy felhasználónévvel és jelszóval történő hitelesítést igényel.", "proxyPromptPassword": "Jelszó", "proxyPromptTitle": "Proxy hitelesítés", @@ -110,7 +112,5 @@ "promptCancel": "Mégsem", "promptWarning": "Figyelmeztetés", "promptError": "Hiba", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/id-ID.json b/electron/src/locale/id-ID.json index e7a9c2dfa26..3a5e1411878 100644 --- a/electron/src/locale/id-ID.json +++ b/electron/src/locale/id-ID.json @@ -73,6 +73,8 @@ "menuView": "Lihat", "menuWindow": "Jendela", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Kata sandi", "proxyPromptTitle": "Otentikasi Proxy", @@ -110,7 +112,5 @@ "promptCancel": "Batal", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/is-IS.json b/electron/src/locale/is-IS.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/is-IS.json +++ b/electron/src/locale/is-IS.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/it-IT.json b/electron/src/locale/it-IT.json index 61c87c819d7..80024482ae8 100644 --- a/electron/src/locale/it-IT.json +++ b/electron/src/locale/it-IT.json @@ -73,6 +73,8 @@ "menuView": "Mostra", "menuWindow": "Finestre", "noInternet": "Nessuna connessione Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Il proxy richiede l'autenticazione con nome utente e password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Autenticazione per il Proxy", @@ -110,7 +112,5 @@ "promptCancel": "Annulla", "promptWarning": "Attenzione", "promptError": "Errore", - "urlBlockedPromptText": "Un URL potenzialmente non sicuro è stato bloccato. Se vuoi ancora aprirlo, copia l'URL e apri l'applicazione appropriata.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "Un URL potenzialmente non sicuro è stato bloccato. Se vuoi ancora aprirlo, copia l'URL e apri l'applicazione appropriata." } diff --git a/electron/src/locale/ja-JP.json b/electron/src/locale/ja-JP.json index 4dab73476d2..a554b5ce265 100644 --- a/electron/src/locale/ja-JP.json +++ b/electron/src/locale/ja-JP.json @@ -73,6 +73,8 @@ "menuView": "ビュー", "menuWindow": "ウィンドウ", "noInternet": "インターネット未接続", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "プロキシ認証のためにはユーザー名とパスワードが必要です。", "proxyPromptPassword": "パスワード", "proxyPromptTitle": "プロキシ認証", @@ -110,7 +112,5 @@ "promptCancel": "キャンセル", "promptWarning": "警告", "promptError": "エラー", - "urlBlockedPromptText": "安全でない可能性のあるURLがブロックされました。それでも開きたい場合は、URLをコピーし、適切なアプリケーションで開いてください。", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "安全でない可能性のあるURLがブロックされました。それでも開きたい場合は、URLをコピーし、適切なアプリケーションで開いてください。" } diff --git a/electron/src/locale/lb-LU.json b/electron/src/locale/lb-LU.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/lb-LU.json +++ b/electron/src/locale/lb-LU.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/lt-LT.json b/electron/src/locale/lt-LT.json index 0de8c7ec777..c850d08b515 100644 --- a/electron/src/locale/lt-LT.json +++ b/electron/src/locale/lt-LT.json @@ -73,6 +73,8 @@ "menuView": "Rodinys", "menuWindow": "Langas", "noInternet": "Nėra interneto", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Įgaliotasis serveris reikalauja nustatyti tapatybę naudojant naudotojo vardą ir slaptažodį.", "proxyPromptPassword": "Slaptažodis", "proxyPromptTitle": "Įgaliotojo serverio tapatybės nustatymas", @@ -110,7 +112,5 @@ "promptCancel": "Atsisakyti", "promptWarning": "Įspėjimas", "promptError": "Klaida", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/lv-LV.json b/electron/src/locale/lv-LV.json index ac02431a2d7..d779cfe362e 100644 --- a/electron/src/locale/lv-LV.json +++ b/electron/src/locale/lv-LV.json @@ -73,6 +73,8 @@ "menuView": "Sakts", "menuWindow": "Logs", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/me-ME.json b/electron/src/locale/me-ME.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/me-ME.json +++ b/electron/src/locale/me-ME.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/mk-MK.json b/electron/src/locale/mk-MK.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/mk-MK.json +++ b/electron/src/locale/mk-MK.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/ms-MY.json b/electron/src/locale/ms-MY.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/ms-MY.json +++ b/electron/src/locale/ms-MY.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/mt-MT.json b/electron/src/locale/mt-MT.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/mt-MT.json +++ b/electron/src/locale/mt-MT.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/nl-NL.json b/electron/src/locale/nl-NL.json index 3bbf244180a..5425fff6ec0 100644 --- a/electron/src/locale/nl-NL.json +++ b/electron/src/locale/nl-NL.json @@ -73,6 +73,8 @@ "menuView": "Toon", "menuWindow": "Venster", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Wachtwoord", "proxyPromptTitle": "Proxy-authenticatie", @@ -110,7 +112,5 @@ "promptCancel": "Annuleer", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/no-NO.json b/electron/src/locale/no-NO.json index 4281040a8a8..bf68adcf7d2 100644 --- a/electron/src/locale/no-NO.json +++ b/electron/src/locale/no-NO.json @@ -73,6 +73,8 @@ "menuView": "Vis", "menuWindow": "Vindu", "noInternet": "Ingen Internett", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Proxyen krever godkjenning med brukernavn og passord.", "proxyPromptPassword": "Passord", "proxyPromptTitle": "Proxy autentisering", @@ -110,7 +112,5 @@ "promptCancel": "Avbryt", "promptWarning": "Advarsel", "promptError": "Feil", - "urlBlockedPromptText": "", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "" } diff --git a/electron/src/locale/pl-PL.json b/electron/src/locale/pl-PL.json index 9140efa6d19..9486a5ab44e 100644 --- a/electron/src/locale/pl-PL.json +++ b/electron/src/locale/pl-PL.json @@ -73,6 +73,8 @@ "menuView": "Widok", "menuWindow": "Okno", "noInternet": "Brak połączenia z Internetem", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Serwer proxy wymaga uwierzytelnienia. Podaj login i hasło.", "proxyPromptPassword": "Hasło", "proxyPromptTitle": "Uwierzytelnienie proxy", @@ -110,7 +112,5 @@ "promptCancel": "Anuluj", "promptWarning": "Ostrzeżenie", "promptError": "Błąd", - "urlBlockedPromptText": "Potencjalnie niebezpieczny adres URL został zablokowany. Jeśli nadal chcesz go otworzyć, skopiuj adres URL i samodzielnie wklej go w przeglądarce internetowej.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "Potencjalnie niebezpieczny adres URL został zablokowany. Jeśli nadal chcesz go otworzyć, skopiuj adres URL i samodzielnie wklej go w przeglądarce internetowej." } diff --git a/electron/src/locale/pt-BR.json b/electron/src/locale/pt-BR.json index 1c8765da905..6dd47a5acb4 100644 --- a/electron/src/locale/pt-BR.json +++ b/electron/src/locale/pt-BR.json @@ -73,6 +73,8 @@ "menuView": "Visualizar", "menuWindow": "Janela", "noInternet": "Sem Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Este proxy requer autenticação com nome de usuário e senha.", "proxyPromptPassword": "Senha", "proxyPromptTitle": "Autenticação de Proxy", @@ -110,7 +112,5 @@ "promptCancel": "Cancelar", "promptWarning": "Atenção", "promptError": "Erro", - "urlBlockedPromptText": "Um URL potencialmente inseguro foi bloqueado. Se você ainda deseja abri-lo, copie o URL e abra o aplicativo apropriado.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "Um URL potencialmente inseguro foi bloqueado. Se você ainda deseja abri-lo, copie o URL e abra o aplicativo apropriado." } diff --git a/electron/src/locale/pt-PT.json b/electron/src/locale/pt-PT.json index 3e4e9443616..5081bef313a 100644 --- a/electron/src/locale/pt-PT.json +++ b/electron/src/locale/pt-PT.json @@ -73,6 +73,8 @@ "menuView": "Vizualizar", "menuWindow": "Janela", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Palavra-passe", "proxyPromptTitle": "Autenticação de Proxy", @@ -110,7 +112,5 @@ "promptCancel": "Cancelar", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/ro-RO.json b/electron/src/locale/ro-RO.json index e2e3e322f65..dc767fbb76d 100644 --- a/electron/src/locale/ro-RO.json +++ b/electron/src/locale/ro-RO.json @@ -73,6 +73,8 @@ "menuView": "Vedere", "menuWindow": "Fereastră", "noInternet": "Fără Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Parolă", "proxyPromptTitle": "Proxy Autentificare", @@ -110,7 +112,5 @@ "promptCancel": "Renunță", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/ru-RU.json b/electron/src/locale/ru-RU.json index 481d9a8ea19..9f8097be12b 100644 --- a/electron/src/locale/ru-RU.json +++ b/electron/src/locale/ru-RU.json @@ -73,6 +73,8 @@ "menuView": "Вид", "menuWindow": "Окно", "noInternet": "Нет интернета", + "noUrlConfiguredDescription": "Чтобы воспользоваться приложением, попросите своего администратора подключить вас к нужному бэкенду.", + "noUrlConfiguredTitle": "Приветствуем вас в Wire Gov", "proxyPromptHeadline": "Прокси-сервер требует аутентификации с помощью имени пользователя и пароля.", "proxyPromptPassword": "Пароль", "proxyPromptTitle": "Аутентификация на прокси-сервере", @@ -110,7 +112,5 @@ "promptCancel": "Отмена", "promptWarning": "Предупреждение", "promptError": "Ошибка", - "urlBlockedPromptText": "Потенциально небезопасный URL-адрес заблокирован. Если вы все еще хотите его открыть, скопируйте его и откройте соответствующее приложение.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "Потенциально небезопасный URL-адрес заблокирован. Если вы все еще хотите его открыть, скопируйте его и откройте соответствующее приложение." } diff --git a/electron/src/locale/si-LK.json b/electron/src/locale/si-LK.json index c66cc552ff4..2e104877a3e 100644 --- a/electron/src/locale/si-LK.json +++ b/electron/src/locale/si-LK.json @@ -73,6 +73,8 @@ "menuView": "දකින්න", "menuWindow": "කවුළුව", "noInternet": "අන්තර්ජාලය නැත", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "ප්‍රතියුක්තය සඳහා පරිශ්‍රීලක නාමයක් හා මුරපදයක් වුවමනාය.", "proxyPromptPassword": "මුරපදය", "proxyPromptTitle": "ප්‍රතියුක්තය සත්‍යාපනය", @@ -110,7 +112,5 @@ "promptCancel": "අවලංගු කරන්න", "promptWarning": "අවවාදයයි!", "promptError": "දෝෂයකි", - "urlBlockedPromptText": "අනාරක්‍ෂිත ඒ.ස.නි. (URL) අවහිර කර ඇත. ඔබට තවමත් එය විවෘත කිරීමට වුවමනා නම්, එය පිටපත් කර සුදුසු මෘදුකාංගයක් හරහා විවෘත කරන්න.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "අනාරක්‍ෂිත ඒ.ස.නි. (URL) අවහිර කර ඇත. ඔබට තවමත් එය විවෘත කිරීමට වුවමනා නම්, එය පිටපත් කර සුදුසු මෘදුකාංගයක් හරහා විවෘත කරන්න." } diff --git a/electron/src/locale/sk-SK.json b/electron/src/locale/sk-SK.json index 22402cbe102..180256c15a2 100644 --- a/electron/src/locale/sk-SK.json +++ b/electron/src/locale/sk-SK.json @@ -73,6 +73,8 @@ "menuView": "Zobraziť", "menuWindow": "Okno", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/sl-SI.json b/electron/src/locale/sl-SI.json index bd2b94aa4fd..a0f4ffd0dfd 100644 --- a/electron/src/locale/sl-SI.json +++ b/electron/src/locale/sl-SI.json @@ -73,6 +73,8 @@ "menuView": "Pogled", "menuWindow": "Okno", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/sq-AL.json b/electron/src/locale/sq-AL.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/sq-AL.json +++ b/electron/src/locale/sq-AL.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/sr-Cyrl-ME.json b/electron/src/locale/sr-Cyrl-ME.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/sr-Cyrl-ME.json +++ b/electron/src/locale/sr-Cyrl-ME.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/sr-SP.json b/electron/src/locale/sr-SP.json index 2d286131c88..0f5d6c30674 100644 --- a/electron/src/locale/sr-SP.json +++ b/electron/src/locale/sr-SP.json @@ -73,6 +73,8 @@ "menuView": "Приказ", "menuWindow": "Прозор", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": " Proxy захтева аутентификацију са корисничким именом и лозинком.", "proxyPromptPassword": "Lozinka", "proxyPromptTitle": "Proxy Аутентификација", @@ -110,7 +112,5 @@ "promptCancel": "Odustani", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/sv-SE.json b/electron/src/locale/sv-SE.json index 1e3b6614075..b41507b6a09 100644 --- a/electron/src/locale/sv-SE.json +++ b/electron/src/locale/sv-SE.json @@ -73,6 +73,8 @@ "menuView": "Visa", "menuWindow": "Fönster", "noInternet": "Inget internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Proxyn kräver autentisering med användarnamn och lösenord.", "proxyPromptPassword": "Lösenord", "proxyPromptTitle": "Proxy-autentisering", @@ -84,18 +86,18 @@ "trayOpen": "Öppna", "trayQuit": "Avsluta", "unreadMessages": "Olästa meddelanden", - "sidebarAccountNotification": "New message or missed call", - "sidebarAccountTypeTeam": "Team account", - "sidebarAccountTypePersonal": "Personal account", - "sidebarAccountSelected": "selected", - "sidebarAccountUnselected": "unselected", + "sidebarAccountNotification": "Nytt meddelande eller missat samtal", + "sidebarAccountTypeTeam": "Team-konto", + "sidebarAccountTypePersonal": "Personligt konto", + "sidebarAccountSelected": "markerat", + "sidebarAccountUnselected": "avmarkerat", "sidebarAccountLabel": "{accountType}, {accountName}, {selectionState}", - "sidebarAccountNameFallback": "Account", + "sidebarAccountNameFallback": "Konto", "webviewErrorDescription": "Det går inte att ansluta till servern \"{url}\"", "webviewErrorDescriptionSub": "Låt din servers administratör få kännedom om det här problemet.", "webviewErrorRetryAction": "Försök igen", "webviewErrorTitle": "Den här servern kan inte nås", - "wrapperAddAccount": "Add Another Account", + "wrapperAddAccount": "Lägg till ett annat konto", "wrapperCreateTeam": "Skapa team", "wrapperLogOut": "Logga ut", "wrapperManageTeam": "Hantera Team", @@ -110,7 +112,5 @@ "promptCancel": "Avbryt", "promptWarning": "Varning", "promptError": "Fel", - "urlBlockedPromptText": "En potentiellt osäker webbadress har blockerats. Kopiera URL:en och öppna passande applikation om du fortfarande vill öppna den.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "En potentiellt osäker webbadress har blockerats. Kopiera URL:en och öppna passande applikation om du fortfarande vill öppna den." } diff --git a/electron/src/locale/th-TH.json b/electron/src/locale/th-TH.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/th-TH.json +++ b/electron/src/locale/th-TH.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/tr-CY.json b/electron/src/locale/tr-CY.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/tr-CY.json +++ b/electron/src/locale/tr-CY.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/tr-TR.json b/electron/src/locale/tr-TR.json index 5a2b7d192fe..2d1ba0911ae 100644 --- a/electron/src/locale/tr-TR.json +++ b/electron/src/locale/tr-TR.json @@ -73,6 +73,8 @@ "menuView": "Görüntüle", "menuWindow": "Pencere", "noInternet": "İnternet bağlantısı yok", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Proxy kullanıcı adı ve şifre ile doğrulama gerektirmektedir.", "proxyPromptPassword": "Şifre", "proxyPromptTitle": "Proxy Doğrulaması", @@ -110,7 +112,5 @@ "promptCancel": "İptal", "promptWarning": "Dikkat", "promptError": "Hata", - "urlBlockedPromptText": "Potansiyel tehlikeli bir URL engellendi. Eğer açmak isterseniz, URL'yi kopyalayıp uygun uygulamada açın", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "Potansiyel tehlikeli bir URL engellendi. Eğer açmak isterseniz, URL'yi kopyalayıp uygun uygulamada açın" } diff --git a/electron/src/locale/uk-UA.json b/electron/src/locale/uk-UA.json index bca4224647c..473bf9c1c6a 100644 --- a/electron/src/locale/uk-UA.json +++ b/electron/src/locale/uk-UA.json @@ -73,6 +73,8 @@ "menuView": "Вигляд", "menuWindow": "Вікно", "noInternet": "Відсутнє підключення до інтернету", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Проксі-сервер вимагає аутентифікацію з ім'ям користувача і паролем.", "proxyPromptPassword": "Пароль", "proxyPromptTitle": "Аутентифікація на проксі-сервері", @@ -110,7 +112,5 @@ "promptCancel": "Скасувати", "promptWarning": "Попередження", "promptError": "Помилка", - "urlBlockedPromptText": "Потенційно небезпечний URL було заблоковано. Якщо ви все ще хочете відкрити його, скопіюйте URL в буфер обміну та відкрийте відповідну програму.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "Потенційно небезпечний URL було заблоковано. Якщо ви все ще хочете відкрити його, скопіюйте URL в буфер обміну та відкрийте відповідну програму." } diff --git a/electron/src/locale/uz-UZ.json b/electron/src/locale/uz-UZ.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/uz-UZ.json +++ b/electron/src/locale/uz-UZ.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/vi-VN.json b/electron/src/locale/vi-VN.json index b39fe886e43..e5da1bc371f 100644 --- a/electron/src/locale/vi-VN.json +++ b/electron/src/locale/vi-VN.json @@ -73,6 +73,8 @@ "menuView": "View", "menuWindow": "Window", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "The proxy requires authentication with username and password.", "proxyPromptPassword": "Password", "proxyPromptTitle": "Proxy authentication", @@ -110,7 +112,5 @@ "promptCancel": "Cancel", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/zh-CN.json b/electron/src/locale/zh-CN.json index dfba5ae6ee5..1f9b8af193e 100644 --- a/electron/src/locale/zh-CN.json +++ b/electron/src/locale/zh-CN.json @@ -73,6 +73,8 @@ "menuView": "查看", "menuWindow": "窗口", "noInternet": "无互联网连接", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "代理服务器需要使用用户名及密码验证", "proxyPromptPassword": "密码", "proxyPromptTitle": "代理认证", @@ -110,7 +112,5 @@ "promptCancel": "取消", "promptWarning": "注意", "promptError": "错误", - "urlBlockedPromptText": "一个可能不安全的URL已被屏蔽。如果您仍然想要浏览,请复制 URL 到浏览器中打开。", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "一个可能不安全的URL已被屏蔽。如果您仍然想要浏览,请复制 URL 到浏览器中打开。" } diff --git a/electron/src/locale/zh-HK.json b/electron/src/locale/zh-HK.json index 2d09a41dd2e..d542b78132c 100644 --- a/electron/src/locale/zh-HK.json +++ b/electron/src/locale/zh-HK.json @@ -73,6 +73,8 @@ "menuView": "檢視", "menuWindow": "視窗", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Proxy認證需要您的用戶名及密碼", "proxyPromptPassword": "密碼", "proxyPromptTitle": "Proxy認證", @@ -110,7 +112,5 @@ "promptCancel": "取消", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." } diff --git a/electron/src/locale/zh-TW.json b/electron/src/locale/zh-TW.json index 39ed683fd54..b6264d07692 100644 --- a/electron/src/locale/zh-TW.json +++ b/electron/src/locale/zh-TW.json @@ -73,6 +73,8 @@ "menuView": "檢視", "menuWindow": "視窗", "noInternet": "No Internet", + "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend.", + "noUrlConfiguredTitle": "Welcome to Wire Gov", "proxyPromptHeadline": "Proxy認證需要您的帳號密碼", "proxyPromptPassword": "密碼", "proxyPromptTitle": "Proxy認證", @@ -110,7 +112,5 @@ "promptCancel": "取消", "promptWarning": "Warning", "promptError": "Error", - "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application.", - "noUrlConfiguredTitle": "Welcome to Wire Gov", - "noUrlConfiguredDescription": "To use the app, ask your admin to connect you to the right backend." + "urlBlockedPromptText": "A potentially unsafe URL has been blocked. If you still wish to open it, copy the URL and open the appropriate application." }