Skip to content

Commit

Permalink
Only allow one window to run export per user
Browse files Browse the repository at this point in the history
Export will continue in a different window if one was closed

Close #8063

Co-authored-by: wrd <[email protected]>
Co-authored-by: ivk <[email protected]>
  • Loading branch information
3 people committed Dec 19, 2024
1 parent 4ccec4b commit b0613a4
Show file tree
Hide file tree
Showing 19 changed files with 225 additions and 67 deletions.
15 changes: 15 additions & 0 deletions src/common/api/common/error/ExportError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//@bundleInto:common-min

import { TutanotaError } from "@tutao/tutanota-error"

export const enum ExportErrorReason {
LockedForUser = "LockedForUser",
RunningForUser = "RunningForUser",
}

export class ExportError extends TutanotaError {
// data field is respected by the WorkerProtocol. Other fields might not be passed
constructor(msg: string, readonly data: ExportErrorReason) {
super("ExportError", msg)
}
}
2 changes: 2 additions & 0 deletions src/common/api/common/utils/ErrorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { KeyPermanentlyInvalidatedError } from "../error/KeyPermanentlyInvalidat
import { ParserError } from "../../../misc/parsing/ParserCombinator.js"
import { ContactStoreError } from "../error/ContactStoreError.js"
import { MobilePaymentError } from "../error/MobilePaymentError"
import { ExportError } from "../error/ExportError"

