diff --git a/ci/dev/vscode.patch b/ci/dev/vscode.patch index ec10c3fa..9690a27a 100644 --- a/ci/dev/vscode.patch +++ b/ci/dev/vscode.patch @@ -679,10 +679,10 @@ index eab8591492..26668701f7 100644 options.logService.error(`${logPrefix} socketFactory.connect() failed. Error:`); diff --git a/src/vs/server/browser/client.ts b/src/vs/server/browser/client.ts new file mode 100644 -index 0000000000..649cf32f0a +index 0000000000..8fb2a87303 --- /dev/null +++ b/src/vs/server/browser/client.ts -@@ -0,0 +1,264 @@ +@@ -0,0 +1,208 @@ +import { Emitter } from 'vs/base/common/event'; +import { URI } from 'vs/base/common/uri'; +import { localize } from 'vs/nls'; @@ -690,7 +690,6 @@ index 0000000000..649cf32f0a +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; +import { ILocalizationsService } from 'vs/platform/localizations/common/localizations'; -+import { ILogService } from 'vs/platform/log/common/log'; +import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { PersistentConnectionEventType } from 'vs/platform/remote/common/remoteAgentConnection'; @@ -852,61 +851,6 @@ index 0000000000..649cf32f0a + }); + } + -+ const applyUpdate = async (): Promise => { -+ (services.get(ILogService) as ILogService).debug("Applying update..."); -+ -+ const response = await fetch(normalize(`${options.base}/update/apply`), { -+ headers: { "content-type": "application/json" }, -+ }); -+ const json = await response.json(); -+ if (response.status !== 200 || json.error) { -+ throw new Error(json.error || response.statusText); -+ } -+ (services.get(INotificationService) as INotificationService).info(`Updated to ${json.version}`); -+ }; -+ -+ const getUpdate = async (): Promise => { -+ (services.get(ILogService) as ILogService).debug("Checking for update..."); -+ -+ const response = await fetch(normalize(`${options.base}/update`), { -+ headers: { "content-type": "application/json" }, -+ }); -+ const json = await response.json(); -+ if (response.status !== 200 || json.error) { -+ throw new Error(json.error || response.statusText); -+ } -+ if (json.isLatest) { -+ return; -+ } -+ -+ (services.get(INotificationService) as INotificationService).notify({ -+ severity: Severity.Info, -+ message: `code-server has an update: ${json.version}`, -+ actions: { -+ primary: [{ -+ id: 'update', -+ label: 'Apply Update', -+ tooltip: '', -+ class: undefined, -+ enabled: true, -+ checked: true, -+ dispose: () => undefined, -+ run: applyUpdate, -+ }], -+ } -+ }); -+ }; -+ -+ const updateLoop = (): void => { -+ getUpdate().catch((error) => { -+ (services.get(ILogService) as ILogService).warn(error); -+ }).finally(() => { -+ setTimeout(updateLoop, 300000); -+ }); -+ }; -+ -+ updateLoop(); -+ + // This will be used to set the background color while VS Code loads. + const theme = (services.get(IStorageService) as IStorageService).get("colorThemeData", StorageScope.GLOBAL); + if (theme) { diff --git a/src/browser/pages/update.html b/src/browser/pages/update.html deleted file mode 100644 index 954d3010..00000000 --- a/src/browser/pages/update.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - code-server - - - - - - - -
-
-
-

Update

