Skip to content

Commit d759887

Browse files
committed
Start adding server selection API
1 parent 255c82f commit d759887

File tree

10 files changed

+818
-8
lines changed

10 files changed

+818
-8
lines changed

package-lock.json

Lines changed: 406 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@types/glob": "^7.1.1",
4848
"@types/mocha": "^5.2.6",
4949
"@types/node": "^8.10.60",
50+
"@types/keytar": "^4.4.2",
5051
"glob": "^7.1.6",
5152
"mocha": "^7.1.2",
5253
"ts-loader": "^6.2.2",
@@ -56,7 +57,11 @@
5657
"vscode-test": "^1.3.0"
5758
},
5859
"main": "./out/extension",
59-
"activationEvents": [],
60+
"activationEvents": [
61+
"onCommand:intersystems-community.servermanager.testPickServer",
62+
"onCommand:intersystems-community.servermanager.testPickServerFlushingCachedCredentials",
63+
"onCommand:intersystems-community.servermanager.testPickServerDetailed"
64+
],
6065
"contributes": {
6166
"configuration": {
6267
"title": "InterSystems® Server Manager",
@@ -173,6 +178,23 @@
173178
"additionalProperties": false
174179
}
175180
}
176-
}
181+
},
182+
"commands": [
183+
{
184+
"command": "intersystems-community.servermanager.testPickServer",
185+
"category": "InterSystems Server Manager",
186+
"title": "Test Server Selection"
187+
},
188+
{
189+
"command": "intersystems-community.servermanager.testPickServerFlushingCachedCredentials",
190+
"category": "InterSystems Server Manager",
191+
"title": "Test Server Selection (flush cached credentials)"
192+
},
193+
{
194+
"command": "intersystems-community.servermanager.testPickServerDetailed",
195+
"category": "InterSystems Server Manager",
196+
"title": "Test Server Selection with Details"
197+
}
198+
]
177199
}
178200
}

src/api/getServerNames.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import * as vscode from 'vscode';
2+
import { ServerName, ServerSpec } from '../extension';
3+
4+
export function getServerNames(scope?: vscode.ConfigurationScope): ServerName[] {
5+
let names: ServerName[] = [];
6+
const servers = vscode.workspace.getConfiguration('intersystems', scope).get('servers');
7+
8+
if (typeof servers === 'object' && servers) {
9+
const defaultName: string = servers['/default'] || '';
10+
if (defaultName.length > 0 && servers[defaultName]) {
11+
names.push({
12+
name: defaultName,
13+
description: `${servers[defaultName].description || ''} (default)`,
14+
detail: serverDetail(servers[defaultName])
15+
});
16+
}
17+
for (const key in servers) {
18+
if (!key.startsWith('/') && key !== defaultName) {
19+
names.push({
20+
name: key,
21+
description: servers[key].description || '',
22+
detail: serverDetail(servers[key])
23+
});
24+
}
25+
}
26+
}
27+
return names;
28+
}
29+
30+
function serverDetail(connSpec: ServerSpec): string {
31+
return `${connSpec.webServer.scheme || 'http'}://${connSpec.webServer.host}:${connSpec.webServer.port}/${connSpec.webServer.pathPrefix || ''}`;
32+
}