/**
* Checks if the given instance has an error in the _errors property which is usually written
Expand Down Expand Up @@ -123,6 +124,7 @@ const ErrorNameToType = {
DeviceStorageUnavailableError,
MailBodyTooLargeError,
ImportError,
ExportError,
WebauthnError,
SuspensionError,
LoginIncompleteError,
Expand Down
17 changes: 8 additions & 9 deletions src/common/desktop/ApplicationWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ import { DesktopThemeFacade } from "./DesktopThemeFacade"
import { CancelledError } from "../api/common/error/CancelledError"
import { DesktopFacade } from "../native/common/generatedipc/DesktopFacade.js"
import { CommonNativeFacade } from "../native/common/generatedipc/CommonNativeFacade.js"
import { RemoteBridge } from "./ipc/RemoteBridge.js"
import { RemoteBridge, WindowCleanup } from "./ipc/RemoteBridge.js"
import { InterWindowEventFacadeSendDispatcher } from "../native/common/generatedipc/InterWindowEventFacadeSendDispatcher.js"
import { handleProtocols } from "./net/ProtocolProxy.js"
import { PerWindowSqlCipherFacade } from "./db/PerWindowSqlCipherFacade.js"
import HandlerDetails = Electron.HandlerDetails

const MINIMUM_WINDOW_SIZE: number = 350
Expand Down Expand Up @@ -47,10 +46,11 @@ const VIRTUAL_APP_URL_BASE = "asset://app"
const VIRTUAL_APP_URL = VIRTUAL_APP_URL_BASE + "/index-desktop.html"

export class ApplicationWindow {
// these depend on window and are initialized later
private _desktopFacade!: DesktopFacade
private _commonNativeFacade!: CommonNativeFacade
private _interWindowEventSender!: InterWindowEventFacadeSendDispatcher
private _sqlCipherFacade!: PerWindowSqlCipherFacade
private windowCleanup!: WindowCleanup

_browserWindow!: BrowserWindow

Expand Down Expand Up @@ -193,7 +193,7 @@ export class ApplicationWindow {
this._desktopFacade = sendingFacades.desktopFacade
this._commonNativeFacade = sendingFacades.commonNativeFacade
this._interWindowEventSender = sendingFacades.interWindowEventSender
this._sqlCipherFacade = sendingFacades.sqlCipherFacade
this.windowCleanup = sendingFacades.windowCleanup
}

private async loadInitialUrl(noAutoLogin: boolean) {
Expand Down Expand Up @@ -312,7 +312,7 @@ export class ApplicationWindow {

this._browserWindow
.on("closed", async () => {
await this.closeDb()
await this.cleanup()
})
.on("focus", () => this.localShortcut.enableAll(this._browserWindow))
.on("blur", (_: FocusEvent) => this.localShortcut.disableAll(this._browserWindow))
Expand Down Expand Up @@ -390,7 +390,7 @@ export class ApplicationWindow {
}

async reload(queryParams: Record<string, string | boolean>) {
await this.closeDb()
await this.cleanup()
// try to do this asap as to not get the window destroyed on us
this.remoteBridge.unsubscribe(this._browserWindow.webContents.ipc)
this.userId = null
Expand All @@ -399,10 +399,9 @@ export class ApplicationWindow {
await this._browserWindow.loadURL(url)
}

private async closeDb() {
private async cleanup() {
if (this.userId) {
log.debug(TAG, `closing offline db for userId ${this.userId}`)
await this._sqlCipherFacade.closeDb()
await this.windowCleanup.onCleanup(this.userId)
}
}

Expand Down
18 changes: 14 additions & 4 deletions src/common/desktop/DesktopMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import path from "node:path"
import { DesktopContextMenu } from "./DesktopContextMenu.js"
import { DesktopNativePushFacade } from "./sse/DesktopNativePushFacade.js"
import { NativeCredentialsFacade } from "../native/common/generatedipc/NativeCredentialsFacade.js"
import { FacadeHandler, RemoteBridge } from "./ipc/RemoteBridge.js"
import { DispatcherFactory, FacadeHandler, RemoteBridge, WindowCleanup } from "./ipc/RemoteBridge.js"
import { DesktopSettingsFacade } from "./config/DesktopSettingsFacade.js"
import { ApplicationWindow } from "./ApplicationWindow.js"
import { DesktopCommonSystemFacade } from "./DesktopCommonSystemFacade.js"
Expand Down Expand Up @@ -73,6 +73,7 @@ import { AlarmScheduler } from "../calendar/date/AlarmScheduler.js"
import { DesktopExternalCalendarFacade } from "./ipc/DesktopExternalCalendarFacade.js"
import { customFetch } from "./net/NetAgent"
import { MailboxExportPersistence } from "./export/MailboxExportPersistence.js"
import { DesktopExportLock } from "./export/DesktopExportLock"

/**
* Should be injected during build time.
Expand Down Expand Up @@ -214,6 +215,8 @@ async function createComponents(): Promise<Components> {
manageDownloadsForSession(session, dictUrl)
})

const desktopExportLock = new DesktopExportLock()

const wm = new WindowManager(conf, tray, notifier, electron, shortcutManager, appIcon)
const themeFacade = new DesktopThemeFacade(conf, wm, electron.nativeTheme)
const schedulerImpl = new SchedulerImpl(dateProvider, global, global)
Expand Down Expand Up @@ -261,16 +264,23 @@ async function createComponents(): Promise<Components> {
const pushFacade = new DesktopNativePushFacade(sse, desktopAlarmScheduler, alarmStorage, sseStorage)
const settingsFacade = new DesktopSettingsFacade(conf, desktopUtils, integrator, updater, lang)

const dispatcherFactory = (window: ApplicationWindow) => {
const dispatcherFactory: DispatcherFactory = (window: ApplicationWindow) => {
// @ts-ignore
const logger: Logger = global.logger
const desktopCommonSystemFacade = new DesktopCommonSystemFacade(window, logger)
const sqlCipherFacade = new PerWindowSqlCipherFacade(offlineDbRefCounter)
const mailboxExportPersistence = new MailboxExportPersistence(conf)
const windowCleanup: WindowCleanup = {
async onCleanup(userId: Id): Promise<void> {
desktopExportLock.unlock(userId)
log.debug(TAG, `closing offline db for userId ${userId}`)
await sqlCipherFacade.closeDb()
},
}
const dispatcher = new DesktopGlobalDispatcher(
desktopCommonSystemFacade,
new DesktopDesktopSystemFacade(wm, window, sock),
new DesktopExportFacade(tfs, electron, conf, window, dragIcons, mailboxExportPersistence, fs, dateProvider),
new DesktopExportFacade(tfs, electron, conf, window, dragIcons, mailboxExportPersistence, fs, dateProvider, desktopExportLock),
new DesktopExternalCalendarFacade(),
new DesktopFileFacade(window, conf, dateProvider, customFetch, electron, tfs, fs),
new DesktopInterWindowEventFacade(window, wm),
Expand All @@ -283,7 +293,7 @@ async function createComponents(): Promise<Components> {
themeFacade,
new DesktopWebauthnFacade(window, webDialogController),
)
return { desktopCommonSystemFacade, sqlCipherFacade, dispatcher }
return { desktopCommonSystemFacade, windowCleanup, dispatcher }
}

const facadeHandlerFactory = (window: ApplicationWindow): FacadeHandler => {
Expand Down
2 changes: 1 addition & 1 deletion src/common/desktop/DesktopWindowManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { DesktopNotifier } from "./DesktopNotifier"
import { DesktopContextMenu } from "./DesktopContextMenu"
import { log } from "./DesktopLog"
import type { LocalShortcutManager } from "./electron-localshortcut/LocalShortcut"
import { BuildConfigKey, DesktopConfigEncKey, DesktopConfigKey } from "./config/ConfigKeys"
import { DesktopConfigEncKey, DesktopConfigKey } from "./config/ConfigKeys"
import { isRectContainedInRect } from "./DesktopUtils"
import { DesktopThemeFacade } from "./DesktopThemeFacade"
import { ElectronExports } from "./ElectronExportTypes"
Expand Down
36 changes: 27 additions & 9 deletions src/common/desktop/export/DesktopExportFacade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { MailboxExportPersistence, MailboxExportState } from "./MailboxExportPer
import { DateProvider } from "../../api/common/DateProvider.js"
import { formatSortableDate } from "@tutao/tutanota-utils"
import { FileOpenError } from "../../api/common/error/FileOpenError.js"
import { ExportError, ExportErrorReason } from "../../api/common/error/ExportError"
import { DesktopExportLock, LockResult } from "./DesktopExportLock"

const EXPORT_DIR = "export"

Expand All @@ -33,6 +35,7 @@ export class DesktopExportFacade implements ExportFacade {
private readonly mailboxExportPersistence: MailboxExportPersistence,
private readonly fs: typeof FsModule,
private readonly dateProvider: DateProvider,
private readonly desktopExportLock: DesktopExportLock,
) {}

async checkFileExistsInExportDir(fileName: string): Promise<boolean> {
Expand Down Expand Up @@ -84,16 +87,20 @@ export class DesktopExportFacade implements ExportFacade {
}

async startMailboxExport(userId: string, mailboxId: string, mailBagId: string, mailId: string): Promise<void> {
if (this.desktopExportLock.acquireLock(userId) === LockResult.AlreadyLocked) {
throw new ExportError(`Export is locked for user: ${userId}`, ExportErrorReason.LockedForUser)
}
const previousExportState = await this.mailboxExportPersistence.getStateForUser(userId)
if (previousExportState != null && previousExportState.type !== "finished") {
throw new Error("Export is already running for this user")
throw new ExportError(`Export is already running for user: ${userId}`, ExportErrorReason.RunningForUser)
}
const directory = await this.electron.dialog
.showOpenDialog(this.window._browserWindow, {
properties: ["openDirectory"],
})
.then(({ filePaths }) => filePaths[0] ?? null)
if (directory == null) {
this.desktopExportLock.unlock(userId)
throw new CancelledError("Directory picking canceled")
}
const folderName = `TutaExport-${formatSortableDate(new Date(this.dateProvider.now()))}`
Expand Down Expand Up @@ -134,20 +141,30 @@ export class DesktopExportFacade implements ExportFacade {
}

async getMailboxExportState(userId: string): Promise<MailboxExportState | null> {
return await this.mailboxExportPersistence.getStateForUser(userId)
const state = await this.mailboxExportPersistence.getStateForUser(userId)
if (state && state.type === "running") {
if (this.desktopExportLock.acquireLock(userId) === LockResult.AlreadyLocked) {
return {
type: "locked",
userId,
}
}
}
return state
}

async endMailboxExport(userId: string): Promise<void> {
const previousExportState = await this.mailboxExportPersistence.getStateForUser(userId)
if (previousExportState == null) {
if (previousExportState && previousExportState.type === "running") {
await this.mailboxExportPersistence.setStateForUser({
type: "finished",
userId,
exportDirectoryPath: previousExportState.exportDirectoryPath,
mailboxId: previousExportState.mailboxId,
})
} else {
throw new ProgrammingError("An Export was not previously running")
}
await this.mailboxExportPersistence.setStateForUser({
type: "finished",
userId,
exportDirectoryPath: previousExportState.exportDirectoryPath,
mailboxId: previousExportState.mailboxId,
})
}

async saveMailboxExport(bundle: MailBundle, userId: string, mailBagId: string, mailId: string): Promise<void> {
Expand Down Expand Up @@ -180,6 +197,7 @@ export class DesktopExportFacade implements ExportFacade {

async clearExportState(userId: string): Promise<void> {
await this.mailboxExportPersistence.clearStateForUser(userId)
this.desktopExportLock.unlock(userId)
}

async openExportDirectory(userId: string): Promise<void> {
Expand Down
22 changes: 22 additions & 0 deletions src/common/desktop/export/DesktopExportLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const enum LockResult {
LockAcquired,
AlreadyLocked,
}

/** A simple lock to prevent parallel mailbox export in multiple windows */
export class DesktopExportLock {
private readonly locks: Set<Id> = new Set()

acquireLock(userId: Id): LockResult {
if (this.locks.has(userId)) {
return LockResult.AlreadyLocked
} else {
this.locks.add(userId)
return LockResult.LockAcquired
}
}

unlock(userId: Id): void {
this.locks.delete(userId)
}
}
4 changes: 4 additions & 0 deletions src/common/desktop/export/MailboxExportPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export type MailboxExportState =
mailboxId: Id
exportDirectoryPath: string
}
| {
type: "locked"
userId: Id
}

