Path: blob/main/src/vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';6import { localize } from '../../../../nls.js';7import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js';8import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';9import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';10import { IFileService } from '../../../../platform/files/common/files.js';11import { IProductService } from '../../../../platform/product/common/productService.js';12import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';13import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';14import { createSyncHeaders, IAuthenticationProvider, IResourceRefHandle } from '../../../../platform/userDataSync/common/userDataSync.js';15import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationService } from '../../../services/authentication/common/authentication.js';16import { IExtensionService } from '../../../services/extensions/common/extensions.js';17import { EDIT_SESSIONS_SIGNED_IN, EditSession, EDIT_SESSION_SYNC_CATEGORY, IEditSessionsStorageService, EDIT_SESSIONS_SIGNED_IN_KEY, IEditSessionsLogService, SyncResource, EDIT_SESSIONS_PENDING_KEY } from '../common/editSessions.js';18import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';19import { generateUuid } from '../../../../base/common/uuid.js';20import { getCurrentAuthenticationSessionInfo } from '../../../services/authentication/browser/authenticationService.js';21import { isWeb } from '../../../../base/common/platform.js';22import { IUserDataSyncMachinesService, UserDataSyncMachinesService } from '../../../../platform/userDataSync/common/userDataSyncMachines.js';23import { Emitter } from '../../../../base/common/event.js';24import { CancellationError } from '../../../../base/common/errors.js';25import { EditSessionsStoreClient } from '../common/editSessionsStorageClient.js';26import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js';2728type ExistingSession = IQuickPickItem & { session: AuthenticationSession & { providerId: string } };29type AuthenticationProviderOption = IQuickPickItem & { provider: IAuthenticationProvider };3031export class EditSessionsWorkbenchService extends Disposable implements IEditSessionsStorageService {3233declare _serviceBrand: undefined;3435public readonly SIZE_LIMIT = Math.floor(1024 * 1024 * 1.9); // 2 MB3637private serverConfiguration;38private machineClient: IUserDataSyncMachinesService | undefined;3940private authenticationInfo: { sessionId: string; token: string; providerId: string } | undefined;41private static CACHED_SESSION_STORAGE_KEY = 'editSessionAccountPreference';4243private initialized = false;44private readonly signedInContext: IContextKey<boolean>;4546get isSignedIn() {47return this.existingSessionId !== undefined;48}4950private _didSignIn = new Emitter<void>();51get onDidSignIn() {52return this._didSignIn.event;53}5455private _didSignOut = new Emitter<void>();56get onDidSignOut() {57return this._didSignOut.event;58}5960private _lastWrittenResources = new Map<SyncResource, { ref: string; content: string }>();61get lastWrittenResources() {62return this._lastWrittenResources;63}6465private _lastReadResources = new Map<SyncResource, { ref: string; content: string }>();66get lastReadResources() {67return this._lastReadResources;68}6970storeClient: EditSessionsStoreClient | undefined; // TODO@joyceerhl lifecycle hack7172constructor(73@IFileService private readonly fileService: IFileService,74@IStorageService private readonly storageService: IStorageService,75@IQuickInputService private readonly quickInputService: IQuickInputService,76@IAuthenticationService private readonly authenticationService: IAuthenticationService,77@IExtensionService private readonly extensionService: IExtensionService,78@IEnvironmentService private readonly environmentService: IEnvironmentService,79@IEditSessionsLogService private readonly logService: IEditSessionsLogService,80@IProductService private readonly productService: IProductService,81@IContextKeyService private readonly contextKeyService: IContextKeyService,82@IDialogService private readonly dialogService: IDialogService,83@ISecretStorageService private readonly secretStorageService: ISecretStorageService84) {85super();86this.serverConfiguration = this.productService['editSessions.store'];87// If the user signs out of the current session, reset our cached auth state in memory and on disk88this._register(this.authenticationService.onDidChangeSessions((e) => this.onDidChangeSessions(e.event)));8990// If another window changes the preferred session storage, reset our cached auth state in memory91this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, EditSessionsWorkbenchService.CACHED_SESSION_STORAGE_KEY, this._store)(() => this.onDidChangeStorage()));9293this.registerSignInAction();94this.registerResetAuthenticationAction();9596this.signedInContext = EDIT_SESSIONS_SIGNED_IN.bindTo(this.contextKeyService);97this.signedInContext.set(this.existingSessionId !== undefined);98}99100/**101* @param resource: The resource to retrieve content for.102* @param content An object representing resource state to be restored.103* @returns The ref of the stored state.104*/105async write(resource: SyncResource, content: string | EditSession): Promise<string> {106await this.initialize('write', false);107if (!this.initialized) {108throw new Error('Please sign in to store your edit session.');109}110111if (typeof content !== 'string' && content.machine === undefined) {112content.machine = await this.getOrCreateCurrentMachineId();113}114115content = typeof content === 'string' ? content : JSON.stringify(content);116const ref = await this.storeClient!.writeResource(resource, content, null, undefined, createSyncHeaders(generateUuid()));117118this._lastWrittenResources.set(resource, { ref, content });119120return ref;121}122123/**124* @param resource: The resource to retrieve content for.125* @param ref: A specific content ref to retrieve content for, if it exists.126* If undefined, this method will return the latest saved edit session, if any.127*128* @returns An object representing the requested or latest state, if any.129*/130async read(resource: SyncResource, ref: string | undefined): Promise<{ ref: string; content: string } | undefined> {131await this.initialize('read', false);132if (!this.initialized) {133throw new Error('Please sign in to apply your latest edit session.');134}135136let content: string | undefined | null;137const headers = createSyncHeaders(generateUuid());138try {139if (ref !== undefined) {140content = await this.storeClient?.resolveResourceContent(resource, ref, undefined, headers);141} else {142const result = await this.storeClient?.readResource(resource, null, undefined, headers);143content = result?.content;144ref = result?.ref;145}146} catch (ex) {147this.logService.error(ex);148}149150// TODO@joyceerhl Validate session data, check schema version151if (content !== undefined && content !== null && ref !== undefined) {152this._lastReadResources.set(resource, { ref, content });153return { ref, content };154}155return undefined;156}157158async delete(resource: SyncResource, ref: string | null) {159await this.initialize('write', false);160if (!this.initialized) {161throw new Error(`Unable to delete edit session with ref ${ref}.`);162}163164try {165await this.storeClient?.deleteResource(resource, ref);166} catch (ex) {167this.logService.error(ex);168}169}170171async list(resource: SyncResource): Promise<IResourceRefHandle[]> {172await this.initialize('read', false);173if (!this.initialized) {174throw new Error(`Unable to list edit sessions.`);175}176177try {178return this.storeClient?.getAllResourceRefs(resource) ?? [];179} catch (ex) {180this.logService.error(ex);181}182183return [];184}185186public async initialize(reason: 'read' | 'write', silent: boolean = false) {187if (this.initialized) {188return true;189}190this.initialized = await this.doInitialize(reason, silent);191this.signedInContext.set(this.initialized);192if (this.initialized) {193this._didSignIn.fire();194}195return this.initialized;196197}198199/**200*201* Ensures that the store client is initialized,202* meaning that authentication is configured and it203* can be used to communicate with the remote storage service204*/205private async doInitialize(reason: 'read' | 'write', silent: boolean): Promise<boolean> {206// Wait for authentication extensions to be registered207await this.extensionService.whenInstalledExtensionsRegistered();208209if (!this.serverConfiguration?.url) {210throw new Error('Unable to initialize sessions sync as session sync preference is not configured in product.json.');211}212213if (this.storeClient === undefined) {214return false;215}216217this._register(this.storeClient.onTokenFailed(() => {218this.logService.info('Clearing edit sessions authentication preference because of successive token failures.');219this.clearAuthenticationPreference();220}));221222if (this.machineClient === undefined) {223this.machineClient = new UserDataSyncMachinesService(this.environmentService, this.fileService, this.storageService, this.storeClient, this.logService, this.productService);224}225226// If we already have an existing auth session in memory, use that227if (this.authenticationInfo !== undefined) {228return true;229}230231const authenticationSession = await this.getAuthenticationSession(reason, silent);232if (authenticationSession !== undefined) {233this.authenticationInfo = authenticationSession;234this.storeClient.setAuthToken(authenticationSession.token, authenticationSession.providerId);235}236237return authenticationSession !== undefined;238}239240private cachedMachines: Map<string, string> | undefined;241242async getMachineById(machineId: string) {243await this.initialize('read', false);244245if (!this.cachedMachines) {246const machines = await this.machineClient!.getMachines();247this.cachedMachines = machines.reduce((map, machine) => map.set(machine.id, machine.name), new Map<string, string>());248}249250return this.cachedMachines.get(machineId);251}252253private async getOrCreateCurrentMachineId(): Promise<string> {254const currentMachineId = await this.machineClient!.getMachines().then((machines) => machines.find((m) => m.isCurrent)?.id);255256if (currentMachineId === undefined) {257await this.machineClient!.addCurrentMachine();258return await this.machineClient!.getMachines().then((machines) => machines.find((m) => m.isCurrent)!.id);259}260261return currentMachineId;262}263264private async getAuthenticationSession(reason: 'read' | 'write', silent: boolean) {265// If the user signed in previously and the session is still available, reuse that without prompting the user again266if (this.existingSessionId) {267this.logService.info(`Searching for existing authentication session with ID ${this.existingSessionId}`);268const existingSession = await this.getExistingSession();269if (existingSession) {270this.logService.info(`Found existing authentication session with ID ${existingSession.session.id}`);271return { sessionId: existingSession.session.id, token: existingSession.session.idToken ?? existingSession.session.accessToken, providerId: existingSession.session.providerId };272} else {273this._didSignOut.fire();274}275}276277// If settings sync is already enabled, avoid asking again to authenticate278if (this.shouldAttemptEditSessionInit()) {279this.logService.info(`Reusing user data sync enablement`);280const authenticationSessionInfo = await getCurrentAuthenticationSessionInfo(this.secretStorageService, this.productService);281if (authenticationSessionInfo !== undefined) {282this.logService.info(`Using current authentication session with ID ${authenticationSessionInfo.id}`);283this.existingSessionId = authenticationSessionInfo.id;284return { sessionId: authenticationSessionInfo.id, token: authenticationSessionInfo.accessToken, providerId: authenticationSessionInfo.providerId };285}286}287288// If we aren't supposed to prompt the user because289// we're in a silent flow, just return here290if (silent) {291return;292}293294// Ask the user to pick a preferred account295const authenticationSession = await this.getAccountPreference(reason);296if (authenticationSession !== undefined) {297this.existingSessionId = authenticationSession.id;298return { sessionId: authenticationSession.id, token: authenticationSession.idToken ?? authenticationSession.accessToken, providerId: authenticationSession.providerId };299}300301return undefined;302}303304private shouldAttemptEditSessionInit(): boolean {305return isWeb && this.storageService.isNew(StorageScope.APPLICATION) && this.storageService.isNew(StorageScope.WORKSPACE);306}307308/**309*310* Prompts the user to pick an authentication option for storing and getting edit sessions.311*/312private async getAccountPreference(reason: 'read' | 'write'): Promise<AuthenticationSession & { providerId: string } | undefined> {313const disposables = new DisposableStore();314const quickpick = disposables.add(this.quickInputService.createQuickPick<ExistingSession | AuthenticationProviderOption | IQuickPickItem>({ useSeparators: true }));315quickpick.ok = false;316quickpick.placeholder = reason === 'read' ? localize('choose account read placeholder', "Select an account to restore your working changes from the cloud") : localize('choose account placeholder', "Select an account to store your working changes in the cloud");317quickpick.ignoreFocusOut = true;318quickpick.items = await this.createQuickpickItems();319320return new Promise((resolve, reject) => {321disposables.add(quickpick.onDidHide((e) => {322reject(new CancellationError());323disposables.dispose();324}));325326disposables.add(quickpick.onDidAccept(async (e) => {327const selection = quickpick.selectedItems[0];328const session = 'provider' in selection ? { ...await this.authenticationService.createSession(selection.provider.id, selection.provider.scopes), providerId: selection.provider.id } : ('session' in selection ? selection.session : undefined);329resolve(session);330quickpick.hide();331}));332333quickpick.show();334});335}336337private async createQuickpickItems(): Promise<(ExistingSession | AuthenticationProviderOption | IQuickPickSeparator | IQuickPickItem & { canceledAuthentication: boolean })[]> {338const options: (ExistingSession | AuthenticationProviderOption | IQuickPickSeparator | IQuickPickItem & { canceledAuthentication: boolean })[] = [];339340options.push({ type: 'separator', label: localize('signed in', "Signed In") });341342const sessions = await this.getAllSessions();343options.push(...sessions);344345options.push({ type: 'separator', label: localize('others', "Others") });346347for (const authenticationProvider of (await this.getAuthenticationProviders())) {348const signedInForProvider = sessions.some(account => account.session.providerId === authenticationProvider.id);349if (!signedInForProvider || this.authenticationService.getProvider(authenticationProvider.id).supportsMultipleAccounts) {350const providerName = this.authenticationService.getProvider(authenticationProvider.id).label;351options.push({ label: localize('sign in using account', "Sign in with {0}", providerName), provider: authenticationProvider });352}353}354355return options;356}357358/**359*360* Returns all authentication sessions available from {@link getAuthenticationProviders}.361*/362private async getAllSessions() {363const authenticationProviders = await this.getAuthenticationProviders();364const accounts = new Map<string, ExistingSession>();365let currentSession: ExistingSession | undefined;366367for (const provider of authenticationProviders) {368const sessions = await this.authenticationService.getSessions(provider.id, provider.scopes);369370for (const session of sessions) {371const item = {372label: session.account.label,373description: this.authenticationService.getProvider(provider.id).label,374session: { ...session, providerId: provider.id }375};376accounts.set(item.session.account.id, item);377if (this.existingSessionId === session.id) {378currentSession = item;379}380}381}382383if (currentSession !== undefined) {384accounts.set(currentSession.session.account.id, currentSession);385}386387return [...accounts.values()].sort((a, b) => a.label.localeCompare(b.label));388}389390/**391*392* Returns all authentication providers which can be used to authenticate393* to the remote storage service, based on product.json configuration394* and registered authentication providers.395*/396private async getAuthenticationProviders() {397if (!this.serverConfiguration) {398throw new Error('Unable to get configured authentication providers as session sync preference is not configured in product.json.');399}400401// Get the list of authentication providers configured in product.json402const authenticationProviders = this.serverConfiguration.authenticationProviders;403const configuredAuthenticationProviders = Object.keys(authenticationProviders).reduce<IAuthenticationProvider[]>((result, id) => {404result.push({ id, scopes: authenticationProviders[id].scopes });405return result;406}, []);407408// Filter out anything that isn't currently available through the authenticationService409const availableAuthenticationProviders = this.authenticationService.declaredProviders;410411return configuredAuthenticationProviders.filter(({ id }) => availableAuthenticationProviders.some(provider => provider.id === id));412}413414private get existingSessionId() {415return this.storageService.get(EditSessionsWorkbenchService.CACHED_SESSION_STORAGE_KEY, StorageScope.APPLICATION);416}417418private set existingSessionId(sessionId: string | undefined) {419this.logService.trace(`Saving authentication session preference for ID ${sessionId}.`);420if (sessionId === undefined) {421this.storageService.remove(EditSessionsWorkbenchService.CACHED_SESSION_STORAGE_KEY, StorageScope.APPLICATION);422} else {423this.storageService.store(EditSessionsWorkbenchService.CACHED_SESSION_STORAGE_KEY, sessionId, StorageScope.APPLICATION, StorageTarget.MACHINE);424}425}426427private async getExistingSession() {428const accounts = await this.getAllSessions();429return accounts.find((account) => account.session.id === this.existingSessionId);430}431432private async onDidChangeStorage(): Promise<void> {433const newSessionId = this.existingSessionId;434const previousSessionId = this.authenticationInfo?.sessionId;435436if (previousSessionId !== newSessionId) {437this.logService.trace(`Resetting authentication state because authentication session ID preference changed from ${previousSessionId} to ${newSessionId}.`);438this.authenticationInfo = undefined;439this.initialized = false;440}441}442443private clearAuthenticationPreference(): void {444this.authenticationInfo = undefined;445this.initialized = false;446this.existingSessionId = undefined;447this.signedInContext.set(false);448}449450private onDidChangeSessions(e: AuthenticationSessionsChangeEvent): void {451if (this.authenticationInfo?.sessionId && e.removed?.find(session => session.id === this.authenticationInfo?.sessionId)) {452this.clearAuthenticationPreference();453}454}455456private registerSignInAction() {457if (!this.serverConfiguration?.url) {458return;459}460const that = this;461const id = 'workbench.editSessions.actions.signIn';462const when = ContextKeyExpr.and(ContextKeyExpr.equals(EDIT_SESSIONS_PENDING_KEY, false), ContextKeyExpr.equals(EDIT_SESSIONS_SIGNED_IN_KEY, false));463this._register(registerAction2(class ResetEditSessionAuthenticationAction extends Action2 {464constructor() {465super({466id,467title: localize('sign in', 'Turn on Cloud Changes...'),468category: EDIT_SESSION_SYNC_CATEGORY,469precondition: when,470menu: [{471id: MenuId.CommandPalette,472},473{474id: MenuId.AccountsContext,475group: '2_editSessions',476when,477}]478});479}480481async run() {482return await that.initialize('write', false);483}484}));485486this._register(MenuRegistry.appendMenuItem(MenuId.AccountsContext, {487group: '2_editSessions',488command: {489id,490title: localize('sign in badge', 'Turn on Cloud Changes... (1)'),491},492when: ContextKeyExpr.and(ContextKeyExpr.equals(EDIT_SESSIONS_PENDING_KEY, true), ContextKeyExpr.equals(EDIT_SESSIONS_SIGNED_IN_KEY, false))493}));494}495496private registerResetAuthenticationAction() {497const that = this;498this._register(registerAction2(class ResetEditSessionAuthenticationAction extends Action2 {499constructor() {500super({501id: 'workbench.editSessions.actions.resetAuth',502title: localize('reset auth.v3', 'Turn off Cloud Changes...'),503category: EDIT_SESSION_SYNC_CATEGORY,504precondition: ContextKeyExpr.equals(EDIT_SESSIONS_SIGNED_IN_KEY, true),505menu: [{506id: MenuId.CommandPalette,507},508{509id: MenuId.AccountsContext,510group: '2_editSessions',511when: ContextKeyExpr.equals(EDIT_SESSIONS_SIGNED_IN_KEY, true),512}]513});514}515516async run() {517const result = await that.dialogService.confirm({518message: localize('sign out of cloud changes clear data prompt', 'Do you want to disable storing working changes in the cloud?'),519checkbox: { label: localize('delete all cloud changes', 'Delete all stored data from the cloud.') }520});521if (result.confirmed) {522if (result.checkboxChecked) {523that.storeClient?.deleteResource('editSessions', null);524}525that.clearAuthenticationPreference();526}527}528}));529}530}531532533