Archived
1
0

Implement automatic updates

This commit is contained in:
Asher
2020-02-14 15:57:51 -06:00
parent b8fa7da972
commit db54f78e8e
14 changed files with 531 additions and 36 deletions

View File

@ -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("/")

View File

@ -16,6 +16,7 @@ body {
button {
font-family: inherit;
font-size: inherit;
}
.center-container {

View File

@ -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;

View File

@ -13,18 +13,23 @@
</head>
<body>
<div class="center-container">
<div class="app-lists">
<div class="app-list">
<div class="info-blocks">
<div class="info-block">
<h2 class="header">Running Applications</h2>
{{APP_LIST:RUNNING}}
</div>
<div class="app-list">
<div class="info-block">
<h2 class="header">Update</h2>
{{UPDATE:NAME}}
</div>
<div class="info-block">
<h2 class="header">Editors</h2>
{{APP_LIST:EDITORS}}
</div>
<div class="app-list">
<div class="info-block">
<h2 class="header">Other</h2>
{{APP_LIST:OTHER}}
</div>

View File

@ -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;
}

View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<meta http-equiv="Content-Security-Policy" content="style-src 'self'; manifest-src 'self'; img-src 'self' data:;">
<title>code-server</title>
<link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
<link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet">
</head>
<body>
<div class="center-container">
<form class="update-form" method="post">
<h2 class="header">Update</h2>
{{UPDATE_STATUS}}
{{ERROR}}
<div class="links">
<a class="link" href="{{BASE}}">go home</a>
</div>
</form>
</div>
</body>
</html>

View File

@ -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 `<div class="app-row">
<a class="open" href=".${app.path}">
return `<div class="block-row">
<a class="item -link" href=".${app.path}">
${
app.icon
? `<img class="icon" src="data:image/png;base64,${app.icon}"></img>`
@ -127,4 +133,23 @@ export class MainHttpProvider extends HttpProvider {
}
</div>`
}
private async getUpdate(): Promise<string> {
if (!this.update.enabled) {
return "Updates are disabled"
}
const update = await this.update.getUpdate()
if (!update) {
return `<div class="block-row">
<span class="item">No updates available</span>
<span class="current" >Current: ${this.update.currentVersion}</span>
</div>`
}
return `<div class="block-row">
<a class="item -link" href="./update">Update available: ${update.version}</a>
<span class="current" >Current: ${this.update.currentVersion}</span>
</div>`
}
}

View File

@ -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 ? `<div class="error">${error.message}</div>` : "")
.replace(/{{VALUE}}/, value || "")
.replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : "")
return response
}

302
src/node/app/update.ts Normal file
View File

@ -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<Update | undefined>
public constructor(options: HttpProviderOptions, public readonly enabled: boolean) {
super(options)
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
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<HttpResponse> {
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 ? `<div class="error">${error.message}</div>` : "")
return response
}
public async handleWebSocket(): Promise<undefined> {
return undefined
}
/**
* Query for and return the latest update.
*/
public async getUpdate(): Promise<Update | undefined> {
if (!this.enabled) {
throw new Error("updates are not enabled")
}
if (!this.update) {
this.update = this._getUpdate()
}
return this.update
}
private async _getUpdate(): Promise<Update | undefined> {
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<string> {
if (!this.enabled) {
return "Updates are disabled"
}
const update = await this.getUpdate()
if (!update) {
return "No updates available"
}
return `<button type="submit" class="apply">
Update to ${update.version}
</button>
<div class="current">Current: ${this.currentVersion}</div>`
}
public async tryUpdate(route: Route): Promise<HttpResponse> {
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<void> {
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<string> {
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<string> {
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<string> {
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<Buffer> {
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<http.IncomingMessage> {
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)
})
}
}

View File

@ -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<Required<Args>> = {
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" },

View File

@ -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<void> => {
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<void> => {
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")

View File

@ -355,6 +355,10 @@ export interface HttpProvider1<A1, T> {
new (options: HttpProviderOptions, a1: A1): T
}
export interface HttpProvider2<A1, A2, T> {
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<T extends HttpProvider>(endpoint: string, provider: HttpProvider0<T>): T
public registerHttpProvider<A1, T extends HttpProvider>(endpoint: string, provider: HttpProvider1<A1, T>, a1: A1): T
public registerHttpProvider<A1, A2, T extends HttpProvider>(
endpoint: string,
provider: HttpProvider2<A1, A2, T>,
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