From db54f78e8ed1596e5fc4f3c0c1c2c54eda6fddc8 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 14 Feb 2020 15:57:51 -0600 Subject: [PATCH 1/2] Implement automatic updates --- package.json | 9 +- src/browser/pages/app.ts | 1 + src/browser/pages/global.css | 1 + src/browser/pages/home.css | 30 ++-- src/browser/pages/home.html | 13 +- src/browser/pages/update.css | 40 +++++ src/browser/pages/update.html | 25 +++ src/node/app/app.ts | 41 ++++- src/node/app/login.ts | 4 +- src/node/app/update.ts | 302 ++++++++++++++++++++++++++++++++++ src/node/cli.ts | 2 + src/node/entry.ts | 6 +- src/node/http.ts | 14 +- yarn.lock | 79 ++++++++- 14 files changed, 531 insertions(+), 36 deletions(-) create mode 100644 src/browser/pages/update.css create mode 100644 src/browser/pages/update.html create mode 100644 src/node/app/update.ts diff --git a/package.json b/package.json index 73f29e70..3ae61242 100644 --- a/package.json +++ b/package.json @@ -21,14 +21,15 @@ }, "devDependencies": { "@coder/nbin": "^1.2.7", + "@types/adm-zip": "^0.4.32", "@types/fs-extra": "^8.0.1", "@types/mocha": "^5.2.7", "@types/node": "^12.12.7", "@types/parcel-bundler": "^1.12.1", "@types/pem": "^1.9.5", "@types/safe-compare": "^1.1.0", - "@types/tar-fs": "^1.16.1", - "@types/tar-stream": "^1.6.1", + "@types/semver": "^7.1.0", + "@types/tar-fs": "^1.16.2", "@types/ws": "^6.0.4", "@typescript-eslint/eslint-plugin": "^2.0.0", "@typescript-eslint/parser": "^2.0.0", @@ -52,12 +53,14 @@ }, "dependencies": { "@coder/logger": "1.1.11", + "adm-zip": "^0.4.14", "fs-extra": "^8.1.0", "httpolyglot": "^0.1.2", "pem": "^1.14.2", "safe-compare": "^1.1.4", + "semver": "^7.1.3", + "tar": "^6.0.1", "tar-fs": "^2.0.0", - "tar-stream": "^2.1.0", "ws": "^7.2.0" } } diff --git a/src/browser/pages/app.ts b/src/browser/pages/app.ts index ea453b0e..f4990195 100644 --- a/src/browser/pages/app.ts +++ b/src/browser/pages/app.ts @@ -5,6 +5,7 @@ import "./error.css" import "./global.css" import "./home.css" import "./login.css" +import "./update.css" const options = getOptions() const parts = window.location.pathname.replace(/^\//g, "").split("/") diff --git a/src/browser/pages/global.css b/src/browser/pages/global.css index e4c03134..5aa52865 100644 --- a/src/browser/pages/global.css +++ b/src/browser/pages/global.css @@ -16,6 +16,7 @@ body { button { font-family: inherit; + font-size: inherit; } .center-container { diff --git a/src/browser/pages/home.css b/src/browser/pages/home.css index 4ead54af..c14a539a 100644 --- a/src/browser/pages/home.css +++ b/src/browser/pages/home.css @@ -1,50 +1,54 @@ -.app-lists { - max-width: 400px; +.info-blocks { + max-width: 500px; width: 100%; } -.app-list > .header { - margin: 1rem 0; +.info-block > .header { + font-size: 1.3rem; + margin: 1.5rem 0; } -.app-list > .none { +.info-block > .none { color: #b6b6b6; } -.app-list + .app-list { +.info-block + .info-block { border-top: 1px solid #666; margin-top: 1rem; } -.app-row { +.block-row { display: flex; } -.app-row > .open { +.block-row > .item { color: #b6b6b6; - cursor: pointer; display: flex; flex: 1; text-decoration: none; } -.app-row > .open:hover { +.block-row > .item.-link { + cursor: pointer; +} + +.block-row > .item.-link:hover { color: #fafafa; } -.app-row > .open > .icon { +.block-row > .item > .icon { height: 1rem; margin-right: 5px; width: 1rem; } -.app-row > .open > .icon.-missing { +.block-row > .item > .icon.-missing { background-color: #eee; color: #b6b6b6; text-align: center; } -.app-row > .open > .icon.-missing::after { +.block-row > .item > .icon.-missing::after { content: "?"; font-size: 0.7rem; vertical-align: middle; diff --git a/src/browser/pages/home.html b/src/browser/pages/home.html index abe33a1a..d67bb5f5 100644 --- a/src/browser/pages/home.html +++ b/src/browser/pages/home.html @@ -13,18 +13,23 @@
-
-
+
+

Running Applications

{{APP_LIST:RUNNING}}
-
+
+

Update

+ {{UPDATE:NAME}} +
+ +

Editors

{{APP_LIST:EDITORS}}
-
+

Other

{{APP_LIST:OTHER}}
diff --git a/src/browser/pages/update.css b/src/browser/pages/update.css new file mode 100644 index 00000000..21b5e610 --- /dev/null +++ b/src/browser/pages/update.css @@ -0,0 +1,40 @@ +.update-form { + text-align: center; +} + +.update-form > .apply { + background-color: transparent; + color: #b6b6b6; + cursor: pointer; + border: 1px solid #b6b6b6; + box-sizing: border-box; + padding: 1rem 2rem; +} + +.update-form > .apply:hover { + color: #fafafa; + border-color: #fafafa; +} + +.update-form > .current { + margin-top: 1rem; +} + +.update-form > .links { + margin-top: 1rem; +} + +.update-form > .links > .link { + color: #b6b6b6; + text-decoration: none; +} + +.update-form > .links > .link:hover { + color: #fcfcfc; + text-decoration: underline; +} + +.update-form > .error { + color: red; + margin-top: 1rem; +} diff --git a/src/browser/pages/update.html b/src/browser/pages/update.html new file mode 100644 index 00000000..7ddefa33 --- /dev/null +++ b/src/browser/pages/update.html @@ -0,0 +1,25 @@ + + + + + + + code-server + + + + + + +
+
+

Update

+ {{UPDATE_STATUS}} + {{ERROR}} + +
+
+ + diff --git a/src/node/app/app.ts b/src/node/app/app.ts index ff17b252..62672e45 100644 --- a/src/node/app/app.ts +++ b/src/node/app/app.ts @@ -6,12 +6,17 @@ import { HttpCode, HttpError } from "../../common/http" import { Options } from "../../common/util" import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" import { ApiHttpProvider } from "./api" +import { UpdateHttpProvider } from "./update" /** * Top-level and fallback HTTP provider. */ export class MainHttpProvider extends HttpProvider { - public constructor(options: HttpProviderOptions, private readonly api: ApiHttpProvider) { + public constructor( + options: HttpProviderOptions, + private readonly api: ApiHttpProvider, + private readonly update: UpdateHttpProvider + ) { super(options) } @@ -77,13 +82,14 @@ export class MainHttpProvider extends HttpProvider { response.content = response.content .replace(/{{COMMIT}}/g, this.options.commit) .replace(/{{BASE}}/g, this.base(route)) - .replace(/{{APP_LIST:RUNNING}}/g, this.getAppRows(recent.running)) + .replace(/{{UPDATE:NAME}}/, await this.getUpdate()) + .replace(/{{APP_LIST:RUNNING}}/, this.getAppRows(recent.running)) .replace( - /{{APP_LIST:EDITORS}}/g, + /{{APP_LIST:EDITORS}}/, this.getAppRows(apps.filter((app) => app.categories && app.categories.includes("Editor"))) ) .replace( - /{{APP_LIST:OTHER}}/g, + /{{APP_LIST:OTHER}}/, this.getAppRows(apps.filter((app) => !app.categories || !app.categories.includes("Editor"))) ) return response @@ -94,8 +100,8 @@ export class MainHttpProvider extends HttpProvider { response.content = response.content .replace(/{{COMMIT}}/g, this.options.commit) .replace(/{{BASE}}/g, this.base(route)) - .replace(/{{APP_NAME}}/g, name) - .replace(/"{{OPTIONS}}"/g, `'${JSON.stringify(options)}'`) + .replace(/{{APP_NAME}}/, name) + .replace(/"{{OPTIONS}}"/, `'${JSON.stringify(options)}'`) return response } @@ -108,8 +114,8 @@ export class MainHttpProvider extends HttpProvider { } private getAppRow(app: Application): string { - return `
- + return `` } + + private async getUpdate(): Promise { + if (!this.update.enabled) { + return "Updates are disabled" + } + + const update = await this.update.getUpdate() + if (!update) { + return `
+ No updates available + Current: ${this.update.currentVersion} +
` + } + + return `
+ Update available: ${update.version} + Current: ${this.update.currentVersion} +
` + } } diff --git a/src/node/app/login.ts b/src/node/app/login.ts index 207d7084..1b79b4b7 100644 --- a/src/node/app/login.ts +++ b/src/node/app/login.ts @@ -50,8 +50,8 @@ export class LoginHttpProvider extends HttpProvider { response.content = response.content .replace(/{{COMMIT}}/g, this.options.commit) .replace(/{{BASE}}/g, this.base(route)) - .replace(/{{VALUE}}/g, value || "") - .replace(/{{ERROR}}/g, error ? `
${error.message}
` : "") + .replace(/{{VALUE}}/, value || "") + .replace(/{{ERROR}}/, error ? `
${error.message}
` : "") return response } diff --git a/src/node/app/update.ts b/src/node/app/update.ts new file mode 100644 index 00000000..34df2cfc --- /dev/null +++ b/src/node/app/update.ts @@ -0,0 +1,302 @@ +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 zip from "adm-zip" +import * as zlib from "zlib" +import { HttpCode, HttpError } from "../../common/http" +import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" +import { tmpdir } from "../util" +import { ipcMain } from "../wrapper" + +export interface Update { + version: string +} + +/** + * Update HTTP provider. + */ +export class UpdateHttpProvider extends HttpProvider { + private update?: Promise + + public constructor(options: HttpProviderOptions, public readonly enabled: boolean) { + super(options) + } + + public async handleRequest(route: Route, request: http.IncomingMessage): Promise { + switch (route.base) { + case "/": { + this.ensureMethod(request, ["GET", "POST"]) + if (route.requestPath !== "/index.html") { + throw new HttpError("Not found", HttpCode.NotFound) + } else if (!this.authenticated(request)) { + return { redirect: "/login" } + } + + switch (request.method) { + case "GET": + return this.getRoot(route) + case "POST": + return this.tryUpdate(route) + } + } + } + + return undefined + } + + public async getRoot(route: Route, error?: Error): Promise { + const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/update.html") + response.content = response.content + .replace(/{{COMMIT}}/g, this.options.commit) + .replace(/{{BASE}}/g, this.base(route)) + .replace(/{{UPDATE_STATUS}}/, await this.getUpdateHtml()) + .replace(/{{ERROR}}/, error ? `
${error.message}
` : "") + return response + } + + public async handleWebSocket(): Promise { + return undefined + } + + /** + * Query for and return the latest update. + */ + public async getUpdate(): Promise { + if (!this.enabled) { + throw new Error("updates are not enabled") + } + + if (!this.update) { + this.update = this._getUpdate() + } + + return this.update + } + + private async _getUpdate(): Promise { + const url = "https://api.github.com/repos/cdr/code-server/releases/latest" + try { + const buffer = await this.request(url) + const data = JSON.parse(buffer.toString()) + const latest = { version: data.name } + logger.debug("Got latest version", field("latest", latest.version)) + return this.isLatestVersion(latest) ? undefined : latest + } catch (error) { + logger.error("Failed to get latest version", field("error", error.message)) + return undefined + } + } + + public get currentVersion(): string { + return require(path.resolve(__dirname, "../../../package.json")).version + } + + /** + * Return true if the currently installed version is the latest. + */ + private isLatestVersion(latest: Update): boolean { + const version = this.currentVersion + logger.debug("Comparing versions", field("current", version), field("latest", latest.version)) + return latest.version === version || semver.lt(latest.version, version) + } + + private async getUpdateHtml(): Promise { + if (!this.enabled) { + return "Updates are disabled" + } + + const update = await this.getUpdate() + if (!update) { + return "No updates available" + } + + return ` +
Current: ${this.currentVersion}
` + } + + public async tryUpdate(route: Route): Promise { + try { + const update = await this.getUpdate() + if (!update) { + throw new Error("no update available") + } + await this.downloadUpdate(update) + return { + redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/", + } + } catch (error) { + return this.getRoot(route, error) + } + } + + private async downloadUpdate(update: Update): Promise { + const releaseName = await this.getReleaseName(update) + const url = `https://github.com/cdr/code-server/releases/download/${update.version.replace}/${releaseName}` + + await fs.mkdirp(tmpdir) + + const response = await this.requestResponse(url) + + try { + let downloadPath = path.join(tmpdir, releaseName) + if (downloadPath.endsWith(".tar.gz")) { + downloadPath = await this.extractTar(response, downloadPath) + } else { + downloadPath = await this.extractZip(response, downloadPath) + } + logger.debug("Downloaded update", field("path", downloadPath)) + + const target = path.resolve(__dirname, "../") + logger.debug("Replacing files", field("target", target)) + await fs.unlink(target) + await fs.move(downloadPath, target) + + 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 + } + + private async extractZip(response: Readable, downloadPath: string): Promise { + logger.debug("Downloading zip", field("path", downloadPath)) + + response.pause() + await fs.remove(downloadPath) + + const write = fs.createWriteStream(downloadPath) + response.pipe(write) + response.on("error", (error) => write.destroy(error)) + response.on("close", () => write.end()) + + await new Promise((resolve, reject) => { + write.on("error", reject) + write.on("close", resolve) + response.resume + }) + + const zipPath = downloadPath + downloadPath = downloadPath.replace(/\.zip$/, "") + await fs.remove(downloadPath) + + logger.debug("Extracting zip", field("path", zipPath)) + + await new Promise((resolve, reject) => { + new zip(zipPath).extractAllToAsync(downloadPath, true, (error) => { + return error ? reject(error) : resolve() + }) + }) + + await fs.remove(zipPath) + + return downloadPath + } + + /** + * Given an update return the name for the packaged archived. + */ + private 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}.${target === "darwin" ? "zip" : "tar.gz"}` + } + + private async request(uri: string): Promise { + const response = await this.requestResponse(uri) + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let bufferLength = 0 + response.on("data", (chunk) => { + bufferLength += chunk.length + chunks.push(chunk) + }) + response.on("error", reject) + response.on("end", () => { + resolve(Buffer.concat(chunks, bufferLength)) + }) + }) + } + + private async requestResponse(uri: string): Promise { + let redirects = 0 + const maxRedirects = 10 + return new Promise((resolve, reject) => { + const request = (uri: string): void => { + logger.debug("Making request", field("uri", uri)) + https.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => { + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + ++redirects + if (redirects > maxRedirects) { + return reject(new Error("reached max redirects")) + } + response.destroy() + return request(url.resolve(uri, response.headers.location)) + } + + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 400) { + return reject(new Error(`${response.statusCode || "500"}`)) + } + + resolve(response) + }) + } + request(uri) + }) + } +} diff --git a/src/node/cli.ts b/src/node/cli.ts index 8026209f..34409ac4 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -14,6 +14,7 @@ export interface Args extends VsArgs { readonly auth?: AuthType readonly cert?: OptionalString readonly "cert-key"?: string + readonly "disable-updates"?: boolean readonly help?: boolean readonly host?: string readonly json?: boolean @@ -66,6 +67,7 @@ const options: Options> = { description: "Path to certificate. Generated if no path is provided.", }, "cert-key": { type: "string", path: true, description: "Path to certificate key when using non-generated cert." }, + "disable-updates": { type: "boolean", description: "Disable automatic updates." }, host: { type: "string", description: "Host for the HTTP server." }, help: { type: "boolean", short: "h", description: "Show this output." }, json: { type: "boolean" }, diff --git a/src/node/entry.ts b/src/node/entry.ts index d0b7c87d..b9a03a93 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -3,6 +3,7 @@ import { Args, optionDescriptions, parse } from "./cli" import { ApiHttpProvider } from "./app/api" import { MainHttpProvider } from "./app/app" import { LoginHttpProvider } from "./app/login" +import { UpdateHttpProvider } from "./app/update" import { VscodeHttpProvider } from "./app/vscode" import { AuthType, HttpServer } from "./http" import { generateCertificate, generatePassword, hash, open } from "./util" @@ -41,9 +42,10 @@ const main = async (args: Args): Promise => { const httpServer = new HttpServer(options) const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer) + const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, !args["disable-updates"]) httpServer.registerHttpProvider("/vscode", VscodeHttpProvider, args) httpServer.registerHttpProvider("/login", LoginHttpProvider) - httpServer.registerHttpProvider("/", MainHttpProvider, api) + httpServer.registerHttpProvider("/", MainHttpProvider, api, update) ipcMain().onDispose(() => httpServer.dispose()) @@ -72,6 +74,8 @@ const main = async (args: Args): Promise => { logger.info(" - Not serving HTTPS") } + logger.info(` - Automatic updates are ${update.enabled ? "enabled" : "disabled"}`) + if (serverAddress && !options.socket && args.open) { // The web socket doesn't seem to work if browsing with 0.0.0.0. const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost") diff --git a/src/node/http.ts b/src/node/http.ts index 9d2783b6..2c51567c 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -355,6 +355,10 @@ export interface HttpProvider1 { new (options: HttpProviderOptions, a1: A1): T } +export interface HttpProvider2 { + new (options: HttpProviderOptions, a1: A1, a2: A2): T +} + /** * An HTTP server. Its main role is to route incoming HTTP requests to the * appropriate provider for that endpoint then write out the response. It also @@ -404,8 +408,14 @@ export class HttpServer { */ public registerHttpProvider(endpoint: string, provider: HttpProvider0): T public registerHttpProvider(endpoint: string, provider: HttpProvider1, a1: A1): T + public registerHttpProvider( + endpoint: string, + provider: HttpProvider2, + a1: A1, + a2: A2 + ): T // eslint-disable-next-line @typescript-eslint/no-explicit-any - public registerHttpProvider(endpoint: string, provider: any, a1?: any): any { + public registerHttpProvider(endpoint: string, provider: any, ...args: any[]): any { endpoint = endpoint.replace(/^\/+|\/+$/g, "") if (this.providers.has(`/${endpoint}`)) { throw new Error(`${endpoint} is already registered`) @@ -420,7 +430,7 @@ export class HttpServer { commit: this.options.commit, password: this.options.password, }, - a1 + ...args ) this.providers.set(`/${endpoint}`, p) return p diff --git a/yarn.lock b/yarn.lock index 802a4f1d..e426b347 100644 --- a/yarn.lock +++ b/yarn.lock @@ -856,6 +856,13 @@ "@parcel/utils" "^1.11.0" physical-cpu-count "^2.0.0" +"@types/adm-zip@^0.4.32": + version "0.4.32" + resolved "https://registry.yarnpkg.com/@types/adm-zip/-/adm-zip-0.4.32.tgz#6de01309af60677065d2e52b417a023303220931" + integrity sha512-hv1O7ySn+XvP5OeDQcJFWwVb2v+GFGO1A9aMTQ5B/bzxb7WW21O8iRhVdsKKr8QwuiagzGmPP+gsUAYZ6bRddQ== + dependencies: + "@types/node" "*" + "@types/color-name@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" @@ -940,7 +947,14 @@ resolved "https://registry.yarnpkg.com/@types/safe-compare/-/safe-compare-1.1.0.tgz#47ed9b9ca51a3a791b431cd59b28f47fa9bf1224" integrity sha512-1ri+LJhh0gRxIa37IpGytdaW7yDEHeJniBSMD1BmitS07R1j63brcYCzry+l0WJvGdEKQNQ7DYXO2epgborWPw== -"@types/tar-fs@^1.16.1": +"@types/semver@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.1.0.tgz#c8c630d4c18cd326beff77404887596f96408408" + integrity sha512-pOKLaubrAEMUItGNpgwl0HMFPrSAFic8oSVIvfu1UwcgGNmNyK9gyhBHKmBnUTwwVvpZfkzUC0GaMgnL6P86uA== + dependencies: + "@types/node" "*" + +"@types/tar-fs@^1.16.2": version "1.16.2" resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-1.16.2.tgz#6f5acea15d3b7777b8bf3f1c6d4e80ce71288f34" integrity sha512-eds/pbRf0Fe0EKmrHDbs8mRkfbjz2upAdoUfREw14dPboZaHqqZ1Y+uVeoakoPavpZMpj22nhUTAYkX5bz3DXA== @@ -948,7 +962,7 @@ "@types/node" "*" "@types/tar-stream" "*" -"@types/tar-stream@*", "@types/tar-stream@^1.6.1": +"@types/tar-stream@*": version "1.6.1" resolved "https://registry.yarnpkg.com/@types/tar-stream/-/tar-stream-1.6.1.tgz#67d759068ff781d976cad978893bb7a334ec8809" integrity sha512-pYCDOPuRE+4tXFk1rSMYiuI+kSrXiJ4av1bboQbkcEBA2rqwEWfIn9kdMSH+5nYu58WksHuxwx+7kVbtg0Le7w== @@ -1086,6 +1100,11 @@ acorn@^7.0.0, acorn@^7.1.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c" integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ== +adm-zip@^0.4.14: + version "0.4.14" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.14.tgz#2cf312bcc9f8875df835b0f6040bd89be0a727a9" + integrity sha512-/9aQCnQHF+0IiCl0qhXoK7qs//SwYE7zX8lsr/DNk1BRAHYxeLZPL4pguwK29gUEqasYQjqPtEpDRSWEkdHn9g== + ajv@^6.10.0, ajv@^6.10.2, ajv@^6.5.5: version "6.11.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.11.0.tgz#c3607cbc8ae392d8a5a536f25b21f8e5f3f87fe9" @@ -1834,6 +1853,11 @@ chownr@^1.1.1: resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.3.tgz#42d837d5239688d55f303003a508230fa6727142" integrity sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw== +chownr@^1.1.3: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" @@ -3206,6 +3230,13 @@ fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -4567,6 +4598,21 @@ minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= +minipass@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.1.tgz#7607ce778472a185ad6d89082aa2070f79cedcd5" + integrity sha512-UFqVihv6PQgwj8/yTGvl9kPz7xIAY+R5z6XYjRInD3Gk3qx6QGSD6zEcpeG4Dy/lQnv1J6zv8ejV90hyYIKf3w== + dependencies: + yallist "^4.0.0" + +minizlib@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.0.tgz#fd52c645301ef09a63a2c209697c294c6ce02cf3" + integrity sha512-EzTZN/fjSvifSX0SlqUERCN39o6T40AMarPbv0MrarSFtIITCBh7bi+dU8nxGFHuqs9jdIAeoYoKuQAAASsPPA== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" @@ -4582,6 +4628,11 @@ mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: dependencies: minimist "0.0.8" +mkdirp@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.3.tgz#4cf2e30ad45959dddea53ad97d518b6c8205e1ea" + integrity sha512-6uCP4Qc0sWsgMLy1EOqqS/3rjDHOEnsStVr/4vtAIK2Y5i2kA7lFFejYrpIyiN9w0pYf4ckeCYT9f1r1P9KX5g== + mocha@^6.2.0: version "6.2.2" resolved "https://registry.yarnpkg.com/mocha/-/mocha-6.2.2.tgz#5d8987e28940caf8957a7d7664b910dc5b2fea20" @@ -6332,6 +6383,11 @@ semver@^6.1.2, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +semver@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.3.tgz#e4345ce73071c53f336445cfc19efb1c311df2a6" + integrity sha512-ekM0zfiA9SCBlsKa2X1hxyxiI4L3B6EbVJkkdgQXnSEEaHlGdvyodMruTiulSRWMMB4NeIuYNMC9rTKTz97GxA== + send@0.17.1: version "0.17.1" resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" @@ -6995,7 +7051,7 @@ tar-fs@^2.0.0: pump "^3.0.0" tar-stream "^2.0.0" -tar-stream@^2.0.0, tar-stream@^2.1.0: +tar-stream@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.0.tgz#d1aaa3661f05b38b5acc9b7020efdca5179a2cc3" integrity sha512-+DAn4Nb4+gz6WZigRzKEZl1QuJVOLtAwwF+WUxy1fJ6X63CaGaUAxJRD2KEn1OMfcbCjySTYpNC6WmfQoIEOdw== @@ -7006,6 +7062,18 @@ tar-stream@^2.0.0, tar-stream@^2.1.0: inherits "^2.0.3" readable-stream "^3.1.1" +tar@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.1.tgz#7b3bd6c313cb6e0153770108f8d70ac298607efa" + integrity sha512-bKhKrrz2FJJj5s7wynxy/fyxpE0CmCjmOQ1KV4KkgXFWOgoIT/NbTMnB1n+LFNrNk0SSBVGGxcK5AGsyC+pW5Q== + dependencies: + chownr "^1.1.3" + fs-minipass "^2.0.0" + minipass "^3.0.0" + minizlib "^2.1.0" + mkdirp "^1.0.3" + yallist "^4.0.0" + terser@^3.7.3: version "3.17.0" resolved "https://registry.yarnpkg.com/terser/-/terser-3.17.0.tgz#f88ffbeda0deb5637f9d24b0da66f4e15ab10cb2" @@ -7698,6 +7766,11 @@ y18n@^4.0.0: resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yaml@^1.7.2: version "1.7.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.7.2.tgz#f26aabf738590ab61efaca502358e48dc9f348b2" From 0ec83f8736238e0ba7483b330ed485d0ab14762a Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 14 Feb 2020 16:41:42 -0600 Subject: [PATCH 2/2] Check updates daily instead of every time Also add a way to force a check. --- src/browser/pages/home.css | 21 ++++++++++----- src/node/app/app.ts | 35 ++++++++++++++++++++----- src/node/app/update.ts | 52 ++++++++++++++++++++++++++------------ src/node/app/vscode.ts | 14 +++------- src/node/settings.ts | 24 ++++++++++++++++-- 5 files changed, 105 insertions(+), 41 deletions(-) diff --git a/src/browser/pages/home.css b/src/browser/pages/home.css index c14a539a..018623ba 100644 --- a/src/browser/pages/home.css +++ b/src/browser/pages/home.css @@ -22,17 +22,24 @@ } .block-row > .item { - color: #b6b6b6; - display: flex; + color: #c4c4c4; flex: 1; +} + +.block-row > .item.-row { + display: flex; +} + +.block-row > .item > .sub { + color: #888; +} + +.block-row .-link { + cursor: pointer; text-decoration: none; } -.block-row > .item.-link { - cursor: pointer; -} - -.block-row > .item.-link:hover { +.block-row .-link:hover { color: #fafafa; } diff --git a/src/node/app/app.ts b/src/node/app/app.ts index 62672e45..136c4cc6 100644 --- a/src/node/app/app.ts +++ b/src/node/app/app.ts @@ -115,7 +115,7 @@ export class MainHttpProvider extends HttpProvider { private getAppRow(app: Application): string { return `
- + ${ app.icon ? `` @@ -139,17 +139,40 @@ export class MainHttpProvider extends HttpProvider { return "Updates are disabled" } + const humanize = (time: number): string => { + const d = new Date(time) + const pad = (t: number): string => (t < 10 ? "0" : "") + t + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + ` ${pad(d.getHours())}:${pad(d.getMinutes())}` + ) + } + const update = await this.update.getUpdate() - if (!update) { + if (this.update.isLatestVersion(update)) { return `` } return `
- Update available: ${update.version} - Current: ${this.update.currentVersion} + + ${update.version} +
Out of date
+
+
+ ${humanize(update.checked)} + Check now +
+
Current: ${this.update.currentVersion}
` } } diff --git a/src/node/app/update.ts b/src/node/app/update.ts index 34df2cfc..55bb9dec 100644 --- a/src/node/app/update.ts +++ b/src/node/app/update.ts @@ -1,4 +1,5 @@ import { field, logger } from "@coder/logger" +import zip from "adm-zip" import * as cp from "child_process" import * as fs from "fs-extra" import * as http from "http" @@ -10,14 +11,15 @@ import { Readable, Writable } from "stream" import * as tar from "tar-fs" import * as url from "url" import * as util from "util" -import zip from "adm-zip" import * as zlib from "zlib" import { HttpCode, HttpError } from "../../common/http" import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" +import { settings } from "../settings" import { tmpdir } from "../util" import { ipcMain } from "../wrapper" export interface Update { + checked: number version: string } @@ -25,7 +27,8 @@ export interface Update { * Update HTTP provider. */ export class UpdateHttpProvider extends HttpProvider { - private update?: Promise + private update?: Promise + private updateInterval = 1000 * 60 * 60 * 24 // Milliseconds between update checks. public constructor(options: HttpProviderOptions, public readonly enabled: boolean) { super(options) @@ -33,6 +36,10 @@ export class UpdateHttpProvider extends HttpProvider { public async handleRequest(route: Route, request: http.IncomingMessage): Promise { switch (route.base) { + case "/check": + this.ensureMethod(request) + this.getUpdate(true) + return { redirect: "/login" } case "/": { this.ensureMethod(request, ["GET", "POST"]) if (route.requestPath !== "/index.html") { @@ -70,29 +77,38 @@ export class UpdateHttpProvider extends HttpProvider { /** * Query for and return the latest update. */ - public async getUpdate(): Promise { + public async getUpdate(force?: boolean): Promise { if (!this.enabled) { throw new Error("updates are not enabled") } if (!this.update) { - this.update = this._getUpdate() + this.update = this._getUpdate(force) + this.update.then(() => (this.update = undefined)) } return this.update } - private async _getUpdate(): Promise { + private async _getUpdate(force?: boolean): Promise { const url = "https://api.github.com/repos/cdr/code-server/releases/latest" + const now = Date.now() try { - const buffer = await this.request(url) - const data = JSON.parse(buffer.toString()) - const latest = { version: data.name } - logger.debug("Got latest version", field("latest", latest.version)) - return this.isLatestVersion(latest) ? undefined : latest + let { update } = !force ? await settings.read() : { update: undefined } + if (!update || update.checked + this.updateInterval < now) { + const buffer = await this.request(url) + const data = JSON.parse(buffer.toString()) + update = { checked: now, version: data.name as string } + settings.write({ update }) + } + logger.debug("Got latest version", field("latest", update.version)) + return update } catch (error) { logger.error("Failed to get latest version", field("error", error.message)) - return undefined + return { + checked: now, + version: "unknown", + } } } @@ -103,10 +119,14 @@ export class UpdateHttpProvider extends HttpProvider { /** * Return true if the currently installed version is the latest. */ - private isLatestVersion(latest: Update): boolean { + public isLatestVersion(latest: Update): boolean { const version = this.currentVersion logger.debug("Comparing versions", field("current", version), field("latest", latest.version)) - return latest.version === version || semver.lt(latest.version, version) + try { + return latest.version === version || semver.lt(latest.version, version) + } catch (error) { + return true + } } private async getUpdateHtml(): Promise { @@ -115,8 +135,8 @@ export class UpdateHttpProvider extends HttpProvider { } const update = await this.getUpdate() - if (!update) { - return "No updates available" + if (this.isLatestVersion(update)) { + throw new Error("No update available") } return `