Skip to content

Commit f96efb6

Browse files
committed
feat: support launching agents/vscode app without protocol handler
1 parent 85e03a4 commit f96efb6

11 files changed

Lines changed: 206 additions & 33 deletions

File tree

build/gulpfile.vscode.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,12 +414,18 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d
414414
// Preserve the host's mutex name before overlaying embedded properties,
415415
// so the embedded app can poll for the correct InnoSetup -ready mutex.
416416
const hostMutexName = json['win32MutexName'];
417+
// Preserve the host's darwinSiblingBundleIdentifier so the embedded
418+
// app knows the host's bundle identifier for launching via `open -b`.
419+
const hostSiblingBundleId = json['darwinSiblingBundleIdentifier'];
417420
Object.keys(embedded).forEach(key => {
418421
json[key] = embedded[key as keyof EmbeddedProductInfo];
419422
});
420423
if (hostMutexName) {
421424
json['win32SetupMutexName'] = hostMutexName;
422425
}
426+
if (hostSiblingBundleId) {
427+
json['darwinSiblingBundleIdentifier'] = hostSiblingBundleId;
428+
}
423429
return json;
424430
}))
425431
.pipe(rename('product.sub.json'))

build/lib/embeddedType.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type EmbeddedProductInfo = {
99
applicationName: string;
1010
dataFolderName: string;
1111
darwinBundleIdentifier: string;
12+
darwinSiblingBundleIdentifier?: string;
1213
urlProtocol: string;
1314
win32AppUserModelId: string;
1415
win32MutexName: string;

src/vs/base/common/product.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ export interface IProductConfiguration {
222222
readonly 'editSessions.store'?: Omit<ConfigurationSyncStore, 'insidersUrl' | 'stableUrl'>;
223223
readonly darwinUniversalAssetId?: string;
224224
readonly darwinBundleIdentifier?: string;
225+
readonly darwinSiblingBundleIdentifier?: string;
225226
readonly profileTemplatesUrl?: string;
226227

227228
readonly commonlyUsedSettings?: string[];
@@ -282,6 +283,7 @@ export type IEmbeddedProductConfiguration = Pick<IProductConfiguration,
282283
'applicationName' |
283284
'dataFolderName' |
284285
'darwinBundleIdentifier' |
286+
'darwinSiblingBundleIdentifier' |
285287
'urlProtocol' |
286288
'win32AppUserModelId' |
287289
'win32MutexName' |

src/vs/code/node/cli.ts

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { ChildProcess, spawn, SpawnOptions, StdioOptions } from 'child_process';
7-
import { chmodSync, existsSync, readFileSync, statSync, truncateSync, unlinkSync, promises } from 'fs';
7+
import { chmodSync, existsSync, readFileSync, statSync, truncateSync, unlinkSync } from 'fs';
88
import { homedir, tmpdir } from 'os';
99
import type { ProfilingSession, Target } from 'v8-inspect-profiler';
1010
import { Event } from '../../base/common/event.js';
@@ -20,6 +20,7 @@ import { addArg, parseCLIProcessArgv } from '../../platform/environment/node/arg
2020
import { getStdinFilePath, hasStdinWithoutTty, readFromStdin, stdinDataListener } from '../../platform/environment/node/stdin.js';
2121
import { createWaitMarkerFileSync } from '../../platform/environment/node/wait.js';
2222
import product from '../../platform/product/common/product.js';
23+
import { resolveSiblingWindowsExePath } from '../../platform/native/node/siblingApp.js';
2324
import { CancellationTokenSource } from '../../base/common/cancellation.js';
2425
import { isUNC, randomPath } from '../../base/common/extpath.js';
2526
import { Utils } from '../../platform/profiling/common/profiling.js';
@@ -487,15 +488,11 @@ export async function main(argv: string[]): Promise<void> {
487488

488489
// Figure out the app to launch: with --agents we try to launch the embedded app on Windows
489490
let execToLaunch = process.execPath;
490-
if (isWindows && args.agents && product.win32SiblingExeBasename) {
491-
const siblingExe = join(dirname(process.execPath), `${product.win32SiblingExeBasename}.exe`);
492-
try {
493-
if (statSync(siblingExe).isFile()) {
494-
execToLaunch = siblingExe;
495-
argv = argv.filter(arg => arg !== '--agents');
496-
}
497-
} catch (error) {
498-
/* may not exist on disk */
491+
if (isWindows && args.agents) {
492+
const siblingExe = resolveSiblingWindowsExePath(product);
493+
if (siblingExe) {
494+
execToLaunch = siblingExe;
495+
argv = argv.filter(arg => arg !== '--agents');
499496
}
500497
}
501498

@@ -516,24 +513,12 @@ export async function main(argv: string[]): Promise<void> {
516513
const spawnArgs = ['-n', '-g'];
517514

518515
// Figure out the app to launch: with --agents we try to launch the embedded app
519-
let appToLaunch = process.execPath;
520-
if (args.agents) {
521-
// process.execPath is e.g. /Applications/Code.app/Contents/MacOS/Electron
522-
// Embedded app is at /Applications/Code.app/Contents/Applications/<EmbeddedApp>.app
523-
const contentsPath = dirname(dirname(process.execPath));
524-
const applicationsPath = join(contentsPath, 'Applications');
525-
try {
526-
const files = await promises.readdir(applicationsPath);
527-
const embeddedApp = files.find(file => file.endsWith('.app'));
528-
if (embeddedApp) {
529-
appToLaunch = join(applicationsPath, embeddedApp);
530-
argv = argv.filter(arg => arg !== '--agents');
531-
}
532-
} catch (error) {
533-
/* may not exist on disk */
534-
}
516+
if (args.agents && product.darwinSiblingBundleIdentifier) {
517+
spawnArgs.push('-b', product.darwinSiblingBundleIdentifier);
518+
argv = argv.filter(arg => arg !== '--agents');
519+
} else {
520+
spawnArgs.push('-a', process.execPath); // -a opens the given application.
535521
}
536-
spawnArgs.push('-a', appToLaunch); // -a opens the given application.
537522

538523
if (args.verbose || args.status) {
539524
spawnArgs.push('--wait-apps'); // `open --wait-apps`: blocks until the launched app is closed (even if they were already running)

src/vs/platform/native/common/native.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,14 @@ export interface ICommonNativeHostService {
131131

132132
openAgentsWindow(options?: { readonly forceNewWindow?: boolean }): Promise<void>;
133133

134+
/**
135+
* Launches the sibling application (host ↔ embedded).
136+
* The launched process is detached with its own process group.
137+
*
138+
* @param args CLI arguments to pass to the sibling application.
139+
*/
140+
launchSiblingApp(args?: string[]): Promise<void>;
141+
134142
isFullScreen(options?: INativeHostOptions): Promise<boolean>;
135143
toggleFullScreen(options?: INativeHostOptions): Promise<void>;
136144

src/vs/platform/native/electron-main/nativeHostMainService.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { AddFirstParameterToFunctions } from '../../../base/common/types.js';
1818
import { URI } from '../../../base/common/uri.js';
1919
import { virtualMachineHint } from '../../../base/node/id.js';
2020
import { Promises, SymlinkSupport } from '../../../base/node/pfs.js';
21+
import { launchSiblingApp } from '../node/siblingApp.js';
2122
import { findFreePort, isPortFree } from '../../../base/node/ports.js';
2223
import { localize } from '../../../nls.js';
2324
import { ISerializableCommandAction } from '../../action/common/action.js';
@@ -313,6 +314,13 @@ export class NativeHostMainService extends Disposable implements INativeHostMain
313314
});
314315
}
315316

317+
async launchSiblingApp(_windowId: number | undefined, args?: string[]): Promise<void> {
318+
const result = launchSiblingApp(this.productService, args);
319+
if (!result) {
320+
this.logService.warn('[launchSiblingApp] Could not resolve sibling app on this platform');
321+
}
322+
}
323+
316324
async isFullScreen(windowId: number | undefined, options?: INativeHostOptions): Promise<boolean> {
317325
const window = this.windowById(options?.targetWindowId, windowId);
318326
return window?.isFullScreen ?? false;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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 { ChildProcess, spawn } from 'child_process';
7+
import { statSync } from 'fs';
8+
import { dirname, join } from '../../../base/common/path.js';
9+
import { isMacintosh, isWindows, INodeProcess } from '../../../base/common/platform.js';
10+
import { IProductConfiguration } from '../../../base/common/product.js';
11+
12+
export interface ISiblingAppLaunchResult {
13+
readonly child: ChildProcess;
14+
}
15+
16+
/**
17+
* Launches the sibling application (host ↔ embedded) using a detached
18+
* child process with its own process group.
19+
*
20+
* @param product The product configuration of the **current** process.
21+
* @param args CLI arguments to forward to the sibling app.
22+
* @returns The spawned detached child process, or `undefined` if the
23+
* sibling could not be resolved on the current platform.
24+
*/
25+
export function launchSiblingApp(product: IProductConfiguration, args: string[] = []): ISiblingAppLaunchResult | undefined {
26+
if (isMacintosh) {
27+
const bundleId = resolveSiblingDarwinBundleIdentifier(product);
28+
if (!bundleId) {
29+
return undefined;
30+
}
31+
const spawnArgs = ['-n', '-g', '-b', bundleId];
32+
if (args.length > 0) {
33+
spawnArgs.push('--args', ...args);
34+
}
35+
const child = spawn('open', spawnArgs, {
36+
detached: true,
37+
stdio: 'ignore',
38+
});
39+
child.unref();
40+
return { child };
41+
}
42+
43+
if (isWindows) {
44+
const exePath = resolveSiblingWindowsExePath(product);
45+
if (!exePath) {
46+
return undefined;
47+
}
48+
const child = spawn(exePath, args, {
49+
detached: true,
50+
stdio: 'ignore',
51+
});
52+
child.unref();
53+
return { child };
54+
}
55+
56+
return undefined;
57+
}
58+
59+
/**
60+
* Returns the macOS bundle identifier for the sibling app.
61+
*/
62+
function resolveSiblingDarwinBundleIdentifier(product: IProductConfiguration): string | undefined {
63+
const isEmbedded = !!(process as INodeProcess).isEmbeddedApp;
64+
return isEmbedded
65+
? product.embedded?.darwinSiblingBundleIdentifier
66+
: product.darwinSiblingBundleIdentifier;
67+
}
68+
69+
/**
70+
* Resolves the sibling app's Windows executable path.
71+
*/
72+
export function resolveSiblingWindowsExePath(product: IProductConfiguration): string | undefined {
73+
const isEmbedded = !!(process as INodeProcess).isEmbeddedApp;
74+
const siblingBasename = isEmbedded
75+
? product.embedded?.win32SiblingExeBasename
76+
: product.win32SiblingExeBasename;
77+
78+
if (!siblingBasename) {
79+
return undefined;
80+
}
81+
82+
const siblingExe = join(dirname(process.execPath), `${siblingBasename}.exe`);
83+
try {
84+
if (statSync(siblingExe).isFile()) {
85+
return siblingExe;
86+
}
87+
} catch {
88+
// may not exist on disk
89+
}
90+
91+
return undefined;
92+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js';
7+
import { Schemas } from '../../../../base/common/network.js';
8+
import { URI } from '../../../../base/common/uri.js';
9+
import { registerAction2 } from '../../../../platform/actions/common/actions.js';
10+
import { IProductService } from '../../../../platform/product/common/productService.js';
11+
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
12+
import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js';
13+
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
14+
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
15+
import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
16+
import { INativeHostService } from '../../../../platform/native/common/native.js';
17+
import { CopilotCLISessionType } from '../../../services/sessions/common/session.js';
18+
import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js';
19+
import { OpenSessionWorktreeInVSCodeAction, resolveRemoteAuthority } from '../browser/chat.contribution.js';
20+
21+
/**
22+
* Desktop override for {@link OpenSessionWorktreeInVSCodeAction}.
23+
*
24+
* Launches the host VS Code app via {@link INativeHostService.launchSiblingApp}
25+
*/
26+
registerAction2(class extends OpenSessionWorktreeInVSCodeAction {
27+
28+
override async run(accessor: ServicesAccessor): Promise<void> {
29+
const telemetryService = accessor.get(ITelemetryService);
30+
logSessionsInteraction(telemetryService, 'openInVSCode');
31+
32+
const nativeHostService = accessor.get(INativeHostService);
33+
const productService = accessor.get(IProductService);
34+
const sessionsManagementService = accessor.get(ISessionsManagementService);
35+
const sessionsProvidersService = accessor.get(ISessionsProvidersService);
36+
const remoteAgentHostService = accessor.get(IRemoteAgentHostService);
37+
38+
const activeSession = sessionsManagementService.activeSession.get();
39+
const workspace = activeSession?.workspace.get();
40+
const repo = workspace?.repositories[0];
41+
const rawFolderUri = activeSession?.sessionType === CopilotCLISessionType.id ? repo?.workingDirectory ?? repo?.uri : undefined;
42+
const folderUri = rawFolderUri?.scheme === AGENT_HOST_SCHEME ? fromAgentHostUri(rawFolderUri) : rawFolderUri;
43+
const remoteAuthority = activeSession
44+
? resolveRemoteAuthority(activeSession.providerId, sessionsProvidersService, remoteAgentHostService)
45+
: undefined;
46+
47+
const args: string[] = ['--new-window'];
48+
49+
if (folderUri) {
50+
if (remoteAuthority) {
51+
args.push('--folder-uri', URI.from({ scheme: Schemas.vscodeRemote, authority: remoteAuthority, path: folderUri.path }).toString());
52+
} else {
53+
args.push('--folder-uri', folderUri.toString());
54+
}
55+
}
56+
57+
if (activeSession) {
58+
const scheme = productService.quality === 'stable'
59+
? 'vscode'
60+
: productService.quality === 'exploration'
61+
? 'vscode-exploration'
62+
: productService.quality === 'insider'
63+
? 'vscode-insiders'
64+
: productService.urlProtocol;
65+
const params = new URLSearchParams();
66+
params.set('session', activeSession.resource.toString());
67+
args.push('--open-url', URI.from({ scheme, query: params.toString() }).toString());
68+
}
69+
70+
await nativeHostService.launchSiblingApp(args);
71+
}
72+
});

src/vs/sessions/sessions.desktop.main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ import '../workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribu
178178
// Chat
179179
import '../workbench/contrib/chat/electron-browser/chat.contribution.js';
180180
import './contrib/agentFeedback/browser/agentFeedback.contribution.js';
181+
import './contrib/chat/electron-browser/openInVSCode.contribution.js';
181182

182183
// Encryption
183184
import '../workbench/contrib/encryption/electron-browser/encryption.contribution.js';

src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,9 @@ import { INativeHostService } from '../../../../../platform/native/common/native
1010
import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js';
1111
import { CHAT_CATEGORY } from '../../browser/actions/chatActions.js';
1212
import { IsSessionsWindowContext } from '../../../../common/contextkeys.js';
13-
import { IOpenerService } from '../../../../../platform/opener/common/opener.js';
1413
import { IProductService } from '../../../../../platform/product/common/productService.js';
15-
import { URI } from '../../../../../base/common/uri.js';
1614
import { isMacintosh, isWindows } from '../../../../../base/common/platform.js';
1715
import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js';
18-
import { Schemas } from '../../../../../base/common/network.js';
1916
import { ProductQualityContext } from '../../../../../platform/contextkey/common/contextkeys.js';
2017

2118
export class OpenAgentsWindowAction extends Action2 {
@@ -36,14 +33,13 @@ export class OpenAgentsWindowAction extends Action2 {
3633
}
3734

3835
async run(accessor: ServicesAccessor, options?: { forceNewWindow?: boolean }) {
39-
const openerService = accessor.get(IOpenerService);
4036
const productService = accessor.get(IProductService);
4137
const environmentService = accessor.get(IWorkbenchEnvironmentService);
38+
const nativeHostService = accessor.get(INativeHostService);
4239

4340
if (environmentService.isBuilt && (isMacintosh || isWindows) && productService.embedded?.urlProtocol) {
44-
await openerService.open(URI.from({ scheme: productService.embedded.urlProtocol, authority: Schemas.file }), { openExternal: true });
41+
await nativeHostService.launchSiblingApp();
4542
} else {
46-
const nativeHostService = accessor.get(INativeHostService);
4743
await nativeHostService.openAgentsWindow({ forceNewWindow: options?.forceNewWindow ?? true });
4844
}
4945
}

0 commit comments

Comments
 (0)