src/api/getServerSpec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as vscode from 'vscode';
2+
import { ServerSpec } from '../extension';
3+
import { Keychain } from '../keychain';
4+
5+
interface CredentialSet {
6+
username: string,
7+
password: string
8+
}
9+
10+
let credentialCache = new Map<string, CredentialSet>();
11+
12+
export async function getServerSpec(name: string, scope?: vscode.ConfigurationScope, flushCredentialCache: boolean = false): Promise<ServerSpec | undefined> {
13+
if (flushCredentialCache) {
14+
credentialCache[name] = undefined;
15+
}
16+
let server: ServerSpec | undefined = vscode.workspace.getConfiguration('intersystems.servers', scope).get(name);
17+
18+
// Unknown server
19+
if (!server) {
20+
return undefined;
21+
}
22+
23+
server.name = name;
24+
server.storePassword = server.storePassword ?? true;
25+
server.description = server.description || '';
26+
server.webServer.scheme = server.webServer.scheme || 'http';
27+
server.webServer.pathPrefix = server.webServer.pathPrefix || '';
28+
29+
// Obtain a username (including blank to try connecting anonymously)
30+
if (!server.username) {
31+
await vscode.window
32+
.showInputBox({
33+
placeHolder: `Username to connect to InterSystems server '${name}' as`,
34+
prompt: 'Leave empty to attempt unauthenticated access',
35+
ignoreFocusOut: true,
36+
})
37+
.then((username) => {
38+
if (username && server) {
39+
server.username = username;
40+
} else {
41+
return undefined;
42+
}
43+
});
44+
if (!server.username) {
45+
server.username = '';
46+
server.password = '';
47+
}
48+
}
49+
50+
// Obtain password from session cache or keychain unless trying to connect anonymously
51+
if (server.username && !server.password) {
52+
if (credentialCache[name] && credentialCache[name].username === server.username) {
53+
server.password = credentialCache[name];
54+
} else if (server.storePassword) {
55+
const keychain = new Keychain(name);
56+
const password = await keychain.getPassword().then(result => {
57+
if (typeof result === 'string') {
58+
return result;
59+
} else {
60+
return undefined;
61+
}
62+
});
63+
if (password) {
64+
server.password = password;
65+
credentialCache[name] = {username: server.username, password: password};
66+
}
67+
}
68+
69+
}
70+
if (server.username && !server.password) {
71+
await vscode.window
72+
.showInputBox({
73+
password: true,
74+
placeHolder: `Password for user '${server.username}' on InterSystems server '${name}'`,
75+
validateInput: (value => {
76+
return value.length > 0 ? '' : 'Mandatory field';
77+
}),
78+
ignoreFocusOut: true,
79+
})
80+
.then((password) => {
81+
if (password && server) {
82+
server.password = password;
83+
credentialCache[name] = {username: server.username, password: password};
84+
} else {
85+
server = undefined;
86+
}
87+
})
88+
}
89+
return server;
90+
}

src/api/pickServer.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as vscode from 'vscode';
2+
import { ServerSpec } from '../extension';
3+
import { getServerNames } from './getServerNames';
4+
import { getServerSpec } from './getServerSpec';
5+
6+
export async function pickServer(scope?: vscode.ConfigurationScope, options: vscode.QuickPickOptions = {}, flushCredentialCache: boolean = false): Promise<ServerSpec | undefined> {
7+
const names = getServerNames(scope);
8+
9+
let qpItems: vscode.QuickPickItem[] = [];
10+
11+
options.matchOnDescription = options?.matchOnDescription || true;
12+
options.placeHolder = options?.placeHolder || 'Pick an InterSystems server';
13+
options.canPickMany = false;
14+
15+
names.forEach(element => {
16+
qpItems.push({label: element.name, description: element.description, detail: options?.matchOnDetail ? element.detail : undefined});
17+
});
18+
return await vscode.window.showQuickPick(qpItems, options).then(item => {
19+
if (item) {
20+
const name = item.label;
21+
return getServerSpec(name, scope, flushCredentialCache).then(connSpec => {
22+
if (connSpec) {
23+
connSpec.name = name;
24+
}
25+
return connSpec;
26+
});
27+
}
28+
})
29+
}

src/commands/testPickServer.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import * as vscode from 'vscode';
2+
import { extensionId } from '../extension';
3+
4+
export async function testPickServer() {
5+
await commonTestPickServer();
6+
}
7+
8+
export async function testPickServerWithoutCachedCredentials() {
9+
await commonTestPickServer(undefined, true);
10+
}
11+
12+
export async function testPickServerDetailed() {
13+
await commonTestPickServer({matchOnDetail: true});
14+
}
15+
16+
async function commonTestPickServer(options?: vscode.QuickPickOptions, flushCredentialCache: boolean = false) {
17+
// Deliberately uses its own API in the same way as other extensions would
18+
const serverManagerExtension = vscode.extensions.getExtension(extensionId);
19+
if (!serverManagerExtension) {
20+
vscode.window.showErrorMessage(`Extension '${extensionId}' is not installed, or has been disabled.`)
21+
return
22+
}
23+
if (!serverManagerExtension.isActive) {
24+
serverManagerExtension.activate();
25+
}
26+
const myApi = serverManagerExtension.exports;
27+
28+
const connSpec = await myApi.pickServer(undefined, options, flushCredentialCache);
29+
if (connSpec) {
30+
vscode.window.showInformationMessage(`Picked server '${connSpec.name}' at ${connSpec.webServer.scheme}://${connSpec.webServer.host}:${connSpec.webServer.port}/${connSpec.webServer.pathPrefix} ${!connSpec.username ? 'with unauthenticated access' : 'as user ' + connSpec.username }.`, 'OK');
31+
}
32+
}

