Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/extensionManagement/common/extensionTipsService.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 { isNonEmptyArray } from '../../../base/common/arrays.js';
7
import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js';
8
import { IConfigBasedExtensionTip as IRawConfigBasedExtensionTip } from '../../../base/common/product.js';
9
import { joinPath } from '../../../base/common/resources.js';
10
import { URI } from '../../../base/common/uri.js';
11
import { IConfigBasedExtensionTip, IExecutableBasedExtensionTip, IExtensionManagementService, IExtensionTipsService, ILocalExtension } from './extensionManagement.js';
12
import { IFileService } from '../../files/common/files.js';
13
import { IProductService } from '../../product/common/productService.js';
14
import { disposableTimeout } from '../../../base/common/async.js';
15
import { IStringDictionary } from '../../../base/common/collections.js';
16
import { Event } from '../../../base/common/event.js';
17
import { join } from '../../../base/common/path.js';
18
import { isWindows } from '../../../base/common/platform.js';
19
import { env } from '../../../base/common/process.js';
20
import { areSameExtensions } from './extensionManagementUtil.js';
21
import { IExtensionRecommendationNotificationService, RecommendationsNotificationResult, RecommendationSource } from '../../extensionRecommendations/common/extensionRecommendations.js';
22
import { ExtensionType } from '../../extensions/common/extensions.js';
23
import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js';
24
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
25
26
//#region Base Extension Tips Service
27
28
export class ExtensionTipsService extends Disposable implements IExtensionTipsService {
29
30
_serviceBrand: any;
31
32
private readonly allConfigBasedTips: Map<string, IRawConfigBasedExtensionTip> = new Map<string, IRawConfigBasedExtensionTip>();
33
34
constructor(
35
@IFileService protected readonly fileService: IFileService,
36
@IProductService private readonly productService: IProductService,
37
) {
38
super();
39
if (this.productService.configBasedExtensionTips) {
40
Object.entries(this.productService.configBasedExtensionTips).forEach(([, value]) => this.allConfigBasedTips.set(value.configPath, value));
41
}
42
}
43
44
getConfigBasedTips(folder: URI): Promise<IConfigBasedExtensionTip[]> {
45
return this.getValidConfigBasedTips(folder);
46
}
47
48
async getImportantExecutableBasedTips(): Promise<IExecutableBasedExtensionTip[]> {
49
return [];
50
}
51
52
async getOtherExecutableBasedTips(): Promise<IExecutableBasedExtensionTip[]> {
53
return [];
54
}
55
56
private async getValidConfigBasedTips(folder: URI): Promise<IConfigBasedExtensionTip[]> {
57
const result: IConfigBasedExtensionTip[] = [];
58
for (const [configPath, tip] of this.allConfigBasedTips) {
59
if (tip.configScheme && tip.configScheme !== folder.scheme) {
60
continue;
61
}
62
try {
63
const content = (await this.fileService.readFile(joinPath(folder, configPath))).value.toString();
64
for (const [key, value] of Object.entries(tip.recommendations)) {
65
if (!value.contentPattern || new RegExp(value.contentPattern, 'mig').test(content)) {
66
result.push({
67
extensionId: key,
68
extensionName: value.name,
69
configName: tip.configName,
70
important: !!value.important,
71
isExtensionPack: !!value.isExtensionPack,
72
whenNotInstalled: value.whenNotInstalled
73
});
74
}
75
}
76
} catch (error) { /* Ignore */ }
77
}
78
return result;
79
}
80
}
81
82
//#endregion
83
84
//#region Native Extension Tips Service (enables unit testing having it here in "common")
85
86
type ExeExtensionRecommendationsClassification = {
87
owner: 'sandy081';
88
comment: 'Information about executable based extension recommendation';
89
extensionId: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'id of the recommended extension' };
90
exeName: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'name of the executable for which extension is being recommended' };
91
};
92
93
type IExeBasedExtensionTips = {
94
readonly exeFriendlyName: string;
95
readonly windowsPath?: string;
96
readonly recommendations: { extensionId: string; extensionName: string; isExtensionPack: boolean; whenNotInstalled?: string[] }[];
97
};
98
99
const promptedExecutableTipsStorageKey = 'extensionTips/promptedExecutableTips';
100
const lastPromptedMediumImpExeTimeStorageKey = 'extensionTips/lastPromptedMediumImpExeTime';
101
102
export abstract class AbstractNativeExtensionTipsService extends ExtensionTipsService {
103
104
private readonly highImportanceExecutableTips: Map<string, IExeBasedExtensionTips> = new Map<string, IExeBasedExtensionTips>();
105
private readonly mediumImportanceExecutableTips: Map<string, IExeBasedExtensionTips> = new Map<string, IExeBasedExtensionTips>();
106
private readonly allOtherExecutableTips: Map<string, IExeBasedExtensionTips> = new Map<string, IExeBasedExtensionTips>();
107
108
private highImportanceTipsByExe = new Map<string, IExecutableBasedExtensionTip[]>();
109
private mediumImportanceTipsByExe = new Map<string, IExecutableBasedExtensionTip[]>();
110
111
constructor(
112
private readonly userHome: URI,
113
private readonly windowEvents: {
114
readonly onDidOpenMainWindow: Event<unknown>;
115
readonly onDidFocusMainWindow: Event<unknown>;
116
},
117
private readonly telemetryService: ITelemetryService,
118
private readonly extensionManagementService: IExtensionManagementService,
119
private readonly storageService: IStorageService,
120
private readonly extensionRecommendationNotificationService: IExtensionRecommendationNotificationService,
121
fileService: IFileService,
122
productService: IProductService
123
) {
124
super(fileService, productService);
125
if (productService.exeBasedExtensionTips) {
126
Object.entries(productService.exeBasedExtensionTips).forEach(([key, exeBasedExtensionTip]) => {
127
const highImportanceRecommendations: { extensionId: string; extensionName: string; isExtensionPack: boolean }[] = [];
128
const mediumImportanceRecommendations: { extensionId: string; extensionName: string; isExtensionPack: boolean }[] = [];
129
const otherRecommendations: { extensionId: string; extensionName: string; isExtensionPack: boolean }[] = [];
130
Object.entries(exeBasedExtensionTip.recommendations).forEach(([extensionId, value]) => {
131
if (value.important) {
132
if (exeBasedExtensionTip.important) {
133
highImportanceRecommendations.push({ extensionId, extensionName: value.name, isExtensionPack: !!value.isExtensionPack });
134
} else {
135
mediumImportanceRecommendations.push({ extensionId, extensionName: value.name, isExtensionPack: !!value.isExtensionPack });
136
}
137
} else {
138
otherRecommendations.push({ extensionId, extensionName: value.name, isExtensionPack: !!value.isExtensionPack });
139
}
140
});
141
if (highImportanceRecommendations.length) {
142
this.highImportanceExecutableTips.set(key, { exeFriendlyName: exeBasedExtensionTip.friendlyName, windowsPath: exeBasedExtensionTip.windowsPath, recommendations: highImportanceRecommendations });
143
}
144
if (mediumImportanceRecommendations.length) {
145
this.mediumImportanceExecutableTips.set(key, { exeFriendlyName: exeBasedExtensionTip.friendlyName, windowsPath: exeBasedExtensionTip.windowsPath, recommendations: mediumImportanceRecommendations });
146
}
147
if (otherRecommendations.length) {
148
this.allOtherExecutableTips.set(key, { exeFriendlyName: exeBasedExtensionTip.friendlyName, windowsPath: exeBasedExtensionTip.windowsPath, recommendations: otherRecommendations });
149
}
150
});
151
}
152
153
/*
154
3s has come out to be the good number to fetch and prompt important exe based recommendations
155
Also fetch important exe based recommendations for reporting telemetry
156
*/
157
disposableTimeout(async () => {
158
await this.collectTips();
159
this.promptHighImportanceExeBasedTip();
160
this.promptMediumImportanceExeBasedTip();
161
}, 3000, this._store);
162
}
163
164
override async getImportantExecutableBasedTips(): Promise<IExecutableBasedExtensionTip[]> {
165
const highImportanceExeTips = await this.getValidExecutableBasedExtensionTips(this.highImportanceExecutableTips);
166
const mediumImportanceExeTips = await this.getValidExecutableBasedExtensionTips(this.mediumImportanceExecutableTips);
167
return [...highImportanceExeTips, ...mediumImportanceExeTips];
168
}
169
170
override getOtherExecutableBasedTips(): Promise<IExecutableBasedExtensionTip[]> {
171
return this.getValidExecutableBasedExtensionTips(this.allOtherExecutableTips);
172
}
173
174
private async collectTips(): Promise<void> {
175
const highImportanceExeTips = await this.getValidExecutableBasedExtensionTips(this.highImportanceExecutableTips);
176
const mediumImportanceExeTips = await this.getValidExecutableBasedExtensionTips(this.mediumImportanceExecutableTips);
177
const local = await this.extensionManagementService.getInstalled();
178
179
this.highImportanceTipsByExe = this.groupImportantTipsByExe(highImportanceExeTips, local);
180
this.mediumImportanceTipsByExe = this.groupImportantTipsByExe(mediumImportanceExeTips, local);
181
}
182
183
private groupImportantTipsByExe(importantExeBasedTips: IExecutableBasedExtensionTip[], local: ILocalExtension[]): Map<string, IExecutableBasedExtensionTip[]> {
184
const importantExeBasedRecommendations = new Map<string, IExecutableBasedExtensionTip>();
185
importantExeBasedTips.forEach(tip => importantExeBasedRecommendations.set(tip.extensionId.toLowerCase(), tip));
186
187
const { installed, uninstalled: recommendations } = this.groupByInstalled([...importantExeBasedRecommendations.keys()], local);
188
189
/* Log installed and uninstalled exe based recommendations */
190
for (const extensionId of installed) {
191
const tip = importantExeBasedRecommendations.get(extensionId);
192
if (tip) {
193
this.telemetryService.publicLog2<{ exeName: string; extensionId: string }, ExeExtensionRecommendationsClassification>('exeExtensionRecommendations:alreadyInstalled', { extensionId, exeName: tip.exeName });
194
}
195
}
196
for (const extensionId of recommendations) {
197
const tip = importantExeBasedRecommendations.get(extensionId);
198
if (tip) {
199
this.telemetryService.publicLog2<{ exeName: string; extensionId: string }, ExeExtensionRecommendationsClassification>('exeExtensionRecommendations:notInstalled', { extensionId, exeName: tip.exeName });
200
}
201
}
202
203
const promptedExecutableTips = this.getPromptedExecutableTips();
204
const tipsByExe = new Map<string, IExecutableBasedExtensionTip[]>();
205
for (const extensionId of recommendations) {
206
const tip = importantExeBasedRecommendations.get(extensionId);
207
if (tip && (!promptedExecutableTips[tip.exeName] || !promptedExecutableTips[tip.exeName].includes(tip.extensionId))) {
208
let tips = tipsByExe.get(tip.exeName);
209
if (!tips) {
210
tips = [];
211
tipsByExe.set(tip.exeName, tips);
212
}
213
tips.push(tip);
214
}
215
}
216
217
return tipsByExe;
218
}
219
220
/**
221
* High importance tips are prompted once per restart session
222
*/
223
private promptHighImportanceExeBasedTip(): void {
224
if (this.highImportanceTipsByExe.size === 0) {
225
return;
226
}
227
228
const [exeName, tips] = [...this.highImportanceTipsByExe.entries()][0];
229
this.promptExeRecommendations(tips)
230
.then(result => {
231
switch (result) {
232
case RecommendationsNotificationResult.Accepted:
233
this.addToRecommendedExecutables(tips[0].exeName, tips);
234
break;
235
case RecommendationsNotificationResult.Ignored:
236
this.highImportanceTipsByExe.delete(exeName);
237
break;
238
case RecommendationsNotificationResult.IncompatibleWindow: {
239
// Recommended in incompatible window. Schedule the prompt after active window change
240
const onActiveWindowChange = Event.once(Event.latch(Event.any(this.windowEvents.onDidOpenMainWindow, this.windowEvents.onDidFocusMainWindow)));
241
this._register(onActiveWindowChange(() => this.promptHighImportanceExeBasedTip()));
242
break;
243
}
244
case RecommendationsNotificationResult.TooMany: {
245
// Too many notifications. Schedule the prompt after one hour
246
const disposable = this._register(new MutableDisposable());
247
disposable.value = disposableTimeout(() => { disposable.dispose(); this.promptHighImportanceExeBasedTip(); }, 60 * 60 * 1000 /* 1 hour */);
248
break;
249
}
250
}
251
});
252
}
253
254
/**
255
* Medium importance tips are prompted once per 7 days
256
*/
257
private promptMediumImportanceExeBasedTip(): void {
258
if (this.mediumImportanceTipsByExe.size === 0) {
259
return;
260
}
261
262
const lastPromptedMediumExeTime = this.getLastPromptedMediumExeTime();
263
const timeSinceLastPrompt = Date.now() - lastPromptedMediumExeTime;
264
const promptInterval = 7 * 24 * 60 * 60 * 1000; // 7 Days
265
if (timeSinceLastPrompt < promptInterval) {
266
// Wait until interval and prompt
267
const disposable = this._register(new MutableDisposable());
268
disposable.value = disposableTimeout(() => { disposable.dispose(); this.promptMediumImportanceExeBasedTip(); }, promptInterval - timeSinceLastPrompt);
269
return;
270
}
271
272
const [exeName, tips] = [...this.mediumImportanceTipsByExe.entries()][0];
273
this.promptExeRecommendations(tips)
274
.then(result => {
275
switch (result) {
276
case RecommendationsNotificationResult.Accepted: {
277
// Accepted: Update the last prompted time and caches.
278
this.updateLastPromptedMediumExeTime(Date.now());
279
this.mediumImportanceTipsByExe.delete(exeName);
280
this.addToRecommendedExecutables(tips[0].exeName, tips);
281
282
// Schedule the next recommendation for next internval
283
const disposable1 = this._register(new MutableDisposable());
284
disposable1.value = disposableTimeout(() => { disposable1.dispose(); this.promptMediumImportanceExeBasedTip(); }, promptInterval);
285
break;
286
}
287
case RecommendationsNotificationResult.Ignored:
288
// Ignored: Remove from the cache and prompt next recommendation
289
this.mediumImportanceTipsByExe.delete(exeName);
290
this.promptMediumImportanceExeBasedTip();
291
break;
292
293
case RecommendationsNotificationResult.IncompatibleWindow: {
294
// Recommended in incompatible window. Schedule the prompt after active window change
295
const onActiveWindowChange = Event.once(Event.latch(Event.any(this.windowEvents.onDidOpenMainWindow, this.windowEvents.onDidFocusMainWindow)));
296
this._register(onActiveWindowChange(() => this.promptMediumImportanceExeBasedTip()));
297
break;
298
}
299
case RecommendationsNotificationResult.TooMany: {
300
// Too many notifications. Schedule the prompt after one hour
301
const disposable2 = this._register(new MutableDisposable());
302
disposable2.value = disposableTimeout(() => { disposable2.dispose(); this.promptMediumImportanceExeBasedTip(); }, 60 * 60 * 1000 /* 1 hour */);
303
break;
304
}
305
}
306
});
307
}
308
309
private async promptExeRecommendations(tips: IExecutableBasedExtensionTip[]): Promise<RecommendationsNotificationResult> {
310
const installed = await this.extensionManagementService.getInstalled(ExtensionType.User);
311
const extensions = tips
312
.filter(tip => !tip.whenNotInstalled || tip.whenNotInstalled.every(id => installed.every(local => !areSameExtensions(local.identifier, { id }))))
313
.map(({ extensionId }) => extensionId.toLowerCase());
314
return this.extensionRecommendationNotificationService.promptImportantExtensionsInstallNotification({ extensions, source: RecommendationSource.EXE, name: tips[0].exeFriendlyName, searchValue: `@exe:"${tips[0].exeName}"` });
315
}
316
317
private getLastPromptedMediumExeTime(): number {
318
let value = this.storageService.getNumber(lastPromptedMediumImpExeTimeStorageKey, StorageScope.APPLICATION);
319
if (!value) {
320
value = Date.now();
321
this.updateLastPromptedMediumExeTime(value);
322
}
323
return value;
324
}
325
326
private updateLastPromptedMediumExeTime(value: number): void {
327
this.storageService.store(lastPromptedMediumImpExeTimeStorageKey, value, StorageScope.APPLICATION, StorageTarget.MACHINE);
328
}
329
330
private getPromptedExecutableTips(): IStringDictionary<string[]> {
331
return JSON.parse(this.storageService.get(promptedExecutableTipsStorageKey, StorageScope.APPLICATION, '{}'));
332
}
333
334
private addToRecommendedExecutables(exeName: string, tips: IExecutableBasedExtensionTip[]) {
335
const promptedExecutableTips = this.getPromptedExecutableTips();
336
promptedExecutableTips[exeName] = tips.map(({ extensionId }) => extensionId.toLowerCase());
337
this.storageService.store(promptedExecutableTipsStorageKey, JSON.stringify(promptedExecutableTips), StorageScope.APPLICATION, StorageTarget.USER);
338
}
339
340
private groupByInstalled(recommendationsToSuggest: string[], local: ILocalExtension[]): { installed: string[]; uninstalled: string[] } {
341
const installed: string[] = [], uninstalled: string[] = [];
342
const installedExtensionsIds = local.reduce((result, i) => { result.add(i.identifier.id.toLowerCase()); return result; }, new Set<string>());
343
recommendationsToSuggest.forEach(id => {
344
if (installedExtensionsIds.has(id.toLowerCase())) {
345
installed.push(id);
346
} else {
347
uninstalled.push(id);
348
}
349
});
350
return { installed, uninstalled };
351
}
352
353
private async getValidExecutableBasedExtensionTips(executableTips: Map<string, IExeBasedExtensionTips>): Promise<IExecutableBasedExtensionTip[]> {
354
const result: IExecutableBasedExtensionTip[] = [];
355
356
const checkedExecutables: Map<string, boolean> = new Map<string, boolean>();
357
for (const exeName of executableTips.keys()) {
358
const extensionTip = executableTips.get(exeName);
359
if (!extensionTip || !isNonEmptyArray(extensionTip.recommendations)) {
360
continue;
361
}
362
363
const exePaths: string[] = [];
364
if (isWindows) {
365
if (extensionTip.windowsPath) {
366
exePaths.push(extensionTip.windowsPath.replace('%USERPROFILE%', () => env['USERPROFILE']!)
367
.replace('%ProgramFiles(x86)%', () => env['ProgramFiles(x86)']!)
368
.replace('%ProgramFiles%', () => env['ProgramFiles']!)
369
.replace('%APPDATA%', () => env['APPDATA']!)
370
.replace('%WINDIR%', () => env['WINDIR']!));
371
}
372
} else {
373
exePaths.push(join('/usr/local/bin', exeName));
374
exePaths.push(join('/usr/bin', exeName));
375
exePaths.push(join(this.userHome.fsPath, exeName));
376
}
377
378
for (const exePath of exePaths) {
379
let exists = checkedExecutables.get(exePath);
380
if (exists === undefined) {
381
exists = await this.fileService.exists(URI.file(exePath));
382
checkedExecutables.set(exePath, exists);
383
}
384
if (exists) {
385
for (const { extensionId, extensionName, isExtensionPack, whenNotInstalled } of extensionTip.recommendations) {
386
result.push({
387
extensionId,
388
extensionName,
389
isExtensionPack,
390
exeName,
391
exeFriendlyName: extensionTip.exeFriendlyName,
392
windowsPath: extensionTip.windowsPath,
393
whenNotInstalled: whenNotInstalled
394
});
395
}
396
}
397
}
398
}
399
400
return result;
401
}
402
}
403
404
//#endregion
405
406