Skip to content

Commit 513bd62

Browse files
authored
feat(dev): add logic to download beta artifacts from a URL (#3038)
## Problem We have no easy way to share beta builds with users ## Solution Add codepath to download artifact from a URL. The user is prompted to install the new artifact if it is different from the currently installed extension. **This codepath is never accessible in a release version** `src/dev/config.ts` is used to specify the beta URL to ensure that this behavior is dependent on compilation. ### Packaging A 'config' file has been added to specify development constants. Keeping this as a separate file makes it easier to catch potential leaks and clean the git history of possibly sensitive information. If `betaUrl` is specified then the VSIX will be built with an artificially high version to stop auto-updates e.g. `1.999.0-xxxxxxxx`.
1 parent c146b6e commit 513bd62

File tree

8 files changed

+249
-16
lines changed

8 files changed

+249
-16
lines changed

CONTRIBUTING.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,10 @@ As a simple example, let's say I wanted to add a new icon for CloudWatch log str
333333
getIcon('aws-cloudwatch-log-stream')
334334
```
335335

336+
### Beta artifacts
337+
338+
The Toolkit codebase contains logic in `src/dev/beta.ts` to support development during private betas. Creating a beta artifact requires a _stable_ URL to source Toolkit builds from. This URL should be added to `src/dev/config.ts`. Subsequent Toolkit artifacts will have their version set to `1.999.0` with a commit hash. Builds will automatically query the URL to check for a new build once a day and on every reload.
339+
336340
## Importing icons from other open source repos
337341

338342
If you are contribuing visual assets from other open source repos, the source repo must have a compatible license (such as MIT), and we need to document the source of the images. Follow these steps:

scripts/build/package.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
*/
55

66
//
7-
// Creates an artifact that can be given to users for testing alpha builds:
7+
// Creates an artifact that can be given to users for testing alpha/beta builds:
88
//
9-
// aws-toolkit-vscode-1.99.0-SNAPSHOT.vsix
9+
// aws-toolkit-vscode-1.999.0-xxxxxxxxxxxx.vsix
10+
//
11+
// Where `xxxxxxxxxxxx` is the first 12 characters of the commit hash that produced the artifact
1012
//
1113
// The script works like this:
1214
// 1. temporarily change `version` in package.json
@@ -18,6 +20,9 @@ import type * as manifest from '../../package.json'
1820
import * as child_process from 'child_process'
1921
import * as fs from 'fs-extra'
2022

23+
// Importing from `src` isn't great but it does make things simple
24+
import { betaUrl } from '../../src/dev/config'
25+
2126
const packageJsonFile = './package.json'
2227
const webpackConfigJsFile = './webpack.config.js'
2328

@@ -54,6 +59,13 @@ function isRelease(): boolean {
5459
return child_process.execSync('git tag -l --contains HEAD').toString() !== ''
5560
}
5661

62+
/**
63+
* Whether or not this a private beta build
64+
*/
65+
function isBeta(): boolean {
66+
return !!betaUrl
67+
}
68+
5769
/**
5870
* Gets a suffix to append to the version-string, or empty for release builds.
5971
*
@@ -79,6 +91,10 @@ function main() {
7991
try {
8092
release = isRelease()
8193

94+
if (release && isBeta()) {
95+
throw new Error('Cannot package VSIX as both a release and a beta simultaneously')
96+
}
97+
8298
if (!release || args.debug) {
8399
// Create backup files so we can restore the originals later.
84100
fs.copyFileSync(packageJsonFile, `${packageJsonFile}.bk`)
@@ -87,7 +103,13 @@ function main() {
87103
const packageJson: typeof manifest = JSON.parse(fs.readFileSync(packageJsonFile, { encoding: 'utf-8' }))
88104
const versionSuffix = getVersionSuffix()
89105
const version = packageJson.version
90-
packageJson.version = args.debug ? `1.99.0${versionSuffix}` : version.replace('-SNAPSHOT', versionSuffix)
106+
// Setting the version to an arbitrarily high number stops VSC from auto-updating the beta extension
107+
const betaOrDebugVersion = `1.999.0${versionSuffix}`
108+
if (isBeta() || args.debug) {
109+
packageJson.version = betaOrDebugVersion
110+
} else {
111+
packageJson.version = version.replace('-SNAPSHOT', versionSuffix)
112+
}
91113

92114
if (args.skipClean) {
93115
// Clearly we need `prepublish` to be a standalone script and not a bunch of `npm` commands

src/dev/activation.ts

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

66
import * as vscode from 'vscode'
7+
import * as config from './config'
78
import { ExtContext } from '../shared/extensions'
89
import { createCommonButtons } from '../shared/ui/buttons'
910
import { createQuickPick } from '../shared/ui/pickerPrompter'
@@ -13,6 +14,10 @@ import { Commands } from '../shared/vscode/commands2'
1314
import { createInputBox } from '../shared/ui/inputPrompter'
1415
import { Wizard } from '../shared/wizards/wizard'
1516
import { deleteDevEnvCommand, installVsixCommand, openTerminalCommand } from './codecatalyst'
17+
import { watchBetaVSIX } from './beta'
18+
import { isCloud9 } from '../shared/extensionUtilities'
19+
import { entries } from '../shared/utilities/tsUtils'
20+
import { isReleaseVersion } from '../shared/vscode/env'
1621

1722
interface MenuOption {
1823
readonly label: string
@@ -96,10 +101,10 @@ export function activate(ctx: ExtContext): void {
96101

97102
const editor = new ObjectEditor(ctx.extensionContext)
98103
ctx.extensionContext.subscriptions.push(openStorageCommand.register(editor))
99-
}
100104

101-
function entries<T extends Record<string, U>, U>(obj: T): { [P in keyof T]: [P, T[P]] }[keyof T][] {
102-
return Object.entries(obj) as { [P in keyof T]: [P, T[P]] }[keyof T][]
105+
if (!isCloud9() && !isReleaseVersion() && config.betaUrl) {
106+
ctx.extensionContext.subscriptions.push(watchBetaVSIX(config.betaUrl))
107+
}
103108
}
104109

105110
async function openMenu(ctx: ExtContext, options: typeof menuOptions): Promise<void> {

src/dev/beta.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*!
2+
* Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as path from 'path'
7+
import * as nls from 'vscode-nls'
8+
import * as vscode from 'vscode'
9+
import * as AdmZip from 'adm-zip'
10+
import got from 'got'
11+
import globals from '../shared/extensionGlobals'
12+
import { getLogger } from '../shared/logger'
13+
import { VSCODE_EXTENSION_ID } from '../shared/extensions'
14+
import { makeTemporaryToolkitFolder } from '../shared/filesystemUtilities'
15+
import { reloadWindowPrompt } from '../shared/utilities/vsCodeUtils'
16+
import { isUserCancelledError, ToolkitError } from '../shared/errors'
17+
import { SystemUtilities } from '../shared/systemUtilities'
18+
import { telemetry } from '../shared/telemetry/telemetry'
19+
import { cast } from '../shared/utilities/typeConstructors'
20+
import { CancellationError } from '../shared/utilities/timeoutUtils'
21+
22+
const localize = nls.loadMessageBundle()
23+
24+
const downloadIntervalMs = 1000 * 60 * 60 * 24 // A day in milliseconds
25+
const betaToolkitKey = 'dev.beta'
26+
27+
interface BetaToolkit {
28+
readonly needUpdate: boolean
29+
readonly lastCheck: number
30+
}
31+
32+
function getBetaToolkitData(vsixUrl: string): BetaToolkit | undefined {
33+
return globals.context.globalState.get<Record<string, BetaToolkit>>(betaToolkitKey, {})[vsixUrl]
34+
}
35+
36+
async function updateBetaToolkitData(vsixUrl: string, data: BetaToolkit) {
37+
await globals.context.globalState.update(betaToolkitKey, {
38+
...globals.context.globalState.get<Record<string, BetaToolkit>>(betaToolkitKey, {}),
39+
[vsixUrl]: data,
40+
})
41+
}
42+
43+
/**
44+
* Watch the beta VSIX daily for changes.
45+
* If this is the first time we are watching the beta version or if its been 24 hours since it was last checked then try to prompt for update
46+
*/
47+
export function watchBetaVSIX(vsixUrl: string): vscode.Disposable {
48+
getLogger().info(`dev: watching ${vsixUrl} for beta artifacts`)
49+
50+
const toolkit = getBetaToolkitData(vsixUrl)
51+
if (!toolkit || toolkit.needUpdate || Date.now() - toolkit.lastCheck > downloadIntervalMs) {
52+
runAutoUpdate(vsixUrl)
53+
}
54+
55+
const interval = globals.clock.setInterval(() => runAutoUpdate(vsixUrl), downloadIntervalMs)
56+
return { dispose: () => clearInterval(interval) }
57+
}
58+
59+
async function runAutoUpdate(vsixUrl: string) {
60+
getLogger().debug(`dev: checking ${vsixUrl} for a new version`)
61+
62+
try {
63+
await telemetry.aws_autoUpdateBeta.run(() => checkBetaUrl(vsixUrl))
64+
} catch (e) {
65+
if (!isUserCancelledError(e)) {
66+
getLogger().warn(`dev: beta extension auto-update failed: %s`, e)
67+
}
68+
}
69+
}
70+
71+
/**
72+
* Prompt to update the beta extension when required
73+
*/
74+
async function checkBetaUrl(vsixUrl: string): Promise<void> {
75+
const resp = await got(vsixUrl).buffer()
76+
const latestBetaInfo = await getExtensionInfo(resp)
77+
if (VSCODE_EXTENSION_ID.awstoolkit !== `${latestBetaInfo.publisher}.${latestBetaInfo.name}`) {
78+
throw new ToolkitError('URL does not point to an AWS Toolkit artifact', { code: 'InvalidExtensionName' })
79+
}
80+
81+
const currentVersion = vscode.extensions.getExtension(VSCODE_EXTENSION_ID.awstoolkit)?.packageJSON.version
82+
if (latestBetaInfo.version !== currentVersion) {
83+
const tmpFolder = await makeTemporaryToolkitFolder()
84+
const betaPath = vscode.Uri.joinPath(vscode.Uri.file(tmpFolder), path.basename(vsixUrl))
85+
await SystemUtilities.writeFile(betaPath, resp)
86+
87+
try {
88+
await promptInstallToolkit(betaPath, latestBetaInfo.version, vsixUrl)
89+
} finally {
90+
await SystemUtilities.remove(tmpFolder)
91+
}
92+
} else {
93+
await updateBetaToolkitData(vsixUrl, {
94+
lastCheck: Date.now(),
95+
needUpdate: false,
96+
})
97+
}
98+
}
99+
100+
interface ExtensionInfo {
101+
readonly name: string
102+
readonly version: string
103+
readonly publisher: string
104+
}
105+
106+
/**
107+
* Get information about the extension or error if no version could be found
108+
*
109+
* @param extension The URI of the extension on disk or the raw data
110+
* @returns The version + name of the extension
111+
* @throws Error if the extension manifest could not be found or parsed
112+
*/
113+
async function getExtensionInfo(extension: Buffer): Promise<ExtensionInfo>
114+
async function getExtensionInfo(extensionLocation: vscode.Uri): Promise<ExtensionInfo>
115+
async function getExtensionInfo(extensionOrLocation: vscode.Uri | Buffer): Promise<ExtensionInfo> {
116+
const fileNameOrData = extensionOrLocation instanceof vscode.Uri ? extensionOrLocation.fsPath : extensionOrLocation
117+
const packageFile = new AdmZip(fileNameOrData).getEntry('extension/package.json')
118+
const packageJSON = packageFile?.getData().toString()
119+
if (!packageJSON) {
120+
throw new ToolkitError('Extension does not have a `package.json`', { code: 'NoPackageJson' })
121+
}
122+
123+
try {
124+
const data = JSON.parse(packageJSON)
125+
126+
return {
127+
name: cast(data.name, String),
128+
version: cast(data.version, String),
129+
publisher: cast(data.publisher, String),
130+
}
131+
} catch (e) {
132+
throw ToolkitError.chain(e, 'Unable to parse extension data', { code: 'BadParse' })
133+
}
134+
}
135+
136+
async function promptInstallToolkit(pluginPath: vscode.Uri, newVersion: string, vsixUrl: string): Promise<void> {
137+
const vsixName = path.basename(pluginPath.fsPath)
138+
const installBtn = localize('AWS.missingExtension.install', 'Install...')
139+
140+
const response = await vscode.window.showInformationMessage(
141+
localize(
142+
'AWS.dev.beta.updatePrompt',
143+
`New version of AWS Toolkit is available at the [beta URL]({0}). Install the new version "{1}" to continue using the beta.`,
144+
vsixUrl,
145+
newVersion
146+
),
147+
installBtn
148+
)
149+
150+
switch (response) {
151+
case installBtn:
152+
try {
153+
getLogger().info(`dev: installing artifact ${vsixName}`)
154+
await vscode.commands.executeCommand('workbench.extensions.installExtension', pluginPath)
155+
await updateBetaToolkitData(vsixUrl, {
156+
lastCheck: Date.now(),
157+
needUpdate: false,
158+
})
159+
reloadWindowPrompt(localize('AWS.dev.beta.reloadPrompt', 'Reload now to use the new beta AWS Toolkit.'))
160+
} catch (e) {
161+
throw ToolkitError.chain(e, `Failed to install ${vsixName}`, { code: 'FailedExtensionInstall' })
162+
}
163+
break
164+
case undefined:
165+
await updateBetaToolkitData(vsixUrl, {
166+
lastCheck: Date.now(),
167+
needUpdate: true,
168+
})
169+
throw new CancellationError('user')
170+
}
171+
}

src/dev/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*!
2+
* Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
// This file is strictly used for private development
7+
// Nothing in this file should have a truthy value on mainline
8+
9+
export const betaUrl = ''

src/shared/settings.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,15 @@ function createSettingsClass<T extends TypeDescriptor>(section: string, descript
244244
return cast<Inner[K]>(value, descriptor[key])
245245
}
246246

247+
protected getOrUndefined<K extends keyof Inner>(key: K & string) {
248+
const value = this.config.get(key)
249+
if (value === undefined) {
250+
return value
251+
}
252+
253+
return this.get(key, undefined)
254+
}
255+
247256
private readonly getChangedEmitter = once(() => {
248257
// For a setting `aws.foo.bar`:
249258
// - the "section" is `aws.foo`
@@ -255,13 +264,13 @@ function createSettingsClass<T extends TypeDescriptor>(section: string, descript
255264
// value is a valid way to express that the key exists but no (valid) value is set.
256265

257266
const props = keys(descriptor)
258-
const store = toRecord(props, p => this.get(p, undefined))
267+
const store = toRecord(props, p => this.getOrUndefined(p))
259268
const emitter = new vscode.EventEmitter<{ readonly key: keyof T }>()
260269
const listener = this.settings.onDidChangeSection(section, event => {
261270
const isDifferent = (p: keyof T & string) => {
262271
const isDifferentLazy = () => {
263272
const previous = store[p]
264-
return previous !== (store[p] = this.get(p, undefined))
273+
return previous !== (store[p] = this.getOrUndefined(p))
265274
}
266275

267276
return event.affectsConfiguration(p) || isDifferentLazy()
@@ -529,7 +538,7 @@ export class Experiments extends Settings.define(
529538
}
530539
}
531540

532-
const DEV_SETTINGS = {
541+
const devSettings = {
533542
forceCloud9: Boolean,
534543
forceDevMode: Boolean,
535544
forceInstallTools: Boolean,
@@ -538,8 +547,7 @@ const DEV_SETTINGS = {
538547
endpoints: Record(String, String),
539548
cawsStage: String,
540549
}
541-
542-
type ResolvedDevSettings = FromDescriptor<typeof DEV_SETTINGS>
550+
type ResolvedDevSettings = FromDescriptor<typeof devSettings>
543551
type AwsDevSetting = keyof ResolvedDevSettings
544552

545553
/**
@@ -548,7 +556,7 @@ type AwsDevSetting = keyof ResolvedDevSettings
548556
* forcing certain behaviors, changing hard-coded endpoints, emitting extra debug info, etc.
549557
*
550558
* These settings should _not_ be placed in `package.json` as they are not meant to be seen by
551-
* the average user. Instead, add a new field to {@link DEV_SETTINGS this object} with the
559+
* the average user. Instead, add a new field to {@link devSettings this object} with the
552560
* desired name/type.
553561
*
554562
* Note that a default value _must_ be supplied when calling {@link get} because developer
@@ -572,7 +580,7 @@ type AwsDevSetting = keyof ResolvedDevSettings
572580
* })
573581
* ```
574582
*/
575-
export class DevSettings extends Settings.define('aws.dev', DEV_SETTINGS) {
583+
export class DevSettings extends Settings.define('aws.dev', devSettings) {
576584
private readonly trappedSettings: Partial<ResolvedDevSettings> = {}
577585
private readonly onDidChangeActiveSettingsEmitter = new vscode.EventEmitter<void>()
578586

@@ -602,10 +610,12 @@ export class DevSettings extends Settings.define('aws.dev', DEV_SETTINGS) {
602610
static #instance: DevSettings
603611

604612
public static get instance() {
605-
const instance = (this.#instance ??= new this())
606-
instance.get('forceDevMode', false)
613+
if (this.#instance === undefined) {
614+
this.#instance = new this()
615+
this.#instance.get('forceDevMode', false)
616+
}
607617

608-
return instance
618+
return this.#instance
609619
}
610620
}
611621

src/shared/telemetry/vscodeTelemetry.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949
}
5050
]
5151
},
52+
{
53+
"name": "aws_autoUpdateBeta",
54+
"description": "Emitted whenever the Toolkit checks, and potentially installs, a beta version",
55+
"passive": true
56+
},
5257
{
5358
"name": "aws_installCli",
5459
"description": "Called after attempting to install a local copy of a missing CLI",

0 commit comments

Comments
 (0)