export class MailboxExportPersistence {
constructor(private readonly conf: DesktopConfig) {}
Expand Down
17 changes: 12 additions & 5 deletions src/common/desktop/ipc/RemoteBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ import { DesktopFacadeSendDispatcher } from "../../native/common/generatedipc/De
import { CommonNativeFacadeSendDispatcher } from "../../native/common/generatedipc/CommonNativeFacadeSendDispatcher.js"
import { DesktopCommonSystemFacade } from "../DesktopCommonSystemFacade.js"
import { InterWindowEventFacadeSendDispatcher } from "../../native/common/generatedipc/InterWindowEventFacadeSendDispatcher.js"
import { PerWindowSqlCipherFacade } from "../db/PerWindowSqlCipherFacade.js"

export interface SendingFacades {
desktopFacade: DesktopFacade
commonNativeFacade: CommonNativeFacade
interWindowEventSender: InterWindowEventFacadeSendDispatcher
sqlCipherFacade: PerWindowSqlCipherFacade
windowCleanup: WindowCleanup
}

const primaryIpcConfig: IpcConfig<"to-main", "to-renderer"> = {
Expand All @@ -24,18 +23,26 @@ const primaryIpcConfig: IpcConfig<"to-main", "to-renderer"> = {

export type DispatcherFactory = (window: ApplicationWindow) => {
desktopCommonSystemFacade: DesktopCommonSystemFacade
sqlCipherFacade: PerWindowSqlCipherFacade
dispatcher: DesktopGlobalDispatcher
windowCleanup: WindowCleanup
}
export type FacadeHandler = (message: Request<"facade">) => Promise<any>
export type FacadeHandlerFactory = (window: ApplicationWindow) => FacadeHandler

/**
* An action that is invoked when the window is detached from a
* user session e.g. when it's closed or reloaded.
*/
export interface WindowCleanup {
onCleanup(userId: Id): Promise<void>
}

export class RemoteBridge {
constructor(private readonly dispatcherFactory: DispatcherFactory, private readonly facadeHandlerFactory: FacadeHandlerFactory) {}

createBridge(window: ApplicationWindow): SendingFacades {
const webContents = window._browserWindow.webContents
const { desktopCommonSystemFacade, sqlCipherFacade, dispatcher } = this.dispatcherFactory(window)
const { desktopCommonSystemFacade, windowCleanup, dispatcher } = this.dispatcherFactory(window)
const facadeHandler = this.facadeHandlerFactory(window)

const transport = new ElectronWebContentsTransport<typeof primaryIpcConfig, JsRequestType, NativeRequestType>(webContents, primaryIpcConfig)
Expand All @@ -60,7 +67,7 @@ export class RemoteBridge {
desktopFacade: new DesktopFacadeSendDispatcher(nativeInterface),
commonNativeFacade: new CommonNativeFacadeSendDispatcher(nativeInterface),
interWindowEventSender: new InterWindowEventFacadeSendDispatcher(nativeInterface),
sqlCipherFacade,
windowCleanup,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/common/misc/TranslationKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1834,3 +1834,4 @@ export type TranslationKeyType =
| "you_label"
| "emptyString_msg"
| "exportFinished_label"
| "exportRunningElsewhere_label"
2 changes: 1 addition & 1 deletion src/mail-app/mailLocator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,7 +1151,7 @@ class MailLocator {
readonly mailExportController: () => Promise<MailExportController> = lazyMemoized(async () => {
const { htmlSanitizer } = await import("../common/misc/HtmlSanitizer")
const { MailExportController } = await import("./native/main/MailExportController.js")
return new MailExportController(this.mailExportFacade, htmlSanitizer, this.exportFacade, this.logins, this.mailboxModel)
return new MailExportController(this.mailExportFacade, htmlSanitizer, this.exportFacade, this.logins, this.mailboxModel, await this.scheduler())
})

/**
Expand Down
Loading

0 comments on commit b0613a4

Please sign in to comment.