@theia/workspace
Version:
Theia - Workspace Extension
572 lines (493 loc) • 23.3 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2021 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ConfirmDialog, Dialog, StorageService } from '@theia/core/lib/browser';
import { MarkdownString, MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string';
import { StatusBar, StatusBarAlignment } from '@theia/core/lib/browser/status-bar/status-bar';
import { OS, ContributionProvider, DisposableCollection } from '@theia/core';
import { Emitter, Event } from '@theia/core/lib/common';
import URI from '@theia/core/lib/common/uri';
import { PreferenceChange, PreferenceSchemaService, PreferenceScope, PreferenceService } from '@theia/core/lib/common/preferences';
import { MessageService } from '@theia/core/lib/common/message-service';
import { nls } from '@theia/core/lib/common/nls';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { inject, injectable, named, postConstruct, preDestroy } from '@theia/core/shared/inversify';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import {
WorkspaceTrustPreferences, WORKSPACE_TRUST_EMPTY_WINDOW, WORKSPACE_TRUST_ENABLED, WORKSPACE_TRUST_STARTUP_PROMPT, WORKSPACE_TRUST_TRUSTED_FOLDERS, WorkspaceTrustPrompt
} from '../common/workspace-trust-preferences';
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
import { WorkspaceService } from './workspace-service';
import { WorkspaceCommands } from './workspace-commands';
import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
import { WorkspaceTrustDialog } from './workspace-trust-dialog';
import { UntitledWorkspaceService } from '../common/untitled-workspace-service';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
const STORAGE_TRUSTED = 'trusted';
export const WORKSPACE_TRUST_STATUS_BAR_ID = 'workspace-trust-status';
/**
* Contribution interface for features that are restricted in untrusted workspaces.
* Implementations can provide information about what is being restricted.
*/
export const WorkspaceRestrictionContribution = Symbol('WorkspaceRestrictionContribution');
export interface WorkspaceRestrictionContribution {
/**
* Returns the restrictions currently active due to workspace trust.
* Called when building the restricted mode status bar tooltip.
*/
getRestrictions(): WorkspaceRestriction[];
/**
* Returns whether a window reload is required when workspace trust changes to `newTrust`.
* Called before reloading on trust change to avoid unnecessary reloads when no
* trust-restricted items are actually affected.
* If not implemented, the contribution is assumed not to require a reload.
*/
requiresReloadOnTrustChange?(newTrust: boolean): boolean;
}
export interface WorkspaceRestriction {
/** Display name of the feature being restricted */
label: string;
/** Optional details (e.g., list of blocked items) */
details?: string[];
}
export class WorkspaceTrustService {
protected readonly workspaceService: WorkspaceService;
protected readonly preferences: PreferenceService;
protected readonly storage: StorageService;
protected readonly messageService: MessageService;
protected readonly workspaceTrustPref: WorkspaceTrustPreferences;
protected readonly preferenceSchemaService: PreferenceSchemaService;
protected readonly windowService: WindowService;
protected readonly contextKeyService: ContextKeyService;
protected readonly statusBar: StatusBar;
protected readonly restrictionContributions: ContributionProvider<WorkspaceRestrictionContribution>;
protected readonly untitledWorkspaceService: UntitledWorkspaceService;
protected readonly envVariablesServer: EnvVariablesServer;
protected workspaceTrust = new Deferred<boolean>();
protected currentTrust: boolean | undefined;
protected pendingTrustDialog: Deferred<boolean> | undefined;
protected pendingTrustRequest: Deferred<boolean | undefined> | undefined;
protected readonly onDidChangeWorkspaceTrustEmitter = new Emitter<boolean>();
readonly onDidChangeWorkspaceTrust: Event<boolean> = this.onDidChangeWorkspaceTrustEmitter.event;
protected readonly toDispose = new DisposableCollection(this.onDidChangeWorkspaceTrustEmitter);
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
await this.workspaceService.ready;
await this.workspaceTrustPref.ready;
await this.preferenceSchemaService.ready;
await this.resolveWorkspaceTrust();
this.toDispose.push(
this.preferences.onPreferenceChanged(change => this.handlePreferenceChange(change))
);
this.toDispose.push(
this.workspaceService.onWorkspaceChanged(() => this.handleWorkspaceChanged())
);
// Show status bar item if starting in restricted mode
const initialTrust = await this.getWorkspaceTrust();
this.updateRestrictedModeIndicator(initialTrust);
// React to trust changes
this.toDispose.push(
this.onDidChangeWorkspaceTrust(trust => {
this.updateRestrictedModeIndicator(trust);
})
);
}
protected onStop(): void {
this.toDispose.dispose();
}
getWorkspaceTrust(): Promise<boolean> {
// Return current trust if already resolved, otherwise wait for initial resolution
if (this.currentTrust !== undefined) {
return Promise.resolve(this.currentTrust);
}
return this.workspaceTrust.promise;
}
protected async resolveWorkspaceTrust(givenTrust?: boolean): Promise<void> {
if (!this.isWorkspaceTrustResolved()) {
const trust = givenTrust ?? await this.calculateWorkspaceTrust();
if (trust !== undefined) {
await this.storeWorkspaceTrust(trust);
this.contextKeyService.setContext('isWorkspaceTrusted', trust);
this.currentTrust = trust;
this.workspaceTrust.resolve(trust);
this.onDidChangeWorkspaceTrustEmitter.fire(trust);
if (trust && this.workspaceTrustPref[WORKSPACE_TRUST_ENABLED]) {
await this.addToTrustedFolders();
}
}
}
}
async setWorkspaceTrust(trusted: boolean, reload = true): Promise<void> {
if (this.currentTrust === trusted) {
return;
}
const needsReload = reload && this.shouldReloadForTrustChange(trusted);
if (needsReload && !await this.confirmRestart()) {
return;
}
// Must be set before add/removeFromTrustedFolders to prevent handlePreferenceChange from recursing.
this.currentTrust = trusted;
this.contextKeyService.setContext('isWorkspaceTrusted', trusted);
this.onDidChangeWorkspaceTrustEmitter.fire(trusted);
await this.storeWorkspaceTrust(trusted);
await (trusted ? this.addToTrustedFolders() : this.removeFromTrustedFolders());
if (needsReload) {
this.windowService.reload();
}
}
protected shouldReloadForTrustChange(newTrust: boolean): boolean {
return this.restrictionContributions.getContributions()
.some(c => c.requiresReloadOnTrustChange?.(newTrust) ?? false);
}
protected isWorkspaceTrustResolved(): boolean {
return this.workspaceTrust.state !== 'unresolved';
}
protected async calculateWorkspaceTrust(): Promise<boolean | undefined> {
const trustEnabled = this.workspaceTrustPref[WORKSPACE_TRUST_ENABLED];
if (!trustEnabled) {
return true;
}
// Empty workspace - no folders open
if (await this.isEmptyWorkspace()) {
return !!this.workspaceTrustPref[WORKSPACE_TRUST_EMPTY_WINDOW];
}
if (await this.areAllWorkspaceUrisTrusted()) {
return true;
}
if (this.workspaceTrustPref[WORKSPACE_TRUST_STARTUP_PROMPT] === WorkspaceTrustPrompt.NEVER) {
return false;
}
// For ONCE mode, check stored trust first
if (this.workspaceTrustPref[WORKSPACE_TRUST_STARTUP_PROMPT] === WorkspaceTrustPrompt.ONCE) {
const storedTrust = await this.loadWorkspaceTrust();
if (storedTrust !== undefined) {
return storedTrust;
}
}
// For ALWAYS mode or ONCE mode with no stored decision, show dialog
return this.showTrustPromptDialog();
}
/**
* Check if the workspace is empty (no workspace or folder opened, or
* an untitled workspace with no folders).
* A saved workspace file with 0 folders is NOT empty - it still needs trust
* evaluation because it could have tasks defined.
*/
protected async isEmptyWorkspace(): Promise<boolean> {
const workspace = this.workspaceService.workspace;
if (!workspace) {
return true;
}
const roots = this.workspaceService.tryGetRoots();
// Only consider it empty if it's an untitled workspace with no folders
// Use secure check with configDirUri for trust-related decisions
if (roots.length === 0) {
const configDirUri = new URI(await this.envVariablesServer.getConfigDirUri());
if (this.untitledWorkspaceService.isUntitledWorkspace(workspace.resource, configDirUri)) {
return true;
}
}
return false;
}
/**
* Get the URIs that need to be trusted for the current workspace.
* This includes all workspace folder URIs, plus the workspace file URI
* for saved workspaces (since workspace files can contain tasks/settings).
*/
protected getWorkspaceUris(): URI[] {
const uris = this.workspaceService.tryGetRoots().map(root => root.resource);
const workspace = this.workspaceService.workspace;
// For saved workspaces, include the workspace file itself
if (workspace && this.workspaceService.saved) {
uris.push(workspace.resource);
}
return uris;
}
/**
* Check if all workspace URIs are trusted.
* A workspace is trusted only if ALL of its folders (and the workspace
* file for saved workspaces) are trusted.
*/
protected async areAllWorkspaceUrisTrusted(): Promise<boolean> {
const uris = this.getWorkspaceUris();
if (uris.length === 0) {
return false;
}
return uris.every(uri => this.isUriTrusted(uri));
}
/**
* Check if a URI is trusted. A URI is trusted if it or any of its
* parent folders is in the trusted folders list.
*/
protected isUriTrusted(uri: URI): boolean {
const trustedFolders = this.workspaceTrustPref[WORKSPACE_TRUST_TRUSTED_FOLDERS] || [];
const caseSensitive = !OS.backend.isWindows;
const normalizedUri = uri.normalizePath();
return trustedFolders.some(folder => {
try {
const folderUri = new URI(folder).normalizePath();
// Check if the trusted folder is equal to or a parent of the URI
return folderUri.isEqualOrParent(normalizedUri, caseSensitive);
} catch {
return false; // Invalid URI in preferences
}
});
}
protected async showTrustPromptDialog(): Promise<boolean> {
// If dialog is already open, wait for its result
if (this.pendingTrustDialog) {
return this.pendingTrustDialog.promise;
}
this.pendingTrustDialog = new Deferred<boolean>();
try {
// Show the workspace folders in the dialog
const folderUris = this.workspaceService.tryGetRoots().map(root => root.resource);
const dialog = new WorkspaceTrustDialog(folderUris);
const result = await dialog.open();
const trusted = result === true;
this.pendingTrustDialog.resolve(trusted);
return trusted;
} catch (e) {
this.pendingTrustDialog.resolve(false);
throw e;
} finally {
this.pendingTrustDialog = undefined;
}
}
async addToTrustedFolders(): Promise<void> {
const uris = this.getWorkspaceUris();
if (uris.length === 0) {
return;
}
const currentFolders = this.workspaceTrustPref[WORKSPACE_TRUST_TRUSTED_FOLDERS] || [];
const newFolders = [...currentFolders];
let changed = false;
for (const uri of uris) {
if (!this.isUriTrusted(uri)) {
newFolders.push(uri.toString());
changed = true;
}
}
if (changed) {
await this.preferences.set(
WORKSPACE_TRUST_TRUSTED_FOLDERS,
newFolders,
PreferenceScope.User
);
}
}
async removeFromTrustedFolders(): Promise<void> {
const uris = this.getWorkspaceUris();
if (uris.length === 0) {
return;
}
const currentFolders = this.workspaceTrustPref[WORKSPACE_TRUST_TRUSTED_FOLDERS] || [];
const caseSensitive = !OS.backend.isWindows;
const normalizedUris = uris.map(uri => uri.normalizePath());
const updatedFolders = currentFolders.filter(folder => {
try {
const folderUri = new URI(folder).normalizePath();
// Remove folder if it exactly matches any workspace URI
return !normalizedUris.some(wsUri => wsUri.isEqual(folderUri, caseSensitive));
} catch {
return true; // Keep invalid URIs
}
});
if (updatedFolders.length !== currentFolders.length) {
await this.preferences.set(
WORKSPACE_TRUST_TRUSTED_FOLDERS,
updatedFolders,
PreferenceScope.User
);
}
}
protected async loadWorkspaceTrust(): Promise<boolean | undefined> {
if (this.workspaceTrustPref[WORKSPACE_TRUST_STARTUP_PROMPT] === WorkspaceTrustPrompt.ONCE) {
return this.storage.getData<boolean>(STORAGE_TRUSTED);
}
}
protected async storeWorkspaceTrust(trust: boolean): Promise<void> {
if (this.workspaceTrustPref[WORKSPACE_TRUST_STARTUP_PROMPT] === WorkspaceTrustPrompt.ONCE) {
return this.storage.setData(STORAGE_TRUSTED, trust);
}
}
protected async handlePreferenceChange(change: PreferenceChange): Promise<void> {
// Handle trustedFolders changes regardless of scope
if (change.preferenceName === WORKSPACE_TRUST_TRUSTED_FOLDERS) {
// For empty windows with emptyWindow setting enabled, trust should remain true
if (await this.isEmptyWorkspace() && this.workspaceTrustPref[WORKSPACE_TRUST_EMPTY_WINDOW]) {
return;
}
const areAllUrisTrusted = await this.areAllWorkspaceUrisTrusted();
if (areAllUrisTrusted !== this.currentTrust) {
await this.setWorkspaceTrust(areAllUrisTrusted);
}
return;
}
if (change.scope === PreferenceScope.User) {
if (change.preferenceName === WORKSPACE_TRUST_STARTUP_PROMPT && this.workspaceTrustPref[WORKSPACE_TRUST_STARTUP_PROMPT] !== WorkspaceTrustPrompt.ONCE) {
this.storage.setData(STORAGE_TRUSTED, undefined);
}
if (change.preferenceName === WORKSPACE_TRUST_ENABLED) {
if (!await this.isEmptyWorkspace() && this.isWorkspaceTrustResolved() && await this.confirmRestart()) {
this.windowService.reload();
}
this.resolveWorkspaceTrust();
}
// Handle emptyWindow setting change for empty windows
if (change.preferenceName === WORKSPACE_TRUST_EMPTY_WINDOW && await this.isEmptyWorkspace()) {
// For empty windows, directly update trust based on the new setting value
const shouldTrust = !!this.workspaceTrustPref[WORKSPACE_TRUST_EMPTY_WINDOW];
if (this.currentTrust !== shouldTrust) {
// No reload needed: in an empty window there are no workspace folders,
// so no extensions are blocked by trust and no extension host restart is required.
await this.setWorkspaceTrust(shouldTrust, false);
}
}
}
}
protected async handleWorkspaceChanged(): Promise<void> {
// Reset trust state for the new workspace
this.workspaceTrust = new Deferred<boolean>();
this.currentTrust = undefined;
// Re-evaluate trust for the new workspace
await this.resolveWorkspaceTrust();
// Update status bar indicator
const trust = await this.getWorkspaceTrust();
this.updateRestrictedModeIndicator(trust);
}
protected async confirmRestart(): Promise<boolean> {
const shouldRestart = await new ConfirmDialog({
title: nls.localizeByDefault('A setting has changed that requires a restart to take effect.'),
msg: nls.localizeByDefault('Press the restart button to restart {0} and enable the setting.', FrontendApplicationConfigProvider.get().applicationName),
ok: nls.localizeByDefault('Restart'),
cancel: Dialog.CANCEL,
}).open();
return shouldRestart === true;
}
protected updateRestrictedModeIndicator(trusted: boolean): void {
if (trusted) {
this.hideRestrictedModeStatusBarItem();
} else {
this.showRestrictedModeStatusBarItem();
}
}
protected showRestrictedModeStatusBarItem(): void {
this.statusBar.setElement(WORKSPACE_TRUST_STATUS_BAR_ID, {
text: '$(shield) ' + nls.localizeByDefault('Restricted Mode'),
alignment: StatusBarAlignment.LEFT,
backgroundColor: 'var(--theia-statusBarItem-prominentBackground)',
color: 'var(--theia-statusBarItem-prominentForeground)',
priority: 5000,
tooltip: this.createRestrictedModeTooltip(),
command: WorkspaceCommands.MANAGE_WORKSPACE_TRUST.id
});
}
protected createRestrictedModeTooltip(): MarkdownString {
const md = new MarkdownStringImpl('', { supportThemeIcons: true });
md.appendMarkdown(`**${nls.localizeByDefault('Restricted Mode')}**\n\n`);
md.appendMarkdown(nls.localize('theia/workspace/restrictedModeDescription',
'Some features are disabled because this workspace is not trusted.'));
md.appendMarkdown('\n\n');
md.appendMarkdown(nls.localize('theia/workspace/restrictedModeNote',
'*Please note: The workspace trust feature is currently under development in Theia; not all features are integrated with workspace trust yet*'));
const restrictions = this.collectRestrictions();
if (restrictions.length > 0) {
md.appendMarkdown('\n\n---\n\n');
for (const restriction of restrictions) {
md.appendMarkdown(`**${restriction.label}**\n\n`);
if (restriction.details && restriction.details.length > 0) {
for (const detail of restriction.details) {
md.appendMarkdown(`- ${detail}\n`);
}
md.appendMarkdown('\n');
}
}
}
md.appendMarkdown('\n\n---\n\n');
md.appendMarkdown(nls.localize('theia/workspace/clickToManageTrust', 'Click to manage trust settings.'));
return md;
}
protected collectRestrictions(): WorkspaceRestriction[] {
const restrictions: WorkspaceRestriction[] = [];
for (const contribution of this.restrictionContributions.getContributions()) {
restrictions.push(...contribution.getRestrictions());
}
return restrictions;
}
protected hideRestrictedModeStatusBarItem(): void {
this.statusBar.removeElement(WORKSPACE_TRUST_STATUS_BAR_ID);
}
/**
* Refreshes the restricted mode status bar item.
* Call this when restriction contributions change.
*/
refreshRestrictedModeIndicator(): void {
if (this.currentTrust === false) {
this.showRestrictedModeStatusBarItem();
}
}
/**
* Request workspace trust from the user. This method follows VS Code's pattern:
* - If already trusted, returns true immediately
* - If there's already a pending trust request, returns the same promise (avoiding duplicate dialogs)
* - Otherwise, shows a dialog and waits for the user's response
*
* Unlike the initial trust resolution, this can be called multiple times and will
* prompt the user each time (unless a dialog is already open).
*/
async requestWorkspaceTrust(): Promise<boolean | undefined> {
// If already trusted, return true immediately
if (this.currentTrust === true) {
return true;
}
// If there's already a pending request, return the same promise to avoid duplicate dialogs
if (this.pendingTrustRequest) {
return this.pendingTrustRequest.promise;
}
// Create a new pending request
this.pendingTrustRequest = new Deferred<boolean | undefined>();
try {
const grantedTrust = await this.showTrustPromptDialog();
if (grantedTrust) {
await this.setWorkspaceTrust(true);
}
this.pendingTrustRequest.resolve(grantedTrust);
return grantedTrust;
} catch (e) {
this.pendingTrustRequest.resolve(undefined);
throw e;
} finally {
this.pendingTrustRequest = undefined;
}
}
}