[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 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.
spawn("npx", [`electron --inspect=5858 ./${buildDir}/`], {
shell: true,
spawn("node_modules/.bin/electron", ["--inspect=5858", `./${buildDir}/`], {
stdio: "inherit",
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.
*/
currentDirectory?: string
/**
* Environment variables to set on subprocesses
*/
env?: { [key: string]: string | undefined }
}
/**
@ -134,6 +139,7 @@ export class CommandExecutor {
stdoutEncoding = ProcessIOEncoding.Utf8,
stderrEncoding = ProcessIOEncoding.Utf8,
currentDirectory,
env,
} = params
if (isNaN(timeout) || timeout < 0) {
@ -145,6 +151,7 @@ export class CommandExecutor {
const process = this.childProcess.spawn(executable, args, {
timeout: timeout === 0 ? undefined : timeout,
cwd: currentDirectory,
env,
})
let stdout = initializeOutputBuffer(stdoutEncoding)

View file

@ -330,7 +330,7 @@ async function createComponents(): Promise<Components> {
new DesktopDesktopSystemFacade(wm, window, sock),
new DesktopExportFacade(tfs, electron, conf, window, dragIcons, mailboxExportPersistence, fs, dateProvider, desktopExportLock),
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),
nativeCredentialsFacade,
desktopCrypto,

View file

@ -206,7 +206,7 @@ export class DesktopExportFacade implements ExportFacade {
if (exportState == null || exportState.type !== "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> {

View file

@ -11,6 +11,7 @@ import { sha256Hash } from "@tutao/tutanota-crypto"
import { assertNotNull, splitUint8ArrayInChunks, stringToUtf8Uint8Array, uint8ArrayToBase64, uint8ArrayToHex } from "@tutao/tutanota-utils"
import { looksExecutable, nonClobberingFilename } from "../PathUtils.js"
import url from "node:url"
import type { WriteStream } from "node:fs"
import FsModule from "node:fs"
import { Buffer } from "node:buffer"
import { default as stream } from "node:stream"
@ -26,9 +27,9 @@ import { TempFs } from "./TempFs.js"
import { HttpMethod } from "../../api/common/EntityFunctions"
import { FetchImpl } from "../net/NetAgent"
import { OpenDialogOptions } from "electron"
import type { WriteStream } from "node:fs"
import { newPromise } from "@tutao/tutanota-utils/dist/Utils"
import { CommandExecutor } from "../CommandExecutor"
const TAG = "[DesktopFileFacade]"
@ -45,6 +46,8 @@ export class DesktopFileFacade implements FileFacade {
private readonly tfs: TempFs,
private readonly fs: FsExports,
private readonly path: PathExports,
private readonly commandExecutor: CommandExecutor,
private readonly process: NodeJS.Process,
) {
this.lastOpenedFileManagerAt = null
}
@ -139,32 +142,49 @@ export class DesktopFileFacade implements FileFacade {
return fileUri
}
open(location: string /* , mimeType: string omitted */): Promise<void> {
const tryOpen = () =>
async open(location: string /* , mimeType: string omitted */): Promise<void> {
const openWithElectronShell = () =>
this.electron.shell
.openPath(location) // may resolve with "" or an error message
.catch(() => "failed to open path.")
.then((errMsg) => (errMsg === "" ? Promise.resolve() : Promise.reject(new FileOpenError("Could not open " + location + ", " + errMsg))))
.catch((e) => {
const message = "failed to open path." + e
throw new FileOpenError("Could not open " + location + ", " + message)
})
// only windows will happily execute a just downloaded program
if (process.platform === "win32" && looksExecutable(location)) {
return this.electron.dialog
.showMessageBox({
type: "warning",
buttons: [lang.get("yes_label"), lang.get("no_label")],
title: lang.get("executableOpen_label"),
message: lang.get("executableOpen_msg"),
defaultId: 1, // default button
if (this.process.platform === "win32" && looksExecutable(location)) {
const { response } = await this.electron.dialog.showMessageBox({
type: "warning",
buttons: [lang.get("yes_label"), lang.get("no_label")],
title: lang.get("executableOpen_label"),
message: lang.get("executableOpen_msg"),
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 }) => {
if (response === 0) {
return tryOpen()
} else {
return Promise.resolve()
}
.catch((e) => {
throw new FileOpenError("Could not open " + location + ", " + e)
})
} else {
return tryOpen()
await openWithElectronShell()
}
}
@ -307,7 +327,7 @@ export class DesktopFileFacade implements FileFacade {
if (lastOpenedFileManagerAt == null || this.dateProvider.now() - lastOpenedFileManagerAt > fileManagerTimeout) {
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 type fs from "node:fs"
import { assertThrows } from "@tutao/tutanota-test-utils"
import n from "../../nodemocker.js"
import { stringToUtf8Uint8Array } from "@tutao/tutanota-utils"
import { DesktopConfig } from "../../../../src/common/desktop/config/DesktopConfig.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 { HttpMethod } from "../../../../src/common/api/common/EntityFunctions"
import { FetchImpl, FetchResult } from "../../../../src/common/desktop/net/NetAgent"
import { CommandExecutor } from "../../../../src/common/desktop/CommandExecutor"
const DEFAULT_DOWNLOAD_PATH = "/a/download/path/"
@ -30,6 +30,8 @@ o.spec("DesktopFileFacade", function () {
let tfs: TempFs
let ff: DesktopFileFacade
let path: PathExports
let executor: CommandExecutor
let process: Writeable<Partial<NodeJS.Process>>
o.beforeEach(function () {
win = object()
@ -38,18 +40,23 @@ o.spec("DesktopFileFacade", function () {
tfs = object()
path = object()
fs.promises = object()
process = {
env: {},
}
when(fs.promises.stat(matchers.anything())).thenResolve({ size: 42 })
electron = object()
// @ts-ignore read-only prop
electron["shell"] = object()
// @ts-ignore read-only prop
electron["dialog"] = object()
process.platform = "linux"
conf = object()
du = 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("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("open valid", async function () {
when(electron.shell.openPath("/some/folder/file")).thenResolve("")
await ff.open("/some/folder/file")
o.spec("open on linux", () => {
o.test("open on 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 = "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 () => {
await assertThrows(Error, () => ff.open("invalid"))
verify(electron.shell.openPath("invalid"), { times: 1 })
})
o("open on windows", async function () {
n.setPlatform("win32")
o.test("open on windows", async function () {
process.platform = "win32"
when(electron.dialog.showMessageBox(matchers.anything())).thenReturn(
Promise.resolve({
response: 1,
@ -384,7 +418,7 @@ o.spec("DesktopFileFacade", function () {
const dir = "/path/to"
const p = dir + "/file.txt"
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 () {
@ -394,10 +428,10 @@ o.spec("DesktopFileFacade", function () {
await ff.showInFileExplorer(p)
when(dp.now()).thenReturn(time)
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)
await ff.showInFileExplorer(p)
verify(electron.shell.openPath(dir), { times: 2 })
verify(electron.shell.showItemInFolder(p), { times: 2 })
})
})