Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/editSessions/browser/editSessionsStorageService.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
7
import { localize } from '../../../../nls.js';
8
import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js';
9
import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
10
import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';
11
import { IFileService } from '../../../../platform/files/common/files.js';
12
import { IProductService } from '../../../../platform/product/common/productService.js';
13
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';
14
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
15
import { createSyncHeaders, IAuthenticationProvider, IResourceRefHandle } from '../../../../platform/userDataSync/common/userDataSync.js';
16
import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationService } from '../../../services/authentication/common/authentication.js';
17
import { IExtensionService } from '../../../services/extensions/common/extensions.js';
18
import { 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';
19
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
20
import { generateUuid } from '../../../../base/common/uuid.js';
21
import { getCurrentAuthenticationSessionInfo } from '../../../services/authentication/browser/authenticationService.js';
22
import { isWeb } from '../../../../base/common/platform.js';
23
import { IUserDataSyncMachinesService, UserDataSyncMachinesService } from '../../../../platform/userDataSync/common/userDataSyncMachines.js';
24
import { Emitter } from '../../../../base/common/event.js';
25
import { CancellationError } from '../../../../base/common/errors.js';
26
import { EditSessionsStoreClient } from '../common/editSessionsStorageClient.js';
27
import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js';
28
29
type ExistingSession = IQuickPickItem & { session: AuthenticationSession & { providerId: string } };
30
type AuthenticationProviderOption = IQuickPickItem & { provider: IAuthenticationProvider };
31
32
export class EditSessionsWorkbenchService extends Disposable implements IEditSessionsStorageService {
33
34
declare _serviceBrand: undefined;
35
36
public readonly SIZE_LIMIT = Math.floor(1024 * 1024 * 1.9); // 2 MB
37
38
private serverConfiguration;
39
private machineClient: IUserDataSyncMachinesService | undefined;
40
41
private authenticationInfo: { sessionId: string; token: string; providerId: string } | undefined;
42
private static CACHED_SESSION_STORAGE_KEY = 'editSessionAccountPreference';
43
44
private initialized = false;
45
private readonly signedInContext: IContextKey<boolean>;
46
47
get isSignedIn() {
48
return this.existingSessionId !== undefined;
49
}
50
51
private _didSignIn = new Emitter<void>();
52
get onDidSignIn() {
53
return this._didSignIn.event;
54
}
55
56
private _didSignOut = new Emitter<void>();
57
get onDidSignOut() {
58
return this._didSignOut.event;
59
}
60
61
private _lastWrittenResources = new Map<SyncResource, { ref: string; content: string }>();
62
get lastWrittenResources() {
63
return this._lastWrittenResources;
64
}
65
66
private _lastReadResources = new Map<SyncResource, { ref: string; content: string }>();
67
get lastReadResources() {
68
return this._lastReadResources;
69
}
70
71
storeClient: EditSessionsStoreClient | undefined; // TODO@joyceerhl lifecycle hack
72
73
constructor(
74
@IFileService private readonly fileService: IFileService,
75
@IStorageService private readonly storageService: IStorageService,
76
@IQuickInputService private readonly quickInputService: IQuickInputService,
77
@IAuthenticationService private readonly authenticationService: IAuthenticationService,
78
@IExtensionService private readonly extensionService: IExtensionService,
79
@IEnvironmentService private readonly environmentService: IEnvironmentService,
80
@IEditSessionsLogService private readonly logService: IEditSessionsLogService,
81
@IProductService private readonly productService: IProductService,
82
@IContextKeyService private readonly contextKeyService: IContextKeyService,
83
@IDialogService private readonly dialogService: IDialogService,
84
@ISecretStorageService private readonly secretStorageService: ISecretStorageService
85
) {
86
super();
87
this.serverConfiguration = this.productService['editSessions.store'];
88
// If the user signs out of the current session, reset our cached auth state in memory and on disk
89
this._register(this.authenticationService.onDidChangeSessions((e) => this.onDidChangeSessions(e.event)));
90
91
// If another window changes the preferred session storage, reset our cached auth state in memory
92
this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, EditSessionsWorkbenchService.CACHED_SESSION_STORAGE_KEY, this._store)(() => this.onDidChangeStorage()));
93
94
this.registerSignInAction();
95
this.registerResetAuthenticationAction();
96
97
this.signedInContext = EDIT_SESSIONS_SIGNED_IN.bindTo(this.contextKeyService);
98
this.signedInContext.set(this.existingSessionId !== undefined);
99
}
100
101
/**
102
* @param resource: The resource to retrieve content for.
103
* @param content An object representing resource state to be restored.
104
* @returns The ref of the stored state.
105
*/
106
async write(resource: SyncResource, content: string | EditSession): Promise<string> {
107
await this.initialize('write', false);
108
if (!this.initialized) {
109
throw new Error('Please sign in to store your edit session.');
110
}
111
112
if (typeof content !== 'string' && content.machine === undefined) {
113
content.machine = await this.getOrCreateCurrentMachineId();
114
}
115
116
content = typeof content === 'string' ? content : JSON.stringify(content);
117
const ref = await this.storeClient!.writeResource(resource, content, null, undefined, createSyncHeaders(generateUuid()));
118
119
this._lastWrittenResources.set(resource, { ref, content });
120
121
return ref;
122
}
123
124
/**
125
* @param resource: The resource to retrieve content for.
126
* @param ref: A specific content ref to retrieve content for, if it exists.
127
* If undefined, this method will return the latest saved edit session, if any.
128
*
129
* @returns An object representing the requested or latest state, if any.
130
*/
131
async read(resource: SyncResource, ref: string | undefined): Promise<{ ref: string; content: string } | undefined> {
132
await this.initialize('read', false);
133
if (!this.initialized) {
134
throw new Error('Please sign in to apply your latest edit session.');
135
}
136
137
let content: string | undefined | null;
138
const headers = createSyncHeaders(generateUuid());
139
try {
140
if (ref !== undefined) {
141
content = await this.storeClient?.resolveResourceContent(resource, ref, undefined, headers);
142
} else {
143
const result = await this.storeClient?.readResource(resource, null, undefined, headers);
144
content = result?.content;
145
ref = result?.ref;
146
}
147
} catch (ex) {
148
this.logService.error(ex);
149
}
150
151
// TODO@joyceerhl Validate session data, check schema version
152
if (content !== undefined && content !== null && ref !== undefined) {
153
this._lastReadResources.set(resource, { ref, content });
154
return { ref, content };
155
}
156
return undefined;
157
}
158
159
async delete(resource: SyncResource, ref: string | null) {
160
await this.initialize('write', false);
161
if (!this.initialized) {
162
throw new Error(`Unable to delete edit session with ref ${ref}.`);
163
}
164
165
try {
166
await this.storeClient?.deleteResource(resource, ref);
167
} catch (ex) {
168
this.logService.error(ex);
169
}
170
}
171
172
async list(resource: SyncResource): Promise<IResourceRefHandle[]> {
173
await this.initialize('read', false);
174
if (!this.initialized) {
175
throw new Error(`Unable to list edit sessions.`);
176
}
177
178
try {
179
return this.storeClient?.getAllResourceRefs(resource) ?? [];
180
} catch (ex) {
181
this.logService.error(ex);
182
}
183
184
return [];
185
}
186
187
public async initialize(reason: 'read' | 'write', silent: boolean = false) {
188
if (this.initialized) {
189
return true;
190
}
191
this.initialized = await this.doInitialize(reason, silent);
192
this.signedInContext.set(this.initialized);
193
if (this.initialized) {
194
this._didSignIn.fire();
195
}
196
return this.initialized;
197
198
}
199
200
/**
201
*
202
* Ensures that the store client is initialized,
203
* meaning that authentication is configured and it
204
* can be used to communicate with the remote storage service
205
*/
206
private async doInitialize(reason: 'read' | 'write', silent: boolean): Promise<boolean> {
207
// Wait for authentication extensions to be registered
208
await this.extensionService.whenInstalledExtensionsRegistered();
209
210
if (!this.serverConfiguration?.url) {
211
throw new Error('Unable to initialize sessions sync as session sync preference is not configured in product.json.');
212
}
213
214
if (this.storeClient === undefined) {
215
return false;
216
}
217
218
this._register(this.storeClient.onTokenFailed(() => {
219
this.logService.info('Clearing edit sessions authentication preference because of successive token failures.');
220
this.clearAuthenticationPreference();
221
}));
222
223
if (this.machineClient === undefined) {
224
this.machineClient = new UserDataSyncMachinesService(this.environmentService, this.fileService, this.storageService, this.storeClient, this.logService, this.productService);
225
}
226
227
// If we already have an existing auth session in memory, use that
228
if (this.authenticationInfo !== undefined) {
229
return true;
230
}
231
232
const authenticationSession = await this.getAuthenticationSession(reason, silent);
233
if (authenticationSession !== undefined) {
234
this.authenticationInfo = authenticationSession;
235
this.storeClient.setAuthToken(authenticationSession.token, authenticationSession.providerId);
236
}
237
238
return authenticationSession !== undefined;
239
}
240
241
private cachedMachines: Map<string, string> | undefined;
242
243
async getMachineById(machineId: string) {
244
await this.initialize('read', false);
245
246
if (!this.cachedMachines) {
247
const machines = await this.machineClient!.getMachines();
248
this.cachedMachines = machines.reduce((map, machine) => map.set(machine.id, machine.name), new Map<string, string>());
249
}
250
251
return this.cachedMachines.get(machineId);
252
}
253
254
private async getOrCreateCurrentMachineId(): Promise<string> {
255
const currentMachineId = await this.machineClient!.getMachines().then((machines) => machines.find((m) => m.isCurrent)?.id);
256
257
if (currentMachineId === undefined) {
258
await this.machineClient!.addCurrentMachine();
259
return await this.machineClient!.getMachines().then((machines) => machines.find((m) => m.isCurrent)!.id);
260
}
261
262
return currentMachineId;
263
}
264
265
private async getAuthenticationSession(reason: 'read' | 'write', silent: boolean) {
266
// If the user signed in previously and the session is still available, reuse that without prompting the user again
267
if (this.existingSessionId) {
268
this.logService.info(`Searching for existing authentication session with ID ${this.existingSessionId}`);
269
const existingSession = await this.getExistingSession();
270
if (existingSession) {
271
this.logService.info(`Found existing authentication session with ID ${existingSession.session.id}`);
272
return { sessionId: existingSession.session.id, token: existingSession.session.idToken ?? existingSession.session.accessToken, providerId: existingSession.session.providerId };
273
} else {
274
this._didSignOut.fire();
275
}
276
}
277
278
// If settings sync is already enabled, avoid asking again to authenticate
279
if (this.shouldAttemptEditSessionInit()) {
280
this.logService.info(`Reusing user data sync enablement`);
281
const authenticationSessionInfo = await getCurrentAuthenticationSessionInfo(this.secretStorageService, this.productService);
282
if (authenticationSessionInfo !== undefined) {
283
this.logService.info(`Using current authentication session with ID ${authenticationSessionInfo.id}`);
284
this.existingSessionId = authenticationSessionInfo.id;
285
return { sessionId: authenticationSessionInfo.id, token: authenticationSessionInfo.accessToken, providerId: authenticationSessionInfo.providerId };
286
}
287
}
288
289
// If we aren't supposed to prompt the user because
290
// we're in a silent flow, just return here
291
if (silent) {
292
return;
293
}
294
295
// Ask the user to pick a preferred account
296
const authenticationSession = await this.getAccountPreference(reason);
297
if (authenticationSession !== undefined) {
298
this.existingSessionId = authenticationSession.id;
299
return { sessionId: authenticationSession.id, token: authenticationSession.idToken ?? authenticationSession.accessToken, providerId: authenticationSession.providerId };
300
}
301
302
return undefined;
303
}
304
305
private shouldAttemptEditSessionInit(): boolean {
306
return isWeb && this.storageService.isNew(StorageScope.APPLICATION) && this.storageService.isNew(StorageScope.WORKSPACE);
307
}
308
309
/**
310
*
311
* Prompts the user to pick an authentication option for storing and getting edit sessions.
312
*/
313
private async getAccountPreference(reason: 'read' | 'write'): Promise<AuthenticationSession & { providerId: string } | undefined> {
314
const disposables = new DisposableStore();
315
const quickpick = disposables.add(this.quickInputService.createQuickPick<ExistingSession | AuthenticationProviderOption | IQuickPickItem>({ useSeparators: true }));
316
quickpick.ok = false;
317
quickpick.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");
318
quickpick.ignoreFocusOut = true;
319
quickpick.items = await this.createQuickpickItems();
320
321
return new Promise((resolve, reject) => {
322
disposables.add(quickpick.onDidHide((e) => {
323
reject(new CancellationError());
324
disposables.dispose();
325
}));
326
327
disposables.add(quickpick.onDidAccept(async (e) => {
328
const selection = quickpick.selectedItems[0];
329
const session = 'provider' in selection ? { ...await this.authenticationService.createSession(selection.provider.id, selection.provider.scopes), providerId: selection.provider.id } : ('session' in selection ? selection.session : undefined);
330
resolve(session);
331
quickpick.hide();
332
}));
333
334
quickpick.show();
335
});
336
}
337
338
private async createQuickpickItems(): Promise<(ExistingSession | AuthenticationProviderOption | IQuickPickSeparator | IQuickPickItem & { canceledAuthentication: boolean })[]> {
339
const options: (ExistingSession | AuthenticationProviderOption | IQuickPickSeparator | IQuickPickItem & { canceledAuthentication: boolean })[] = [];
340
341
options.push({ type: 'separator', label: localize('signed in', "Signed In") });
342
343
const sessions = await this.getAllSessions();
344
options.push(...sessions);
345
346
options.push({ type: 'separator', label: localize('others', "Others") });
347
348
for (const authenticationProvider of (await this.getAuthenticationProviders())) {
349
const signedInForProvider = sessions.some(account => account.session.providerId === authenticationProvider.id);
350
if (!signedInForProvider || this.authenticationService.getProvider(authenticationProvider.id).supportsMultipleAccounts) {
351
const providerName = this.authenticationService.getProvider(authenticationProvider.id).label;
352
options.push({ label: localize('sign in using account', "Sign in with {0}", providerName), provider: authenticationProvider });
353
}
354
}
355
356
return options;
357
}
358
359
/**
360
*
361
* Returns all authentication sessions available from {@link getAuthenticationProviders}.
362
*/
363
private async getAllSessions() {
364
const authenticationProviders = await this.getAuthenticationProviders();
365
const accounts = new Map<string, ExistingSession>();
366
let currentSession: ExistingSession | undefined;
367
368
for (const provider of authenticationProviders) {
369
const sessions = await this.authenticationService.getSessions(provider.id, provider.scopes);
370
371
for (const session of sessions) {
372
const item = {
373
label: session.account.label,
374
description: this.authenticationService.getProvider(provider.id).label,
375
session: { ...session, providerId: provider.id }
376
};
377
accounts.set(item.session.account.id, item);
378
if (this.existingSessionId === session.id) {
379
currentSession = item;
380
}
381
}
382
}
383
384
if (currentSession !== undefined) {
385
accounts.set(currentSession.session.account.id, currentSession);
386
}
387
388
return [...accounts.values()].sort((a, b) => a.label.localeCompare(b.label));
389
}
390
391
/**
392
*
393
* Returns all authentication providers which can be used to authenticate
394
* to the remote storage service, based on product.json configuration
395
* and registered authentication providers.
396
*/
397
private async getAuthenticationProviders() {
398
if (!this.serverConfiguration) {
399
throw new Error('Unable to get configured authentication providers as session sync preference is not configured in product.json.');
400
}
401
402
// Get the list of authentication providers configured in product.json
403
const authenticationProviders = this.serverConfiguration.authenticationProviders;
404
const configuredAuthenticationProviders = Object.keys(authenticationProviders).reduce<IAuthenticationProvider[]>((result, id) => {
405
result.push({ id, scopes: authenticationProviders[id].scopes });
406
return result;
407
}, []);
408
409
// Filter out anything that isn't currently available through the authenticationService
410
const availableAuthenticationProviders = this.authenticationService.declaredProviders;
411
412
return configuredAuthenticationProviders.filter(({ id }) => availableAuthenticationProviders.some(provider => provider.id === id));
413
}
414
415
private get existingSessionId() {
416
return this.storageService.get(EditSessionsWorkbenchService.CACHED_SESSION_STORAGE_KEY, StorageScope.APPLICATION);
417
}
418
419
private set existingSessionId(sessionId: string | undefined) {
420
this.logService.trace(`Saving authentication session preference for ID ${sessionId}.`);
421
if (sessionId === undefined) {
422
this.storageService.remove(EditSessionsWorkbenchService.CACHED_SESSION_STORAGE_KEY, StorageScope.APPLICATION);
423
} else {
424
this.storageService.store(EditSessionsWorkbenchService.CACHED_SESSION_STORAGE_KEY, sessionId, StorageScope.APPLICATION, StorageTarget.MACHINE);
425
}
426
}
427
428
private async getExistingSession() {
429
const accounts = await this.getAllSessions();
430
return accounts.find((account) => account.session.id === this.existingSessionId);
431
}
432
433
private async onDidChangeStorage(): Promise<void> {
434
const newSessionId = this.existingSessionId;
435
const previousSessionId = this.authenticationInfo?.sessionId;
436
437
if (previousSessionId !== newSessionId) {
438
this.logService.trace(`Resetting authentication state because authentication session ID preference changed from ${previousSessionId} to ${newSessionId}.`);
439
this.authenticationInfo = undefined;
440
this.initialized = false;
441
}
442
}
443
444
private clearAuthenticationPreference(): void {
445
this.authenticationInfo = undefined;
446
this.initialized = false;
447
this.existingSessionId = undefined;
448
this.signedInContext.set(false);
449
}
450
451
private onDidChangeSessions(e: AuthenticationSessionsChangeEvent): void {
452
if (this.authenticationInfo?.sessionId && e.removed?.find(session => session.id === this.authenticationInfo?.sessionId)) {
453
this.clearAuthenticationPreference();
454
}
455
}
456
457
private registerSignInAction() {
458
if (!this.serverConfiguration?.url) {
459
return;
460
}
461
const that = this;
462
const id = 'workbench.editSessions.actions.signIn';
463
const when = ContextKeyExpr.and(ContextKeyExpr.equals(EDIT_SESSIONS_PENDING_KEY, false), ContextKeyExpr.equals(EDIT_SESSIONS_SIGNED_IN_KEY, false));
464
this._register(registerAction2(class ResetEditSessionAuthenticationAction extends Action2 {
465
constructor() {
466
super({
467
id,
468
title: localize('sign in', 'Turn on Cloud Changes...'),
469
category: EDIT_SESSION_SYNC_CATEGORY,
470
precondition: when,
471
menu: [{
472
id: MenuId.CommandPalette,
473
},
474
{
475
id: MenuId.AccountsContext,
476
group: '2_editSessions',
477
when,
478
}]
479
});
480
}
481
482
async run() {
483
return await that.initialize('write', false);
484
}
485
}));
486
487
this._register(MenuRegistry.appendMenuItem(MenuId.AccountsContext, {
488
group: '2_editSessions',
489
command: {
490
id,
491
title: localize('sign in badge', 'Turn on Cloud Changes... (1)'),
492
},
493
when: ContextKeyExpr.and(ContextKeyExpr.equals(EDIT_SESSIONS_PENDING_KEY, true), ContextKeyExpr.equals(EDIT_SESSIONS_SIGNED_IN_KEY, false))
494
}));
495
}
496
497
private registerResetAuthenticationAction() {
498
const that = this;
499
this._register(registerAction2(class ResetEditSessionAuthenticationAction extends Action2 {
500
constructor() {
501
super({
502
id: 'workbench.editSessions.actions.resetAuth',
503
title: localize('reset auth.v3', 'Turn off Cloud Changes...'),
504
category: EDIT_SESSION_SYNC_CATEGORY,
505
precondition: ContextKeyExpr.equals(EDIT_SESSIONS_SIGNED_IN_KEY, true),
506
menu: [{
507
id: MenuId.CommandPalette,
508
},
509
{
510
id: MenuId.AccountsContext,
511
group: '2_editSessions',
512
when: ContextKeyExpr.equals(EDIT_SESSIONS_SIGNED_IN_KEY, true),
513
}]
514
});
515
}
516
517
async run() {
518
const result = await that.dialogService.confirm({
519
message: localize('sign out of cloud changes clear data prompt', 'Do you want to disable storing working changes in the cloud?'),
520
checkbox: { label: localize('delete all cloud changes', 'Delete all stored data from the cloud.') }
521
});
522
if (result.confirmed) {
523
if (result.checkboxChecked) {
524
that.storeClient?.deleteResource('editSessions', null);
525
}
526
that.clearAuthenticationPreference();
527
}
528
}
529
}));
530
}
531
}
532
533