[desktop] Fix opening files & directories on Linux

Electron uses xdg-open to open files. On GNOME it will try to use gio
open which reads user configs from a few files. To detect those config
files it uses `XDG_CURRENT_DESKTOP` env variable.

Electron overwrites `XDG_CURRENT_DESKTOP` to enable some compatibility
functions in Chromium. This breaks gio because it can't locate the
config files for default apps. It can be reproduced on Ubuntu with
GNOME. There is a fix in Electron to restore the env for opening files,
but it doesn't work.

We worked around that by invoking xdg-open directly with correct env.

Additionally, we fixed opening the directories having the same issue and
getting stuck by using showItemInFolder instead. This also has an
additional benefit of highlighting the file in the file manager.

Close #9696

Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
Co-authored-by: wrd <wrd@tutao.de>
Co-authored-by: paw <paw-hub@users.noreply.github.com>
This commit is contained in:
ivk 2025-09-25 18:05:10 +02:00 committed by hrb-hub
parent 02a285f08c
commit 439260efbb
6 changed files with 100 additions and 40 deletions

View file

@ -48,8 +48,7 @@ await program
const buildDir = app === "calendar" ? "build-calendar-app" : "build" const buildDir = app === "calendar" ? "build-calendar-app" : "build"
const env = Object.assign({}, process.env, { ELECTRON_ENABLE_SECURITY_WARNINGS: "TRUE", ELECTRON_START_WITH_DEV_TOOLS: devTools }) const env = Object.assign({}, process.env, { ELECTRON_ENABLE_SECURITY_WARNINGS: "TRUE", ELECTRON_START_WITH_DEV_TOOLS: devTools })
// we don't want to quit here because we want to keep piping output to our stdout. // we don't want to quit here because we want to keep piping output to our stdout.
spawn("npx", [`electron --inspect=5858 ./${buildDir}/`], { spawn("node_modules/.bin/electron", ["--inspect=5858", `./${buildDir}/`], {
shell: true,
stdio: "inherit", stdio: "inherit",
env: options.verbose ? Object.assign({}, env, { ELECTRON_ENABLE_LOGGING: 1 }) : env, env: options.verbose ? Object.assign({}, env, { ELECTRON_ENABLE_LOGGING: 1 }) : env,
}) })

View file

