Path: blob/main/src/vs/platform/extensionManagement/common/extensionTipsService.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 { isNonEmptyArray } from '../../../base/common/arrays.js';6import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js';7import { IConfigBasedExtensionTip as IRawConfigBasedExtensionTip } from '../../../base/common/product.js';8import { joinPath } from '../../../base/common/resources.js';9import { URI } from '../../../base/common/uri.js';10import { IConfigBasedExtensionTip, IExecutableBasedExtensionTip, IExtensionManagementService, IExtensionTipsService, ILocalExtension } from './extensionManagement.js';11import { IFileService } from '../../files/common/files.js';12import { IProductService } from '../../product/common/productService.js';13import { disposableTimeout } from '../../../base/common/async.js';14import { IStringDictionary } from '../../../base/common/collections.js';15import { Event } from '../../../base/common/event.js';16import { join } from '../../../base/common/path.js';17import { isWindows } from '../../../base/common/platform.js';18import { env } from '../../../base/common/process.js';19import { areSameExtensions } from './extensionManagementUtil.js';20import { IExtensionRecommendationNotificationService, RecommendationsNotificationResult, RecommendationSource } from '../../extensionRecommendations/common/extensionRecommendations.js';21import { ExtensionType } from '../../extensions/common/extensions.js';22import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js';23import { ITelemetryService } from '../../telemetry/common/telemetry.js';2425//#region Base Extension Tips Service2627export class ExtensionTipsService extends Disposable implements IExtensionTipsService {2829_serviceBrand: any;3031private readonly allConfigBasedTips: Map<string, IRawConfigBasedExtensionTip> = new Map<string, IRawConfigBasedExtensionTip>();3233constructor(34@IFileService protected readonly fileService: IFileService,35@IProductService private readonly productService: IProductService,36) {37super();38if (this.productService.configBasedExtensionTips) {39Object.entries(this.productService.configBasedExtensionTips).forEach(([, value]) => this.allConfigBasedTips.set(value.configPath, value));40}41}4243getConfigBasedTips(folder: URI): Promise<IConfigBasedExtensionTip[]> {44return this.getValidConfigBasedTips(folder);45}4647async getImportantExecutableBasedTips(): Promise<IExecutableBasedExtensionTip[]> {48return [];49}5051async getOtherExecutableBasedTips(): Promise<IExecutableBasedExtensionTip[]> {52return [];53}5455private async getValidConfigBasedTips(folder: URI): Promise<IConfigBasedExtensionTip[]> {56const result: IConfigBasedExtensionTip[] = [];57for (const [configPath, tip] of this.allConfigBasedTips) {58if (tip.configScheme && tip.configScheme !== folder.scheme) {59continue;60}61try {62const content = (await this.fileService.readFile(joinPath(folder, configPath))).value.toString();63for (const [key, value] of Object.entries(tip.recommendations)) {64if (!value.contentPattern || new RegExp(value.contentPattern, 'mig').test(content)) {65result.push({66extensionId: key,67extensionName: value.name,68configName: tip.configName,69important: !!value.important,70isExtensionPack: !!value.isExtensionPack,71whenNotInstalled: value.whenNotInstalled72});73}74}75} catch (error) { /* Ignore */ }76}77return result;78}79}8081//#endregion8283//#region Native Extension Tips Service (enables unit testing having it here in "common")8485type ExeExtensionRecommendationsClassification = {86owner: 'sandy081';87comment: 'Information about executable based extension recommendation';88extensionId: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'id of the recommended extension' };89exeName: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'name of the executable for which extension is being recommended' };90};9192type IExeBasedExtensionTips = {93readonly exeFriendlyName: string;94readonly windowsPath?: string;95readonly recommendations: { extensionId: string; extensionName: string; isExtensionPack: boolean; whenNotInstalled?: string[] }[];96};9798const promptedExecutableTipsStorageKey = 'extensionTips/promptedExecutableTips';99const lastPromptedMediumImpExeTimeStorageKey = 'extensionTips/lastPromptedMediumImpExeTime';100101export abstract class AbstractNativeExtensionTipsService extends ExtensionTipsService {102103private readonly highImportanceExecutableTips: Map<string, IExeBasedExtensionTips> = new Map<string, IExeBasedExtensionTips>();104private readonly mediumImportanceExecutableTips: Map<string, IExeBasedExtensionTips> = new Map<string, IExeBasedExtensionTips>();105private readonly allOtherExecutableTips: Map<string, IExeBasedExtensionTips> = new Map<string, IExeBasedExtensionTips>();106107private highImportanceTipsByExe = new Map<string, IExecutableBasedExtensionTip[]>();108private mediumImportanceTipsByExe = new Map<string, IExecutableBasedExtensionTip[]>();109110constructor(111private readonly userHome: URI,112private readonly windowEvents: {113readonly onDidOpenMainWindow: Event<unknown>;114readonly onDidFocusMainWindow: Event<unknown>;115},116private readonly telemetryService: ITelemetryService,117private readonly extensionManagementService: IExtensionManagementService,118private readonly storageService: IStorageService,119private readonly extensionRecommendationNotificationService: IExtensionRecommendationNotificationService,120fileService: IFileService,121productService: IProductService122) {123super(fileService, productService);124if (productService.exeBasedExtensionTips) {125Object.entries(productService.exeBasedExtensionTips).forEach(([key, exeBasedExtensionTip]) => {126const highImportanceRecommendations: { extensionId: string; extensionName: string; isExtensionPack: boolean }[] = [];127const mediumImportanceRecommendations: { extensionId: string; extensionName: string; isExtensionPack: boolean }[] = [];128const otherRecommendations: { extensionId: string; extensionName: string; isExtensionPack: boolean }[] = [];129Object.entries(exeBasedExtensionTip.recommendations).forEach(([extensionId, value]) => {130if (value.important) {131if (exeBasedExtensionTip.important) {132highImportanceRecommendations.push({ extensionId, extensionName: value.name, isExtensionPack: !!value.isExtensionPack });133} else {134mediumImportanceRecommendations.push({ extensionId, extensionName: value.name, isExtensionPack: !!value.isExtensionPack });135}136} else {137otherRecommendations.push({ extensionId, extensionName: value.name, isExtensionPack: !!value.isExtensionPack });138}139});140if (highImportanceRecommendations.length) {141this.highImportanceExecutableTips.set(key, { exeFriendlyName: exeBasedExtensionTip.friendlyName, windowsPath: exeBasedExtensionTip.windowsPath, recommendations: highImportanceRecommendations });142}143if (mediumImportanceRecommendations.length) {144this.mediumImportanceExecutableTips.set(key, { exeFriendlyName: exeBasedExtensionTip.friendlyName, windowsPath: exeBasedExtensionTip.windowsPath, recommendations: mediumImportanceRecommendations });145}146if (otherRecommendations.length) {147this.allOtherExecutableTips.set(key, { exeFriendlyName: exeBasedExtensionTip.friendlyName, windowsPath: exeBasedExtensionTip.windowsPath, recommendations: otherRecommendations });148}149});150}151152/*1533s has come out to be the good number to fetch and prompt important exe based recommendations154Also fetch important exe based recommendations for reporting telemetry155*/156disposableTimeout(async () => {157await this.collectTips();158this.promptHighImportanceExeBasedTip();159this.promptMediumImportanceExeBasedTip();160}, 3000, this._store);161}162163override async getImportantExecutableBasedTips(): Promise<IExecutableBasedExtensionTip[]> {164const highImportanceExeTips = await this.getValidExecutableBasedExtensionTips(this.highImportanceExecutableTips);165const mediumImportanceExeTips = await this.getValidExecutableBasedExtensionTips(this.mediumImportanceExecutableTips);166return [...highImportanceExeTips, ...mediumImportanceExeTips];167}168169override getOtherExecutableBasedTips(): Promise<IExecutableBasedExtensionTip[]> {170return this.getValidExecutableBasedExtensionTips(this.allOtherExecutableTips);171}172173private async collectTips(): Promise<void> {174const highImportanceExeTips = await this.getValidExecutableBasedExtensionTips(this.highImportanceExecutableTips);175const mediumImportanceExeTips = await this.getValidExecutableBasedExtensionTips(this.mediumImportanceExecutableTips);176const local = await this.extensionManagementService.getInstalled();177178this.highImportanceTipsByExe = this.groupImportantTipsByExe(highImportanceExeTips, local);179this.mediumImportanceTipsByExe = this.groupImportantTipsByExe(mediumImportanceExeTips, local);180}181182private groupImportantTipsByExe(importantExeBasedTips: IExecutableBasedExtensionTip[], local: ILocalExtension[]): Map<string, IExecutableBasedExtensionTip[]> {183const importantExeBasedRecommendations = new Map<string, IExecutableBasedExtensionTip>();184importantExeBasedTips.forEach(tip => importantExeBasedRecommendations.set(tip.extensionId.toLowerCase(), tip));185186const { installed, uninstalled: recommendations } = this.groupByInstalled([...importantExeBasedRecommendations.keys()], local);187188/* Log installed and uninstalled exe based recommendations */189for (const extensionId of installed) {190const tip = importantExeBasedRecommendations.get(extensionId);191if (tip) {192this.telemetryService.publicLog2<{ exeName: string; extensionId: string }, ExeExtensionRecommendationsClassification>('exeExtensionRecommendations:alreadyInstalled', { extensionId, exeName: tip.exeName });193}194}195for (const extensionId of recommendations) {196const tip = importantExeBasedRecommendations.get(extensionId);197if (tip) {198this.telemetryService.publicLog2<{ exeName: string; extensionId: string }, ExeExtensionRecommendationsClassification>('exeExtensionRecommendations:notInstalled', { extensionId, exeName: tip.exeName });199}200}201202const promptedExecutableTips = this.getPromptedExecutableTips();203const tipsByExe = new Map<string, IExecutableBasedExtensionTip[]>();204for (const extensionId of recommendations) {205const tip = importantExeBasedRecommendations.get(extensionId);206if (tip && (!promptedExecutableTips[tip.exeName] || !promptedExecutableTips[tip.exeName].includes(tip.extensionId))) {207let tips = tipsByExe.get(tip.exeName);208if (!tips) {209tips = [];210tipsByExe.set(tip.exeName, tips);211}212tips.push(tip);213}214}215216return tipsByExe;217}218219/**220* High importance tips are prompted once per restart session221*/222private promptHighImportanceExeBasedTip(): void {223if (this.highImportanceTipsByExe.size === 0) {224return;225}226227const [exeName, tips] = [...this.highImportanceTipsByExe.entries()][0];228this.promptExeRecommendations(tips)229.then(result => {230switch (result) {231case RecommendationsNotificationResult.Accepted:232this.addToRecommendedExecutables(tips[0].exeName, tips);233break;234case RecommendationsNotificationResult.Ignored:235this.highImportanceTipsByExe.delete(exeName);236break;237case RecommendationsNotificationResult.IncompatibleWindow: {238// Recommended in incompatible window. Schedule the prompt after active window change239const onActiveWindowChange = Event.once(Event.latch(Event.any(this.windowEvents.onDidOpenMainWindow, this.windowEvents.onDidFocusMainWindow)));240this._register(onActiveWindowChange(() => this.promptHighImportanceExeBasedTip()));241break;242}243case RecommendationsNotificationResult.TooMany: {244// Too many notifications. Schedule the prompt after one hour245const disposable = this._register(new MutableDisposable());246disposable.value = disposableTimeout(() => { disposable.dispose(); this.promptHighImportanceExeBasedTip(); }, 60 * 60 * 1000 /* 1 hour */);247break;248}249}250});251}252253/**254* Medium importance tips are prompted once per 7 days255*/256private promptMediumImportanceExeBasedTip(): void {257if (this.mediumImportanceTipsByExe.size === 0) {258return;259}260261const lastPromptedMediumExeTime = this.getLastPromptedMediumExeTime();262const timeSinceLastPrompt = Date.now() - lastPromptedMediumExeTime;263const promptInterval = 7 * 24 * 60 * 60 * 1000; // 7 Days264if (timeSinceLastPrompt < promptInterval) {265// Wait until interval and prompt266const disposable = this._register(new MutableDisposable());267disposable.value = disposableTimeout(() => { disposable.dispose(); this.promptMediumImportanceExeBasedTip(); }, promptInterval - timeSinceLastPrompt);268return;269}270271const [exeName, tips] = [...this.mediumImportanceTipsByExe.entries()][0];272this.promptExeRecommendations(tips)273.then(result => {274switch (result) {275case RecommendationsNotificationResult.Accepted: {276// Accepted: Update the last prompted time and caches.277this.updateLastPromptedMediumExeTime(Date.now());278this.mediumImportanceTipsByExe.delete(exeName);279this.addToRecommendedExecutables(tips[0].exeName, tips);280281// Schedule the next recommendation for next internval282const disposable1 = this._register(new MutableDisposable());283disposable1.value = disposableTimeout(() => { disposable1.dispose(); this.promptMediumImportanceExeBasedTip(); }, promptInterval);284break;285}286case RecommendationsNotificationResult.Ignored:287// Ignored: Remove from the cache and prompt next recommendation288this.mediumImportanceTipsByExe.delete(exeName);289this.promptMediumImportanceExeBasedTip();290break;291292case RecommendationsNotificationResult.IncompatibleWindow: {293// Recommended in incompatible window. Schedule the prompt after active window change294const onActiveWindowChange = Event.once(Event.latch(Event.any(this.windowEvents.onDidOpenMainWindow, this.windowEvents.onDidFocusMainWindow)));295this._register(onActiveWindowChange(() => this.promptMediumImportanceExeBasedTip()));296break;297}298case RecommendationsNotificationResult.TooMany: {299// Too many notifications. Schedule the prompt after one hour300const disposable2 = this._register(new MutableDisposable());301disposable2.value = disposableTimeout(() => { disposable2.dispose(); this.promptMediumImportanceExeBasedTip(); }, 60 * 60 * 1000 /* 1 hour */);302break;303}304}305});306}307308private async promptExeRecommendations(tips: IExecutableBasedExtensionTip[]): Promise<RecommendationsNotificationResult> {309const installed = await this.extensionManagementService.getInstalled(ExtensionType.User);310const extensions = tips311.filter(tip => !tip.whenNotInstalled || tip.whenNotInstalled.every(id => installed.every(local => !areSameExtensions(local.identifier, { id }))))312.map(({ extensionId }) => extensionId.toLowerCase());313return this.extensionRecommendationNotificationService.promptImportantExtensionsInstallNotification({ extensions, source: RecommendationSource.EXE, name: tips[0].exeFriendlyName, searchValue: `@exe:"${tips[0].exeName}"` });314}315316private getLastPromptedMediumExeTime(): number {317let value = this.storageService.getNumber(lastPromptedMediumImpExeTimeStorageKey, StorageScope.APPLICATION);318if (!value) {319value = Date.now();320this.updateLastPromptedMediumExeTime(value);321}322return value;323}324325private updateLastPromptedMediumExeTime(value: number): void {326this.storageService.store(lastPromptedMediumImpExeTimeStorageKey, value, StorageScope.APPLICATION, StorageTarget.MACHINE);327}328329private getPromptedExecutableTips(): IStringDictionary<string[]> {330return JSON.parse(this.storageService.get(promptedExecutableTipsStorageKey, StorageScope.APPLICATION, '{}'));331}332333private addToRecommendedExecutables(exeName: string, tips: IExecutableBasedExtensionTip[]) {334const promptedExecutableTips = this.getPromptedExecutableTips();335promptedExecutableTips[exeName] = tips.map(({ extensionId }) => extensionId.toLowerCase());336this.storageService.store(promptedExecutableTipsStorageKey, JSON.stringify(promptedExecutableTips), StorageScope.APPLICATION, StorageTarget.USER);337}338339private groupByInstalled(recommendationsToSuggest: string[], local: ILocalExtension[]): { installed: string[]; uninstalled: string[] } {340const installed: string[] = [], uninstalled: string[] = [];341const installedExtensionsIds = local.reduce((result, i) => { result.add(i.identifier.id.toLowerCase()); return result; }, new Set<string>());342recommendationsToSuggest.forEach(id => {343if (installedExtensionsIds.has(id.toLowerCase())) {344installed.push(id);345} else {346uninstalled.push(id);347}348});349return { installed, uninstalled };350}351352private async getValidExecutableBasedExtensionTips(executableTips: Map<string, IExeBasedExtensionTips>): Promise<IExecutableBasedExtensionTip[]> {353const result: IExecutableBasedExtensionTip[] = [];354355const checkedExecutables: Map<string, boolean> = new Map<string, boolean>();356for (const exeName of executableTips.keys()) {357const extensionTip = executableTips.get(exeName);358if (!extensionTip || !isNonEmptyArray(extensionTip.recommendations)) {359continue;360}361362const exePaths: string[] = [];363if (isWindows) {364if (extensionTip.windowsPath) {365exePaths.push(extensionTip.windowsPath.replace('%USERPROFILE%', () => env['USERPROFILE']!)366.replace('%ProgramFiles(x86)%', () => env['ProgramFiles(x86)']!)367.replace('%ProgramFiles%', () => env['ProgramFiles']!)368.replace('%APPDATA%', () => env['APPDATA']!)369.replace('%WINDIR%', () => env['WINDIR']!));370}371} else {372exePaths.push(join('/usr/local/bin', exeName));373exePaths.push(join('/usr/bin', exeName));374exePaths.push(join(this.userHome.fsPath, exeName));375}376377for (const exePath of exePaths) {378let exists = checkedExecutables.get(exePath);379if (exists === undefined) {380exists = await this.fileService.exists(URI.file(exePath));381checkedExecutables.set(exePath, exists);382}383if (exists) {384for (const { extensionId, extensionName, isExtensionPack, whenNotInstalled } of extensionTip.recommendations) {385result.push({386extensionId,387extensionName,388isExtensionPack,389exeName,390exeFriendlyName: extensionTip.exeFriendlyName,391windowsPath: extensionTip.windowsPath,392whenNotInstalled: whenNotInstalled393});394}395}396}397}398399return result;400}401}402403//#endregion404405406