aboutsummaryrefslogtreecommitdiff
path: root/editors/code/src/installation
diff options
context:
space:
mode:
authorbors[bot] <26634292+bors[bot]@users.noreply.github.com>2020-03-16 10:26:31 +0000
committerGitHub <[email protected]>2020-03-16 10:26:31 +0000
commit200c275c2e9955371e61f6ad7684084655df46fc (patch)
treec4b61de644cec37cffca9010d56afc4136d23ca8 /editors/code/src/installation
parenta99cac671c3e6105a0192acbb1a91cb83e453018 (diff)
parent5a0041c5aaeee49be84ce771fb0360ae55cbd8b2 (diff)
Merge #3534
3534: Feature: vscode impl nightlies download and installation r=Veetaha a=Veetaha I need to test things more, but the core shape of the code is quite well-formed. The main problem is that we save the release date only for nightlies and there are no means to get the release date of the stable extension (i.e. for this we would need to consult the github releases via a network request, or we would need to somehow save this info into package.json or any other file accessible from the extension code during the deployment step, but this will be very hard I guess). So there is an invariant that the users can install nightly only from our extension and they can't do it manually, because when installing the nightly `.vsix` we actually save its release date to `globalState` Closes: #3402 TODO: - [x] More manual tests and documentation cc @matklad @lnicola Co-authored-by: Veetaha <[email protected]> Co-authored-by: Veetaha <[email protected]>
Diffstat (limited to 'editors/code/src/installation')
-rw-r--r--editors/code/src/installation/download_artifact.ts50
-rw-r--r--editors/code/src/installation/downloads.ts (renamed from editors/code/src/installation/download_file.ts)46
-rw-r--r--editors/code/src/installation/extension.ts144
-rw-r--r--editors/code/src/installation/fetch_artifact_release_info.ts3
-rw-r--r--editors/code/src/installation/interfaces.ts18
-rw-r--r--editors/code/src/installation/server.ts104
6 files changed, 250 insertions, 115 deletions
diff --git a/editors/code/src/installation/download_artifact.ts b/editors/code/src/installation/download_artifact.ts
deleted file mode 100644
index 97e4d67c2..000000000
--- a/editors/code/src/installation/download_artifact.ts
+++ /dev/null
@@ -1,50 +0,0 @@
1import * as vscode from "vscode";
2import * as path from "path";
3import { promises as fs } from "fs";
4
5import { ArtifactReleaseInfo } from "./interfaces";
6import { downloadFile } from "./download_file";
7import { assert } from "../util";
8
9/**
10 * Downloads artifact from given `downloadUrl`.
11 * Creates `installationDir` if it is not yet created and put the artifact under
12 * `artifactFileName`.
13 * Displays info about the download progress in an info message printing the name
14 * of the artifact as `displayName`.
15 */
16export async function downloadArtifact(
17 { downloadUrl, releaseName }: ArtifactReleaseInfo,
18 artifactFileName: string,
19 installationDir: string,
20 displayName: string,
21) {
22 await fs.mkdir(installationDir).catch(err => assert(
23 err?.code === "EEXIST",
24 `Couldn't create directory "${installationDir}" to download ` +
25 `${artifactFileName} artifact: ${err?.message}`
26 ));
27
28 const installationPath = path.join(installationDir, artifactFileName);
29
30 await vscode.window.withProgress(
31 {
32 location: vscode.ProgressLocation.Notification,
33 cancellable: false, // FIXME: add support for canceling download?
34 title: `Downloading ${displayName} (${releaseName})`
35 },
36 async (progress, _cancellationToken) => {
37 let lastPrecentage = 0;
38 const filePermissions = 0o755; // (rwx, r_x, r_x)
39 await downloadFile(downloadUrl, installationPath, filePermissions, (readBytes, totalBytes) => {
40 const newPercentage = (readBytes / totalBytes) * 100;
41 progress.report({
42 message: newPercentage.toFixed(0) + "%",
43 increment: newPercentage - lastPrecentage
44 });
45
46 lastPrecentage = newPercentage;
47 });
48 }
49 );
50}
diff --git a/editors/code/src/installation/download_file.ts b/editors/code/src/installation/downloads.ts
index ee8949d61..7ce2e2960 100644
--- a/editors/code/src/installation/download_file.ts
+++ b/editors/code/src/installation/downloads.ts
@@ -1,8 +1,11 @@
1import fetch from "node-fetch"; 1import fetch from "node-fetch";
2import * as vscode from "vscode";
3import * as path from "path";
2import * as fs from "fs"; 4import * as fs from "fs";
3import * as stream from "stream"; 5import * as stream from "stream";
4import * as util from "util"; 6import * as util from "util";
5import { log, assert } from "../util"; 7import { log, assert } from "../util";
8import { ArtifactReleaseInfo } from "./interfaces";
6 9
7const pipeline = util.promisify(stream.pipeline); 10const pipeline = util.promisify(stream.pipeline);
8 11
@@ -49,3 +52,46 @@ export async function downloadFile(
49 // Issue at nodejs repo: https://github.com/nodejs/node/issues/31776 52 // Issue at nodejs repo: https://github.com/nodejs/node/issues/31776
50 }); 53 });
51} 54}
55
56/**
57 * Downloads artifact from given `downloadUrl`.
58 * Creates `installationDir` if it is not yet created and puts the artifact under
59 * `artifactFileName`.
60 * Displays info about the download progress in an info message printing the name
61 * of the artifact as `displayName`.
62 */
63export async function downloadArtifactWithProgressUi(
64 { downloadUrl, releaseName }: ArtifactReleaseInfo,
65 artifactFileName: string,
66 installationDir: string,
67 displayName: string,
68) {
69 await fs.promises.mkdir(installationDir).catch(err => assert(
70 err?.code === "EEXIST",
71 `Couldn't create directory "${installationDir}" to download ` +
72 `${artifactFileName} artifact: ${err?.message}`
73 ));
74
75 const installationPath = path.join(installationDir, artifactFileName);
76
77 await vscode.window.withProgress(
78 {
79 location: vscode.ProgressLocation.Notification,
80 cancellable: false, // FIXME: add support for canceling download?
81 title: `Downloading rust-analyzer ${displayName} (${releaseName})`
82 },
83 async (progress, _cancellationToken) => {
84 let lastPrecentage = 0;
85 const filePermissions = 0o755; // (rwx, r_x, r_x)
86 await downloadFile(downloadUrl, installationPath, filePermissions, (readBytes, totalBytes) => {
87 const newPercentage = (readBytes / totalBytes) * 100;
88 progress.report({
89 message: newPercentage.toFixed(0) + "%",
90 increment: newPercentage - lastPrecentage
91 });
92
93 lastPrecentage = newPercentage;
94 });
95 }
96 );
97}
diff --git a/editors/code/src/installation/extension.ts b/editors/code/src/installation/extension.ts
new file mode 100644
index 000000000..eea6fded2
--- /dev/null
+++ b/editors/code/src/installation/extension.ts
@@ -0,0 +1,144 @@
1import * as vscode from "vscode";
2import * as path from "path";
3import { promises as fs } from 'fs';
4
5import { vscodeReinstallExtension, vscodeReloadWindow, log, vscodeInstallExtensionFromVsix, assert, notReentrant } from "../util";
6import { Config, UpdatesChannel } from "../config";
7import { ArtifactReleaseInfo, ArtifactSource } from "./interfaces";
8import { downloadArtifactWithProgressUi } from "./downloads";
9import { fetchArtifactReleaseInfo } from "./fetch_artifact_release_info";
10
11const HEURISTIC_NIGHTLY_RELEASE_PERIOD_IN_HOURS = 25;
12
13/**
14 * Installs `stable` or latest `nightly` version or does nothing if the current
15 * extension version is what's needed according to `desiredUpdateChannel`.
16 */
17export async function ensureProperExtensionVersion(config: Config): Promise<never | void> {
18 // User has built lsp server from sources, she should manage updates manually
19 if (config.serverSource?.type === ArtifactSource.Type.ExplicitPath) return;
20
21 const currentUpdChannel = config.installedExtensionUpdateChannel;
22 const desiredUpdChannel = config.updatesChannel;
23
24 if (currentUpdChannel === UpdatesChannel.Stable) {
25 // Release date is present only when we are on nightly
26 await config.installedNightlyExtensionReleaseDate.set(null);
27 }
28
29 if (desiredUpdChannel === UpdatesChannel.Stable) {
30 // VSCode should handle updates for stable channel
31 if (currentUpdChannel === UpdatesChannel.Stable) return;
32
33 if (!await askToDownloadProperExtensionVersion(config)) return;
34
35 await vscodeReinstallExtension(config.extensionId);
36 await vscodeReloadWindow(); // never returns
37 }
38
39 if (currentUpdChannel === UpdatesChannel.Stable) {
40 if (!await askToDownloadProperExtensionVersion(config)) return;
41
42 return await tryDownloadNightlyExtension(config);
43 }
44
45 const currentExtReleaseDate = config.installedNightlyExtensionReleaseDate.get();
46
47 if (currentExtReleaseDate === null) {
48 void vscode.window.showErrorMessage(
49 "Nightly release date must've been set during the installation. " +
50 "Did you download and install the nightly .vsix package manually?"
51 );
52 throw new Error("Nightly release date was not set in globalStorage");
53 }
54
55 const dateNow = new Date;
56 const hoursSinceLastUpdate = diffInHours(currentExtReleaseDate, dateNow);
57 log.debug(
58 "Current rust-analyzer nightly was downloaded", hoursSinceLastUpdate,
59 "hours ago, namely:", currentExtReleaseDate, "and now is", dateNow
60 );
61
62 if (hoursSinceLastUpdate < HEURISTIC_NIGHTLY_RELEASE_PERIOD_IN_HOURS) {
63 return;
64 }
65 if (!await askToDownloadProperExtensionVersion(config, "The installed nightly version is most likely outdated. ")) {
66 return;
67 }
68
69 await tryDownloadNightlyExtension(config, releaseInfo => {
70 assert(
71 currentExtReleaseDate.getTime() === config.installedNightlyExtensionReleaseDate.get()?.getTime(),
72 "Other active VSCode instance has reinstalled the extension"
73 );
74
75 if (releaseInfo.releaseDate.getTime() === currentExtReleaseDate.getTime()) {
76 vscode.window.showInformationMessage(
77 "Whoops, it appears that your nightly version is up-to-date. " +
78 "There might be some problems with the upcomming nightly release " +
79 "or you traveled too far into the future. Sorry for that 😅! "
80 );
81 return false;
82 }
83 return true;
84 });
85}
86
87async function askToDownloadProperExtensionVersion(config: Config, reason = "") {
88 if (!config.askBeforeDownload) return true;
89
90 const stableOrNightly = config.updatesChannel === UpdatesChannel.Stable ? "stable" : "latest nightly";
91
92 // In case of reentering this function and showing the same info message
93 // (e.g. after we had shown this message, the user changed the config)
94 // vscode will dismiss the already shown one (i.e. return undefined).
95 // This behaviour is what we want, but likely it is not documented
96
97 const userResponse = await vscode.window.showInformationMessage(
98 reason + `Do you want to download the ${stableOrNightly} rust-analyzer extension ` +
99 `version and reload the window now?`,
100 "Download now", "Cancel"
101 );
102 return userResponse === "Download now";
103}
104
105/**
106 * Shutdowns the process in case of success (i.e. reloads the window) or throws an error.
107 *
108 * ACHTUNG!: this function has a crazy amount of state transitions, handling errors during
109 * each of them would result in a ton of code (especially accounting for cross-process
110 * shared mutable `globalState` access). Enforcing no reentrancy for this is best-effort.
111 */
112const tryDownloadNightlyExtension = notReentrant(async (
113 config: Config,
114 shouldDownload: (releaseInfo: ArtifactReleaseInfo) => boolean = () => true
115): Promise<never | void> => {
116 const vsixSource = config.nightlyVsixSource;
117 try {
118 const releaseInfo = await fetchArtifactReleaseInfo(vsixSource.repo, vsixSource.file, vsixSource.tag);
119
120 if (!shouldDownload(releaseInfo)) return;
121
122 await downloadArtifactWithProgressUi(releaseInfo, vsixSource.file, vsixSource.dir, "nightly extension");
123
124 const vsixPath = path.join(vsixSource.dir, vsixSource.file);
125
126 await vscodeInstallExtensionFromVsix(vsixPath);
127 await config.installedNightlyExtensionReleaseDate.set(releaseInfo.releaseDate);
128 await fs.unlink(vsixPath);
129
130 await vscodeReloadWindow(); // never returns
131 } catch (err) {
132 log.downloadError(err, "nightly extension", vsixSource.repo.name);
133 }
134});
135
136function diffInHours(a: Date, b: Date): number {
137 // Discard the time and time-zone information (to abstract from daylight saving time bugs)
138 // https://stackoverflow.com/a/15289883/9259330
139
140 const utcA = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
141 const utcB = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
142
143 return (utcA - utcB) / (1000 * 60 * 60);
144}
diff --git a/editors/code/src/installation/fetch_artifact_release_info.ts b/editors/code/src/installation/fetch_artifact_release_info.ts
index b1b5a3485..1ad3b8338 100644
--- a/editors/code/src/installation/fetch_artifact_release_info.ts
+++ b/editors/code/src/installation/fetch_artifact_release_info.ts
@@ -59,12 +59,15 @@ export async function fetchArtifactReleaseInfo(
59 59
60 return { 60 return {
61 releaseName: release.name, 61 releaseName: release.name,
62 releaseDate: new Date(release.published_at),
62 downloadUrl: artifact.browser_download_url 63 downloadUrl: artifact.browser_download_url
63 }; 64 };
64 65
65 // We omit declaration of tremendous amount of fields that we are not using here 66 // We omit declaration of tremendous amount of fields that we are not using here
66 interface GithubRelease { 67 interface GithubRelease {
67 name: string; 68 name: string;
69 // eslint-disable-next-line camelcase
70 published_at: string;
68 assets: Array<{ 71 assets: Array<{
69 name: string; 72 name: string;
70 // eslint-disable-next-line camelcase 73 // eslint-disable-next-line camelcase
diff --git a/editors/code/src/installation/interfaces.ts b/editors/code/src/installation/interfaces.ts
index 50b635921..1a8ea0884 100644
--- a/editors/code/src/installation/interfaces.ts
+++ b/editors/code/src/installation/interfaces.ts
@@ -1,5 +1,3 @@
1import * as vscode from "vscode";
2
3export interface GithubRepo { 1export interface GithubRepo {
4 name: string; 2 name: string;
5 owner: string; 3 owner: string;
@@ -9,6 +7,7 @@ export interface GithubRepo {
9 * Metadata about particular artifact retrieved from GitHub releases. 7 * Metadata about particular artifact retrieved from GitHub releases.
10 */ 8 */
11export interface ArtifactReleaseInfo { 9export interface ArtifactReleaseInfo {
10 releaseDate: Date;
12 releaseName: string; 11 releaseName: string;
13 downloadUrl: string; 12 downloadUrl: string;
14} 13}
@@ -42,6 +41,9 @@ export namespace ArtifactSource {
42 */ 41 */
43 repo: GithubRepo; 42 repo: GithubRepo;
44 43
44
45 // FIXME: add installationPath: string;
46
45 /** 47 /**
46 * Directory on the filesystem where the bundled binary is stored. 48 * Directory on the filesystem where the bundled binary is stored.
47 */ 49 */
@@ -57,17 +59,5 @@ export namespace ArtifactSource {
57 * Tag of github release that denotes a version required by this extension. 59 * Tag of github release that denotes a version required by this extension.
58 */ 60 */
59 tag: string; 61 tag: string;
60
61 /**
62 * Object that provides `get()/update()` operations to store metadata
63 * about the actual binary, e.g. its actual version.
64 */
65 storage: vscode.Memento;
66
67 /**
68 * Ask for the user permission before downloading the artifact.
69 */
70 askBeforeDownload: boolean;
71 } 62 }
72
73} 63}
diff --git a/editors/code/src/installation/server.ts b/editors/code/src/installation/server.ts
index ef1c45ff6..05730a778 100644
--- a/editors/code/src/installation/server.ts
+++ b/editors/code/src/installation/server.ts
@@ -1,14 +1,16 @@
1import * as vscode from "vscode"; 1import * as vscode from "vscode";
2import * as path from "path"; 2import * as path from "path";
3import { promises as dns } from "dns";
4import { spawnSync } from "child_process"; 3import { spawnSync } from "child_process";
5 4
6import { ArtifactSource } from "./interfaces"; 5import { ArtifactSource } from "./interfaces";
7import { fetchArtifactReleaseInfo } from "./fetch_artifact_release_info"; 6import { fetchArtifactReleaseInfo } from "./fetch_artifact_release_info";
8import { downloadArtifact } from "./download_artifact"; 7import { downloadArtifactWithProgressUi } from "./downloads";
9import { log, assert } from "../util"; 8import { log, assert, notReentrant } from "../util";
9import { Config, NIGHTLY_TAG } from "../config";
10
11export async function ensureServerBinary(config: Config): Promise<null | string> {
12 const source = config.serverSource;
10 13
11export async function ensureServerBinary(source: null | ArtifactSource): Promise<null | string> {
12 if (!source) { 14 if (!source) {
13 vscode.window.showErrorMessage( 15 vscode.window.showErrorMessage(
14 "Unfortunately we don't ship binaries for your platform yet. " + 16 "Unfortunately we don't ship binaries for your platform yet. " +
@@ -35,18 +37,11 @@ export async function ensureServerBinary(source: null | ArtifactSource): Promise
35 return null; 37 return null;
36 } 38 }
37 case ArtifactSource.Type.GithubRelease: { 39 case ArtifactSource.Type.GithubRelease: {
38 const prebuiltBinaryPath = path.join(source.dir, source.file); 40 if (!shouldDownloadServer(source, config)) {
39 41 return path.join(source.dir, source.file);
40 const installedVersion: null | string = getServerVersion(source.storage);
41 const requiredVersion: string = source.tag;
42
43 log.debug("Installed version:", installedVersion, "required:", requiredVersion);
44
45 if (isBinaryAvailable(prebuiltBinaryPath) && installedVersion === requiredVersion) {
46 return prebuiltBinaryPath;
47 } 42 }
48 43
49 if (source.askBeforeDownload) { 44 if (config.askBeforeDownload) {
50 const userResponse = await vscode.window.showInformationMessage( 45 const userResponse = await vscode.window.showInformationMessage(
51 `Language server version ${source.tag} for rust-analyzer is not installed. ` + 46 `Language server version ${source.tag} for rust-analyzer is not installed. ` +
52 "Do you want to download it now?", 47 "Do you want to download it now?",
@@ -55,38 +50,56 @@ export async function ensureServerBinary(source: null | ArtifactSource): Promise
55 if (userResponse !== "Download now") return null; 50 if (userResponse !== "Download now") return null;
56 } 51 }
57 52
58 if (!await downloadServer(source)) return null; 53 return await downloadServer(source, config);
59
60 return prebuiltBinaryPath;
61 } 54 }
62 } 55 }
63} 56}
64 57
65async function downloadServer(source: ArtifactSource.GithubRelease): Promise<boolean> { 58function shouldDownloadServer(
59 source: ArtifactSource.GithubRelease,
60 config: Config
61): boolean {
62 if (!isBinaryAvailable(path.join(source.dir, source.file))) return true;
63
64 const installed = {
65 tag: config.serverReleaseTag.get(),
66 date: config.serverReleaseDate.get()
67 };
68 const required = {
69 tag: source.tag,
70 date: config.installedNightlyExtensionReleaseDate.get()
71 };
72
73 log.debug("Installed server:", installed, "required:", required);
74
75 if (required.tag !== NIGHTLY_TAG || installed.tag !== NIGHTLY_TAG) {
76 return required.tag !== installed.tag;
77 }
78
79 assert(required.date !== null, "Extension release date should have been saved during its installation");
80 assert(installed.date !== null, "Server release date should have been saved during its installation");
81
82 return installed.date.getTime() !== required.date.getTime();
83}
84
85/**
86 * Enforcing no reentrancy for this is best-effort.
87 */
88const downloadServer = notReentrant(async (
89 source: ArtifactSource.GithubRelease,
90 config: Config,
91): Promise<null | string> => {
66 try { 92 try {
67 const releaseInfo = await fetchArtifactReleaseInfo(source.repo, source.file, source.tag); 93 const releaseInfo = await fetchArtifactReleaseInfo(source.repo, source.file, source.tag);
68 94
69 await downloadArtifact(releaseInfo, source.file, source.dir, "language server"); 95 await downloadArtifactWithProgressUi(releaseInfo, source.file, source.dir, "language server");
70 await setServerVersion(source.storage, releaseInfo.releaseName); 96 await Promise.all([
97 config.serverReleaseTag.set(releaseInfo.releaseName),
98 config.serverReleaseDate.set(releaseInfo.releaseDate)
99 ]);
71 } catch (err) { 100 } catch (err) {
72 vscode.window.showErrorMessage( 101 log.downloadError(err, "language server", source.repo.name);
73 `Failed to download language server from ${source.repo.name} ` + 102 return null;
74 `GitHub repository: ${err.message}`
75 );
76
77 log.error(err);
78
79 dns.resolve('example.com').then(
80 addrs => log.debug("DNS resolution for example.com was successful", addrs),
81 err => {
82 log.error(
83 "DNS resolution for example.com failed, " +
84 "there might be an issue with Internet availability"
85 );
86 log.error(err);
87 }
88 );
89 return false;
90 } 103 }
91 104
92 const binaryPath = path.join(source.dir, source.file); 105 const binaryPath = path.join(source.dir, source.file);
@@ -101,8 +114,8 @@ async function downloadServer(source: ArtifactSource.GithubRelease): Promise<boo
101 "Rust analyzer language server was successfully installed 🦀" 114 "Rust analyzer language server was successfully installed 🦀"
102 ); 115 );
103 116
104 return true; 117 return binaryPath;
105} 118});
106 119
107function isBinaryAvailable(binaryPath: string): boolean { 120function isBinaryAvailable(binaryPath: string): boolean {
108 const res = spawnSync(binaryPath, ["--version"]); 121 const res = spawnSync(binaryPath, ["--version"]);
@@ -115,14 +128,3 @@ function isBinaryAvailable(binaryPath: string): boolean {
115 128
116 return res.status === 0; 129 return res.status === 0;
117} 130}
118
119function getServerVersion(storage: vscode.Memento): null | string {
120 const version = storage.get<null | string>("server-version", null);
121 log.debug("Get server-version:", version);
122 return version;
123}
124
125async function setServerVersion(storage: vscode.Memento, version: string): Promise<void> {
126 log.debug("Set server-version:", version);
127 await storage.update("server-version", version.toString());
128}