@ -27,6 +27,11 @@ export interface ProcessParams {
* By default, the current working directory will be used. * By default, the current working directory will be used.
*/ */
currentDirectory?: string currentDirectory?: string
/**
* Environment variables to set on subprocesses
*/
env?: { [key: string]: string | undefined }
} }
/** /**
@ -134,6 +139,7 @@ export class CommandExecutor {
stdoutEncoding = ProcessIOEncoding.Utf8, stdoutEncoding = ProcessIOEncoding.Utf8,
stderrEncoding = ProcessIOEncoding.Utf8, stderrEncoding = ProcessIOEncoding.Utf8,
currentDirectory, currentDirectory,
env,
} = params } = params
if (isNaN(timeout) || timeout < 0) { if (isNaN(timeout) || timeout < 0) {
@ -145,6 +151,7 @@ export class CommandExecutor {
const process = this.childProcess.spawn(executable, args, { const process = this.childProcess.spawn(executable, args, {
timeout: timeout === 0 ? undefined : timeout, timeout: timeout === 0 ? undefined : timeout,
cwd: currentDirectory, cwd: currentDirectory,
env,
}) })
let stdout = initializeOutputBuffer(stdoutEncoding) let stdout = initializeOutputBuffer(stdoutEncoding)

View file

@ -330,7 +330,7 @@ async function createComponents(): Promise<Components> {
new DesktopDesktopSystemFacade(wm, window, sock), new DesktopDesktopSystemFacade(wm, window, sock),
new DesktopExportFacade(tfs, electron, conf, window, dragIcons, mailboxExportPersistence, fs, dateProvider, desktopExportLock), new DesktopExportFacade(tfs, electron, conf, window, dragIcons, mailboxExportPersistence, fs, dateProvider, desktopExportLock),
new DesktopExternalCalendarFacade(electron.app.userAgentFallback), new DesktopExternalCalendarFacade(electron.app.userAgentFallback),
new DesktopFileFacade(window, conf, dateProvider, customFetch, electron, tfs, fs, path), new DesktopFileFacade(window, conf, dateProvider, customFetch, electron, tfs, fs, path, commandExecutor, process),
new DesktopInterWindowEventFacade(window, wm), new DesktopInterWindowEventFacade(window, wm),
nativeCredentialsFacade, nativeCredentialsFacade,
desktopCrypto, desktopCrypto,

View file

@ -206,7 +206,7 @@ export class DesktopExportFacade implements ExportFacade {
if (exportState == null || exportState.type !== "finished") { if (exportState == null || exportState.type !== "finished") {
throw new ProgrammingError("Export is not finished") throw new ProgrammingError("Export is not finished")
} }
await this.electron.shell.openPath(exportState.exportDirectoryPath) this.electron.shell.showItemInFolder(exportState.exportDirectoryPath)
} }
private async getExportDirectoryPath(): Promise<string> { private async getExportDirectoryPath(): Promise<string> {

View file

@ -11,6 +11,7 @@ import { sha256Hash } from "@tutao/tutanota-crypto"
import { assertNotNull, splitUint8ArrayInChunks, stringToUtf8Uint8Array, uint8ArrayToBase64, uint8ArrayToHex } from "@tutao/tutanota-utils" import { assertNotNull, splitUint8ArrayInChunks, stringToUtf8Uint8Array, uint8ArrayToBase64, uint8ArrayToHex } from "@tutao/tutanota-utils"
import { looksExecutable, nonClobberingFilename } from "../PathUtils.js" import { looksExecutable, nonClobberingFilename } from "../PathUtils.js"
import url from "node:url" import url from "node:url"
import type { WriteStream } from "node:fs"
import FsModule from "node:fs" import FsModule from "node:fs"
import { Buffer } from "node:buffer" import { Buffer } from "node:buffer"
import { default as stream } from "node:stream" import { default as stream } from "node:stream"
@ -26,9 +27,9 @@ import { TempFs } from "./TempFs.js"
import { HttpMethod } from "../../api/common/EntityFunctions" import { HttpMethod } from "../../api/common/EntityFunctions"
import { FetchImpl } from "../net/NetAgent" import { FetchImpl } from "../net/NetAgent"
import { OpenDialogOptions } from "electron" import { OpenDialogOptions } from "electron"
import type { WriteStream } from "node:fs"
import { newPromise } from "@tutao/tutanota-utils/dist/Utils" import { newPromise } from "@tutao/tutanota-utils/dist/Utils"
import { CommandExecutor } from "../CommandExecutor"
const TAG = "[DesktopFileFacade]" const TAG = "[DesktopFileFacade]"
@ -45,6 +46,8 @@ export class DesktopFileFacade implements FileFacade {
private readonly tfs: TempFs, private readonly tfs: TempFs,
private readonly fs: FsExports, private readonly fs: FsExports,
private readonly path: PathExports, private readonly path: PathExports,
private readonly commandExecutor: CommandExecutor,
private readonly process: NodeJS.Process,
) { ) {
this.lastOpenedFileManagerAt = null this.lastOpenedFileManagerAt = null
} }
@ -139,32 +142,49 @@ export class DesktopFileFacade implements FileFacade {
return fileUri return fileUri
} }
open(location: string /* , mimeType: string omitted */): Promise<void> { async open(location: string /* , mimeType: string omitted */): Promise<void> {
const tryOpen = () => const openWithElectronShell = () =>
this.electron.shell this.electron.shell
.openPath(location) // may resolve with "" or an error message .openPath(location) // may resolve with "" or an error message
.catch(() => "failed to open path.") .catch((e) => {
.then((errMsg) => (errMsg === "" ? Promise.resolve() : Promise.reject(new FileOpenError("Could not open " + location + ", " + errMsg)))) const message = "failed to open path." + e
throw new FileOpenError("Could not open " + location + ", " + message)
})
// only windows will happily execute a just downloaded program // only windows will happily execute a just downloaded program
if (process.platform === "win32" && looksExecutable(location)) { if (this.process.platform === "win32" && looksExecutable(location)) {
return this.electron.dialog const { response } = await this.electron.dialog.showMessageBox({
.showMessageBox({ type: "warning",
type: "warning", buttons: [lang.get("yes_label"), lang.get("no_label")],
buttons: [lang.get("yes_label"), lang.get("no_label")], title: lang.get("executableOpen_label"),
title: lang.get("executableOpen_label"), message: lang.get("executableOpen_msg"),
message: lang.get("executableOpen_msg"), defaultId: 1, // default button
defaultId: 1, // default button })
if (response === 0) {
await openWithElectronShell()
}
} else if (this.process.platform === "linux") {
// temporary fix for the electron fix:
// https://github.com/electron/electron/issues/45129#issuecomment-3334644846
// https://github.com/tutao/tutanota/issues/9696
await this.commandExecutor
.run({
executable: "xdg-open",
args: [location],
env:
// electron replaces XDG_CURRENT_DESKTOP in some cases which breaks gio open which breaks xdg-open
this.process.env.ORIGINAL_XDG_CURRENT_DESKTOP == null
? undefined
: {
...this.process.env,
XDG_CURRENT_DESKTOP: this.process.env.ORIGINAL_XDG_CURRENT_DESKTOP,
},
}) })
.then(({ response }) => { .catch((e) => {
if (response === 0) { throw new FileOpenError("Could not open " + location + ", " + e)
return tryOpen()
} else {
return Promise.resolve()
}
}) })
} else { } else {
return tryOpen() await openWithElectronShell()
} }
} }
@ -307,7 +327,7 @@ export class DesktopFileFacade implements FileFacade {
if (lastOpenedFileManagerAt == null || this.dateProvider.now() - lastOpenedFileManagerAt > fileManagerTimeout) { if (lastOpenedFileManagerAt == null || this.dateProvider.now() - lastOpenedFileManagerAt > fileManagerTimeout) {
this.lastOpenedFileManagerAt = this.dateProvider.now() this.lastOpenedFileManagerAt = this.dateProvider.now()
await this.electron.shell.openPath(path.dirname(savePath)) this.electron.shell.showItemInFolder(savePath)
} }
} }
} }

