Portable mode improvements and bug fixes (#287063)

Disabled protocol handlers and registry updates on Windows in portable mode.
Added API proposal to detect if VS Code is running in portable mode from extensions.
Skipped protocol redirect in GitHub authentication in portable mode.
This commit is contained in:
Dmitriy Vasyura
2026-01-24 04:22:53 -08:00
committed by GitHub
parent 141d5452e8
commit aa19df565f
25 changed files with 156 additions and 26 deletions

View File

@@ -46,7 +46,12 @@
if (error) {
document.querySelector('.error-message > .detail').textContent = error;
document.querySelector('body').classList.add('error');
} else if (redirectUri) {
} else if (!redirectUri) {
// Portable mode: authentication succeeded, no redirect needed
document.querySelector('.title').textContent = appName;
document.querySelector('.success-message > .subtitle').textContent = 'You have successfully signed in.';
document.querySelector('.success-message > .detail').textContent = 'You can now close this window.';
} else {
// Wrap the redirect URI so that the browser remembers who triggered the redirect
const wrappedRedirectUri = `https://vscode.dev/redirect?url=${encodeURIComponent(redirectUri)}`;
// Set up the fallback link

View File

@@ -19,7 +19,8 @@
],
"enabledApiProposals": [
"authIssuers",
"authProviderSpecific"
"authProviderSpecific",
"envIsAppPortable"
],
"activationEvents": [],
"capabilities": {

View File

@@ -354,7 +354,7 @@ class LocalServerFlow implements IFlow {
path: '/login/oauth/authorize',
query: searchParams.toString()
});
const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl.toString(true), callbackUri.toString(true));
const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl.toString(true), callbackUri.toString(true), env.isAppPortable);
const port = await server.start();
let codeToExchange;

View File

@@ -87,7 +87,7 @@ export class LoopbackAuthServer implements ILoopbackServer {
return this._startingRedirect.searchParams.get('state') ?? undefined;
}
constructor(serveRoot: string, startingRedirect: string, callbackUri: string) {
constructor(serveRoot: string, startingRedirect: string, callbackUri: string, isPortable: boolean) {
if (!serveRoot) {
throw new Error('serveRoot must be defined');
}
@@ -132,7 +132,11 @@ export class LoopbackAuthServer implements ILoopbackServer {
throw new Error('Nonce does not match.');
}
deferred.resolve({ code, state });
res.writeHead(302, { location: `/?redirect_uri=${encodeURIComponent(callbackUri)}${appNameQueryParam}` });
if (isPortable) {
res.writeHead(302, { location: `/?app_name=${encodeURIComponent(env.appName)}` });
} else {
res.writeHead(302, { location: `/?redirect_uri=${encodeURIComponent(callbackUri)}${appNameQueryParam}` });
}
res.end();
break;
}

View File

@@ -12,7 +12,7 @@ suite('LoopbackAuthServer', () => {
let port: number;
setup(async () => {
server = new LoopbackAuthServer(__dirname, 'http://localhost:8080', 'https://code.visualstudio.com');
server = new LoopbackAuthServer(__dirname, 'http://localhost:8080', 'https://code.visualstudio.com', false);
port = await server.start();
});
@@ -64,3 +64,35 @@ suite('LoopbackAuthServer', () => {
]);
});
});
suite('LoopbackAuthServer (portable mode)', () => {
let server: LoopbackAuthServer;
let port: number;
setup(async () => {
server = new LoopbackAuthServer(__dirname, 'http://localhost:8080', 'https://code.visualstudio.com', true);
port = await server.start();
});
teardown(async () => {
await server.stop();
});
test('should redirect to success page without redirect_uri on /callback', async () => {
server.state = 'valid-state';
const response = await fetch(
`http://localhost:${port}/callback?code=valid-code&state=${server.state}&nonce=${server.nonce}`,
{ redirect: 'manual' }
);
assert.strictEqual(response.status, 302);
// In portable mode, should redirect to success page without redirect_uri
assert.strictEqual(response.headers.get('location'), `/?app_name=${encodeURIComponent(env.appName)}`);
await Promise.race([
server.waitForOAuthResponse().then(result => {
assert.strictEqual(result.code, 'valid-code');
assert.strictEqual(result.state, server.state);
}),
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000))
]);
});
});

View File

@@ -13,6 +13,7 @@
"src/**/*",
"../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.authIssuers.d.ts",
"../../src/vscode-dts/vscode.proposed.authProviderSpecific.d.ts"
"../../src/vscode-dts/vscode.proposed.authProviderSpecific.d.ts",
"../../src/vscode-dts/vscode.proposed.envIsAppPortable.d.ts"
]
}

