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: '*' diff --git a/bin/build-tools/assets/msi-banner.bmp b/bin/build-tools/assets/msi-banner.bmp new file mode 100644 index 00000000000..bdc559de11c Binary files /dev/null and b/bin/build-tools/assets/msi-banner.bmp differ 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..8823354ac76 100644 --- a/bin/build-tools/lib/Config.ts +++ b/bin/build-tools/lib/Config.ts @@ -72,3 +72,10 @@ export interface WindowsInstallerConfig { installerIconUrl: string; loadingGif: string; } + +export interface WindowsMsiConfig { + appId: string; + artifactName: string; + manufacturer: string; + upgradeCode: string; +} 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/build-tools/lib/build-windows-msi.test.ts b/bin/build-tools/lib/build-windows-msi.test.ts new file mode 100644 index 00000000000..86b742321bc --- /dev/null +++ b/bin/build-tools/lib/build-windows-msi.test.ts @@ -0,0 +1,196 @@ +/* + * 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 {buildWindowsMsiConfig, customizeMsiProject, validateWindowsMsiAppDirectory} 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, + manufacturer: process.env.WIN_MSI_MANUFACTURER, + 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_MANUFACTURER', originalEnvironment.manufacturer); + 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.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); + 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-FC0A-005A-8BB72BBADBBB}'; + + const {windowsMsiConfig} = await buildWindowsMsiConfig(wireJsonPath, envFilePath); + + 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 () => { + 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('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 = ` + + = 601]]> + + + + + + `; + + 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, /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', () => { + assert.throws( + () => 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 = ` + = 601]]> + + `; + + assert.throws( + () => customizeMsiProject(project, 'wire', 'com.squirrel.wire.wire', 'Wire', 'msi-banner.bmp'), + /Could not find the desktop shortcut/, + ); + }); + }); + + 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 new file mode 100644 index 00000000000..b6f4c84304c --- /dev/null +++ b/bin/build-tools/lib/build-windows-msi.ts @@ -0,0 +1,277 @@ +/* + * 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_MANUFACTURER = 'Wire Swiss GmbH'; + +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}){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, + bannerFileName: 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 configurationRegistryKey = escapeXmlAttribute(`Software\\Wire\\${productName}`); + const protocolRegistry = [ + ` `, + ` `, + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ` `, + ' ', + ' ', + ' ', + ].join('\n'); + + 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); + 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>/; + if (!electronBuilderOsCondition.test(customizedProject)) { + throw new Error('Could not find the operating system condition in the generated MSI project.'); + } + customizedProject = customizedProject.replace( + electronBuilderOsCondition, + // 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); + 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 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, + }; + + const builderConfig: electronBuilder.Configuration = { + appId: windowsMsiConfig.appId, + buildVersion: commonConfig.version.replace(/-.*$/, ''), + copyright: commonConfig.copyright, + directories: { + buildResources: commonConfig.electronDirectory, + output: commonConfig.distDir, + }, + extraMetadata: { + author: {name: windowsMsiConfig.manufacturer}, + }, + msi: { + artifactName: windowsMsiConfig.artifactName, + createDesktopShortcut: true, + createStartMenuShortcut: true, + oneClick: false, + perMachine: true, + runAfterFinish: false, + shortcutName: commonConfig.name, + upgradeCode: windowsMsiConfig.upgradeCode, + 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); + }, + 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 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, + 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 ...`); + + await validateWindowsMsiAppDirectory(appDirectory, commonConfig.name); + + 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/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/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/bin/deploy-tools/lib/JenkinsWindowsDeployment.test.ts b/bin/deploy-tools/lib/JenkinsWindowsDeployment.test.ts new file mode 100644 index 00000000000..1aee9950971 --- /dev/null +++ b/bin/deploy-tools/lib/JenkinsWindowsDeployment.test.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 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('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/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/bin/deploy-tools/lib/S3Deployer.test.ts b/bin/deploy-tools/lib/S3Deployer.test.ts index a1966c0cd00..bf68139b3fb 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,11 +14,24 @@ * * 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 {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({ @@ -27,7 +40,61 @@ describe('S3Deployer', () => { secretAccessKey: '', }); - await s3Deployer.copyOnS3({bucket: '', s3FromPath: '', s3ToPath: ''}); + await assert.doesNotReject(() => s3Deployer.copyOnS3({bucket: '', s3FromPath: '', s3ToPath: ''})); + }); + }); + + describe('findUploadFiles', () => { + 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', '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')}, + ]); + }); + + 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 b7482bed3d7..2e51a5d8faa 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; @@ -47,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; @@ -65,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}); @@ -94,6 +105,30 @@ 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 (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)}]; + } + 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/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 new file mode 100644 index 00000000000..a1063621e0b --- /dev/null +++ b/docs/windows-msi.md @@ -0,0 +1,84 @@ +# Windows MSI distribution + +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 + +Build the Windows application before building its MSI: + +```shell +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. + +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`. + +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, 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 + +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 +``` + +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 + +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. + +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 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. +- 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. +- 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/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; }); 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(); diff --git a/electron/src/lib/LocalAccountDeletion.ts b/electron/src/lib/LocalAccountDeletion.ts index 43d66a7a41c..a73099bfcf7 100644 --- a/electron/src/lib/LocalAccountDeletion.ts +++ b/electron/src/lib/LocalAccountDeletion.ts @@ -25,13 +25,27 @@ import * as path from 'path'; import {ValidationUtil} from '@wireapp/commons'; -import {getLogger} from '../logging/getLogger'; +import {deleteAccountLogDirectories} from './accountLogDeletion'; -const USER_DATA_DIR = app.getPath('userData'); -const LOG_DIR = path.join(USER_DATA_DIR, 'logs'); +import {getLogger} from '../logging/getLogger'; +import { + LogDirectoryCleanupDependencies, + LogDirectoryCleanupResult, + removeEmptyLogDirectoryAncestors, +} from '../logging/logDirectoryCleanup'; +import {getLogFilenames} from '../logging/logFiles'; +import {getLogDirectory} from '../logging/logPaths'; 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 +67,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 +80,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 +97,59 @@ 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); - await fs.remove(sessionFolder); + 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 directoryMetadata = await directoryCleanupDependencies.getDirectoryMetadata(directoryPath); + + return directoryMetadata.isDirectory && directoryMetadata.isSymbolicLink === false; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false; + } + + 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); + }, + 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..a595ad1f995 --- /dev/null +++ b/electron/src/lib/accountLogDeletion.test.main.ts @@ -0,0 +1,159 @@ +/* + * 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} from 'true-myth'; + +import * as assert from 'assert'; +import * as path from 'path'; + +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'; +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'), + 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}); + 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('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[] = []; + 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 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'); + } + + removedDirectories.push(directoryPath); + }, + reportFailure: (message: string): void => { + failures.push(message); + }, + }, + filePaths, + logDirectory, + }); + + 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 new file mode 100644 index 00000000000..0d4658d1f90 --- /dev/null +++ b/electron/src/lib/accountLogDeletion.ts @@ -0,0 +1,129 @@ +/* + * 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'; + +import {ValidationUtil} from '@wireapp/commons'; + +import {LogDirectoryCleanupResult} from '../logging/logDirectoryCleanup'; + +export type AccountLogDirectoryParameters = { + accountId: string; + filePaths: readonly string[]; + logDirectory: string; +}; + +export type AccountLogDeletionDependencies = { + isSafeDirectory: (directoryPath: string) => Promise; + removeEmptyDirectoryAncestors: (directoryPath: string) => Promise; + removeDirectory: (directoryPath: string) => Promise; + reportFailure: (message: string, error: unknown) => void; +}; + +export type DeleteAccountLogDirectoriesParameters = AccountLogDirectoryParameters & { + 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); +} + +function isNewAccountLogPath(pathSegments: readonly string[], accountId: string): boolean { + 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 parsedDirectory.isJust && parsedDirectory.value.accountId === 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); + 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/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/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/download.test.main.ts b/electron/src/lib/download.test.main.ts index 9ab9f94ec4f..39e21f40037 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() { + return Maybe.nothing(); + }, + async writeArchive() { + 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() { + events.push('choose-destination'); + + return Maybe.just('logs.zip'); + }, + async writeArchive() { + 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..145a2c8e716 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 DownloadLogArchiveOptions = { + chooseDestinationPath: () => Promise>; + writeArchive: (destinationPath: string) => Promise; }; -export const downloadImage = async (bytes: Uint8Array, timestamp?: string): Promise => { - const type = imageType(bytes); +export async function downloadLogArchive(options: DownloadLogArchiveOptions): Promise { + const destinationPath = await options.chooseDestinationPath(); + + if (destinationPath.isJust) { + await options.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/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/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/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." } diff --git a/electron/src/logging/boundedLogWriter.test.main.ts b/electron/src/logging/boundedLogWriter.test.main.ts new file mode 100644 index 00000000000..e1d6388a3e4 --- /dev/null +++ b/electron/src/logging/boundedLogWriter.test.main.ts @@ -0,0 +1,350 @@ +/* + * 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 {createLogCleanup, createLogCleanupFileSystemDependencies} from './logCleanup'; +import {createLogMaintenanceCoordinator} from './logMaintenance'; +import {LogRetentionPolicy} from './logRetention'; + +import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; +import {createFireAndForgetInvoker} from '../lib/fireAndForgetInvoker'; + +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 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({ + afterWrite: ignorePostWriteCleanup, + dependencies: createTestFileSystemDependencies(1), + maintenanceCoordinator: createTestMaintenanceCoordinator(), + 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({ + afterWrite: ignorePostWriteCleanup, + dependencies, + maintenanceCoordinator: createTestMaintenanceCoordinator(), + maximumFileSizeBytes: 1024, + }); + + await Promise.all([ + boundedLogWriter.write({logFilePath: firstLogFilePath, message: 'first'}), + boundedLogWriter.write({logFilePath: secondLogFilePath, message: 'second'}), + ]); + + assert.strictEqual(maximumConcurrentAppendCount, 2); + }), + ); + + 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 afterWrite(): Promise { + rotationCount += 1; + }, + dependencies: createTestFileSystemDependencies(2), + maintenanceCoordinator: createTestMaintenanceCoordinator(), + 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({ + afterWrite: ignorePostWriteCleanup, + dependencies: createTestFileSystemDependencies(3), + maintenanceCoordinator: createTestMaintenanceCoordinator(), + 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({ + afterWrite: ignorePostWriteCleanup, + dependencies: createTestFileSystemDependencies(4), + maintenanceCoordinator: createTestMaintenanceCoordinator(), + 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'); + }), + ); + + 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( + '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) => { + 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 new file mode 100644 index 00000000000..bcaf7b3d800 --- /dev/null +++ b/electron/src/logging/boundedLogWriter.ts @@ -0,0 +1,213 @@ +/* + * 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'; + +import {LogMaintenanceCoordinator} from './logMaintenance'; + +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 = { + afterWrite: () => Promise; + dependencies: BoundedLogWriterDependencies; + maintenanceCoordinator: LogMaintenanceCoordinator; + maximumFileSizeBytes: number; +}; + +export type WriteLogMessageParameters = { + logFilePath: string; + message: string; +}; + +export type BoundedLogWriter = { + runMaintenance(operation: () => Promise): Promise; + 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 = { + dependencies: BoundedLogWriterDependencies; + 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'); +} + +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; + let didRotate = false; + + if (wouldExceedMaximumFileSize) { + await rotateLogFile({ + collisionIndex: 0, + currentTimeMilliseconds: parameters.dependencies.getCurrentTimeMilliseconds(), + dependencies: parameters.dependencies, + 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 { + if (pendingWrites.get(logFilePath) === pendingWrite) { + pendingWrites.delete(logFilePath); + } +} + +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 = runQueuedWrite({ + afterWrite: parameters.afterWrite, + dependencies: parameters.dependencies, + maintenanceCoordinator: parameters.maintenanceCoordinator, + maximumFileSizeBytes: parameters.maximumFileSizeBytes, + previousWrite, + writeParameters, + }); + + let pendingWrite: Promise = Promise.resolve(); + + async function removePendingWriteAfterCompletion(): Promise { + try { + await writeOperation; + } finally { + removePendingWrite(pendingWrites, writeParameters.logFilePath, pendingWrite); + } + } + + pendingWrite = removePendingWriteAfterCompletion(); + pendingWrites.set(writeParameters.logFilePath, pendingWrite); + + return pendingWrite; + } + + return { + runMaintenance: parameters.maintenanceCoordinator.runMaintenance, + write: writeLogMessage, + }; +} diff --git a/electron/src/logging/desktopLogWriter.ts b/electron/src/logging/desktopLogWriter.ts new file mode 100644 index 00000000000..c6e9cb37b1b --- /dev/null +++ b/electron/src/logging/desktopLogWriter.ts @@ -0,0 +1,133 @@ +/* + * 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 { + BoundedLogWriter, + BoundedLogWriterDependencies, + createBoundedLogWriter, + 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'; +} + +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 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 afterWrite(): Promise { + await runDesktopLogCleanup(); + }, + dependencies: createFileSystemDependencies(), + maintenanceCoordinator: desktopLogMaintenanceCoordinator, + maximumFileSizeBytes: 10 * 1024 * 1024, +}); + +let currentDesktopLogCleanup: Maybe> = Maybe.nothing>(); + +export function runDesktopLogCleanupWithinMaintenance(): Promise { + return cleanupDesktopLogs({ + activeFilePaths: new Set(), + 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 fireAndForgetDesktopLogOperation(asyncAction: () => Promise): void { + desktopLogFireAndForgetInvoker.fireAndForget(asyncAction); +} diff --git a/electron/src/logging/getLogger.ts b/electron/src/logging/getLogger.ts index c9176d0ab88..2aa957489f4 100644 --- a/electron/src/logging/getLogger.ts +++ b/electron/src/logging/getLogger.ts @@ -17,30 +17,41 @@ * */ -import * as Electron from 'electron'; import * as logdown from 'logdown'; -import * as path from 'path'; - import {LogFactory, LoggerOptions} from '@wireapp/commons'; +import {fireAndForgetDesktopLogOperation, writeBoundedLogMessage} from './desktopLogWriter'; +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'); 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}`; + + fireAndForgetDesktopLogOperation(async (): Promise => { + await 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: logFile, + logFilePath: getMainProcessLogPath({date: new Date(), logDirectory: getLogDirectory()}), namespace: LOGGER_NAMESPACE, separator: '/', }; @@ -49,5 +60,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..45d345c9ae3 --- /dev/null +++ b/electron/src/logging/logCleanup.test.main.ts @@ -0,0 +1,285 @@ +/* + * 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 {Result} from 'true-myth'; + +import * as assert from 'assert'; +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, LogRetentionPolicy} from './logRetention'; + +import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; + +type CleanupTestState = { + failures: string[]; + removedDirectories: string[]; + removedFiles: string[]; +}; + +const testLogRetentionPolicy: LogRetentionPolicy = { + maximumAgeMilliseconds: 100, + maximumTotalSizeBytes: 100, +}; + +type CleanupTestOptions = { + activeFilePaths: ReadonlySet; + policy?: LogRetentionPolicy; +}; + +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); + + return Result.ok(); + }, + async removeFile(filePath: string): Promise { + state.removedFiles.push(filePath); + }, + reportFailure: (message: string): void => { + state.failures.push(message); + }, + }; +} + +function createCleanupOptions(options: CleanupTestOptions): RunLogCleanupParameters { + const retentionPolicy = options.policy ?? testLogRetentionPolicy; + + return { + activeFilePaths: options.activeFilePaths, + logDirectory: 'logs', + 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[] = [ + { + filePath: 'logs/2026-08-20/accounts/account/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(createCleanupOptions({activeFilePaths: new Set()})); + + 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, []); + }); + + 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(createCleanupOptions({activeFilePaths: 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(createCleanupOptions({activeFilePaths: 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 cleanupOptions = createCleanupOptions({activeFilePaths: new Set()}); + + await Promise.all([cleanup.run(cleanupOptions), cleanup.run(cleanupOptions)]); + + 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 new file mode 100644 index 00000000000..ee28f4b9f57 --- /dev/null +++ b/electron/src/logging/logCleanup.ts @@ -0,0 +1,236 @@ +/* + * 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 {getLogDirectoryAncestors} from './logDirectoryAncestors'; +import { + LogDirectoryCleanupDependencies, + LogDirectoryCleanupResult, + removeEmptyLogDirectory, +} from './logDirectoryCleanup'; +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 = 500 * 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 compareDirectoryDepth(firstPath: string, secondPath: string): number { + const pathLengthDifference = secondPath.length - firstPath.length; + + if (pathLengthDifference !== 0) { + return pathLengthDifference; + } + + 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 [...parentDirectories].toSorted(compareDirectoryDepth); +} + +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[], + logDirectory: string, + dependencies: LogCleanupDependencies, +): Promise { + for (const directoryPath of getParentDirectories(filePaths, logDirectory)) { + try { + 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); + } + } +} + +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(retentionPlan.filesToDelete, parameters.logDirectory, dependencies); +} + +function createLogDirectoryCleanupDependencies(): LogDirectoryCleanupDependencies { + return { + 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); + }, + }; +} + +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}); + }, + 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 { + return removeEmptyLogDirectory({directoryPath, dependencies: directoryCleanupDependencies}); + }, + 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( + createLogCleanupFileSystemDependencies((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..cd8093f336f --- /dev/null +++ b/electron/src/logging/logCleanupScheduler.test.main.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 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 = () => {}; + let scheduledIntervalMilliseconds = 0; + let wasUnrefed = false; + let cleanupRunCount = 0; + const interval: UnrefableInterval = { + unref: (): void => { + wasUnrefed = true; + }, + }; + const invoker = createFireAndForgetInvoker({ + reportFailure(error: unknown): void { + throw error; + }, + }); + + scheduleLogCleanup({ + fireAndForget: invoker.fireAndForget, + 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..fc193fb8379 --- /dev/null +++ b/electron/src/logging/logCleanupScheduler.ts @@ -0,0 +1,37 @@ +/* + * 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 = { + fireAndForget: (asyncAction: () => Promise) => void; + intervalMilliseconds: number; + runCleanup: () => Promise; + setInterval: (callback: () => void, intervalMilliseconds: number) => UnrefableInterval; +}; + +export function scheduleLogCleanup(parameters: ScheduleLogCleanupParameters): void { + const cleanupInterval = parameters.setInterval(() => { + parameters.fireAndForget(parameters.runCleanup); + }, parameters.intervalMilliseconds); + + cleanupInterval.unref(); +} 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(); +} diff --git a/electron/src/logging/logExport.test.main.ts b/electron/src/logging/logExport.test.main.ts new file mode 100644 index 00000000000..b11e051240b --- /dev/null +++ b/electron/src/logging/logExport.test.main.ts @@ -0,0 +1,661 @@ +/* + * 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 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 { + createLogSnapshot, + CreateLogSnapshotOptions, + exportLogFiles, + LogSnapshot, + LogSnapshotFileSystemDependencies, + streamLogFilesToZip, +} from './logExport'; +import {createLogArchiveDependencies, createLogSnapshotFileSystemDependencies} from './logExportDependencies'; +import {getLogFilenames} from './logFiles'; +import {createLogMaintenanceCoordinator} from './logMaintenance'; + +import {withTemporaryDirectory} from '../../test/withTemporaryDirectory'; +import {createFireAndForgetInvoker} from '../lib/fireAndForgetInvoker'; + +type CreateSnapshotTestOptions = { + cleanup: () => Promise; + discoverLogFilePaths: () => readonly string[]; + logDirectory: string; + maintenanceCoordinator: ReturnType; + reportFailure: (message: string, error: unknown) => void; + temporaryDirectoryPrefix: string; +} & LogSnapshotFileSystemDependencies; + +function runNoopCleanup(): Promise { + return Promise.resolve(); +} + +function createDeferredCompletion(): {promise: Promise; resolve: () => void} { + let resolvePromise: () => void = noop; + const promise = new Promise(resolve => { + resolvePromise = resolve; + }); + + return {promise, resolve: resolvePromise}; +} + +function createTestMaintenanceCoordinator(): ReturnType { + const invoker = createFireAndForgetInvoker({ + reportFailure() { + // The coordinator's public promises report expected operation failures. + }, + }); + + return createLogMaintenanceCoordinator({fireAndForget: invoker.fireAndForget}); +} + +function createSnapshotTestOptions(options: CreateSnapshotTestOptions): CreateLogSnapshotOptions { + const {copyFile, createTemporaryDirectory, ensureDirectory, getFileMetadata, removeDirectory} = options; + + return { + copyFile, + cleanup: options.cleanup, + createTemporaryDirectory, + discoverLogFilePaths: options.discoverLogFilePaths, + ensureDirectory, + getFileMetadata, + logDirectory: options.logDirectory, + reportFailure: options.reportFailure, + removeDirectory, + runMaintenance: options.maintenanceCoordinator.runMaintenance, + temporaryDirectoryPrefix: options.temporaryDirectoryPrefix, + }; +} + +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 snapshotting files', + withTemporaryDirectory('wire-log-export-maintenance-', 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 snapshotFileSystemDependencies = createLogSnapshotFileSystemDependencies(); + const snapshotPromise = createLogSnapshot( + createSnapshotTestOptions({ + async cleanup() { + events.push('cleanup'); + }, + 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-'), + }), + ); + + 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 = { + 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, + 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-'), + }), + ); + + 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); + } + }), + ); + + 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 snapshotFileSystemDependencies = createLogSnapshotFileSystemDependencies(); + const snapshot = await createLogSnapshot( + createSnapshotTestOptions({ + cleanup: runNoopCleanup, + copyFile: snapshotFileSystemDependencies.copyFile, + createTemporaryDirectory: snapshotFileSystemDependencies.createTemporaryDirectory, + discoverLogFilePaths() { + return [ + currentLogRelativePath, + rotatedLogRelativePath, + legacyLogRelativePath, + 'linked.log', + 'linked-account/console.log', + ]; + }, + ensureDirectory: snapshotFileSystemDependencies.ensureDirectory, + getFileMetadata: snapshotFileSystemDependencies.getFileMetadata, + logDirectory: temporaryLogDirectory, + maintenanceCoordinator: createTestMaintenanceCoordinator(), + reportFailure: noop, + removeDirectory: snapshotFileSystemDependencies.removeDirectory, + temporaryDirectoryPrefix: path.join(temporaryLogDirectory, 'snapshot-'), + }), + ); + + 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 = { + async removeDirectory(directoryPath: string) { + removedSnapshotDirectories.push(directoryPath); + await defaultDependencies.removeDirectory(directoryPath); + }, + copyFile: defaultDependencies.copyFile, + createTemporaryDirectory: defaultDependencies.createTemporaryDirectory, + ensureDirectory: defaultDependencies.ensureDirectory, + getFileMetadata: defaultDependencies.getFileMetadata, + }; + + try { + await assert.rejects( + createLogSnapshot( + createSnapshotTestOptions({ + cleanup: runNoopCleanup, + 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-'), + }), + ), + ); + + 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 snapshotFileSystemDependencies = createLogSnapshotFileSystemDependencies(); + const snapshotPromise = createLogSnapshot( + createSnapshotTestOptions({ + cleanup: runNoopCleanup, + 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-'), + }), + ); + const zipStarted = createDeferredCompletion(); + const zipCompletion = createDeferredCompletion(); + let exportCompleted = false; + const exportPromise = exportLogFiles({ + async createSnapshot() { + return snapshotPromise; + }, + destinationPath: path.join(temporaryLogDirectory, 'logs.zip'), + async removeSnapshotDirectory(directoryPath: string) { + await fs.remove(directoryPath); + }, + reportFailure: noop, + async streamSnapshot() { + zipStarted.resolve(); + await zipCompletion.promise; + }, + }).then(() => { + exportCompleted = true; + }); + + await zipStarted.promise; + const writePromise = maintenanceCoordinator.runWrite(async (): Promise => { + await fs.outputFile(path.join(temporaryLogDirectory, 'electron.log'), 'during archive\n'); + }); + + await writePromise; + 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'); + const snapshotFileSystemDependencies = createLogSnapshotFileSystemDependencies(); + const archiveDependencies = createLogArchiveDependencies(noop); + const exportFireAndForgetInvoker = createFireAndForgetInvoker({reportFailure: noop}); + let createdSnapshot: Maybe = Maybe.nothing(); + + const exportPromise = exportLogFiles({ + async createSnapshot() { + const snapshot = await createLogSnapshot( + createSnapshotTestOptions({ + cleanup: runNoopCleanup, + 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-'), + }), + ); + createdSnapshot = Maybe.just(snapshot); + + return snapshot; + }, + destinationPath: archivePath, + 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, + }); + }, + }); + exportFireAndForgetInvoker.fireAndForget(() => { + return exportPromise; + }); + await exportPromise; + await exportFireAndForgetInvoker.waitUntilAllSettled(); + + 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( + '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 = { + 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; + }, + 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: [], + }), + 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 = { + 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; + }, + 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: [], + }), + 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) => { + 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( + createSnapshotTestOptions({ + cleanup: runNoopCleanup, + 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 = { + createArchive: archiveDependencies.createArchive, + createOutputStream() { + return new Writable({ + write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error) => void): void { + if (wrotePartialArchive === false) { + wrotePartialArchive = true; + fs.writeFileSync(archivePath, chunk); + } + + callback(outputFailure); + }, + }); + }, + pathExists: archiveDependencies.pathExists, + removeFile: archiveDependencies.removeFile, + reportFailure: archiveDependencies.reportFailure, + }; + + await assert.rejects( + exportLogFiles({ + async createSnapshot() { + return snapshotPromise; + }, + destinationPath: archivePath, + removeSnapshotDirectory: snapshotFileSystemDependencies.removeDirectory, + 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, + }); + }, + }), + outputFailure, + ); + + const snapshot = await snapshotPromise; + assert.strictEqual(wrotePartialArchive, true); + assert.strictEqual(await fs.pathExists(archivePath), false); + assert.strictEqual(await fs.pathExists(snapshot.directoryPath), false); + }), + ); +}); diff --git a/electron/src/logging/logExport.ts b/electron/src/logging/logExport.ts new file mode 100644 index 00000000000..2661da6d296 --- /dev/null +++ b/electron/src/logging/logExport.ts @@ -0,0 +1,348 @@ +/* + * 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 type {ZipArchive} from 'archiver'; +import noop from 'lodash/noop'; +import {Maybe} from 'true-myth'; + +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 CreateLogSnapshotOptions = { + cleanup: () => Promise; + discoverLogFilePaths: () => readonly string[]; + logDirectory: string; + reportFailure: (message: string, error: unknown) => void; + runMaintenance(operation: () => Promise): Promise; + temporaryDirectoryPrefix: string; +} & LogSnapshotFileSystemDependencies; + +export type StreamLogFilesToZipOptions = { + destinationPath: string; + snapshotFiles: readonly LogSnapshotFile[]; +} & 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 StreamSnapshotOptions = { + destinationPath: string; + snapshotFiles: readonly LogSnapshotFile[]; +}; + +export type ExportLogFilesOptions = { + createSnapshot: () => Promise; + destinationPath: string; + removeSnapshotDirectory: (directoryPath: string) => Promise; + reportFailure: (message: string, error: unknown) => void; + streamSnapshot: (options: StreamSnapshotOptions) => Promise; +}; + +type ResolvePathWithinDirectoryOptions = { + directoryPath: string; + relativePath: string; +}; + +type CopyLogFileToSnapshotOptions = { + logDirectory: string; + relativePath: string; + sourceFilePath: string; + snapshotFilePath: string; +} & Pick; + +type GetSafeSourceFileMetadataOptions = { + logDirectory: string; + sourceFilePath: string; + getFileMetadata: LogSnapshotFileSystemDependencies['getFileMetadata']; +}; + +type RemoveIncompleteDestinationFileOptions = { + destinationPath: string; + destinationAlreadyExisted: boolean; + outputStreamCreated: boolean; +} & Pick; + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +} + +export function resolvePathWithinDirectory(options: ResolvePathWithinDirectoryOptions): string { + const resolvedDirectoryPath = path.resolve(options.directoryPath); + + 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, options.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: ${options.relativePath}`); + } + + return resolvedPath; +} + +function getArchiveRelativePath(relativePath: string): string { + return path.normalize(relativePath).split(path.sep).join('/'); +} + +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; + let sourceFileMetadata: Maybe = Maybe.nothing(); + + for (const pathPart of sourceRelativePath.split(path.sep)) { + currentPath = path.join(currentPath, pathPart); + const currentFileMetadata = await getFileMetadata(currentPath); + sourceFileMetadata = Maybe.just(currentFileMetadata); + + if (currentFileMetadata.isSymbolicLink === true) { + return Maybe.nothing(); + } + } + + return sourceFileMetadata; +} + +async function copyLogFileToSnapshot(options: CopyLogFileToSnapshotOptions): Promise> { + const {copyFile, ensureDirectory, getFileMetadata} = options; + const sourceFileMetadata = await getSafeSourceFileMetadata({ + getFileMetadata, + logDirectory: options.logDirectory, + sourceFilePath: options.sourceFilePath, + }); + + if (sourceFileMetadata.isJust === false) { + return Maybe.nothing(); + } + + if (sourceFileMetadata.value.isFile === false) { + return Maybe.nothing(); + } + + await ensureDirectory(path.dirname(options.snapshotFilePath)); + await copyFile(options.sourceFilePath, options.snapshotFilePath); + + return Maybe.just({ + absolutePath: options.snapshotFilePath, + relativePath: getArchiveRelativePath(options.relativePath), + }); +} + +async function removeSnapshotDirectoryAfterFailure( + snapshotDirectoryPath: Maybe, + options: CreateLogSnapshotOptions, +): Promise { + const {removeDirectory} = options; + + if (snapshotDirectoryPath.isJust === false) { + return; + } + + try { + 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 { + return await options.runMaintenance(async () => { + await options.cleanup(); + + const relativeLogFilePaths = options.discoverLogFilePaths(); + const createdSnapshotDirectoryPath = await createTemporaryDirectory(options.temporaryDirectoryPrefix); + snapshotDirectoryPath = Maybe.just(createdSnapshotDirectoryPath); + const snapshotFiles: LogSnapshotFile[] = []; + + for (const relativePath of relativeLogFilePaths) { + const sourceFilePath = resolvePathWithinDirectory({directoryPath: options.logDirectory, relativePath}); + const snapshotFilePath = resolvePathWithinDirectory({ + directoryPath: createdSnapshotDirectoryPath, + relativePath, + }); + + const snapshotFile = await copyLogFileToSnapshot({ + copyFile, + ensureDirectory, + getFileMetadata, + logDirectory: options.logDirectory, + relativePath, + sourceFilePath, + snapshotFilePath, + }).catch(error => { + options.reportFailure(`Failed to copy log file "${relativePath}" to the temporary snapshot`, error); + + return Maybe.nothing(); + }); + + if (snapshotFile.isJust) { + snapshotFiles.push(snapshotFile.value); + } + } + + return {directoryPath: createdSnapshotDirectoryPath, files: snapshotFiles}; + }); + } catch (error) { + await removeSnapshotDirectoryAfterFailure(snapshotDirectoryPath, options); + throw error; + } +} + +async function removeIncompleteDestinationFile(options: RemoveIncompleteDestinationFileOptions): Promise { + const {removeFile, reportFailure} = options; + + if (options.destinationAlreadyExisted || options.outputStreamCreated === false) { + return; + } + + try { + await removeFile(options.destinationPath); + } catch (error) { + if (isMissingFileError(error)) { + return; + } + + reportFailure(`Failed to remove incomplete log archive "${options.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(options: StreamLogFilesToZipOptions): Promise { + 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 = createOutputStream(options.destinationPath); + outputStream = Maybe.just(createdOutputStream); + outputStreamCreated = true; + const createdOutputCompletion = finished(createdOutputStream); + outputCompletion = Maybe.just(createdOutputCompletion); + const createdArchive = createArchive(); + archive = Maybe.just(createdArchive); + createdArchive.pipe(createdOutputStream); + + for (const snapshotFile of options.snapshotFiles) { + createdArchive.file(snapshotFile.absolutePath, {name: snapshotFile.relativePath}); + } + + const archiveFailure = waitForArchiveFailure(createdArchive); + const createdArchiveFinalization = createdArchive.finalize(); + await Promise.race([createdArchiveFinalization, archiveFailure, createdOutputCompletion]); + await createdArchiveFinalization; + 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 (outputCompletion.isJust) { + await waitForPromiseToSettle(outputCompletion.value); + } + + await removeIncompleteDestinationFile({ + destinationPath: options.destinationPath, + destinationAlreadyExisted, + outputStreamCreated, + removeFile, + reportFailure, + }); + + throw error; + } +} + +export async function exportLogFiles(options: ExportLogFilesOptions): Promise { + const snapshot = await options.createSnapshot(); + + try { + await options.streamSnapshot({destinationPath: options.destinationPath, snapshotFiles: snapshot.files}); + } finally { + try { + await options.removeSnapshotDirectory(snapshot.directoryPath); + } catch (error) { + options.reportFailure(`Failed to remove temporary log snapshot "${snapshot.directoryPath}"`, error); + } + } +} 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/logFiles.test.main.ts b/electron/src/logging/logFiles.test.main.ts new file mode 100644 index 00000000000..a2b9aa4330f --- /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}).toSorted(); + 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/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}; +} diff --git a/electron/src/logging/logPaths.test.main.ts b/electron/src/logging/logPaths.test.main.ts new file mode 100644 index 00000000000..a04b75882e5 --- /dev/null +++ b/electron/src/logging/logPaths.test.main.ts @@ -0,0 +1,84 @@ +/* + * 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({ + 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('uses the account UUID below the UTC daily directory for webview logs', () => { + const actualPath = getWebViewLogPath({ + accountId: 'account-id', + 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', 'accounts', 'account-id', 'console.log'); + + assert.strictEqual(actualPath, expectedPath); + }); + + 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'), + }); + 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 new file mode 100644 index 00000000000..323db3bd0bd --- /dev/null +++ b/electron/src/logging/logPaths.ts @@ -0,0 +1,85 @@ +/* + * 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 {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 = { + accountId: string; + date: Date; + logDirectory: string; +}; + +export type MainProcessLogPathParameters = { + date: Date; + logDirectory: string; +}; + +export type SsoLogPathParameters = { + accountId: string; + date: Date; + logDirectory: string; +}; + +type DailyLogDirectoryParameters = { + date: Date; + logDirectory: 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(getDailyLogDirectory(parameters), MAIN_PROCESS_LOG_FILE_NAME); +} + +export function getWebViewLogPath(parameters: WebViewLogPathParameters): string { + return path.join( + getDailyLogDirectory(parameters), + ACCOUNT_LOG_DIRECTORY_NAME, + parameters.accountId, + config.logFileName, + ); +} + +export function getSsoLogPath(parameters: SsoLogPathParameters): string { + 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/logStartup.test.main.ts b/electron/src/logging/logStartup.test.main.ts new file mode 100644 index 00000000000..3e991b1b1a7 --- /dev/null +++ b/electron/src/logging/logStartup.test.main.ts @@ -0,0 +1,82 @@ +/* + * 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 initial cleanup before scheduling periodic cleanup', async () => { + const events: string[] = []; + const cleanupCompletion = createDeferredCompletion(); + const startup = initializeDesktopLogLifecycle({ + reportCleanupFailure() { + events.push('cleanup-failed'); + }, + async runInitialCleanup() { + events.push('cleanup-started'); + await cleanupCompletion.promise; + events.push('cleanup-completed'); + }, + schedulePeriodicCleanup() { + 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']); + }); + + it('reports cleanup failure and continues startup', async () => { + const events: string[] = []; + const cleanupFailure = new Error('cleanup failed'); + + await initializeDesktopLogLifecycle({ + reportCleanupFailure(error: unknown) { + assert.strictEqual(error, cleanupFailure); + events.push('cleanup-failed'); + }, + async runInitialCleanup() { + throw cleanupFailure; + }, + schedulePeriodicCleanup() { + events.push('schedule-cleanup'); + }, + }); + + assert.deepStrictEqual(events, ['cleanup-failed', 'schedule-cleanup']); + }); +}); diff --git a/electron/src/lib/zip.ts b/electron/src/logging/logStartup.ts similarity index 54% rename from electron/src/lib/zip.ts rename to electron/src/logging/logStartup.ts index 5937c0326b7..1f84acf0576 100644 --- a/electron/src/lib/zip.ts +++ b/electron/src/logging/logStartup.ts @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2020 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,28 +17,18 @@ * */ -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(); +export type InitializeDesktopLogLifecycleOptions = { + reportCleanupFailure: (error: unknown) => void; + runInitialCleanup: () => Promise; + schedulePeriodicCleanup: () => void; +}; +export async function initializeDesktopLogLifecycle(options: InitializeDesktopLogLifecycleOptions): Promise { try { - for (const filename in files) { - zip.file(filename, files[filename], {binary: true}); - } + await options.runInitialCleanup(); } catch (error) { - logger.error(error); + options.reportCleanupFailure(error); } - return zip; -}; - -export const createFile = (zip: JSZip): Promise => { - return zip.generateAsync({compression: 'DEFLATE', type: 'uint8array'}); -}; + options.schedulePeriodicCleanup(); +} diff --git a/electron/src/logging/loggerUtils.ts b/electron/src/logging/loggerUtils.ts index d8f02130371..9f7ac100171 100644 --- a/electron/src/logging/loggerUtils.ts +++ b/electron/src/logging/loggerUtils.ts @@ -17,36 +17,68 @@ * */ -import {app} from 'electron'; -import * as fs from 'fs-extra'; -import globby from 'globby'; - +import * as os from 'os'; import * as path from 'path'; -import {getLogger} from '../logging/getLogger'; +import {runDesktopLogCleanupWithinMaintenance, runDesktopLogMaintenance} from './desktopLogWriter'; +import {getLogger} from './getLogger'; +import {createLogSnapshot, exportLogFiles, streamLogFilesToZip} from './logExport'; +import {createLogArchiveDependencies, createLogSnapshotFileSystemDependencies} from './logExportDependencies'; +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 = {}; +export async function exportLogs(destinationPath: string): Promise { + const logDirectory = getLogDirectory(); + const snapshotFileSystemDependencies = createLogSnapshotFileSystemDependencies(); + const archiveDependencies = createLogArchiveDependencies((message: string, error: unknown): void => { + logger.error(message, error); + }); - const relativeFilePaths = getLogFilenames(); - - for (const relativeFilePath of relativeFilePaths) { - const resolvedPath = path.join(logDir, relativeFilePath); - try { - const fileContent = await fs.readFile(resolvedPath); - logFiles[relativeFilePath] = fileContent; - } catch (error) { - logger.error(error); - } + try { + await exportLogFiles({ + createSnapshot() { + return createLogSnapshot({ + cleanup: runDesktopLogCleanupWithinMaintenance, + discoverLogFilePaths() { + return getLogFilenames({absolute: false, baseDirectory: logDirectory}); + }, + logDirectory, + reportFailure(message: string, error: unknown) { + logger.error(message, error); + }, + runMaintenance: runDesktopLogMaintenance, + temporaryDirectoryPrefix: path.join(os.tmpdir(), 'wire-log-snapshot-'), + copyFile: snapshotFileSystemDependencies.copyFile, + createTemporaryDirectory: snapshotFileSystemDependencies.createTemporaryDirectory, + ensureDirectory: snapshotFileSystemDependencies.ensureDirectory, + getFileMetadata: snapshotFileSystemDependencies.getFileMetadata, + removeDirectory: snapshotFileSystemDependencies.removeDirectory, + }); + }, + destinationPath, + removeSnapshotDirectory: snapshotFileSystemDependencies.removeDirectory, + reportFailure(message: string, error: unknown) { + logger.error(message, error); + }, + streamSnapshot({destinationPath: snapshotDestinationPath, snapshotFiles}) { + return streamLogFilesToZip({ + createArchive: archiveDependencies.createArchive, + createOutputStream: archiveDependencies.createOutputStream, + destinationPath: snapshotDestinationPath, + pathExists: archiveDependencies.pathExists, + removeFile: archiveDependencies.removeFile, + reportFailure: archiveDependencies.reportFailure, + snapshotFiles, + }); + }, + }); + } catch (error) { + logger.error('Failed to export desktop logs.', error); } - - return logFiles; } 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..cb3a10a6c06 --- /dev/null +++ b/electron/src/mainProcess.ts @@ -0,0 +1,867 @@ +/* + * 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 type {WallClock} from '@enormora/wall-clock/wall-clock'; +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 {Maybe} from 'true-myth'; + +import * as path from 'path'; +import {URL, pathToFileURL} from 'url'; + +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, +} 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'; +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 {runDesktopLogCleanup, writeBoundedLogMessage} from './logging/desktopLogWriter'; +import {ENABLE_LOGGING, getLogger} from './logging/getLogger'; +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'; +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 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); + }, +}); +const configuredUserDataPath = getConfiguredPortableUserDataPath(); + +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); +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; +let desktopAppConfig: DesktopAppConfig | undefined; + +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: 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()); + 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 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)); + + 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 () => { + 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) { + 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, wallClock); + 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); + } + + run(): void { + 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.'); + mainProcessFireAndForgetInvoker.fireAndForget(() => WindowUtil.openExternal(details.url)); + + return {action: 'deny'}; + }; + + 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 => { + 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 => { + // 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, 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()); + }); + if (ENABLE_LOGGING) { + const colorCodeRegex = /%c(.+?)%c/gm; + const stylingRegex = /(color:#|font-weight:)[^;]+; /gm; + const accessTokenRegex = /access_token=[^ &]+/gm; + + contents.on('console-message', async (_event, _level, message) => { + const accountId = lifecycle.getAccountId(contents); + + if (accountId.isJust) { + const logFilePath = getWebViewLogPath({ + accountId: accountId.value, + date: new Date(), + logDirectory: getLogDirectory(), + }); + try { + await writeBoundedLogMessage({ + logFilePath, + message: message + .replace(colorCodeRegex, '$1') + .replace(stylingRegex, '') + .replace(accessTokenRegex, ''), + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + logger.error(`Cannot write to log file "${logFilePath}": ${errorMessage}`, 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(); + 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); + }, + }); + }, + }); + }); + }, + }); +} 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. */ 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/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; }); 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/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/lifecycle.ts b/electron/src/runtime/lifecycle.ts index e75fef7f030..eff44fb6ebc 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'; @@ -38,7 +39,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(); @@ -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/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/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(); +} 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; +} diff --git a/electron/src/sso/SingleSignOn.ts b/electron/src/sso/SingleSignOn.ts index 9c9415efc81..88871b4bb2c 100644 --- a/electron/src/sso/SingleSignOn.ts +++ b/electron/src/sso/SingleSignOn.ts @@ -18,7 +18,6 @@ */ import { - app, BrowserWindow, BrowserWindowConstructorOptions, Event as ElectronEvent, @@ -28,23 +27,22 @@ 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 {getWebViewId} from '../runtime/lifecycle'; +import {getLogDirectory, getSsoLogPath} from '../logging/logPaths'; import {config} from '../settings/config'; 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; @@ -68,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); } @@ -109,6 +110,7 @@ export class SingleSignOn { if (typeof argv[config.ARGUMENT.DEVTOOLS] !== 'undefined') { this.ssoWindow?.webContents.openDevTools({mode: 'detach'}); } + return this; }; @@ -141,6 +143,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'}; }); @@ -156,13 +159,18 @@ export class SingleSignOn { if (ENABLE_LOGGING) { 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); + if (this.accountId.isJust) { + const logFilePath = getSsoLogPath({ + accountId: this.accountId.value, + date: new Date(), + logDirectory: getLogDirectory(), + }); 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); } } }); @@ -284,6 +292,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 +302,7 @@ export class SingleSignOn { } catch (error) { SingleSignOn.logger.warn(error); await this.dispatchResponse(SingleSignOn.RESPONSE_TYPES.AUTH_ERROR_COOKIE); + return; } } 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/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/jenkins/deployment.groovy b/jenkins/deployment.groovy index a50a7635f94..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] @@ -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')]) { @@ -36,6 +46,8 @@ node('built-in') { def projectName = env.WRAPPER_BUILD.tokenize('#')[0] def version = env.WRAPPER_BUILD.tokenize('#')[1] + def hasWindowsMsi = false + def hasWindowsSquirrel = false echo("version: ${version}") def buildNumber = version.tokenize('.')[2] def NODE = tool name: 'node-v20.10.0', type: 'nodejs' @@ -59,6 +71,12 @@ node('built-in') { } } + 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}" stage('Install dependencies') { @@ -85,44 +103,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 = '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 = '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 = '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) { + error('No complete Windows Squirrel 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' @@ -255,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}") } } } @@ -336,9 +337,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() @@ -363,9 +365,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() @@ -389,7 +392,7 @@ node('built-in') { // ------------------------------------------------------------------------ // Windows-specific stage: Update RELEASES file for Squirrel auto-updates // ------------------------------------------------------------------------ - if (projectName.contains('Windows')) { + if (hasWindowsSquirrel) { stage('Update RELEASES file') { try { withEnv(["PATH+NODE=${NODE}/bin"]) { 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' - } - -} 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 2d8e9ae1263..6ba202d1144 100644 --- a/jenkins/windows.groovy +++ b/jenkins/windows.groovy @@ -56,21 +56,46 @@ 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 "%%~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}" + throw e + } + } + } else { + echo 'Skipping application signing for non-production build (nightly/custom).' + } + } + + stage('Build installers') { try { withEnv(["PATH+NODE=${NODE}", 'npm_config_target_arch=x64']) { + // 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:installer' - } else if (wireGov) { - bat 'yarn build:win:installer:wire-gov' + bat 'yarn build:win:installers' } else { - // For internal builds disable auto-signing and use internal config - bat 'yarn build:win:installer:internal' + 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)' + bat 'if not exist "wrap\\dist\\*.msi" (echo Missing MSI package & exit /b 1)' } } 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 installers failed**\n${BUILD_URL}" throw e } } @@ -85,7 +110,8 @@ 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\\*-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' wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} signing installer failed**\n${BUILD_URL}" @@ -100,7 +126,9 @@ node('windows') { stage('verify') { if (production || wireGov) { 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 /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' wireSend secret: "${jenkinsbot_secret}", message: "🏞 **${JOB_NAME} ${version} verifying installer failed**\n${BUILD_URL}" @@ -118,7 +146,7 @@ node('windows') { stage('Print hash') { try { if (production) { - bat 'certUtil -hashfile "wrap\\dist\\Wire-Setup.exe" SHA256' + bat 'for %%f in ("wrap\\dist\\*-Setup.exe" "wrap\\dist\\*.msi") do (certUtil -hashfile "%%~ff" SHA256)' } } catch (e) { currentBuild.result = 'FAILED' @@ -133,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) { diff --git a/package.json b/package.json index 9fa35ecc4ce..d2892792fd1 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", @@ -30,6 +32,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.", @@ -49,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", @@ -74,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", @@ -158,17 +162,25 @@ "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", "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: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\"", "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", diff --git a/yarn.lock b/yarn.lock index 32ac312923c..ab0810aeaa9 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" @@ -6015,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 @@ -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" @@ -19514,6 +19836,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" @@ -20863,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 @@ -20899,7 +21230,8 @@ __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 axios: 0.21.2 @@ -20971,6 +21303,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 @@ -21289,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