diff --git a/src/electron-main/api/endpoints-builders/proton-session.ts b/src/electron-main/api/endpoints-builders/proton-session.ts index 30cb1bfac..d441ec0c3 100644 --- a/src/electron-main/api/endpoints-builders/proton-session.ts +++ b/src/electron-main/api/endpoints-builders/proton-session.ts @@ -2,23 +2,14 @@ import {concatMap, first} from "rxjs/operators"; import {from, race, throwError, timer} from "rxjs"; import {pick} from "remeda"; -import {AccountPersistentSession} from "src/shared/model/account"; import {Context} from "src/electron-main/model"; import {IpcMainApiEndpoints} from "src/shared/api/main"; +import {filterProtonSessionTokenCookies} from "src/electron-main/util"; import {resolveInitializedSession} from "src/electron-main/session"; // TODO enable minimal logging // const logger = curryFunctionMembers(electronLog, "[electron-main/api/endpoints-builders/proton-session]"); -function resolveTokenCookies( - items: DeepReadonly["cookies"], -): Readonly<{ accessTokens: typeof items; refreshTokens: typeof items }> { - return { - accessTokens: items.filter(({name}) => name.toUpperCase().startsWith("AUTH-")), - refreshTokens: items.filter(({name}) => name.toUpperCase().startsWith("REFRESH-")), - } as const; -} - function pickTokenCookiePropsToApply( cookie: DeepReadonly, ): Pick { @@ -69,7 +60,7 @@ export async function buildEndpoints( }, } as const; - const {accessTokens, refreshTokens} = resolveTokenCookies(data.session.cookies); + const {accessTokens, refreshTokens} = filterProtonSessionTokenCookies(data.session.cookies); if (accessTokens.length > 1 || refreshTokens.length > 1) { throw new Error([ @@ -97,7 +88,7 @@ export async function buildEndpoints( return false; } - const tokenCookie = resolveTokenCookies(savedSession.cookies); + const tokenCookie = filterProtonSessionTokenCookies(savedSession.cookies); const accessTokenCookie = [...tokenCookie.accessTokens].pop(); const refreshTokenCookie = [...tokenCookie.refreshTokens].pop(); diff --git a/src/electron-main/session.ts b/src/electron-main/session.ts index 793a34daf..4563f6d1d 100644 --- a/src/electron-main/session.ts +++ b/src/electron-main/session.ts @@ -1,4 +1,4 @@ -import _logger from "electron-log"; +import electronLog from "electron-log"; import {Session, session as electronSession} from "electron"; import {concatMap, first} from "rxjs/operators"; import {from, race, throwError, timer} from "rxjs"; @@ -10,10 +10,11 @@ import {IPC_MAIN_API_NOTIFICATION_ACTIONS} from "src/shared/api/main"; import {LoginFieldContainer} from "src/shared/model/container"; import {ONE_SECOND_MS, PACKAGE_NAME} from "src/shared/constants"; import {curryFunctionMembers, getRandomInt, getWebViewPartition} from "src/shared/util"; +import {filterProtonSessionTokenCookies} from "src/electron-main/util"; import {initWebRequestListenersByAccount} from "src/electron-main/web-request"; import {registerSessionProtocols} from "src/electron-main/protocol"; -const logger = curryFunctionMembers(_logger, "[src/electron-main/session]"); +const _logger = curryFunctionMembers(electronLog, "[src/electron-main/session]"); // TODO move "usedPartitions" prop to "ctx" // eslint-disable-next-line @typescript-eslint/no-use-before-define @@ -50,6 +51,8 @@ export async function initSession( session: Session, {rotateUserAgent}: DeepReadonly>> = {}, ): Promise { + const logger = curryFunctionMembers(_logger, "initSession()"); + if (rotateUserAgent) { if (!ctx.userAgentsPool || !ctx.userAgentsPool.length) { const {userAgents} = await ctx.config$.pipe(first()).toPromise(); @@ -82,7 +85,7 @@ export async function configureSessionByAccount( ctx: DeepReadonly, account: DeepReadonly, ): Promise { - logger.info("configureSessionByAccount()"); + _logger.info("configureSessionByAccount()"); const {proxy} = account; const session = resolveInitializedSession({login: account.login}); @@ -115,6 +118,7 @@ export async function initSessionByAccount( // eslint-disable-next-line max-len account: DeepReadonly, ): Promise { + const logger = curryFunctionMembers(_logger, "initSessionByAccount()"); const partition = getWebViewPartition(account.login); if (usedPartitions.has(partition)) { @@ -130,6 +134,37 @@ export async function initSessionByAccount( await registerSessionProtocols(ctx, session); await configureSessionByAccount(ctx, account); + { + type Cause = "explicit" | "overwrite" | "expired" | "evicted" | "expired-overwrite"; + + const skipCauses: ReadonlyArray = ["expired", "evicted", "expired-overwrite"]; + + session.cookies.on( + "changed", + // TODO electron/TS: drop explicit callback args typing (currently typed as Function in electron.d.ts) + (...[, cookie, cause, removed]: [ + event: unknown, + cookie: Electron.Cookie, + cause: "explicit" | "overwrite" | "expired" | "evicted" | "expired-overwrite", + removed: boolean + ]) => { + if (removed || skipCauses.includes(cause)) { + return; + } + + const protonSessionTokenCookies = filterProtonSessionTokenCookies([cookie]); + + if (protonSessionTokenCookies.accessTokens.length || protonSessionTokenCookies.refreshTokens.length) { + logger.verbose("proton session token cookies modified"); + + IPC_MAIN_API_NOTIFICATION$.next( + IPC_MAIN_API_NOTIFICATION_ACTIONS.ProtonSessionTokenCookiesModified({key: {login: account.login}}), + ); + } + }, + ); + } + usedPartitions.add(partition); } diff --git a/src/electron-main/util.ts b/src/electron-main/util.ts index 505801f14..03e28d333 100644 --- a/src/electron-main/util.ts +++ b/src/electron-main/util.ts @@ -71,3 +71,12 @@ export function readConfigSync({configStore}: DeepReadonly): Config | n ? JSON.parse(configFile.toString()) as Config : null; } + +export const filterProtonSessionTokenCookies = ( + items: readonly T[], +): { readonly accessTokens: typeof items; readonly refreshTokens: typeof items } => { + return { + accessTokens: items.filter(({name}) => name.toUpperCase().startsWith("AUTH-")), + refreshTokens: items.filter(({name}) => name.toUpperCase().startsWith("REFRESH-")), + } as const; +}; diff --git a/src/shared/api/main.ts b/src/shared/api/main.ts index fef9d2315..ffcdfe20d 100644 --- a/src/shared/api/main.ts +++ b/src/shared/api/main.ts @@ -101,6 +101,7 @@ export const IPC_MAIN_API_NOTIFICATION_ACTIONS = unionize({ InfoMessage: ofType<{ message: string }>(), TrayIconDataURL: ofType(), PowerMonitor: ofType<{ message: "suspend" | "resume" | "shutdown" }>(), + ProtonSessionTokenCookiesModified: ofType<{ key: DbModel.DbAccountPk }>(), }, { tag: "type", diff --git a/src/web/browser-window/app/_accounts/account.component.ts b/src/web/browser-window/app/_accounts/account.component.ts index ed8d1b8c5..04294c674 100644 --- a/src/web/browser-window/app/_accounts/account.component.ts +++ b/src/web/browser-window/app/_accounts/account.component.ts @@ -1,4 +1,4 @@ -import {BehaviorSubject, EMPTY, Observable, Subject, Subscription, combineLatest, race, throwError, timer} from "rxjs"; +import {BehaviorSubject, EMPTY, Observable, Subject, Subscription, combineLatest, merge, of, race, throwError, timer} from "rxjs"; import { ChangeDetectionStrategy, Component, @@ -27,6 +27,7 @@ import { switchMap, take, takeUntil, + tap, withLatestFrom, } from "rxjs/operators"; @@ -43,8 +44,8 @@ import {ONE_SECOND_MS, PRODUCT_NAME} from "src/shared/constants"; import {ProtonClientSession} from "src/shared/model/proton"; import {State} from "src/web/browser-window/app/store/reducers/accounts"; import {WebAccount} from "src/web/browser-window/app/model"; +import {curryFunctionMembers, parseUrlOriginWithNullishCheck} from "src/shared/util"; import {getZoneNameBoundWebLogger} from "src/web/browser-window/util"; -import {parseUrlOriginWithNullishCheck} from "src/shared/util"; let componentIndex = 0; @@ -316,36 +317,65 @@ export class AccountComponent extends NgChangesObservableComponent implements On return value; }; - this.subscription.add( - this.store.pipe( - select(OptionsSelectors.CONFIG.persistentSessionSavingInterval), - switchMap((persistentSessionSavingInterval) => { - return combineLatest([ - this.loggedIn$, - this.persistentSession$, - timer(0, persistentSessionSavingInterval), - ]).pipe( - filter(([loggedIn, persistentSession]) => persistentSession && loggedIn), - withLatestFrom(this.account$), - ); - }), - ).subscribe(([, {accountConfig}]) => { - this.logger.verbose("saving proton session"); + { + const logger = curryFunctionMembers(this.logger, "saving proton session"); - (async () => { - await this.ipcMainClient("saveProtonSession")({ - login: accountConfig.login, - clientSession: await resolveSavedProtonClientSession(), - apiEndpointOrigin: parseUrlOriginWithNullishCheck( - this.core.parseEntryUrl(accountConfig, "proton-mail").entryApiUrl, - ), + this.subscription.add( + this.store.pipe( + select(OptionsSelectors.CONFIG.persistentSessionSavingInterval), + switchMap((persistentSessionSavingInterval) => { + return combineLatest([ + this.loggedIn$.pipe( + tap((value) => logger.verbose("trigger: loggedIn$", value)), + ), + this.persistentSession$.pipe( + tap((value) => logger.verbose("trigger: persistentSession$", value)), + ), + merge( + of(null), // fired once to unblock the "combineLatest" + this.store.pipe( + select(OptionsSelectors.FEATURED.mainProcessNotification), + filter(IPC_MAIN_API_NOTIFICATION_ACTIONS.is.ProtonSessionTokenCookiesModified), + debounceTime(ONE_SECOND_MS), + withLatestFrom(this.account$), + filter(([{payload: {key}}, {accountConfig: {login}}]) => key.login === login), + tap(() => logger.verbose("trigger: proton session token cookies modified")), + ), + ), + ( + persistentSessionSavingInterval > 0 // negative value skips the interval-based trigger + ? ( + timer(0, persistentSessionSavingInterval).pipe( + tap((value) => logger.verbose("trigger: interval", value)), + ) + ) + : of(null) // fired once to unblock the "combineLatest" + ), + ]).pipe( + filter(([loggedIn, persistentSession]) => persistentSession && loggedIn), + withLatestFrom(this.account$), + ); + }), + ).subscribe(([, {accountConfig}]) => { + const ipcMainAction = "saveProtonSession"; + + logger.verbose(ipcMainAction); + + (async () => { + await this.ipcMainClient(ipcMainAction)({ + login: accountConfig.login, + clientSession: await resolveSavedProtonClientSession(), + apiEndpointOrigin: parseUrlOriginWithNullishCheck( + this.core.parseEntryUrl(accountConfig, "proton-mail").entryApiUrl, + ), + }); + })().catch((error) => { + // TODO make "AppErrorHandler.handleError" catch promise rejection errors + this.onDispatchInLoggerZone(NOTIFICATION_ACTIONS.Error(error)); }); - })().catch((error) => { - // TODO make "AppErrorHandler.handleError" catch promise rejection errors - this.onDispatchInLoggerZone(NOTIFICATION_ACTIONS.Error(error)); - }); - }), - ); + }), + ); + } this.subscription.add( combineLatest([