-
Update code-server.
-
-
-
- {{UPDATE_STATUS}} {{ERROR}} - -
-
-
-
- - - diff --git a/src/node/app/update.ts b/src/node/app/update.ts index 23cfd88b..a83f578e 100644 --- a/src/node/app/update.ts +++ b/src/node/app/update.ts @@ -1,21 +1,12 @@ import { field, logger } from "@coder/logger" -import * as cp from "child_process" -import * as fs from "fs-extra" import * as http from "http" import * as https from "https" -import * as os from "os" import * as path from "path" import * as semver from "semver" -import { Readable, Writable } from "stream" -import * as tar from "tar-fs" import * as url from "url" -import * as util from "util" -import * as zlib from "zlib" import { HttpCode, HttpError } from "../../common/http" import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" import { settings as globalSettings, SettingsProvider, UpdateSettings } from "../settings" -import { tmpdir } from "../util" -import { ipcMain } from "../wrapper" export interface Update { checked: number @@ -27,7 +18,7 @@ export interface LatestResponse { } /** - * Update HTTP provider. + * HTTP provider for checking updates (does not download/install them). */ export class UpdateHttpProvider extends HttpProvider { private update?: Promise @@ -41,12 +32,6 @@ export class UpdateHttpProvider extends HttpProvider { * that fulfills `LatestResponse`. */ private readonly latestUrl = "https://api.github.com/repos/cdr/code-server/releases/latest", - /** - * The URL for downloading a version of code-server. {{VERSION}} and - * {{RELEASE_NAME}} will be replaced (for example 2.1.0 and - * code-server-2.1.0-linux-x86_64.tar.gz). - */ - private readonly downloadUrl = "https://github.com/cdr/code-server/releases/download/{{VERSION}}/{{RELEASE_NAME}}", /** * Update information will be stored here. If not provided, the global * settings will be used. @@ -64,66 +49,30 @@ export class UpdateHttpProvider extends HttpProvider { throw new HttpError("Not found", HttpCode.NotFound) } - switch (route.base) { - case "/check": - this.getUpdate(true) - if (route.query && route.query.to) { - return { - redirect: Array.isArray(route.query.to) ? route.query.to[0] : route.query.to, - query: { to: undefined }, - } - } - return this.getRoot(route, request) - case "/apply": - return this.tryUpdate(route, request) - case "/": - return this.getRoot(route, request) + if (!this.enabled) { + throw new Error("update checks are disabled") } - throw new HttpError("Not found", HttpCode.NotFound) - } - - public async getRoot( - route: Route, - request: http.IncomingMessage, - errorOrUpdate?: Update | Error, - ): Promise { - if (request.headers["content-type"] === "application/json") { - if (!this.enabled) { + switch (route.base) { + case "/check": + case "/": { + const update = await this.getUpdate(route.base === "/check") return { content: { - isLatest: true, + ...update, + isLatest: this.isLatestVersion(update), }, } } - const update = await this.getUpdate() - return { - content: { - ...update, - isLatest: this.isLatestVersion(update), - }, - } } - const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/update.html") - response.content = response.content - .replace( - /{{UPDATE_STATUS}}/, - errorOrUpdate && !(errorOrUpdate instanceof Error) - ? `Updated to ${errorOrUpdate.version}` - : await this.getUpdateHtml(), - ) - .replace(/{{ERROR}}/, errorOrUpdate instanceof Error ? `
${errorOrUpdate.message}
` : "") - return this.replaceTemplates(route, response) + + throw new HttpError("Not found", HttpCode.NotFound) } /** * Query for and return the latest update. */ public async getUpdate(force?: boolean): Promise { - if (!this.enabled) { - throw new Error("updates are not enabled") - } - // Don't run multiple requests at a time. if (!this.update) { this.update = this._getUpdate(force) @@ -171,128 +120,6 @@ export class UpdateHttpProvider extends HttpProvider { } } - private async getUpdateHtml(): Promise { - if (!this.enabled) { - return "Updates are disabled" - } - - const update = await this.getUpdate() - if (this.isLatestVersion(update)) { - return "No update available" - } - - return `` - } - - public async tryUpdate(route: Route, request: http.IncomingMessage): Promise { - try { - const update = await this.getUpdate() - if (!this.isLatestVersion(update)) { - await this.downloadAndApplyUpdate(update) - return this.getRoot(route, request, update) - } - return this.getRoot(route, request) - } catch (error) { - // For JSON requests propagate the error. Otherwise catch it so we can - // show the error inline with the update button instead of an error page. - if (request.headers["content-type"] === "application/json") { - throw error - } - return this.getRoot(route, error) - } - } - - public async downloadAndApplyUpdate(update: Update, targetPath?: string): Promise { - const releaseName = await this.getReleaseName(update) - const url = this.downloadUrl.replace("{{VERSION}}", update.version).replace("{{RELEASE_NAME}}", releaseName) - - let downloadPath = path.join(tmpdir, "updates", releaseName) - fs.mkdirp(path.dirname(downloadPath)) - - const response = await this.requestResponse(url) - - try { - downloadPath = await this.extractTar(response, downloadPath) - logger.debug("Downloaded update", field("path", downloadPath)) - - // The archive should have a directory inside at the top level with the - // same name as the archive. - const directoryPath = path.join(downloadPath, path.basename(downloadPath)) - await fs.stat(directoryPath) - - if (!targetPath) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - targetPath = path.resolve(__dirname, "../../../") - } - - // Move the old directory to prevent potential data loss. - const backupPath = path.resolve(targetPath, `../${path.basename(targetPath)}.${Date.now().toString()}`) - logger.debug("Replacing files", field("target", targetPath), field("backup", backupPath)) - await fs.move(targetPath, backupPath) - - // Move the new directory. - await fs.move(directoryPath, targetPath) - - await fs.remove(downloadPath) - - if (process.send) { - ipcMain().relaunch(update.version) - } - } catch (error) { - response.destroy(error) - throw error - } - } - - private async extractTar(response: Readable, downloadPath: string): Promise { - downloadPath = downloadPath.replace(/\.tar\.gz$/, "") - logger.debug("Extracting tar", field("path", downloadPath)) - - response.pause() - await fs.remove(downloadPath) - - const decompress = zlib.createGunzip() - response.pipe(decompress as Writable) - response.on("error", (error) => decompress.destroy(error)) - response.on("close", () => decompress.end()) - - const destination = tar.extract(downloadPath) - decompress.pipe(destination) - decompress.on("error", (error) => destination.destroy(error)) - decompress.on("close", () => destination.end()) - - await new Promise((resolve, reject) => { - destination.on("finish", resolve) - destination.on("error", reject) - response.resume() - }) - - return downloadPath - } - - /** - * Given an update return the name for the packaged archived. - */ - public async getReleaseName(update: Update): Promise { - let target: string = os.platform() - if (target === "linux") { - const result = await util - .promisify(cp.exec)("ldd --version") - .catch((error) => ({ - stderr: error.message, - stdout: "", - })) - if (/musl/.test(result.stderr) || /musl/.test(result.stdout)) { - target = "alpine" - } - } - let arch = os.arch() - if (arch === "x64") { - arch = "x86_64" - } - return `code-server-${update.version}-${target}-${arch}.tar.gz` - } - private async request(uri: string): Promise { const response = await this.requestResponse(uri) return new Promise((resolve, reject) => { diff --git a/test/update.test.ts b/test/update.test.ts index 0a83b063..15719bfa 100644 --- a/test/update.test.ts +++ b/test/update.test.ts @@ -2,40 +2,33 @@ import * as assert from "assert" import * as fs from "fs-extra" import * as http from "http" import * as path from "path" -import * as tar from "tar-fs" -import * as zlib from "zlib" import { LatestResponse, UpdateHttpProvider } from "../src/node/app/update" import { AuthType } from "../src/node/http" import { SettingsProvider, UpdateSettings } from "../src/node/settings" import { tmpdir } from "../src/node/util" describe("update", () => { - const archivePath = path.join(tmpdir, "tests/updates/code-server-loose-source") let version = "1.0.0" let spy: string[] = [] const server = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => { if (!request.url) { throw new Error("no url") } + spy.push(request.url) - response.writeHead(200) + + // Return the latest version. if (request.url === "/latest") { const latest: LatestResponse = { name: version, } + response.writeHead(200) return response.end(JSON.stringify(latest)) } - const path = archivePath + (request.url.endsWith(".tar.gz") ? ".tar.gz" : ".zip") - - const stream = fs.createReadStream(path) - stream.on("error", (error: NodeJS.ErrnoException) => { - response.writeHead(500) - response.end(error.message) - }) - response.writeHead(200) - stream.on("close", () => response.end()) - stream.pipe(response) + // Anything else is a 404. + response.writeHead(404) + response.end("not found") }) const jsonPath = path.join(tmpdir, "tests/updates/update.json") @@ -55,7 +48,6 @@ describe("update", () => { }, true, `http://${address.address}:${address.port}/latest`, - `http://${address.address}:${address.port}/download/{{VERSION}}/{{RELEASE_NAME}}`, settings, ) } @@ -71,32 +63,8 @@ describe("update", () => { host: "localhost", }) }) - - const p = provider() - const archiveName = (await p.getReleaseName({ version: "9999999.99999.9999", checked: 0 })).replace( - /.tar.gz$|.zip$/, - "", - ) await fs.remove(path.join(tmpdir, "tests/updates")) - await fs.mkdirp(path.join(archivePath, archiveName)) - - await Promise.all([ - fs.writeFile(path.join(archivePath, archiveName, "code-server"), `console.log("UPDATED")`), - fs.writeFile(path.join(archivePath, archiveName, "node"), `NODE BINARY`), - ]) - - await new Promise((resolve, reject) => { - const write = fs.createWriteStream(archivePath + ".tar.gz") - const compress = zlib.createGzip() - compress.pipe(write) - compress.on("error", (error) => compress.destroy(error)) - compress.on("close", () => write.end()) - tar.pack(archivePath).pipe(compress) - write.on("close", reject) - write.on("finish", () => { - resolve() - }) - }) + await fs.mkdirp(path.join(tmpdir, "tests/updates")) }) after(() => { @@ -184,53 +152,15 @@ describe("update", () => { assert.equal(p.isLatestVersion(update), true) }) - it("should download and apply an update", async () => { - version = "9999999.99999.9999" - - const p = provider() - const update = await p.getUpdate(true) - - // Create an existing version. - const destination = path.join(tmpdir, "tests/updates/code-server") - await fs.mkdirp(destination) - const entry = path.join(destination, "code-server") - await fs.writeFile(entry, `console.log("OLD")`) - assert.equal(`console.log("OLD")`, await fs.readFile(entry, "utf8")) - - // Updating should replace the existing version. - await p.downloadAndApplyUpdate(update, destination) - assert.equal(`console.log("UPDATED")`, await fs.readFile(entry, "utf8")) - - // There should be a backup. - const dir = (await fs.readdir(path.join(tmpdir, "tests/updates"))).filter((dir) => { - return dir.startsWith("code-server.") - }) - assert.equal(dir.length, 1) - assert.equal( - `console.log("OLD")`, - await fs.readFile(path.join(tmpdir, "tests/updates", dir[0], "code-server"), "utf8"), - ) - - const archiveName = await p.getReleaseName(update) - assert.deepEqual(spy, ["/latest", `/download/${version}/${archiveName}`]) - }) - it("should not reject if unable to fetch", async () => { const options = { auth: AuthType.None, - base: "/update", commit: "test", } - let provider = new UpdateHttpProvider(options, true, "invalid", "invalid", settings) + let provider = new UpdateHttpProvider(options, true, "invalid", settings) await assert.doesNotReject(() => provider.getUpdate(true)) - provider = new UpdateHttpProvider( - options, - true, - "http://probably.invalid.dev.localhost/latest", - "http://probably.invalid.dev.localhost/download", - settings, - ) + provider = new UpdateHttpProvider(options, true, "http://probably.invalid.dev.localhost/latest", settings) await assert.doesNotReject(() => provider.getUpdate(true)) }) })