View File

@@ -16,7 +16,8 @@
"enabledApiProposals": [
"nativeWindowHandle",
"authIssuers",
"authenticationChallenges"
"authenticationChallenges",
"envIsAppPortable"
],
"capabilities": {
"virtualWorkspaces": true,

View File

@@ -228,7 +228,8 @@ export class MsalAuthProvider implements AuthenticationProvider {
const flows = getMsalFlows({
extensionHost: this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote,
supportedClient: isSupportedClient(callbackUri),
isBrokerSupported: cachedPca.isBrokerAvailable
isBrokerSupported: cachedPca.isBrokerAvailable,
isPortableMode: env.isAppPortable
});
const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();
@@ -364,7 +365,8 @@ export class MsalAuthProvider implements AuthenticationProvider {
const flows = getMsalFlows({
extensionHost: this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote,
isBrokerSupported: cachedPca.isBrokerAvailable,
supportedClient: isSupportedClient(callbackUri)
supportedClient: isSupportedClient(callbackUri),
isPortableMode: env.isAppPortable
});
const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();

View File

@@ -22,6 +22,7 @@ interface IMsalFlowOptions {
supportsRemoteExtensionHost: boolean;
supportsUnsupportedClient: boolean;
supportsBroker: boolean;
supportsPortableMode: boolean;
}
interface IMsalFlowTriggerOptions {
@@ -47,7 +48,8 @@ class DefaultLoopbackFlow implements IMsalFlow {
options: IMsalFlowOptions = {
supportsRemoteExtensionHost: false,
supportsUnsupportedClient: true,
supportsBroker: true
supportsBroker: true,
supportsPortableMode: true
};
async trigger({ cachedPca, authority, scopes, claims, loginHint, windowHandle, logger }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
@@ -76,7 +78,8 @@ class UrlHandlerFlow implements IMsalFlow {
options: IMsalFlowOptions = {
supportsRemoteExtensionHost: true,
supportsUnsupportedClient: false,
supportsBroker: false
supportsBroker: false,
supportsPortableMode: false
};
async trigger({ cachedPca, authority, scopes, claims, loginHint, windowHandle, logger, uriHandler, callbackUri }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
@@ -105,7 +108,8 @@ class DeviceCodeFlow implements IMsalFlow {
options: IMsalFlowOptions = {
supportsRemoteExtensionHost: true,
supportsUnsupportedClient: true,
supportsBroker: false
supportsBroker: false,
supportsPortableMode: true
};
async trigger({ cachedPca, authority, scopes, claims, logger }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
@@ -128,6 +132,7 @@ export interface IMsalFlowQuery {
extensionHost: ExtensionHost;
supportedClient: boolean;
isBrokerSupported: boolean;
isPortableMode: boolean;
}
export function getMsalFlows(query: IMsalFlowQuery): IMsalFlow[] {
@@ -139,6 +144,7 @@ export function getMsalFlows(query: IMsalFlowQuery): IMsalFlow[] {
}
useFlow &&= flow.options.supportsBroker || !query.isBrokerSupported;
useFlow &&= flow.options.supportsUnsupportedClient || query.supportedClient;
useFlow &&= flow.options.supportsPortableMode || !query.isPortableMode;
if (useFlow) {
flows.push(flow);
}

View File

@@ -11,7 +11,8 @@ suite('getMsalFlows', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Local,
supportedClient: true,
isBrokerSupported: false
isBrokerSupported: false,
isPortableMode: false
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 3);
@@ -24,7 +25,8 @@ suite('getMsalFlows', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Local,
supportedClient: true,
isBrokerSupported: true
isBrokerSupported: true,
isPortableMode: false
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 1);
@@ -35,7 +37,8 @@ suite('getMsalFlows', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Remote,
supportedClient: true,
isBrokerSupported: false
isBrokerSupported: false,
isPortableMode: false
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 2);
@@ -47,7 +50,8 @@ suite('getMsalFlows', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Local,
supportedClient: false,
isBrokerSupported: false
isBrokerSupported: false,
isPortableMode: false
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 2);
@@ -59,7 +63,8 @@ suite('getMsalFlows', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Remote,
supportedClient: false,
isBrokerSupported: false
isBrokerSupported: false,
isPortableMode: false
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 1);
@@ -70,10 +75,24 @@ suite('getMsalFlows', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Local,
supportedClient: false,
isBrokerSupported: true
isBrokerSupported: true,
isPortableMode: false
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 1);
assert.strictEqual(flows[0].label, 'default');
});
test('should exclude protocol handler flow in portable mode', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Local,
supportedClient: true,
isBrokerSupported: false,
isPortableMode: true
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 2);
assert.strictEqual(flows[0].label, 'default');
assert.strictEqual(flows[1].label, 'device code');
});
});

View File

@@ -11,6 +11,7 @@
"../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.nativeWindowHandle.d.ts",
"../../src/vscode-dts/vscode.proposed.authIssuers.d.ts",
"../../src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts"
"../../src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts",
"../../src/vscode-dts/vscode.proposed.envIsAppPortable.d.ts"
]
}

View File

@@ -17,6 +17,7 @@
"documentFiltersExclusive",
"editorInsets",
"embeddings",
"envIsAppPortable",
"extensionRuntime",
"extensionsAny",
"externalUriOpener",

View File

@@ -32,6 +32,7 @@ export interface IEnvironmentMainService extends INativeEnvironmentService {
// --- config
readonly disableUpdates: boolean;
readonly isPortable: boolean;
// TODO@deepak1556 temporary until a real fix lands upstream
readonly enableRDPDisplayTracking: boolean;
@@ -56,6 +57,9 @@ export class EnvironmentMainService extends NativeEnvironmentService implements
@memoize
get disableUpdates(): boolean { return !!this.args['disable-updates']; }
@memoize
get isPortable(): boolean { return !!process.env['VSCODE_PORTABLE']; }
@memoize
get crossOriginIsolated(): boolean { return !!this.args['enable-coi']; }

View File

@@ -225,6 +225,9 @@ const _allApiProposals = {
embeddings: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.embeddings.d.ts',
},
envIsAppPortable: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.envIsAppPortable.d.ts',
},
extensionAffinity: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.extensionAffinity.d.ts',
},

View File

@@ -49,7 +49,9 @@ export class ElectronURLListener extends Disposable {
}
// Windows: install as protocol handler
if (isWindows) {
// Skip in portable mode: the registered command wouldn't preserve
// portable mode settings, causing issues with OAuth flows
if (isWindows && !environmentMainService.isPortable) {
const windowsParameters = environmentMainService.isBuilt ? [] : [`"${environmentMainService.appRoot}"`];
windowsParameters.push('--open-url', '--');
app.setAsDefaultProtocolClient(productService.urlProtocol, process.execPath, windowsParameters);

View File

@@ -428,6 +428,7 @@ export interface INativeWindowConfiguration extends IWindowConfiguration, Native
machineId: string;
sqmId: string;
devDeviceId: string;
isPortable: boolean;
execPath: string;
backupPath?: string;

View File

@@ -1483,6 +1483,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic
machineId: this.machineId,
sqmId: this.sqmId,
devDeviceId: this.devDeviceId,
isPortable: this.environmentMainService.isPortable,
windowId: -1, // Will be filled in by the window once loaded later

View File

@@ -25,6 +25,7 @@ import { IWorkspaceIdentifier, WORKSPACE_EXTENSION } from '../../workspace/commo
import { IWorkspacesManagementMainService } from './workspacesManagementMainService.js';
import { ResourceMap } from '../../../base/common/map.js';
import { IDialogMainService } from '../../dialogs/electron-main/dialogMainService.js';
import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js';
export const IWorkspacesHistoryMainService = createDecorator<IWorkspacesHistoryMainService>('workspacesHistoryMainService');
@@ -56,7 +57,8 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa
@IWorkspacesManagementMainService private readonly workspacesManagementMainService: IWorkspacesManagementMainService,
@ILifecycleMainService private readonly lifecycleMainService: ILifecycleMainService,
@IApplicationStorageMainService private readonly applicationStorageMainService: IApplicationStorageMainService,
@IDialogMainService private readonly dialogMainService: IDialogMainService
@IDialogMainService private readonly dialogMainService: IDialogMainService,
@IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService
) {
super();
@@ -104,7 +106,8 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa
files.push(recent);
// Add to recent documents (Windows only, macOS later)
if (isWindows && recent.fileUri.scheme === Schemas.file) {
// Skip in portable mode to avoid leaving traces on the machine
if (isWindows && recent.fileUri.scheme === Schemas.file && !this.environmentMainService.isPortable) {
app.addRecentDocument(recent.fileUri.fsPath);
}
}
@@ -127,7 +130,8 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa
this._onDidChangeRecentlyOpened.fire();
// Schedule update to recent documents on macOS dock
if (isMacintosh) {
// Skip in portable mode to avoid leaving traces on the machine
if (isMacintosh && !this.environmentMainService.isPortable) {
this.macOSRecentDocumentsUpdater.trigger(() => this.updateMacOSRecentDocuments());
}
}
@@ -153,7 +157,8 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa
this._onDidChangeRecentlyOpened.fire();
// Schedule update to recent documents on macOS dock
if (isMacintosh) {
// Skip in portable mode to avoid leaving traces on the machine
if (isMacintosh && !this.environmentMainService.isPortable) {
this.macOSRecentDocumentsUpdater.trigger(() => this.updateMacOSRecentDocuments());
}
}
@@ -178,7 +183,11 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa
}
await this.saveRecentlyOpened({ workspaces: [], files: [] });
app.clearRecentDocuments();
// Skip in portable mode to avoid leaving traces on the machine
if (!this.environmentMainService.isPortable) {
app.clearRecentDocuments();
}
// Event
this._onDidChangeRecentlyOpened.fire();
@@ -311,6 +320,11 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa
return; // only on windows
}
// Skip in portable mode to avoid leaving traces on the machine
if (this.environmentMainService.isPortable) {
return;
}
await this.updateWindowsJumpList();
this._register(this.onDidChangeRecentlyOpened(() => this.updateWindowsJumpList()));
}

View File

@@ -391,6 +391,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
checkProposedApiEnabled(extension, 'devDeviceId');
return initData.telemetryInfo.devDeviceId ?? initData.telemetryInfo.machineId;
},
get isAppPortable() {
checkProposedApiEnabled(extension, 'envIsAppPortable');
return initData.environment.isPortable ?? false;
},
get sessionId() { return initData.telemetryInfo.sessionId; },
get language() { return initData.environment.appLanguage; },
get appName() { return initData.environment.appName; },

