mirror of
https://github.com/tutao/tutanota.git
synced 2025-10-19 16:03:43 +00:00
[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:
parent
02a285f08c
commit
439260efbb
6 changed files with 100 additions and 40 deletions
3
make.js
3
make.js
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue