cli: allow installation as a service from the UI (#187869)

- When turning on remote tunnel access, a quickpick is now shown asking
  users whether it should be installed as a service or just run in
	the session.
- Picking the service install will install the tunnel as a service on
  the machine, and start it.
- Turning off remote tunnel access will uninstall the service only if
  we were the ones to install it.
- This involved some refactoring to add extra state to the RemoteTunnelService.
  There's now a "mode" that includes the previous "session" and reflects
	the desired end state.
- I also did a cleanup with a `StreamSplitter` to ensure output of the
  CLI gets read line-by-line. This was depended upon by the remote tunnel
	service code, but it's not actually guaranteed.
- Changes in the CLI: allow setting the tunnel name while installing the
  service, and make both service un/installation and renames idempotent.

Closes https://github.com/microsoft/vscode/issues/184663
This commit is contained in:
Connor Peet
2023-07-13 20:23:15 -07:00
committed by GitHub
parent 7bd35446a5
commit 12340da1f1
11 changed files with 491 additions and 199 deletions

View File

@@ -649,6 +649,10 @@ pub struct TunnelServiceInstallArgs {
/// If set, the user accepts the server license terms and the server will be started without a user prompt. /// If set, the user accepts the server license terms and the server will be started without a user prompt.
#[clap(long)] #[clap(long)]
pub accept_server_license_terms: bool, pub accept_server_license_terms: bool,
/// Sets the machine name for port forwarding service
#[clap(long)]
pub name: Option<String>,
} }
#[derive(Args, Debug, Clone)] #[derive(Args, Debug, Clone)]

View File

@@ -135,10 +135,17 @@ pub async fn service(
let manager = create_service_manager(ctx.log.clone(), &ctx.paths); let manager = create_service_manager(ctx.log.clone(), &ctx.paths);
match service_args { match service_args {
TunnelServiceSubCommands::Install(args) => { TunnelServiceSubCommands::Install(args) => {
// ensure logged in, otherwise subsequent serving will fail let auth = Auth::new(&ctx.paths, ctx.log.clone());
Auth::new(&ctx.paths, ctx.log.clone())
.get_credential() if let Some(name) = &args.name {
.await?; // ensure the name matches, and tunnel exists
dev_tunnels::DevTunnels::new(&ctx.log, auth, &ctx.paths)
.rename_tunnel(name)
.await?;
} else {
// still ensure they're logged in, otherwise subsequent serving will fail
auth.get_credential().await?;
}
// likewise for license consent // likewise for license consent
legal::require_consent(&ctx.paths, args.accept_server_license_terms)?; legal::require_consent(&ctx.paths, args.accept_server_license_terms)?;
@@ -203,20 +210,20 @@ pub async fn user(ctx: CommandContext, user_args: TunnelUserSubCommands) -> Resu
Ok(0) Ok(0)
} }
/// Remove the tunnel used by this gateway, if any. /// Remove the tunnel used by this tunnel, if any.
pub async fn rename(ctx: CommandContext, rename_args: TunnelRenameArgs) -> Result<i32, AnyError> { pub async fn rename(ctx: CommandContext, rename_args: TunnelRenameArgs) -> Result<i32, AnyError> {
let auth = Auth::new(&ctx.paths, ctx.log.clone()); let auth = Auth::new(&ctx.paths, ctx.log.clone());
let mut dt = dev_tunnels::DevTunnels::new(&ctx.log, auth, &ctx.paths); let mut dt = dev_tunnels::DevTunnels::new(&ctx.log, auth, &ctx.paths);
dt.rename_tunnel(&rename_args.name).await?; dt.rename_tunnel(&rename_args.name).await?;
ctx.log.result(format!( ctx.log.result(format!(
"Successfully renamed this gateway to {}", "Successfully renamed this tunnel to {}",
&rename_args.name &rename_args.name
)); ));
Ok(0) Ok(0)
} }
/// Remove the tunnel used by this gateway, if any. /// Remove the tunnel used by this tunnel, if any.
pub async fn unregister(ctx: CommandContext) -> Result<i32, AnyError> { pub async fn unregister(ctx: CommandContext) -> Result<i32, AnyError> {
let auth = Auth::new(&ctx.paths, ctx.log.clone()); let auth = Auth::new(&ctx.paths, ctx.log.clone());
let mut dt = dev_tunnels::DevTunnels::new(&ctx.log, auth, &ctx.paths); let mut dt = dev_tunnels::DevTunnels::new(&ctx.log, auth, &ctx.paths);

View File

@@ -117,7 +117,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
Ok(p) => p, Ok(p) => p,
Err(err) => { Err(err) => {
return id.map(|id| { return id.map(|id| {
serial.serialize(&ErrorResponse { serial.serialize(ErrorResponse {
id, id,
error: ResponseError { error: ResponseError {
code: 0, code: 0,
@@ -131,7 +131,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
match callback(param.params, &context) { match callback(param.params, &context) {
Ok(result) => id.map(|id| serial.serialize(&SuccessResponse { id, result })), Ok(result) => id.map(|id| serial.serialize(&SuccessResponse { id, result })),
Err(err) => id.map(|id| { Err(err) => id.map(|id| {
serial.serialize(&ErrorResponse { serial.serialize(ErrorResponse {
id, id,
error: ResponseError { error: ResponseError {
code: -1, code: -1,
@@ -161,7 +161,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
Ok(p) => p, Ok(p) => p,
Err(err) => { Err(err) => {
return future::ready(id.map(|id| { return future::ready(id.map(|id| {
serial.serialize(&ErrorResponse { serial.serialize(ErrorResponse {
id, id,
error: ResponseError { error: ResponseError {
code: 0, code: 0,
@@ -182,7 +182,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
id.map(|id| serial.serialize(&SuccessResponse { id, result })) id.map(|id| serial.serialize(&SuccessResponse { id, result }))
} }
Err(err) => id.map(|id| { Err(err) => id.map(|id| {
serial.serialize(&ErrorResponse { serial.serialize(ErrorResponse {
id, id,
error: ResponseError { error: ResponseError {
code: -1, code: -1,
@@ -222,7 +222,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
return ( return (
None, None,
future::ready(id.map(|id| { future::ready(id.map(|id| {
serial.serialize(&ErrorResponse { serial.serialize(ErrorResponse {
id, id,
error: ResponseError { error: ResponseError {
code: 0, code: 0,
@@ -255,7 +255,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
match callback(servers, param.params, context).await { match callback(servers, param.params, context).await {
Ok(r) => id.map(|id| serial.serialize(&SuccessResponse { id, result: r })), Ok(r) => id.map(|id| serial.serialize(&SuccessResponse { id, result: r })),
Err(err) => id.map(|id| { Err(err) => id.map(|id| {
serial.serialize(&ErrorResponse { serial.serialize(ErrorResponse {
id, id,
error: ResponseError { error: ResponseError {
code: -1, code: -1,
@@ -427,7 +427,7 @@ impl<S: Serialization, C: Send + Sync> RpcDispatcher<S, C> {
Some(Method::Async(callback)) => MaybeSync::Future(callback(id, body)), Some(Method::Async(callback)) => MaybeSync::Future(callback(id, body)),
Some(Method::Duplex(callback)) => MaybeSync::Stream(callback(id, body)), Some(Method::Duplex(callback)) => MaybeSync::Stream(callback(id, body)),
None => MaybeSync::Sync(id.map(|id| { None => MaybeSync::Sync(id.map(|id| {
self.serializer.serialize(&ErrorResponse { self.serializer.serialize(ErrorResponse {
id, id,
error: ResponseError { error: ResponseError {
code: -1, code: -1,

View File

@@ -275,7 +275,9 @@ impl DevTunnels {
/// Renames the current tunnel to the new name. /// Renames the current tunnel to the new name.
pub async fn rename_tunnel(&mut self, name: &str) -> Result<(), AnyError> { pub async fn rename_tunnel(&mut self, name: &str) -> Result<(), AnyError> {
self.update_tunnel_name(None, name).await.map(|_| ()) self.update_tunnel_name(self.launcher_tunnel.load(), name)
.await
.map(|_| ())
} }
/// Updates the name of the existing persisted tunnel to the new name. /// Updates the name of the existing persisted tunnel to the new name.
@@ -286,28 +288,34 @@ impl DevTunnels {
name: &str, name: &str,
) -> Result<(Tunnel, PersistedTunnel), AnyError> { ) -> Result<(Tunnel, PersistedTunnel), AnyError> {
let name = name.to_ascii_lowercase(); let name = name.to_ascii_lowercase();
self.check_is_name_free(&name).await?;
debug!(self.log, "Tunnel name changed, applying updates...");
let (mut full_tunnel, mut persisted, is_new) = match persisted { let (mut full_tunnel, mut persisted, is_new) = match persisted {
Some(persisted) => { Some(persisted) => {
debug!(
self.log,
"Found a persisted tunnel, seeing if the name matches..."
);
self.get_or_create_tunnel(persisted, Some(&name), NO_REQUEST_OPTIONS) self.get_or_create_tunnel(persisted, Some(&name), NO_REQUEST_OPTIONS)
.await .await
} }
None => self None => {
.create_tunnel(&name, NO_REQUEST_OPTIONS) debug!(self.log, "Creating a new tunnel with the requested name");
.await self.create_tunnel(&name, NO_REQUEST_OPTIONS)
.map(|(pt, t)| (t, pt, true)), .await
.map(|(pt, t)| (t, pt, true))
}
}?; }?;
if is_new { let desired_tags = self.get_tags(&name);
if is_new || vec_eq_as_set(&full_tunnel.tags, &desired_tags) {
return Ok((full_tunnel, persisted)); return Ok((full_tunnel, persisted));
} }
full_tunnel.tags = self.get_tags(&name); debug!(self.log, "Tunnel name changed, applying updates...");
let new_tunnel = spanf!( full_tunnel.tags = desired_tags;
let updated_tunnel = spanf!(
self.log, self.log,
self.log.span("dev-tunnel.tag.update"), self.log.span("dev-tunnel.tag.update"),
self.client.update_tunnel(&full_tunnel, NO_REQUEST_OPTIONS) self.client.update_tunnel(&full_tunnel, NO_REQUEST_OPTIONS)
@@ -317,7 +325,7 @@ impl DevTunnels {
persisted.name = name; persisted.name = name;
self.launcher_tunnel.save(Some(persisted.clone()))?; self.launcher_tunnel.save(Some(persisted.clone()))?;
Ok((new_tunnel, persisted)) Ok((updated_tunnel, persisted))
} }
/// Gets the persisted tunnel from the service, or creates a new one. /// Gets the persisted tunnel from the service, or creates a new one.
@@ -443,6 +451,8 @@ impl DevTunnels {
) -> Result<(PersistedTunnel, Tunnel), AnyError> { ) -> Result<(PersistedTunnel, Tunnel), AnyError> {
info!(self.log, "Creating tunnel with the name: {}", name); info!(self.log, "Creating tunnel with the name: {}", name);
self.check_is_name_free(name).await?;
let mut tried_recycle = false; let mut tried_recycle = false;
let new_tunnel = Tunnel { let new_tunnel = Tunnel {
@@ -527,7 +537,7 @@ impl DevTunnels {
options: &TunnelRequestOptions, options: &TunnelRequestOptions,
) -> Result<Tunnel, AnyError> { ) -> Result<Tunnel, AnyError> {
let new_tags = self.get_tags(name); let new_tags = self.get_tags(name);
if vec_eq_unsorted(&tunnel.tags, &new_tags) { if vec_eq_as_set(&tunnel.tags, &new_tags) {
return Ok(tunnel); return Ok(tunnel);
} }
@@ -610,7 +620,7 @@ impl DevTunnels {
} }
async fn check_is_name_free(&mut self, name: &str) -> Result<(), AnyError> { async fn check_is_name_free(&mut self, name: &str) -> Result<(), AnyError> {
let existing = spanf!( let existing: Vec<Tunnel> = spanf!(
self.log, self.log,
self.log.span("dev-tunnel.rename.search"), self.log.span("dev-tunnel.rename.search"),
self.client.list_all_tunnels(&TunnelRequestOptions { self.client.list_all_tunnels(&TunnelRequestOptions {
@@ -998,7 +1008,7 @@ fn clean_hostname_for_tunnel(hostname: &str) -> String {
} }
} }
fn vec_eq_unsorted(a: &[String], b: &[String]) -> bool { fn vec_eq_as_set(a: &[String], b: &[String]) -> bool {
if a.len() != b.len() { if a.len() != b.len() {
return false; return false;
} }

View File

@@ -78,6 +78,7 @@ impl CliServiceManager for WindowsService {
cmd.stderr(Stdio::null()); cmd.stderr(Stdio::null());
cmd.stdout(Stdio::null()); cmd.stdout(Stdio::null());
cmd.stdin(Stdio::null()); cmd.stdin(Stdio::null());
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
cmd.spawn() cmd.spawn()
.map_err(|e| wrapdbg(e, "error starting service"))?; .map_err(|e| wrapdbg(e, "error starting service"))?;
@@ -121,8 +122,12 @@ impl CliServiceManager for WindowsService {
async fn unregister(&self) -> Result<(), AnyError> { async fn unregister(&self) -> Result<(), AnyError> {
let key = WindowsService::open_key()?; let key = WindowsService::open_key()?;
key.delete_value(TUNNEL_ACTIVITY_NAME) match key.delete_value(TUNNEL_ACTIVITY_NAME) {
.map_err(|e| AnyError::from(wrap(e, "error deleting registry key")))?; Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(wrap(e, "error deleting registry key").into()),
}
info!(self.log, "Tunnel service uninstalled"); info!(self.log, "Tunnel service uninstalled");
let r = do_single_rpc_call::<_, ()>( let r = do_single_rpc_call::<_, ()>(

View File

@@ -172,53 +172,59 @@ export class VSBuffer {
writeUInt8(this.buffer, value, offset); writeUInt8(this.buffer, value, offset);
} }
indexOf(subarray: VSBuffer | Uint8Array) { indexOf(subarray: VSBuffer | Uint8Array, offset = 0) {
const needle = subarray instanceof VSBuffer ? subarray.buffer : subarray; return binaryIndexOf(this.buffer, subarray instanceof VSBuffer ? subarray.buffer : subarray, offset);
const needleLen = needle.byteLength;
const haystack = this.buffer;
const haystackLen = haystack.byteLength;
if (needleLen === 0) {
return 0;
}
if (needleLen === 1) {
return haystack.indexOf(needle[0]);
}
if (needleLen > haystackLen) {
return -1;
}
// find index of the subarray using boyer-moore-horspool algorithm
const table = indexOfTable.value;
table.fill(needle.length);
for (let i = 0; i < needle.length; i++) {
table[needle[i]] = needle.length - i - 1;
}
let i = needle.length - 1;
let j = i;
let result = -1;
while (i < haystackLen) {
if (haystack[i] === needle[j]) {
if (j === 0) {
result = i;
break;
}
i--;
j--;
} else {
i += Math.max(needle.length - j, table[haystack[i]]);
j = needle.length - 1;
}
}
return result;
} }
} }
/**
* Like String.indexOf, but works on Uint8Arrays.
* Uses the boyer-moore-horspool algorithm to be reasonably speedy.
*/
export function binaryIndexOf(haystack: Uint8Array, needle: Uint8Array, offset = 0): number {
const needleLen = needle.byteLength;
const haystackLen = haystack.byteLength;
if (needleLen === 0) {
return 0;
}
if (needleLen === 1) {
return haystack.indexOf(needle[0]);
}
if (needleLen > haystackLen - offset) {
return -1;
}
// find index of the subarray using boyer-moore-horspool algorithm
const table = indexOfTable.value;
table.fill(needle.length);
for (let i = 0; i < needle.length; i++) {
table[needle[i]] = needle.length - i - 1;
}
let i = offset + needle.length - 1;
let j = i;
let result = -1;
while (i < haystackLen) {
if (haystack[i] === needle[j]) {
if (j === 0) {
result = i;
break;
}
i--;
j--;
} else {
i += Math.max(needle.length - j, table[haystack[i]]);
j = needle.length - 1;
}
}
return result;
}
export function readUInt16LE(source: Uint8Array, offset: number): number { export function readUInt16LE(source: Uint8Array, offset: number): number {
return ( return (
((source[offset + 0] << 0) >>> 0) | ((source[offset + 0] << 0) >>> 0) |

View File

@@ -0,0 +1,62 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Transform } from 'stream';
import { binaryIndexOf } from 'vs/base/common/buffer';
/**
* A Transform stream that splits the input on the "splitter" substring.
* The resulting chunks will contain (and trail with) the splitter match.
* The last chunk when the stream ends will be emitted even if a splitter
* is not encountered.
*/
export class StreamSplitter extends Transform {
private buffer: Buffer | undefined;
private readonly splitter: Buffer | number;
private readonly spitterLen: number;
constructor(splitter: string | number | Buffer) {
super();
if (typeof splitter === 'number') {
this.splitter = splitter;
this.spitterLen = 1;
} else {
const buf = Buffer.isBuffer(splitter) ? splitter : Buffer.from(splitter);
this.splitter = buf.length === 1 ? buf[0] : buf;
this.spitterLen = buf.length;
}
}
override _transform(chunk: Buffer, _encoding: string, callback: (error?: Error | null, data?: any) => void): void {
if (!this.buffer) {
this.buffer = chunk;
} else {
this.buffer = Buffer.concat([this.buffer, chunk]);
}
let offset = 0;
while (offset < this.buffer.length) {
const index = typeof this.splitter === 'number'
? this.buffer.indexOf(this.splitter, offset)
: binaryIndexOf(this.buffer, this.splitter, offset);
if (index === -1) {
break;
}
this.push(this.buffer.slice(offset, index + this.spitterLen));
offset = index + this.spitterLen;
}
this.buffer = offset === this.buffer.length ? undefined : this.buffer.slice(offset);
callback();
}
override _flush(callback: (error?: Error | null, data?: any) => void): void {
if (this.buffer) {
this.push(this.buffer);
}
callback();
}
}

View File

@@ -0,0 +1,51 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Writable } from 'stream';
import * as assert from 'assert';
import { StreamSplitter } from 'vs/base/node/nodeStreams';
suite('StreamSplitter', () => {
test('should split a stream on a single character splitter', (done) => {
const chunks: string[] = [];
const splitter = new StreamSplitter('\n');
const writable = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
splitter.pipe(writable);
splitter.write('hello\nwor');
splitter.write('ld\n');
splitter.write('foo\nbar\nz');
splitter.end(() => {
assert.deepStrictEqual(chunks, ['hello\n', 'world\n', 'foo\n', 'bar\n', 'z']);
done();
});
});
test('should split a stream on a multi-character splitter', (done) => {
const chunks: string[] = [];
const splitter = new StreamSplitter('---');
const writable = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
splitter.pipe(writable);
splitter.write('hello---wor');
splitter.write('ld---');
splitter.write('foo---bar---z');
splitter.end(() => {
assert.deepStrictEqual(chunks, ['hello---', 'world---', 'foo---', 'bar---', 'z']);
done();
});
});
});

View File

@@ -21,18 +21,33 @@ export interface IRemoteTunnelService {
readonly onDidChangeTunnelStatus: Event<TunnelStatus>; readonly onDidChangeTunnelStatus: Event<TunnelStatus>;
getTunnelStatus(): Promise<TunnelStatus>; getTunnelStatus(): Promise<TunnelStatus>;
getSession(): Promise<IRemoteTunnelSession | undefined>; getMode(): Promise<TunnelMode>;
readonly onDidChangeSession: Event<IRemoteTunnelSession | undefined>; readonly onDidChangeMode: Event<TunnelMode>;
readonly onDidTokenFailed: Event<IRemoteTunnelSession | undefined>; readonly onDidTokenFailed: Event<IRemoteTunnelSession | undefined>;
initialize(session: IRemoteTunnelSession | undefined): Promise<TunnelStatus>; initialize(mode: TunnelMode): Promise<TunnelStatus>;
startTunnel(session: IRemoteTunnelSession): Promise<TunnelStatus>; startTunnel(mode: ActiveTunnelMode): Promise<TunnelStatus>;
stopTunnel(): Promise<void>; stopTunnel(): Promise<void>;
getTunnelName(): Promise<string | undefined>; getTunnelName(): Promise<string | undefined>;
} }
export interface ActiveTunnelMode {
readonly active: true;
readonly session: IRemoteTunnelSession;
readonly asService: boolean;
}
export interface InactiveTunnelMode {
readonly active: false;
}
export const INACTIVE_TUNNEL_MODE: InactiveTunnelMode = { active: false };
/** Saved mode for the tunnel. */
export type TunnelMode = ActiveTunnelMode | InactiveTunnelMode;
export type TunnelStatus = TunnelStates.Connected | TunnelStates.Disconnected | TunnelStates.Connecting | TunnelStates.Uninitialized; export type TunnelStatus = TunnelStates.Connected | TunnelStates.Disconnected | TunnelStates.Connecting | TunnelStates.Uninitialized;
export namespace TunnelStates { export namespace TunnelStates {
@@ -46,13 +61,14 @@ export namespace TunnelStates {
export interface Connected { export interface Connected {
readonly type: 'connected'; readonly type: 'connected';
readonly info: ConnectionInfo; readonly info: ConnectionInfo;
readonly serviceInstallFailed: boolean;
} }
export interface Disconnected { export interface Disconnected {
readonly type: 'disconnected'; readonly type: 'disconnected';
readonly onTokenFailed?: IRemoteTunnelSession; readonly onTokenFailed?: IRemoteTunnelSession;
} }
export const disconnected = (onTokenFailed?: IRemoteTunnelSession): Disconnected => ({ type: 'disconnected', onTokenFailed }); export const disconnected = (onTokenFailed?: IRemoteTunnelSession): Disconnected => ({ type: 'disconnected', onTokenFailed });
export const connected = (info: ConnectionInfo): Connected => ({ type: 'connected', info }); export const connected = (info: ConnectionInfo, serviceInstallFailed: boolean): Connected => ({ type: 'connected', info, serviceInstallFailed });
export const connecting = (progress?: string): Connecting => ({ type: 'connecting', progress }); export const connecting = (progress?: string): Connecting => ({ type: 'connecting', progress });
export const uninitialized: Uninitialized = { type: 'uninitialized' }; export const uninitialized: Uninitialized = { type: 'uninitialized' };

View File

@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information. * Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/ *--------------------------------------------------------------------------------------------*/
import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREVENT_SLEEP, ConnectionInfo, IRemoteTunnelSession, IRemoteTunnelService, LOGGER_NAME, LOG_ID, TunnelStates, TunnelStatus } from 'vs/platform/remoteTunnel/common/remoteTunnel'; import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREVENT_SLEEP, ConnectionInfo, IRemoteTunnelSession, IRemoteTunnelService, LOGGER_NAME, LOG_ID, TunnelStates, TunnelStatus, TunnelMode, INACTIVE_TUNNEL_MODE, ActiveTunnelMode } from 'vs/platform/remoteTunnel/common/remoteTunnel';
import { Emitter } from 'vs/base/common/event'; import { Emitter } from 'vs/base/common/event';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { INativeEnvironmentService } from 'vs/platform/environment/common/environment'; import { INativeEnvironmentService } from 'vs/platform/environment/common/environment';
@@ -20,15 +20,18 @@ import { localize } from 'vs/nls';
import { hostname, homedir } from 'os'; import { hostname, homedir } from 'os';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { isString } from 'vs/base/common/types'; import { isString } from 'vs/base/common/types';
import { StreamSplitter } from 'vs/base/node/nodeStreams';
type RemoteTunnelEnablementClassification = { type RemoteTunnelEnablementClassification = {
owner: 'aeschli'; owner: 'aeschli';
comment: 'Reporting when Remote Tunnel access is turned on or off'; comment: 'Reporting when Remote Tunnel access is turned on or off';
enabled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Flag indicating if Remote Tunnel Access is enabled or not' }; enabled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Flag indicating if Remote Tunnel Access is enabled or not' };
service?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Flag indicating if Remote Tunnel Access is installed as a service' };
}; };
type RemoteTunnelEnablementEvent = { type RemoteTunnelEnablementEvent = {
enabled: boolean; enabled: boolean;
service: boolean;
}; };
const restartTunnelOnConfigurationChanges: readonly string[] = [ const restartTunnelOnConfigurationChanges: readonly string[] = [
@@ -40,6 +43,8 @@ const restartTunnelOnConfigurationChanges: readonly string[] = [
// if set, the remote tunnel access is currently enabled. // if set, the remote tunnel access is currently enabled.
// if not set, the remote tunnel access is currently disabled. // if not set, the remote tunnel access is currently disabled.
const TUNNEL_ACCESS_SESSION = 'remoteTunnelSession'; const TUNNEL_ACCESS_SESSION = 'remoteTunnelSession';
// Boolean indicating whether the tunnel should be installed as a service.
const TUNNEL_ACCESS_IS_SERVICE = 'remoteTunnelIsService';
/** /**
* This service runs on the shared service. It is running the `code-tunnel` command * This service runs on the shared service. It is running the `code-tunnel` command
@@ -55,12 +60,19 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
private readonly _onDidChangeTunnelStatusEmitter = new Emitter<TunnelStatus>(); private readonly _onDidChangeTunnelStatusEmitter = new Emitter<TunnelStatus>();
public readonly onDidChangeTunnelStatus = this._onDidChangeTunnelStatusEmitter.event; public readonly onDidChangeTunnelStatus = this._onDidChangeTunnelStatusEmitter.event;
private readonly _onDidChangeSessionEmitter = new Emitter<IRemoteTunnelSession | undefined>(); private readonly _onDidChangeModeEmitter = new Emitter<TunnelMode>();
public readonly onDidChangeSession = this._onDidChangeSessionEmitter.event; public readonly onDidChangeMode = this._onDidChangeModeEmitter.event;
private readonly _logger: ILogger; private readonly _logger: ILogger;
private _session: IRemoteTunnelSession | undefined; /**
* "Mode" in the terminal state we want to get to -- started, stopped, and
* the attributes associated with each.
*
* At any given time, work may be ongoing to get `_tunnelStatus` into a
* state that reflects the desired `mode`.
*/
private _mode: TunnelMode = INACTIVE_TUNNEL_MODE;
private _tunnelProcess: CancelablePromise<any> | undefined; private _tunnelProcess: CancelablePromise<any> | undefined;
@@ -98,7 +110,7 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
} }
})); }));
this._session = this._restoreSession(); this._mode = this._restoreMode();
this._tunnelStatus = TunnelStates.uninitialized; this._tunnelStatus = TunnelStates.uninitialized;
} }
@@ -111,32 +123,34 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
this._onDidChangeTunnelStatusEmitter.fire(tunnelStatus); this._onDidChangeTunnelStatusEmitter.fire(tunnelStatus);
} }
private setSession(session: IRemoteTunnelSession | undefined) { private setMode(mode: TunnelMode) {
if (!isSameSession(session, this._session)) { if (isSameMode(this._mode, mode)) {
this._session = session; return;
this._onDidChangeSessionEmitter.fire(session); }
this._storeSession(session);
if (session) { this._mode = mode;
this._logger.info(`Session updated: ${session.accountLabel} (${session.providerId})`); this._storeMode(mode);
if (session.token) { this._onDidChangeModeEmitter.fire(this._mode);
this._logger.info(`Session token updated: ${session.accountLabel} (${session.providerId})`); if (mode.active) {
} this._logger.info(`Session updated: ${mode.session.accountLabel} (${mode.session.providerId}) (service=${mode.asService})`);
} else { if (mode.session.token) {
this._logger.info(`Session reset`); this._logger.info(`Session token updated: ${mode.session.accountLabel} (${mode.session.providerId})`);
} }
} else {
this._logger.info(`Session reset`);
} }
} }
async getSession(): Promise<IRemoteTunnelSession | undefined> { getMode(): Promise<TunnelMode> {
return this._session; return Promise.resolve(this._mode);
} }
async initialize(session: IRemoteTunnelSession | undefined): Promise<TunnelStatus> { async initialize(mode: TunnelMode): Promise<TunnelStatus> {
if (this._initialized) { if (this._initialized) {
return this._tunnelStatus; return this._tunnelStatus;
} }
this._initialized = true; this._initialized = true;
this.setSession(session); this.setMode(mode);
try { try {
await this._startTunnelProcessDelayer.trigger(() => this.updateTunnelProcess()); await this._startTunnelProcessDelayer.trigger(() => this.updateTunnelProcess());
} catch (e) { } catch (e) {
@@ -145,6 +159,14 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
return this._tunnelStatus; return this._tunnelStatus;
} }
private readonly defaultOnOutput = (a: string, isErr: boolean) => {
if (isErr) {
this._logger.error(a);
} else {
this._logger.info(a);
}
};
private getTunnelCommandLocation() { private getTunnelCommandLocation() {
if (!this._tunnelCommand) { if (!this._tunnelCommand) {
let binParentLocation; let binParentLocation;
@@ -164,11 +186,12 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
return this._tunnelCommand; return this._tunnelCommand;
} }
async startTunnel(session: IRemoteTunnelSession): Promise<TunnelStatus> { async startTunnel(mode: ActiveTunnelMode): Promise<TunnelStatus> {
if (isSameSession(session, this._session) && this._tunnelStatus.type !== 'disconnected') { if (isSameMode(this._mode, mode) && this._tunnelStatus.type !== 'disconnected') {
return this._tunnelStatus; return this._tunnelStatus;
} }
this.setSession(session);
this.setMode(mode);
try { try {
await this._startTunnelProcessDelayer.trigger(() => this.updateTunnelProcess()); await this._startTunnelProcessDelayer.trigger(() => this.updateTunnelProcess());
@@ -180,41 +203,49 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
async stopTunnel(): Promise<void> { async stopTunnel(): Promise<void> {
this.setSession(undefined);
if (this._tunnelProcess) { if (this._tunnelProcess) {
this._tunnelProcess.cancel(); this._tunnelProcess.cancel();
this._tunnelProcess = undefined; this._tunnelProcess = undefined;
} }
const onOutput = (a: string, isErr: boolean) => { if (!this._mode.active) {
if (isErr) { return;
this._logger.error(a); }
} else {
this._logger.info(a); // Be careful to only uninstall the service if we're the ones who installed it:
} const needsServiceUninstall = this._mode.asService;
}; this.setMode(INACTIVE_TUNNEL_MODE);
try { try {
await this.runCodeTunnelCommand('stop', ['kill'], onOutput); if (needsServiceUninstall) {
this.runCodeTunnelCommand('uninstallService', ['service', 'uninstall']);
}
} catch (e) { } catch (e) {
this._logger.error(e); this._logger.error(e);
} }
this.setTunnelStatus(TunnelStates.disconnected());
try {
await this.runCodeTunnelCommand('stop', ['kill']);
} catch (e) {
this._logger.error(e);
}
this.setTunnelStatus(TunnelStates.disconnected());
} }
private async updateTunnelProcess(): Promise<void> { private async updateTunnelProcess(): Promise<void> {
this.telemetryService.publicLog2<RemoteTunnelEnablementEvent, RemoteTunnelEnablementClassification>('remoteTunnel.enablement', { enabled: !!this._session }); this.telemetryService.publicLog2<RemoteTunnelEnablementEvent, RemoteTunnelEnablementClassification>('remoteTunnel.enablement', {
enabled: this._mode.active,
service: this._mode.active && this._mode.asService,
});
if (this._tunnelProcess) { if (this._tunnelProcess) {
this._tunnelProcess.cancel(); this._tunnelProcess.cancel();
this._tunnelProcess = undefined; this._tunnelProcess = undefined;
} }
let isAttached = false;
let output = ''; let output = '';
let isServiceInstalled = false;
const onOutput = (a: string, isErr: boolean) => { const onOutput = (a: string, isErr: boolean) => {
if (isErr) { if (isErr) {
this._logger.error(a); this._logger.error(a);
@@ -241,22 +272,26 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
tunnel: object | null; tunnel: object | null;
} = JSON.parse(output.trim().split('\n').find(l => l.startsWith('{'))!); } = JSON.parse(output.trim().split('\n').find(l => l.startsWith('{'))!);
isAttached = !!status.tunnel; isServiceInstalled = status.service_installed;
this._logger.info(isAttached ? 'Other tunnel running, attaching...' : 'No other tunnel running'); this._logger.info(status.tunnel ? 'Other tunnel running, attaching...' : 'No other tunnel running');
if (!isAttached && !this._session) {
this._tunnelProcess = undefined; // If a tunnel is running but the mode isn't "active", we'll still attach
// to the tunnel to show its state in the UI. If neither are true, disconnect
if (!status.tunnel && !this._mode.active) {
this.setTunnelStatus(TunnelStates.disconnected()); this.setTunnelStatus(TunnelStates.disconnected());
return; return;
} }
} catch (e) { } catch (e) {
this._logger.error(e); this._logger.error(e);
this._tunnelProcess = undefined;
this.setTunnelStatus(TunnelStates.disconnected()); this.setTunnelStatus(TunnelStates.disconnected());
return; return;
} finally {
if (this._tunnelProcess === statusProcess) {
this._tunnelProcess = undefined;
}
} }
const session = this._session; const session = this._mode.active ? this._mode.session : undefined;
if (session && session.token) { if (session && session.token) {
const token = session.token; const token = session.token;
this.setTunnelStatus(TunnelStates.connecting(localize({ key: 'remoteTunnelService.authorizing', comment: ['{0} is a user account name, {1} a provider name (e.g. Github)'] }, 'Connecting as {0} ({1})', session.accountLabel, session.providerId))); this.setTunnelStatus(TunnelStates.connecting(localize({ key: 'remoteTunnelService.authorizing', comment: ['{0} is a user account name, {1} a provider name (e.g. Github)'] }, 'Connecting as {0} ({1})', session.accountLabel, session.providerId)));
@@ -286,25 +321,67 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
} else { } else {
this.setTunnelStatus(TunnelStates.connecting(localize('remoteTunnelService.openTunnel', 'Opening tunnel'))); this.setTunnelStatus(TunnelStates.connecting(localize('remoteTunnelService.openTunnel', 'Opening tunnel')));
} }
const args = ['--parent-process-id', String(process.pid), '--accept-server-license-terms', '--log', LogLevelToString(this._logger.getLevel())]; const args = ['--accept-server-license-terms', '--log', LogLevelToString(this._logger.getLevel())];
if (hostName) { if (hostName) {
args.push('--name', hostName); args.push('--name', hostName);
} else { } else {
args.push('--random-name'); args.push('--random-name');
} }
let serviceInstallFailed = false;
if (this._mode.active && this._mode.asService && !isServiceInstalled) {
// I thought about calling `code tunnel kill` here, but having multiple
// tunnel processes running is pretty much idempotent. If there's
// another tunnel process running, the service process will
// take over when it exits, no hard feelings.
serviceInstallFailed = await this.installTunnelService(args) === false;
}
return this.serverOrAttachTunnel(session, args, serviceInstallFailed);
}
private async installTunnelService(args: readonly string[]) {
let status: number;
try {
status = await this.runCodeTunnelCommand('serviceInstall', ['service', 'install', ...args]);
} catch (e) {
this._logger.error(e);
status = 1;
}
if (status !== 0) {
const msg = localize('remoteTunnelService.serviceInstallFailed', 'Failed to install tunnel as a service, starting in session...');
this._logger.warn(msg);
this.setTunnelStatus(TunnelStates.connecting(msg));
return false;
}
return true;
}
private async serverOrAttachTunnel(session: IRemoteTunnelSession | undefined, args: string[], serviceInstallFailed: boolean) {
args.push('--parent-process-id', String(process.pid));
if (this._preventSleep()) { if (this._preventSleep()) {
args.push('--no-sleep'); args.push('--no-sleep');
} }
let isAttached = false;
const serveCommand = this.runCodeTunnelCommand('tunnel', args, (message: string, isErr: boolean) => { const serveCommand = this.runCodeTunnelCommand('tunnel', args, (message: string, isErr: boolean) => {
if (isErr) { if (isErr) {
this._logger.error(message); this._logger.error(message);
} else { } else {
this._logger.info(message); this._logger.info(message);
} }
if (message.includes('Connected to an existing tunnel process')) {
isAttached = true;
}
const m = message.match(/Open this link in your browser (https:\/\/([^\/\s]+)\/([^\/\s]+)\/([^\/\s]+))/); const m = message.match(/Open this link in your browser (https:\/\/([^\/\s]+)\/([^\/\s]+)\/([^\/\s]+))/);
if (m) { if (m) {
const info: ConnectionInfo = { link: m[1], domain: m[2], tunnelName: m[4], isAttached }; const info: ConnectionInfo = { link: m[1], domain: m[2], tunnelName: m[4], isAttached };
this.setTunnelStatus(TunnelStates.connected(info)); this.setTunnelStatus(TunnelStates.connected(info, serviceInstallFailed));
} else if (message.match(/error refreshing token/)) { } else if (message.match(/error refreshing token/)) {
serveCommand.cancel(); serveCommand.cancel();
this._onDidTokenFailedEmitter.fire(session); this._onDidTokenFailedEmitter.fire(session);
@@ -317,14 +394,14 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
// process exited unexpectedly // process exited unexpectedly
this._logger.info(`tunnel process terminated`); this._logger.info(`tunnel process terminated`);
this._tunnelProcess = undefined; this._tunnelProcess = undefined;
this._session = undefined; this._mode = INACTIVE_TUNNEL_MODE;
this.setTunnelStatus(TunnelStates.disconnected()); this.setTunnelStatus(TunnelStates.disconnected());
} }
}); });
} }
private runCodeTunnelCommand(logLabel: string, commandArgs: string[], onOutput: (message: string, isError: boolean) => void = () => { }): CancelablePromise<number> { private runCodeTunnelCommand(logLabel: string, commandArgs: string[], onOutput: (message: string, isError: boolean) => void = this.defaultOnOutput): CancelablePromise<number> {
return createCancelablePromise<number>(token => { return createCancelablePromise<number>(token => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (token.isCancellationRequested) { if (token.isCancellationRequested) {
@@ -350,13 +427,13 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
tunnelProcess = spawn(tunnelCommand, ['tunnel', ...commandArgs], { cwd: homedir(), stdio }); tunnelProcess = spawn(tunnelCommand, ['tunnel', ...commandArgs], { cwd: homedir(), stdio });
} }
tunnelProcess.stdout!.on('data', data => { tunnelProcess.stdout!.pipe(new StreamSplitter('\n')).on('data', data => {
if (tunnelProcess) { if (tunnelProcess) {
const message = data.toString(); const message = data.toString();
onOutput(message, false); onOutput(message, false);
} }
}); });
tunnelProcess.stderr!.on('data', data => { tunnelProcess.stderr!.pipe(new StreamSplitter('\n')).on('data', data => {
if (tunnelProcess) { if (tunnelProcess) {
const message = data.toString(); const message = data.toString();
onOutput(message, true); onOutput(message, true);
@@ -394,30 +471,33 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
return name || undefined; return name || undefined;
} }
private _restoreSession(): IRemoteTunnelSession | undefined { private _restoreMode(): TunnelMode {
try { try {
const tunnelAccessSession = this.storageService.get(TUNNEL_ACCESS_SESSION, StorageScope.APPLICATION); const tunnelAccessSession = this.storageService.get(TUNNEL_ACCESS_SESSION, StorageScope.APPLICATION);
const asService = this.storageService.getBoolean(TUNNEL_ACCESS_IS_SERVICE, StorageScope.APPLICATION, false);
if (tunnelAccessSession) { if (tunnelAccessSession) {
const session = JSON.parse(tunnelAccessSession) as IRemoteTunnelSession; const session = JSON.parse(tunnelAccessSession) as IRemoteTunnelSession;
if (session && isString(session.accountLabel) && isString(session.sessionId) && isString(session.providerId)) { if (session && isString(session.accountLabel) && isString(session.sessionId) && isString(session.providerId)) {
return session; return { active: true, session, asService };
} }
this._logger.error('Problems restoring session from storage, invalid format', session); this._logger.error('Problems restoring session from storage, invalid format', session);
} }
} catch (e) { } catch (e) {
this._logger.error('Problems restoring session from storage', e); this._logger.error('Problems restoring session from storage', e);
} }
return undefined; return INACTIVE_TUNNEL_MODE;
} }
private _storeSession(session: IRemoteTunnelSession | undefined): void { private _storeMode(mode: TunnelMode): void {
if (session) { if (mode.active) {
const sessionWithoutToken = { const sessionWithoutToken = {
providerId: session.providerId, sessionId: session.sessionId, accountLabel: session.accountLabel providerId: mode.session.providerId, sessionId: mode.session.sessionId, accountLabel: mode.session.accountLabel
}; };
this.storageService.store(TUNNEL_ACCESS_SESSION, JSON.stringify(sessionWithoutToken), StorageScope.APPLICATION, StorageTarget.MACHINE); this.storageService.store(TUNNEL_ACCESS_SESSION, JSON.stringify(sessionWithoutToken), StorageScope.APPLICATION, StorageTarget.MACHINE);
this.storageService.store(TUNNEL_ACCESS_IS_SERVICE, mode.asService, StorageScope.APPLICATION, StorageTarget.MACHINE);
} else { } else {
this.storageService.remove(TUNNEL_ACCESS_SESSION, StorageScope.APPLICATION); this.storageService.remove(TUNNEL_ACCESS_SESSION, StorageScope.APPLICATION);
this.storageService.remove(TUNNEL_ACCESS_IS_SERVICE, StorageScope.APPLICATION);
} }
} }
} }
@@ -429,3 +509,12 @@ function isSameSession(a1: IRemoteTunnelSession | undefined, a2: IRemoteTunnelSe
return a1 === a2; return a1 === a2;
} }
const isSameMode = (a: TunnelMode, b: TunnelMode) => {
if (a.active !== b.active) {
return false;
} else if (a.active && b.active) {
return a.asService === b.asService && isSameSession(a.session, b.session);
} else {
return true;
}
};

View File

@@ -3,40 +3,39 @@
* Licensed under the MIT License. See License.txt in the project root for license information. * Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/ *--------------------------------------------------------------------------------------------*/
import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
import { IProductService } from 'vs/platform/product/common/productService';
import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREFIX, CONFIGURATION_KEY_PREVENT_SLEEP, ConnectionInfo, IRemoteTunnelSession, IRemoteTunnelService, LOGGER_NAME, LOG_ID } from 'vs/platform/remoteTunnel/common/remoteTunnel';
import { AuthenticationSession, IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication';
import { localize } from 'vs/nls';
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from 'vs/workbench/common/contributions';
import { Registry } from 'vs/platform/registry/common/platform';
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { ILocalizedString } from 'vs/platform/action/common/action';
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { ILogger, ILoggerService, ILogService } from 'vs/platform/log/common/log';
import { INativeEnvironmentService } from 'vs/platform/environment/common/environment';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator, QuickPickItem } from 'vs/platform/quickinput/common/quickInput';
import { IOutputService } from 'vs/workbench/services/output/common/output';
import { IFileService } from 'vs/platform/files/common/files';
import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry';
import { IProgress, IProgressService, IProgressStep, ProgressLocation } from 'vs/platform/progress/common/progress';
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { INotificationService, Severity } from 'vs/platform/notification/common/notification';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { Action } from 'vs/base/common/actions'; import { Action } from 'vs/base/common/actions';
import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
import { IWorkspaceContextService, isUntitledWorkspace } from 'vs/platform/workspace/common/workspace';
import { Schemas } from 'vs/base/common/network'; import { Schemas } from 'vs/base/common/network';
import { URI } from 'vs/base/common/uri';
import { joinPath } from 'vs/base/common/resources';
import { ITunnelApplicationConfig } from 'vs/base/common/product'; import { ITunnelApplicationConfig } from 'vs/base/common/product';
import { joinPath } from 'vs/base/common/resources';
import { isNumber, isObject, isString } from 'vs/base/common/types'; import { isNumber, isObject, isString } from 'vs/base/common/types';
import { URI } from 'vs/base/common/uri';
import { localize } from 'vs/nls';
import { ILocalizedString } from 'vs/platform/action/common/action';
import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry';
import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
import { INativeEnvironmentService } from 'vs/platform/environment/common/environment';
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { ILogger, ILoggerService } from 'vs/platform/log/common/log';
import { INotificationService, Severity } from 'vs/platform/notification/common/notification';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { IProductService } from 'vs/platform/product/common/productService';
import { IProgress, IProgressService, IProgressStep, ProgressLocation } from 'vs/platform/progress/common/progress';
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator, QuickPickItem } from 'vs/platform/quickinput/common/quickInput';
import { Registry } from 'vs/platform/registry/common/platform';
import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREFIX, CONFIGURATION_KEY_PREVENT_SLEEP, ConnectionInfo, INACTIVE_TUNNEL_MODE, IRemoteTunnelService, IRemoteTunnelSession, LOGGER_NAME, LOG_ID, TunnelStatus } from 'vs/platform/remoteTunnel/common/remoteTunnel';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { IWorkspaceContextService, isUntitledWorkspace } from 'vs/platform/workspace/common/workspace';
import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions';
import { AuthenticationSession, IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
import { IOutputService } from 'vs/workbench/services/output/common/output';
import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences';
export const REMOTE_TUNNEL_CATEGORY: ILocalizedString = { export const REMOTE_TUNNEL_CATEGORY: ILocalizedString = {
original: 'Remote-Tunnels', original: 'Remote-Tunnels',
@@ -103,10 +102,8 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
@IProductService productService: IProductService, @IProductService productService: IProductService,
@IStorageService private readonly storageService: IStorageService, @IStorageService private readonly storageService: IStorageService,
@ILoggerService loggerService: ILoggerService, @ILoggerService loggerService: ILoggerService,
@ILogService logService: ILogService,
@IQuickInputService private readonly quickInputService: IQuickInputService, @IQuickInputService private readonly quickInputService: IQuickInputService,
@INativeEnvironmentService private environmentService: INativeEnvironmentService, @INativeEnvironmentService private environmentService: INativeEnvironmentService,
@IFileService fileService: IFileService,
@IRemoteTunnelService private remoteTunnelService: IRemoteTunnelService, @IRemoteTunnelService private remoteTunnelService: IRemoteTunnelService,
@ICommandService private commandService: ICommandService, @ICommandService private commandService: ICommandService,
@IWorkspaceContextService private workspaceContextService: IWorkspaceContextService, @IWorkspaceContextService private workspaceContextService: IWorkspaceContextService,
@@ -127,20 +124,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
} }
this.serverConfiguration = serverConfiguration; this.serverConfiguration = serverConfiguration;
this._register(this.remoteTunnelService.onDidChangeTunnelStatus(status => { this._register(this.remoteTunnelService.onDidChangeTunnelStatus(s => this.handleTunnelStatusUpdate(s)));
this.connectionInfo = undefined;
if (status.type === 'disconnected') {
if (status.onTokenFailed) {
this.expiredSessions.add(status.onTokenFailed.sessionId);
}
this.connectionStateContext.set('disconnected');
} else if (status.type === 'connecting') {
this.connectionStateContext.set('connecting');
} else if (status.type === 'connected') {
this.connectionInfo = status.info;
this.connectionStateContext.set('connected');
}
}));
this.registerCommands(); this.registerCommands();
@@ -149,6 +133,21 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
this.recommendRemoteExtensionIfNeeded(); this.recommendRemoteExtensionIfNeeded();
} }
private handleTunnelStatusUpdate(status: TunnelStatus) {
this.connectionInfo = undefined;
if (status.type === 'disconnected') {
if (status.onTokenFailed) {
this.expiredSessions.add(status.onTokenFailed.sessionId);
}
this.connectionStateContext.set('disconnected');
} else if (status.type === 'connecting') {
this.connectionStateContext.set('connecting');
} else if (status.type === 'connected') {
this.connectionInfo = status.info;
this.connectionStateContext.set('connected');
}
}
private async recommendRemoteExtensionIfNeeded() { private async recommendRemoteExtensionIfNeeded() {
await this.extensionService.whenInstalledExtensionsRegistered(); await this.extensionService.whenInstalledExtensionsRegistered();
@@ -228,10 +227,17 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
} }
private async initialize(): Promise<void> { private async initialize(): Promise<void> {
const session = await this.remoteTunnelService.getSession(); const [mode, status] = await Promise.all([
if (session && session.token) { this.remoteTunnelService.getMode(),
this.remoteTunnelService.getTunnelStatus(),
]);
this.handleTunnelStatusUpdate(status);
if (mode.active && mode.session.token) {
return; // already initialized, token available return; // already initialized, token available
} }
return await this.progressService.withProgress( return await this.progressService.withProgress(
{ {
location: ProgressLocation.Window, location: ProgressLocation.Window,
@@ -248,13 +254,13 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
} }
}); });
let newSession: IRemoteTunnelSession | undefined; let newSession: IRemoteTunnelSession | undefined;
if (session) { if (mode.active) {
const token = await this.getSessionToken(session); const token = await this.getSessionToken(mode.session);
if (token) { if (token) {
newSession = { ...session, token }; newSession = { ...mode.session, token };
} }
} }
const status = await this.remoteTunnelService.initialize(newSession); const status = await this.remoteTunnelService.initialize(mode.active && newSession ? { ...mode, session: newSession } : INACTIVE_TUNNEL_MODE);
listener.dispose(); listener.dispose();
if (status.type === 'connected') { if (status.type === 'connected') {
@@ -267,7 +273,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
} }
private async startTunnel(): Promise<ConnectionInfo | undefined> { private async startTunnel(asService: boolean): Promise<ConnectionInfo | undefined> {
if (this.connectionInfo) { if (this.connectionInfo) {
return this.connectionInfo; return this.connectionInfo;
} }
@@ -301,6 +307,19 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
listener.dispose(); listener.dispose();
completed = true; completed = true;
s(status.info); s(status.info);
if (status.serviceInstallFailed) {
this.notificationService.notify({
severity: Severity.Warning,
message: localize(
{
key: 'remoteTunnel.serviceInstallFailed',
comment: ['{Locked="](command:{0})"}']
},
"Installation as a service failed, and we fell back to running the tunnel for this session. See the [error log](command:{0}) for details.",
RemoteTunnelCommandIds.showLog,
),
});
}
break; break;
case 'disconnected': case 'disconnected':
listener.dispose(); listener.dispose();
@@ -312,7 +331,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
}); });
const token = authenticationSession.session.idToken ?? authenticationSession.session.accessToken; const token = authenticationSession.session.idToken ?? authenticationSession.session.accessToken;
const account: IRemoteTunnelSession = { sessionId: authenticationSession.session.id, token, providerId: authenticationSession.providerId, accountLabel: authenticationSession.session.account.label }; const account: IRemoteTunnelSession = { sessionId: authenticationSession.session.id, token, providerId: authenticationSession.providerId, accountLabel: authenticationSession.session.account.label };
this.remoteTunnelService.startTunnel(account).then(status => { this.remoteTunnelService.startTunnel({ active: true, asService, session: account }).then(status => {
if (!completed && (status.type === 'connected' || status.type === 'disconnected')) { if (!completed && (status.type === 'connected' || status.type === 'disconnected')) {
listener.dispose(); listener.dispose();
if (status.type === 'connected') { if (status.type === 'connected') {
@@ -403,7 +422,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
private async getAllSessions(): Promise<ExistingSessionItem[]> { private async getAllSessions(): Promise<ExistingSessionItem[]> {
const authenticationProviders = await this.getAuthenticationProviders(); const authenticationProviders = await this.getAuthenticationProviders();
const accounts = new Map<string, ExistingSessionItem>(); const accounts = new Map<string, ExistingSessionItem>();
const currentAccount = await this.remoteTunnelService.getSession(); const currentAccount = await this.remoteTunnelService.getMode();
let currentSession: ExistingSessionItem | undefined; let currentSession: ExistingSessionItem | undefined;
for (const provider of authenticationProviders) { for (const provider of authenticationProviders) {
@@ -413,7 +432,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
if (!this.expiredSessions.has(session.id)) { if (!this.expiredSessions.has(session.id)) {
const item = this.createExistingSessionItem(session, provider.id); const item = this.createExistingSessionItem(session, provider.id);
accounts.set(item.session.account.id, item); accounts.set(item.session.account.id, item);
if (currentAccount && currentAccount.sessionId === session.id) { if (currentAccount.active && currentAccount.session.sessionId === session.id) {
currentSession = item; currentSession = item;
} }
} }
@@ -483,6 +502,8 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
const commandService = accessor.get(ICommandService); const commandService = accessor.get(ICommandService);
const storageService = accessor.get(IStorageService); const storageService = accessor.get(IStorageService);
const dialogService = accessor.get(IDialogService); const dialogService = accessor.get(IDialogService);
const quickInputService = accessor.get(IQuickInputService);
const productService = accessor.get(IProductService);
const didNotifyPreview = storageService.getBoolean(REMOTE_TUNNEL_PROMPTED_PREVIEW_STORAGE_KEY, StorageScope.APPLICATION, false); const didNotifyPreview = storageService.getBoolean(REMOTE_TUNNEL_PROMPTED_PREVIEW_STORAGE_KEY, StorageScope.APPLICATION, false);
if (!didNotifyPreview) { if (!didNotifyPreview) {
@@ -497,12 +518,33 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
storageService.store(REMOTE_TUNNEL_PROMPTED_PREVIEW_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.USER); storageService.store(REMOTE_TUNNEL_PROMPTED_PREVIEW_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.USER);
} }
const connectionInfo = await that.startTunnel(); const disposables = new DisposableStore();
const quickPick = quickInputService.createQuickPick<IQuickPickItem & { service: boolean }>();
quickPick.placeholder = localize('tunnel.enable.placeholder', 'Select how you want to enable access');
quickPick.items = [
{ service: false, label: localize('tunnel.enable.session', 'Turn on for this session'), description: localize('tunnel.enable.session.description', 'Run whenever {0} is open', productService.nameShort) },
{ service: true, label: localize('tunnel.enable.service', 'Install as a service'), description: localize('tunnel.enable.service.description', 'Run whenever you\'re logged in') }
];
const asService = await new Promise<boolean | undefined>(resolve => {
disposables.add(quickPick.onDidAccept(() => resolve(quickPick.selectedItems[0]?.service)));
disposables.add(quickPick.onDidHide(() => resolve(undefined)));
quickPick.show();
});
quickPick.dispose();
if (asService === undefined) {
return; // no-op
}
const connectionInfo = await that.startTunnel(/* installAsService= */ asService);
if (connectionInfo) { if (connectionInfo) {
const linkToOpen = that.getLinkToOpen(connectionInfo); const linkToOpen = that.getLinkToOpen(connectionInfo);
const remoteExtension = that.serverConfiguration.extension; const remoteExtension = that.serverConfiguration.extension;
const linkToOpenForMarkdown = linkToOpen.toString(false).replace(/\)/g, '%29'); const linkToOpenForMarkdown = linkToOpen.toString(false).replace(/\)/g, '%29');
await notificationService.notify({ notificationService.notify({
severity: Severity.Info, severity: Severity.Info,
message: message:
localize( localize(
@@ -525,7 +567,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
const usedOnHostMessage: UsedOnHostMessage = { hostName: connectionInfo.tunnelName, timeStamp: new Date().getTime() }; const usedOnHostMessage: UsedOnHostMessage = { hostName: connectionInfo.tunnelName, timeStamp: new Date().getTime() };
storageService.store(REMOTE_TUNNEL_USED_STORAGE_KEY, JSON.stringify(usedOnHostMessage), StorageScope.APPLICATION, StorageTarget.USER); storageService.store(REMOTE_TUNNEL_USED_STORAGE_KEY, JSON.stringify(usedOnHostMessage), StorageScope.APPLICATION, StorageTarget.USER);
} else { } else {
await notificationService.notify({ notificationService.notify({
severity: Severity.Info, severity: Severity.Info,
message: localize('progress.turnOn.failed', message: localize('progress.turnOn.failed',
"Unable to turn on the remote tunnel access. Check the Remote Tunnel Service log for details."), "Unable to turn on the remote tunnel access. Check the Remote Tunnel Service log for details."),
@@ -699,7 +741,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
private async showManageOptions() { private async showManageOptions() {
const account = await this.remoteTunnelService.getSession(); const account = await this.remoteTunnelService.getMode();
return new Promise<void>((c, e) => { return new Promise<void>((c, e) => {
const disposables = new DisposableStore(); const disposables = new DisposableStore();
@@ -721,7 +763,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
items.push({ id: RemoteTunnelCommandIds.showLog, label: localize('manage.showLog', 'Show Log') }); items.push({ id: RemoteTunnelCommandIds.showLog, label: localize('manage.showLog', 'Show Log') });
items.push({ type: 'separator' }); items.push({ type: 'separator' });
items.push({ id: RemoteTunnelCommandIds.configure, label: localize('manage.tunnelName', 'Change Tunnel Name'), description: this.connectionInfo?.tunnelName }); items.push({ id: RemoteTunnelCommandIds.configure, label: localize('manage.tunnelName', 'Change Tunnel Name'), description: this.connectionInfo?.tunnelName });
items.push({ id: RemoteTunnelCommandIds.turnOff, label: RemoteTunnelCommandLabels.turnOff, description: account ? `${account.accountLabel} (${account.providerId})` : undefined }); items.push({ id: RemoteTunnelCommandIds.turnOff, label: RemoteTunnelCommandLabels.turnOff, description: account.active ? `${account.session.accountLabel} (${account.session.providerId})` : undefined });
quickPick.items = items; quickPick.items = items;
disposables.add(quickPick.onDidAccept(() => { disposables.add(quickPick.onDidAccept(() => {