View File

@@ -43,6 +43,7 @@ export interface INativeWorkbenchEnvironmentService extends IBrowserWorkbenchEnv
readonly machineId: string;
readonly sqmId: string;
readonly devDeviceId: string;
readonly isPortable: boolean;
// --- Paths
readonly execPath: string;
@@ -70,6 +71,9 @@ export class NativeWorkbenchEnvironmentService extends AbstractNativeEnvironment
@memoize
get devDeviceId() { return this.configuration.devDeviceId; }
@memoize
get isPortable() { return this.configuration.isPortable; }
@memoize
get remoteAuthority() { return this.configuration.remoteAuthority; }

View File

@@ -313,6 +313,7 @@ export class WebWorkerExtensionHost extends Disposable implements IExtensionHost
appUriScheme: this._productService.urlProtocol,
appLanguage: platform.language,
isExtensionTelemetryLoggingOnly: isLoggingOnly(this._productService, this._environmentService),
isPortable: false,
extensionDevelopmentLocationURI: this._environmentService.extensionDevelopmentLocationURI,
extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI,
globalStorageHome: this._userDataProfilesService.defaultProfile.globalStorageHome,

View File

@@ -67,6 +67,7 @@ export interface IEnvironment {
appLanguage: string;
isExtensionTelemetryLoggingOnly: boolean;
appUriScheme: string;
isPortable?: boolean;
extensionDevelopmentLocationURI?: URI[];
extensionTestsLocationURI?: URI;
globalStorageHome: URI;

View File

@@ -482,6 +482,7 @@ export class NativeLocalProcessExtensionHost extends Disposable implements IExte
appHost: this._productService.embedderIdentifier || 'desktop',
appUriScheme: this._productService.urlProtocol,
isExtensionTelemetryLoggingOnly: isLoggingOnly(this._productService, this._environmentService),
isPortable: this._environmentService.isPortable,
appLanguage: platform.language,
extensionDevelopmentLocationURI: this._environmentService.extensionDevelopmentLocationURI,
extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI,

View File

@@ -59,6 +59,7 @@ const TestNativeWindowConfiguration: INativeWindowConfiguration = {
machineId: 'testMachineId',
sqmId: 'testSqmId',
devDeviceId: 'testdevDeviceId',
isPortable: false,
logLevel: LogLevel.Error,
loggers: [],
mainPid: 0,

View File

@@ -0,0 +1,20 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
declare module 'vscode' {
export namespace env {
/**
* Indicates whether the application is running in portable mode.
*
* Portable mode is enabled when the application is run from a folder that contains
* a `data` directory, allowing for self-contained installations.
*
* Learn more about [Portable Mode](https://code.visualstudio.com/docs/editor/portable).
*/
export const isAppPortable: boolean;
}
}