src/extension.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,71 @@
11
'use strict';
2-
export const extensionId = "intersystems-community.servermanager";
2+
export const extensionId = 'intersystems-community.servermanager';
33

44
import * as vscode from 'vscode';
5+
import { testPickServer, testPickServerWithoutCachedCredentials as testPickServerFlushingCachedCredentials, testPickServerDetailed } from './commands/testPickServer';
6+
import { pickServer } from './api/pickServer';
7+
import { getServerNames } from './api/getServerNames';
8+
import { getServerSpec } from './api/getServerSpec';
9+
10+
export interface ServerName {
11+
name: string,
12+
description: string,
13+
detail: string
14+
}
15+
16+
export interface WebServerSpec {
17+
scheme: string,
18+
host: string,
19+
port: number,
20+
pathPrefix: string
21+
}
22+
23+
export interface ServerSpec {
24+
name: string,
25+
webServer: WebServerSpec,
26+
username: string,
27+
password: string,
28+
storePassword: boolean,
29+
description: string
30+
}
531

632
export function activate(context: vscode.ExtensionContext) {
33+
34+
35+
// Register the commands
36+
context.subscriptions.push(
37+
vscode.commands.registerCommand(`${extensionId}.testPickServer`, () => {
38+
testPickServer();
39+
})
40+
);
41+
context.subscriptions.push(
42+
vscode.commands.registerCommand(`${extensionId}.testPickServerFlushingCachedCredentials`, () => {
43+
testPickServerFlushingCachedCredentials();
44+
})
45+
);
46+
context.subscriptions.push(
47+
vscode.commands.registerCommand(`${extensionId}.testPickServerDetailed`, () => {
48+
testPickServerDetailed();
49+
})
50+
);
51+
52+
let api = {
53+
async pickServer(scope?: vscode.ConfigurationScope, options: vscode.QuickPickOptions = {}, flushCredentialCache: boolean = false): Promise<ServerSpec | undefined> {
54+
return await pickServer(scope, options, flushCredentialCache);
55+
56+
},
57+
getServerNames(scope?: vscode.ConfigurationScope): ServerName[] {
58+
return getServerNames(scope);
59+
},
60+
61+
async getServerSpec(name: string, scope?: vscode.ConfigurationScope): Promise<ServerSpec | undefined> {
62+
return await getServerSpec(name, scope);
63+
}
64+
65+
};
66+
67+
// 'export' public api-surface
68+
return api;
769
}
870

971
export function deactivate() {

src/keychain.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Access the keytar native module shipped in vscode
2+
import type * as keytarType from 'keytar';
3+
import * as vscode from 'vscode';
4+
import logger from './logger';
5+
import { extensionId } from './extension';
6+
7+
function getKeytar(): Keytar | undefined {
8+
try {
9+
return require('keytar');
10+
} catch (err) {
11+
console.log(err);
12+
}
13+
14+
return undefined;
15+
}
16+
17+
export type Keytar = {
18+
getPassword: typeof keytarType['getPassword'];
19+
setPassword: typeof keytarType['setPassword'];
20+
deletePassword: typeof keytarType['deletePassword'];
21+
};
22+
23+
export class Keychain {
24+
private keytar: Keytar;
25+
private serviceId: string;
26+
private accountId: string;
27+
28+
constructor(connectionName: string) {
29+
const keytar = getKeytar();
30+
if (!keytar) {
31+
throw new Error('System keychain unavailable');
32+
}
33+
34+
this.keytar = keytar;
35+
this.serviceId = `${vscode.env.uriScheme}-${extensionId}:password`;
36+
this.accountId = connectionName;
37+
}
38+
39+
async setPassword(password: string): Promise<void> {
40+
try {
41+
return await this.keytar.setPassword(this.serviceId, this.accountId, password);
42+
} catch (e) {
43+
// Ignore
44+
await vscode.window.showErrorMessage(`Writing password to the keychain failed with error '{0}'.`, e.message);
45+
}
46+
}
47+
48+
async getPassword(): Promise<string | null | undefined> {
49+
try {
50+
return await this.keytar.getPassword(this.serviceId, this.accountId);
51+
} catch (e) {
52+
// Ignore
53+
logger.error(`Getting password failed: ${e}`);
54+
return Promise.resolve(undefined);
55+
}
56+
}
57+
58+
async deletePassword(): Promise<boolean | undefined> {
59+
try {
60+
return await this.keytar.deletePassword(this.serviceId, this.accountId);
61+
} catch (e) {
62+
// Ignore
63+
logger.error(`Deleting password failed: ${e}`);
64+
return Promise.resolve(undefined);
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)