View file

@ -7,7 +7,6 @@ import { ElectronExports, FsExports, PathExports } from "../../../../src/common/
import { NotFoundError, PreconditionFailedError, TooManyRequestsError } from "../../../../src/common/api/common/error/RestError.js" import { NotFoundError, PreconditionFailedError, TooManyRequestsError } from "../../../../src/common/api/common/error/RestError.js"
import type fs from "node:fs" import type fs from "node:fs"
import { assertThrows } from "@tutao/tutanota-test-utils" import { assertThrows } from "@tutao/tutanota-test-utils"
import n from "../../nodemocker.js"
import { stringToUtf8Uint8Array } from "@tutao/tutanota-utils" import { stringToUtf8Uint8Array } from "@tutao/tutanota-utils"
import { DesktopConfig } from "../../../../src/common/desktop/config/DesktopConfig.js" import { DesktopConfig } from "../../../../src/common/desktop/config/DesktopConfig.js"
import { DesktopUtils } from "../../../../src/common/desktop/DesktopUtils.js" import { DesktopUtils } from "../../../../src/common/desktop/DesktopUtils.js"
@ -16,6 +15,7 @@ import { TempFs } from "../../../../src/common/desktop/files/TempFs.js"
import { BuildConfigKey, DesktopConfigKey } from "../../../../src/common/desktop/config/ConfigKeys.js" import { BuildConfigKey, DesktopConfigKey } from "../../../../src/common/desktop/config/ConfigKeys.js"
import { HttpMethod } from "../../../../src/common/api/common/EntityFunctions" import { HttpMethod } from "../../../../src/common/api/common/EntityFunctions"
import { FetchImpl, FetchResult } from "../../../../src/common/desktop/net/NetAgent" import { FetchImpl, FetchResult } from "../../../../src/common/desktop/net/NetAgent"
import { CommandExecutor } from "../../../../src/common/desktop/CommandExecutor"
const DEFAULT_DOWNLOAD_PATH = "/a/download/path/" const DEFAULT_DOWNLOAD_PATH = "/a/download/path/"
@ -30,6 +30,8 @@ o.spec("DesktopFileFacade", function () {
let tfs: TempFs let tfs: TempFs
let ff: DesktopFileFacade let ff: DesktopFileFacade
let path: PathExports let path: PathExports
let executor: CommandExecutor
let process: Writeable<Partial<NodeJS.Process>>
o.beforeEach(function () { o.beforeEach(function () {
win = object() win = object()
@ -38,18 +40,23 @@ o.spec("DesktopFileFacade", function () {
tfs = object() tfs = object()
path = object() path = object()
fs.promises = object() fs.promises = object()
process = {
env: {},
}
when(fs.promises.stat(matchers.anything())).thenResolve({ size: 42 }) when(fs.promises.stat(matchers.anything())).thenResolve({ size: 42 })
electron = object() electron = object()
// @ts-ignore read-only prop // @ts-ignore read-only prop
electron["shell"] = object() electron["shell"] = object()
// @ts-ignore read-only prop // @ts-ignore read-only prop
electron["dialog"] = object() electron["dialog"] = object()
process.platform = "linux"
conf = object() conf = object()
du = object() du = object()
dp = object() dp = object()
executor = object()
ff = new DesktopFileFacade(win, conf, dp, fetch, electron, tfs, fs, path) ff = new DesktopFileFacade(win, conf, dp, fetch, electron, tfs, fs, path, executor, process as NodeJS.Process)
}) })
o.spec("saveDataFile", function () { o.spec("saveDataFile", function () {
o("when there's no existing file it will be simply written", async function () { o("when there's no existing file it will be simply written", async function () {
@ -309,18 +316,45 @@ o.spec("DesktopFileFacade", function () {
}) })
o.spec("open", function () { o.spec("open", function () {
o("open valid", async function () { o.spec("open on linux", () => {
when(electron.shell.openPath("/some/folder/file")).thenResolve("") o.test("open on ubuntu", async function () {
await ff.open("/some/folder/file") when(electron.shell.openPath("/some/folder/file")).thenReject(new Error("wrong function"))
when(executor.run(matchers.anything())).thenResolve({})
process.env!.ORIGINAL_XDG_CURRENT_DESKTOP = "original ;)"
process.env!.SOMETHING_ELSE = "something else!"
await ff.open("/some/folder/file")
verify(
executor.run({
executable: "xdg-open",
args: ["/some/folder/file"],
env: {
XDG_CURRENT_DESKTOP: "original ;)",
ORIGINAL_XDG_CURRENT_DESKTOP: "original ;)",
SOMETHING_ELSE: "something else!",
},
}),
)
})
o.test("open on non ubuntu", async function () {
when(electron.shell.openPath("/some/folder/file")).thenReject(new Error("wrong function"))
when(executor.run(matchers.anything())).thenResolve({})
process.env!.ORIGINAL_XDG_CURRENT_DESKTOP = undefined
process.env!.SOMETHING_ELSE = "something else!"
await ff.open("/some/folder/file")
verify(
executor.run({
executable: "xdg-open",
args: ["/some/folder/file"],
env: undefined,
}),
)
})
}) })
o("open invalid", async () => { o.test("open on windows", async function () {
await assertThrows(Error, () => ff.open("invalid")) process.platform = "win32"
verify(electron.shell.openPath("invalid"), { times: 1 })
})
o("open on windows", async function () {
n.setPlatform("win32")
when(electron.dialog.showMessageBox(matchers.anything())).thenReturn( when(electron.dialog.showMessageBox(matchers.anything())).thenReturn(
Promise.resolve({ Promise.resolve({
response: 1, response: 1,
@ -384,7 +418,7 @@ o.spec("DesktopFileFacade", function () {
const dir = "/path/to" const dir = "/path/to"
const p = dir + "/file.txt" const p = dir + "/file.txt"
await ff.showInFileExplorer(p) await ff.showInFileExplorer(p)
verify(electron.shell.openPath(dir), { times: 1 }) verify(electron.shell.showItemInFolder(p), { times: 1 })
}) })
o("two downloads, open two filemanagers after a pause", async function () { o("two downloads, open two filemanagers after a pause", async function () {
@ -394,10 +428,10 @@ o.spec("DesktopFileFacade", function () {
await ff.showInFileExplorer(p) await ff.showInFileExplorer(p)
when(dp.now()).thenReturn(time) when(dp.now()).thenReturn(time)
when(conf.getConst(BuildConfigKey.fileManagerTimeout)).thenResolve(2) when(conf.getConst(BuildConfigKey.fileManagerTimeout)).thenResolve(2)
verify(electron.shell.openPath(dir), { times: 1 }) verify(electron.shell.showItemInFolder(p), { times: 1 })
when(dp.now()).thenReturn(time + 10) when(dp.now()).thenReturn(time + 10)
await ff.showInFileExplorer(p) await ff.showInFileExplorer(p)
verify(electron.shell.openPath(dir), { times: 2 }) verify(electron.shell.showItemInFolder(p), { times: 2 })
}) })
}) })