Compare commits
1 Commits
vscode-1.4
...
node12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3381d996d5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,4 +10,3 @@ release-gcp/
|
|||||||
release-images/
|
release-images/
|
||||||
node_modules
|
node_modules
|
||||||
node-*
|
node-*
|
||||||
/plugins
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ you're in North America or Europe.
|
|||||||
|
|
||||||
Please get in [touch](mailto:jobs@coder.com) with your resume/github if interested.
|
Please get in [touch](mailto:jobs@coder.com) with your resume/github if interested.
|
||||||
|
|
||||||
## For Organizations
|
## Enterprise
|
||||||
|
|
||||||
Visit [our website](https://coder.com) for more information about remote development for your organization or enterprise.
|
Visit [our website](https://coder.com) for more information about our
|
||||||
|
enterprise offerings.
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
parcel build \
|
parcel build \
|
||||||
--public-url "." \
|
--public-url "/static/$(git rev-parse HEAD)/dist" \
|
||||||
--out-dir dist \
|
--out-dir dist \
|
||||||
$([[ $MINIFY ]] || echo --no-minify) \
|
$([[ $MINIFY ]] || echo --no-minify) \
|
||||||
|
src/browser/pages/app.ts \
|
||||||
src/browser/register.ts \
|
src/browser/register.ts \
|
||||||
src/browser/serviceWorker.ts
|
src/browser/serviceWorker.ts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ main() {
|
|||||||
rsync README.md "$RELEASE_PATH"
|
rsync README.md "$RELEASE_PATH"
|
||||||
rsync LICENSE.txt "$RELEASE_PATH"
|
rsync LICENSE.txt "$RELEASE_PATH"
|
||||||
rsync ./lib/vscode/ThirdPartyNotices.txt "$RELEASE_PATH"
|
rsync ./lib/vscode/ThirdPartyNotices.txt "$RELEASE_PATH"
|
||||||
|
|
||||||
# code-server exports types which can be imported and used by plugins. Those
|
|
||||||
# types import ipc.d.ts but it isn't included in the final vscode build so
|
|
||||||
# we'll copy it ourselves here.
|
|
||||||
mkdir -p "$RELEASE_PATH/lib/vscode/src/vs/server"
|
|
||||||
rsync ./lib/vscode/src/vs/server/ipc.d.ts "$RELEASE_PATH/lib/vscode/src/vs/server"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle_code_server() {
|
bundle_code_server() {
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ vscode_yarn() {
|
|||||||
cd lib/vscode
|
cd lib/vscode
|
||||||
yarn --production --frozen-lockfile
|
yarn --production --frozen-lockfile
|
||||||
cd extensions
|
cd extensions
|
||||||
yarn --production --frozen-lockfile
|
# Cannot use --production here. The postinstall here uses a dev dependency.
|
||||||
|
yarn --frozen-lockfile
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ main() {
|
|||||||
./release-standalone/bin/code-server --extensions-dir "$EXTENSIONS_DIR" --install-extension ms-python.python
|
./release-standalone/bin/code-server --extensions-dir "$EXTENSIONS_DIR" --install-extension ms-python.python
|
||||||
local installed_extensions
|
local installed_extensions
|
||||||
installed_extensions="$(./release-standalone/bin/code-server --extensions-dir "$EXTENSIONS_DIR" --list-extensions 2>&1)"
|
installed_extensions="$(./release-standalone/bin/code-server --extensions-dir "$EXTENSIONS_DIR" --list-extensions 2>&1)"
|
||||||
if [[ $installed_extensions != *"info Using config file ~/.config/code-server/config.yaml
|
if [[ $installed_extensions != "info Using config file ~/.config/code-server/config.yaml
|
||||||
ms-python.python" ]]; then
|
ms-python.python" ]]; then
|
||||||
echo "Unexpected output from listing extensions:"
|
echo "Unexpected output from listing extensions:"
|
||||||
echo "$installed_extensions"
|
echo "$installed_extensions"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,6 @@ class Watcher {
|
|||||||
|
|
||||||
const vscode = cp.spawn("yarn", ["watch"], { cwd: this.vscodeSourcePath })
|
const vscode = cp.spawn("yarn", ["watch"], { cwd: this.vscodeSourcePath })
|
||||||
const tsc = cp.spawn("tsc", ["--watch", "--pretty", "--preserveWatchOutput"], { cwd: this.rootPath })
|
const tsc = cp.spawn("tsc", ["--watch", "--pretty", "--preserveWatchOutput"], { cwd: this.rootPath })
|
||||||
const plugin = cp.spawn("yarn", ["build", "--watch"], { cwd: process.env.PLUGIN_DIR })
|
|
||||||
const bundler = this.createBundler()
|
const bundler = this.createBundler()
|
||||||
|
|
||||||
const cleanup = (code?: number | null): void => {
|
const cleanup = (code?: number | null): void => {
|
||||||
@@ -49,10 +48,6 @@ class Watcher {
|
|||||||
tsc.removeAllListeners()
|
tsc.removeAllListeners()
|
||||||
tsc.kill()
|
tsc.kill()
|
||||||
|
|
||||||
Watcher.log("killing plugin")
|
|
||||||
plugin.removeAllListeners()
|
|
||||||
plugin.kill()
|
|
||||||
|
|
||||||
if (server) {
|
if (server) {
|
||||||
Watcher.log("killing server")
|
Watcher.log("killing server")
|
||||||
server.removeAllListeners()
|
server.removeAllListeners()
|
||||||
@@ -74,10 +69,6 @@ class Watcher {
|
|||||||
Watcher.log("tsc terminated unexpectedly")
|
Watcher.log("tsc terminated unexpectedly")
|
||||||
cleanup(code)
|
cleanup(code)
|
||||||
})
|
})
|
||||||
plugin.on("exit", (code) => {
|
|
||||||
Watcher.log("plugin terminated unexpectedly")
|
|
||||||
cleanup(code)
|
|
||||||
})
|
|
||||||
const bundle = bundler.bundle().catch(() => {
|
const bundle = bundler.bundle().catch(() => {
|
||||||
Watcher.log("parcel watcher terminated unexpectedly")
|
Watcher.log("parcel watcher terminated unexpectedly")
|
||||||
cleanup(1)
|
cleanup(1)
|
||||||
@@ -91,7 +82,6 @@ class Watcher {
|
|||||||
|
|
||||||
vscode.stderr.on("data", (d) => process.stderr.write(d))
|
vscode.stderr.on("data", (d) => process.stderr.write(d))
|
||||||
tsc.stderr.on("data", (d) => process.stderr.write(d))
|
tsc.stderr.on("data", (d) => process.stderr.write(d))
|
||||||
plugin.stderr.on("data", (d) => process.stderr.write(d))
|
|
||||||
|
|
||||||
// From https://github.com/chalk/ansi-regex
|
// From https://github.com/chalk/ansi-regex
|
||||||
const pattern = [
|
const pattern = [
|
||||||
@@ -150,27 +140,21 @@ class Watcher {
|
|||||||
bundle.then(restartServer)
|
bundle.then(restartServer)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onLine(plugin, (line, original) => {
|
|
||||||
// tsc outputs blank lines; skip them.
|
|
||||||
if (line !== "") {
|
|
||||||
console.log("[plugin]", original)
|
|
||||||
}
|
|
||||||
if (line.includes("Watching for file changes")) {
|
|
||||||
bundle.then(restartServer)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private createBundler(out = "dist"): Bundler {
|
private createBundler(out = "dist"): Bundler {
|
||||||
return new Bundler(
|
return new Bundler(
|
||||||
[path.join(this.rootPath, "src/browser/register.ts"), path.join(this.rootPath, "src/browser/serviceWorker.ts")],
|
[
|
||||||
|
path.join(this.rootPath, "src/browser/pages/app.ts"),
|
||||||
|
path.join(this.rootPath, "src/browser/register.ts"),
|
||||||
|
path.join(this.rootPath, "src/browser/serviceWorker.ts"),
|
||||||
|
],
|
||||||
{
|
{
|
||||||
outDir: path.join(this.rootPath, out),
|
outDir: path.join(this.rootPath, out),
|
||||||
cacheDir: path.join(this.rootPath, ".cache"),
|
cacheDir: path.join(this.rootPath, ".cache"),
|
||||||
minify: !!process.env.MINIFY,
|
minify: !!process.env.MINIFY,
|
||||||
logLevel: 1,
|
logLevel: 1,
|
||||||
publicUrl: ".",
|
publicUrl: "/static/development/dist",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
FROM centos:7
|
FROM centos:7
|
||||||
|
|
||||||
|
ARG NODE_VERSION=v12.18.3
|
||||||
RUN ARCH="$(uname -m | sed 's/86_64/64/; s/aarch64/arm64/')" && \
|
RUN ARCH="$(uname -m | sed 's/86_64/64/; s/aarch64/arm64/')" && \
|
||||||
curl -fsSL "https://nodejs.org/dist/v14.4.0/node-v14.4.0-linux-$ARCH.tar.xz" | tar -C /usr/local -xJ && \
|
curl -fsSL "https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-linux-$ARCH.tar.xz" | tar -C /usr/local -xJ && \
|
||||||
mv /usr/local/node-v14.4.0-linux-$ARCH /usr/local/node-v14.4.0
|
mv "/usr/local/node-$NODE_VERSION-linux-$ARCH" "/usr/local/node-$NODE_VERSION"
|
||||||
ENV PATH=/usr/local/node-v14.4.0/bin:$PATH
|
ENV PATH=/usr/local/node-$NODE_VERSION/bin:$PATH
|
||||||
RUN npm install -g yarn
|
RUN npm install -g yarn
|
||||||
|
|
||||||
RUN yum groupinstall -y 'Development Tools'
|
RUN yum groupinstall -y 'Development Tools'
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ RUN apt-get update
|
|||||||
RUN apt-get install -y curl gnupg
|
RUN apt-get install -y curl gnupg
|
||||||
|
|
||||||
# Installs node.
|
# Installs node.
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - && \
|
RUN curl -fsSL https://deb.nodesource.com/setup_12.x | bash - && \
|
||||||
apt-get install -y nodejs
|
apt-get install -y nodejs
|
||||||
|
|
||||||
# Installs yarn.
|
# Installs yarn.
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ main() {
|
|||||||
cd "$(dirname "$0")/../.."
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
if [[ $OSTYPE == darwin* ]]; then
|
if [[ $OSTYPE == darwin* ]]; then
|
||||||
curl -L https://nodejs.org/dist/v14.4.0/node-v14.4.0-darwin-x64.tar.gz | tar -xz
|
NODE_VERSION=v12.18.3
|
||||||
PATH="$PWD/node-v14.4.0-darwin-x64/bin:$PATH"
|
curl -L "https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-darwin-x64.tar.gz" | tar -xz
|
||||||
|
PATH="$PWD/node-$NODE_VERSION-darwin-x64/bin:$PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# https://github.com/actions/upload-artifact/issues/38
|
# https://github.com/actions/upload-artifact/issues/38
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Please refer to [VS Code's prerequisites](https://github.com/Microsoft/vscode/wi
|
|||||||
Differences:
|
Differences:
|
||||||
|
|
||||||
- We require a minimum of node v12 but later versions should work.
|
- We require a minimum of node v12 but later versions should work.
|
||||||
- We use [nfpm](https://github.com/goreleaser/nfpm) to build `.deb` and `.rpm` packages.
|
- We use [fnpm](https://github.com/goreleaser/nfpm) to build `.deb` and `.rpm` packages.
|
||||||
- We use [jq](https://stedolan.github.io/jq/) to build code-server releases.
|
- We use [jq](https://stedolan.github.io/jq/) to build code-server releases.
|
||||||
- The [CI container](../ci/images/debian8/Dockerfile) is a useful reference for all our dependencies.
|
- The [CI container](../ci/images/debian8/Dockerfile) is a useful reference for all our dependencies.
|
||||||
|
|
||||||
|
|||||||
15
doc/FAQ.md
15
doc/FAQ.md
@@ -86,20 +86,9 @@ If you have your own marketplace that implements the VS Code Extension Gallery A
|
|||||||
point code-server to it by setting `$SERVICE_URL` and `$ITEM_URL`. These correspond directly
|
point code-server to it by setting `$SERVICE_URL` and `$ITEM_URL`. These correspond directly
|
||||||
to `serviceUrl` and `itemUrl` in VS Code's `product.json`.
|
to `serviceUrl` and `itemUrl` in VS Code's `product.json`.
|
||||||
|
|
||||||
e.g. to use [open-vsx.org](https://open-vsx.org):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export SERVICE_URL=https://open-vsx.org/vscode/gallery
|
|
||||||
export ITEM_URL=https://open-vsx.org/vscode/item
|
|
||||||
```
|
|
||||||
|
|
||||||
While you can technically use Microsoft's marketplace with these, please do not do so as it
|
While you can technically use Microsoft's marketplace with these, please do not do so as it
|
||||||
is against their terms of use. See [above](#differences-compared-to-vs-code) and this
|
is against their terms of use. See [above](#differences-compared-to-vs-code). These variables
|
||||||
discussion regarding the use of the Microsoft URLs in forks:
|
are most valuable to our enterprise customers for whom we have a self hosted marketplace product.
|
||||||
|
|
||||||
https://github.com/microsoft/vscode/issues/31168#issue-244533026
|
|
||||||
|
|
||||||
These variables are most valuable to our enterprise customers for whom we have a self hosted marketplace product.
|
|
||||||
|
|
||||||
## Where are extensions stored?
|
## Where are extensions stored?
|
||||||
|
|
||||||
|
|||||||
Submodule lib/vscode updated: db40434f56...17299e413d
@@ -64,7 +64,7 @@
|
|||||||
"vfile-message": "^2.0.2"
|
"vfile-message": "^2.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@coder/logger": "1.1.16",
|
"@coder/logger": "1.1.11",
|
||||||
"env-paths": "^2.2.0",
|
"env-paths": "^2.2.0",
|
||||||
"fs-extra": "^8.1.0",
|
"fs-extra": "^8.1.0",
|
||||||
"http-proxy": "^1.18.0",
|
"http-proxy": "^1.18.0",
|
||||||
@@ -72,7 +72,6 @@
|
|||||||
"js-yaml": "^3.13.1",
|
"js-yaml": "^3.13.1",
|
||||||
"limiter": "^1.1.5",
|
"limiter": "^1.1.5",
|
||||||
"pem": "^1.14.2",
|
"pem": "^1.14.2",
|
||||||
"rotating-file-stream": "^2.1.1",
|
|
||||||
"safe-compare": "^1.1.4",
|
"safe-compare": "^1.1.4",
|
||||||
"semver": "^7.1.3",
|
"semver": "^7.1.3",
|
||||||
"tar": "^6.0.1",
|
"tar": "^6.0.1",
|
||||||
|
|||||||
@@ -7,32 +7,32 @@
|
|||||||
"description": "Run editors on a remote server.",
|
"description": "Run editors on a remote server.",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-96.png",
|
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-96.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "96x96"
|
"sizes": "96x96"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-128.png",
|
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-128.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "128x128"
|
"sizes": "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-192.png",
|
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-192.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "192x192"
|
"sizes": "192x192"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-256.png",
|
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-256.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "256x256"
|
"sizes": "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-384.png",
|
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "384x384"
|
"sizes": "384x384"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-512.png",
|
"src": "{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-512.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"sizes": "512x512"
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/browser/pages/app.html
Normal file
28
src/browser/pages/app.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!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' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-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/pwa-icon-384.png" />
|
||||||
|
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
|
||||||
|
<meta id="coder-options" data-settings="{{OPTIONS}}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
|
||||||
|
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/pages/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
src/browser/pages/app.ts
Normal file
37
src/browser/pages/app.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { getOptions, normalize } from "../../common/util"
|
||||||
|
import { ApiEndpoint } from "../../common/http"
|
||||||
|
|
||||||
|
import "./error.css"
|
||||||
|
import "./global.css"
|
||||||
|
import "./home.css"
|
||||||
|
import "./login.css"
|
||||||
|
import "./update.css"
|
||||||
|
|
||||||
|
const options = getOptions()
|
||||||
|
|
||||||
|
const isInput = (el: Element): el is HTMLInputElement => {
|
||||||
|
return !!(el as HTMLInputElement).name
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("form").forEach((form) => {
|
||||||
|
if (!form.classList.contains("-x11")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const values: { [key: string]: string } = {}
|
||||||
|
Array.from(form.elements).forEach((element) => {
|
||||||
|
if (isInput(element)) {
|
||||||
|
values[element.name] = element.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
fetch(normalize(`${options.base}/api/${ApiEndpoint.process}`), {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// TEMP: Until we can get the real ready event.
|
||||||
|
const event = new CustomEvent("ide-ready")
|
||||||
|
window.dispatchEvent(event)
|
||||||
@@ -11,10 +11,14 @@
|
|||||||
content="style-src 'self'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
|
content="style-src 'self'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
|
||||||
/>
|
/>
|
||||||
<title>{{ERROR_TITLE}} - code-server</title>
|
<title>{{ERROR_TITLE}} - code-server</title>
|
||||||
<link rel="icon" href="{{CS_STATIC_BASE}}/src/browser/media/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
|
||||||
<link rel="manifest" href="{{CS_STATIC_BASE}}/src/browser/media/manifest.json" crossorigin="use-credentials" />
|
<link
|
||||||
<link rel="apple-touch-icon" href="{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-384.png" />
|
rel="manifest"
|
||||||
<link href="{{CS_STATIC_BASE}}/dist/register.css" rel="stylesheet" />
|
href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json"
|
||||||
|
crossorigin="use-credentials"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png" />
|
||||||
|
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
|
||||||
<meta id="coder-options" data-settings="{{OPTIONS}}" />
|
<meta id="coder-options" data-settings="{{OPTIONS}}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -29,6 +33,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script data-cfasync="false" src="{{CS_STATIC_BASE}}/dist/register.js"></script>
|
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
51
src/browser/pages/home.css
Normal file
51
src/browser/pages/home.css
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
.block-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row > .item {
|
||||||
|
flex: 1;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row > button.item {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row > .item > .sub {
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row .-link {
|
||||||
|
color: rgb(87, 114, 245);
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row .-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row > .item > .icon {
|
||||||
|
height: 1rem;
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row > .item > .icon.-missing {
|
||||||
|
background-color: rgba(87, 114, 245, 0.2);
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-form {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-form > .kill {
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
}
|
||||||
59
src/browser/pages/home.html
Normal file
59
src/browser/pages/home.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!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' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-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/pwa-icon-384.png" />
|
||||||
|
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
|
||||||
|
<meta id="coder-options" data-settings="{{OPTIONS}}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="center-container">
|
||||||
|
<div class="card-box">
|
||||||
|
<div class="header">
|
||||||
|
<h2 class="main">Editors</h2>
|
||||||
|
<div class="sub">Choose an editor to launch below.</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
{{APP_LIST:EDITORS}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-box">
|
||||||
|
<div class="header">
|
||||||
|
<h2 class="main">Other</h2>
|
||||||
|
<div class="sub">Choose an application to launch below.</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
{{APP_LIST:OTHER}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-box">
|
||||||
|
<div class="header">
|
||||||
|
<h2 class="main">Version</h2>
|
||||||
|
<div class="sub">Version information and updates.</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
{{UPDATE:NAME}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
|
||||||
|
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/pages/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -11,10 +11,14 @@
|
|||||||
content="style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
|
content="style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;"
|
||||||
/>
|
/>
|
||||||
<title>code-server login</title>
|
<title>code-server login</title>
|
||||||
<link rel="icon" href="{{CS_STATIC_BASE}}/src/browser/media/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
|
||||||
<link rel="manifest" href="{{CS_STATIC_BASE}}/src/browser/media/manifest.json" crossorigin="use-credentials" />
|
<link
|
||||||
<link rel="apple-touch-icon" href="{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-384.png" />
|
rel="manifest"
|
||||||
<link href="{{CS_STATIC_BASE}}/dist/register.css" rel="stylesheet" />
|
href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json"
|
||||||
|
crossorigin="use-credentials"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png" />
|
||||||
|
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
|
||||||
<meta id="coder-options" data-settings="{{OPTIONS}}" />
|
<meta id="coder-options" data-settings="{{OPTIONS}}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -46,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
<script data-cfasync="false" src="{{CS_STATIC_BASE}}/dist/register.js"></script>
|
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const parts = window.location.pathname.replace(/^\//g, "").split("/")
|
const parts = window.location.pathname.replace(/^\//g, "").split("/")
|
||||||
parts[parts.length - 1] = "{{BASE}}"
|
parts[parts.length - 1] = "{{BASE}}"
|
||||||
|
|||||||
43
src/browser/pages/update.html
Normal file
43
src/browser/pages/update.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!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:; font-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/pwa-icon-384.png" />
|
||||||
|
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
|
||||||
|
<meta id="coder-options" data-settings="{{OPTIONS}}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="center-container">
|
||||||
|
<div class="card-box">
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="main">Update</h1>
|
||||||
|
<div class="sub">Update code-server.</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<form class="update-form" action="{{BASE}}/update/apply">
|
||||||
|
{{UPDATE_STATUS}} {{ERROR}}
|
||||||
|
<div class="links">
|
||||||
|
<a class="link" href="{{BASE}}{{TO}}">go home</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<script>
|
|
||||||
globalThis.MonacoPerformanceMarks = globalThis.MonacoPerformanceMarks || [];
|
|
||||||
globalThis.MonacoPerformanceMarks.push('renderer/started', Date.now());
|
|
||||||
</script>
|
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="font-src 'self' data:; connect-src ws: wss: 'self' https:; default-src ws: wss: 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; manifest-src 'self'; img-src 'self' data: https:;"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Disable pinch zooming -->
|
<!-- Disable pinch zooming -->
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
@@ -23,17 +24,21 @@
|
|||||||
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}" />
|
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}" />
|
||||||
|
|
||||||
<!-- Workbench Icon/Manifest/CSS -->
|
<!-- Workbench Icon/Manifest/CSS -->
|
||||||
<link rel="icon" href="{{CS_STATIC_BASE}}/src/browser/media/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
|
||||||
<link rel="manifest" href="{{CS_STATIC_BASE}}/src/browser/media/manifest.json" crossorigin="use-credentials" />
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json"
|
||||||
|
crossorigin="use-credentials"
|
||||||
|
/>
|
||||||
<!-- PROD_ONLY
|
<!-- PROD_ONLY
|
||||||
<link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="{{CS_STATIC_BASE}}/lib/vscode/out/vs/workbench/workbench.web.api.css">
|
<link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="{{BASE}}/static/{{COMMIT}}/lib/vscode/out/vs/workbench/workbench.web.api.css">
|
||||||
END_PROD_ONLY -->
|
END_PROD_ONLY -->
|
||||||
<link rel="apple-touch-icon" href="{{CS_STATIC_BASE}}/src/browser/media/pwa-icon-384.png" />
|
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
|
||||||
<!-- Prefetch to avoid waterfall -->
|
<!-- Prefetch to avoid waterfall -->
|
||||||
<!-- PROD_ONLY
|
<!-- PROD_ONLY
|
||||||
<link rel="prefetch" href="{{CS_STATIC_BASE}}/lib/vscode/node_modules/semver-umd/lib/semver-umd.js">
|
<link rel="prefetch" href="{{BASE}}/static/{{COMMIT}}/lib/vscode/node_modules/semver-umd/lib/semver-umd.js">
|
||||||
END_PROD_ONLY -->
|
END_PROD_ONLY -->
|
||||||
|
|
||||||
<meta id="coder-options" data-settings="{{OPTIONS}}" />
|
<meta id="coder-options" data-settings="{{OPTIONS}}" />
|
||||||
@@ -43,6 +48,10 @@
|
|||||||
|
|
||||||
<!-- Startup (do not modify order of script tags!) -->
|
<!-- Startup (do not modify order of script tags!) -->
|
||||||
<script>
|
<script>
|
||||||
|
const parts = window.location.pathname.replace(/^\//g, "").split("/")
|
||||||
|
parts[parts.length - 1] = "{{BASE}}"
|
||||||
|
const url = new URL(window.location.origin + "/" + parts.join("/"))
|
||||||
|
const staticBase = url.href.replace(/\/+$/, "") + "/static/{{COMMIT}}/lib/vscode"
|
||||||
let nlsConfig
|
let nlsConfig
|
||||||
try {
|
try {
|
||||||
nlsConfig = JSON.parse(document.getElementById("vscode-remote-nls-configuration").getAttribute("data-settings"))
|
nlsConfig = JSON.parse(document.getElementById("vscode-remote-nls-configuration").getAttribute("data-settings"))
|
||||||
@@ -55,7 +64,7 @@
|
|||||||
}
|
}
|
||||||
// FIXME: Only works if path separators are /.
|
// FIXME: Only works if path separators are /.
|
||||||
const path = nlsConfig._resolvedLanguagePackCoreLocation + "/" + bundle.replace(/\//g, "!") + ".nls.json"
|
const path = nlsConfig._resolvedLanguagePackCoreLocation + "/" + bundle.replace(/\//g, "!") + ".nls.json"
|
||||||
fetch(`{{BASE}}/resource/?path=${encodeURIComponent(path)}`)
|
fetch(`${url.href}/resource/?path=${encodeURIComponent(path)}`)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
bundles[bundle] = json
|
bundles[bundle] = json
|
||||||
@@ -68,30 +77,26 @@
|
|||||||
/* Probably fine. */
|
/* Probably fine. */
|
||||||
}
|
}
|
||||||
self.require = {
|
self.require = {
|
||||||
baseUrl: "{{CS_STATIC_BASE}}/lib/vscode/out",
|
baseUrl: `${staticBase}/out`,
|
||||||
recordStats: true,
|
|
||||||
paths: {
|
paths: {
|
||||||
"vscode-textmate": `../node_modules/vscode-textmate/release/main`,
|
"vscode-textmate": `${staticBase}/node_modules/vscode-textmate/release/main`,
|
||||||
"vscode-oniguruma": `../node_modules/vscode-oniguruma/release/main`,
|
"vscode-oniguruma": `${staticBase}/node_modules/vscode-oniguruma/release/main`,
|
||||||
xterm: `../node_modules/xterm/lib/xterm.js`,
|
xterm: `${staticBase}/node_modules/xterm/lib/xterm.js`,
|
||||||
"xterm-addon-search": `../node_modules/xterm-addon-search/lib/xterm-addon-search.js`,
|
"xterm-addon-search": `${staticBase}/node_modules/xterm-addon-search/lib/xterm-addon-search.js`,
|
||||||
"xterm-addon-unicode11": `../node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js`,
|
"xterm-addon-unicode11": `${staticBase}/node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js`,
|
||||||
"xterm-addon-webgl": `../node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js`,
|
"xterm-addon-webgl": `${staticBase}/node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js`,
|
||||||
"semver-umd": `../node_modules/semver-umd/lib/semver-umd.js`,
|
"semver-umd": `${staticBase}/node_modules/semver-umd/lib/semver-umd.js`,
|
||||||
"iconv-lite-umd": `../node_modules/iconv-lite-umd/lib/iconv-lite-umd.js`,
|
"iconv-lite-umd": `${staticBase}/node_modules/iconv-lite-umd/lib/iconv-lite-umd.js`,
|
||||||
jschardet: `../node_modules/jschardet/dist/jschardet.min.js`,
|
jschardet: `${staticBase}/node_modules/jschardet/dist/jschardet.min.js`,
|
||||||
},
|
},
|
||||||
"vs/nls": nlsConfig,
|
"vs/nls": nlsConfig,
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script data-cfasync="false" src="{{CS_STATIC_BASE}}/dist/register.js"></script>
|
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
|
||||||
<script data-cfasync="false" src="{{CS_STATIC_BASE}}/lib/vscode/out/vs/loader.js"></script>
|
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/lib/vscode/out/vs/loader.js"></script>
|
||||||
<script>
|
|
||||||
globalThis.MonacoPerformanceMarks.push('willLoadWorkbenchMain', Date.now());
|
|
||||||
</script>
|
|
||||||
<!-- PROD_ONLY
|
<!-- PROD_ONLY
|
||||||
<script data-cfasync="false" src="{{CS_STATIC_BASE}}/lib/vscode/out/vs/workbench/workbench.web.api.nls.js"></script>
|
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/lib/vscode/out/vs/workbench/workbench.web.api.nls.js"></script>
|
||||||
<script data-cfasync="false" src="{{CS_STATIC_BASE}}/lib/vscode/out/vs/workbench/workbench.web.api.js"></script>
|
<script data-cfasync="false" src="{{BASE}}/static/{{COMMIT}}/lib/vscode/out/vs/workbench/workbench.web.api.js"></script>
|
||||||
END_PROD_ONLY -->
|
END_PROD_ONLY -->
|
||||||
<script>
|
<script>
|
||||||
require(["vs/code/browser/workbench/workbench"], function () {})
|
require(["vs/code/browser/workbench/workbench"], function () {})
|
||||||
|
|||||||
@@ -2,17 +2,13 @@ import { getOptions, normalize } from "../common/util"
|
|||||||
|
|
||||||
const options = getOptions()
|
const options = getOptions()
|
||||||
|
|
||||||
import "./pages/error.css"
|
|
||||||
import "./pages/global.css"
|
|
||||||
import "./pages/login.css"
|
|
||||||
|
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
const path = normalize(`${options.csStaticBase}/dist/serviceWorker.js`)
|
const path = normalize(`${options.base}/static/${options.commit}/dist/serviceWorker.js`)
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register(path, {
|
.register(path, {
|
||||||
scope: options.base || "/",
|
scope: options.base || "/",
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(function () {
|
||||||
console.log("[Service Worker] registered")
|
console.log("[Service Worker] registered")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/common/api.ts
Normal file
60
src/common/api.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
export interface Application {
|
||||||
|
readonly categories?: string[]
|
||||||
|
readonly comment?: string
|
||||||
|
readonly directory?: string
|
||||||
|
readonly exec?: string
|
||||||
|
readonly genericName?: string
|
||||||
|
readonly icon?: string
|
||||||
|
readonly installed?: boolean
|
||||||
|
readonly name: string
|
||||||
|
/**
|
||||||
|
* Path if this is a browser app (like VS Code).
|
||||||
|
*/
|
||||||
|
readonly path?: string
|
||||||
|
/**
|
||||||
|
* PID if this is a process.
|
||||||
|
*/
|
||||||
|
readonly pid?: number
|
||||||
|
readonly version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplicationsResponse {
|
||||||
|
readonly applications: ReadonlyArray<Application>
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SessionError {
|
||||||
|
FailedToStart = 4000,
|
||||||
|
Starting = 4001,
|
||||||
|
InvalidState = 4002,
|
||||||
|
Unknown = 4003,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionResponse {
|
||||||
|
/**
|
||||||
|
* Whether the process was spawned or an existing one was returned.
|
||||||
|
*/
|
||||||
|
created: boolean
|
||||||
|
pid: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentResponse {
|
||||||
|
readonly paths: string[]
|
||||||
|
readonly workspaces: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthRequest {
|
||||||
|
readonly event: "health"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientMessage = HealthRequest
|
||||||
|
|
||||||
|
export interface HealthResponse {
|
||||||
|
readonly event: "health"
|
||||||
|
readonly connections: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMessage = HealthResponse
|
||||||
|
|
||||||
|
export interface ReadyMessage {
|
||||||
|
protocol: string
|
||||||
|
}
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
import { Callback } from "./types"
|
|
||||||
|
|
||||||
export interface Disposable {
|
export interface Disposable {
|
||||||
dispose(): void
|
dispose(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Event<T> {
|
export interface Event<T> {
|
||||||
(listener: Callback<T>): Disposable
|
(listener: (value: T) => void): Disposable
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emitter typecasts for a single event type.
|
* Emitter typecasts for a single event type.
|
||||||
*/
|
*/
|
||||||
export class Emitter<T> {
|
export class Emitter<T> {
|
||||||
private listeners: Array<Callback<T>> = []
|
private listeners: Array<(value: T) => void> = []
|
||||||
|
|
||||||
public get event(): Event<T> {
|
public get event(): Event<T> {
|
||||||
return (cb: Callback<T>): Disposable => {
|
return (cb: (value: T) => void): Disposable => {
|
||||||
this.listeners.push(cb)
|
this.listeners.push(cb)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,8 +9,16 @@ export enum HttpCode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
public constructor(message: string, public readonly code: number, public readonly details?: object) {
|
public constructor(message: string, public readonly code: number) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = this.constructor.name
|
this.name = this.constructor.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ApiEndpoint {
|
||||||
|
applications = "/applications",
|
||||||
|
process = "/process",
|
||||||
|
recent = "/recent",
|
||||||
|
run = "/run",
|
||||||
|
status = "/status",
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export type Callback<T, R = void> = (t: T) => R
|
|
||||||
@@ -2,8 +2,9 @@ import { logger, field } from "@coder/logger"
|
|||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
base: string
|
base: string
|
||||||
csStaticBase: string
|
commit: string
|
||||||
logLevel: number
|
logLevel: number
|
||||||
|
pid?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,11 +16,7 @@ export const split = (str: string, delimiter: string): [string, string] => {
|
|||||||
return index !== -1 ? [str.substring(0, index).trim(), str.substring(index + 1)] : [str, ""]
|
return index !== -1 ? [str.substring(0, index).trim(), str.substring(index + 1)] : [str, ""]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const plural = (count: number): string => (count === 1 ? "" : "s")
|
||||||
* Appends an 's' to the provided string if count is greater than one;
|
|
||||||
* otherwise the string is returned
|
|
||||||
*/
|
|
||||||
export const plural = (count: number, str: string): string => (count === 1 ? str : `${str}s`)
|
|
||||||
|
|
||||||
export const generateUuid = (length = 24): string => {
|
export const generateUuid = (length = 24): string => {
|
||||||
const possible = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
const possible = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
@@ -36,35 +33,21 @@ export const normalize = (url: string, keepTrailing = false): string => {
|
|||||||
return url.replace(/\/\/+/g, "/").replace(/\/+$/, keepTrailing ? "/" : "")
|
return url.replace(/\/\/+/g, "/").replace(/\/+$/, keepTrailing ? "/" : "")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove leading and trailing slashes.
|
|
||||||
*/
|
|
||||||
export const trimSlashes = (url: string): string => {
|
|
||||||
return url.replace(/^\/+|\/+$/g, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a relative base against the window location. This is used for
|
|
||||||
* anything that doesn't work with a relative path.
|
|
||||||
*/
|
|
||||||
export const resolveBase = (base?: string): string => {
|
|
||||||
// After resolving the base will either start with / or be an empty string.
|
|
||||||
if (!base || base.startsWith("/")) {
|
|
||||||
return base ?? ""
|
|
||||||
}
|
|
||||||
const parts = location.pathname.split("/")
|
|
||||||
parts[parts.length - 1] = base
|
|
||||||
const url = new URL(location.origin + "/" + parts.join("/"))
|
|
||||||
return normalize(url.pathname)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get options embedded in the HTML or query params.
|
* Get options embedded in the HTML or query params.
|
||||||
*/
|
*/
|
||||||
export const getOptions = <T extends Options>(): T => {
|
export const getOptions = <T extends Options>(): T => {
|
||||||
let options: T
|
let options: T
|
||||||
try {
|
try {
|
||||||
options = JSON.parse(document.getElementById("coder-options")!.getAttribute("data-settings")!)
|
const el = document.getElementById("coder-options")
|
||||||
|
if (!el) {
|
||||||
|
throw new Error("no options element")
|
||||||
|
}
|
||||||
|
const value = el.getAttribute("data-settings")
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("no options value")
|
||||||
|
}
|
||||||
|
options = JSON.parse(value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options = {} as T
|
options = {} as T
|
||||||
}
|
}
|
||||||
@@ -78,26 +61,17 @@ export const getOptions = <T extends Options>(): T => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.level = options.logLevel
|
if (typeof options.logLevel !== "undefined") {
|
||||||
|
logger.level = options.logLevel
|
||||||
options.base = resolveBase(options.base)
|
}
|
||||||
options.csStaticBase = resolveBase(options.csStaticBase)
|
if (options.base) {
|
||||||
|
const parts = location.pathname.replace(/^\//g, "").split("/")
|
||||||
|
parts[parts.length - 1] = options.base
|
||||||
|
const url = new URL(location.origin + "/" + parts.join("/"))
|
||||||
|
options.base = normalize(url.pathname, true)
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug("got options", field("options", options))
|
logger.debug("got options", field("options", options))
|
||||||
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap the value in an array if it's not already an array. If the value is
|
|
||||||
* undefined return an empty array.
|
|
||||||
*/
|
|
||||||
export const arrayify = <T>(value?: T | T[]): T[] => {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if (typeof value === "undefined") {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return [value]
|
|
||||||
}
|
|
||||||
|
|||||||
312
src/node/app/api.ts
Normal file
312
src/node/app/api.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
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 net from "net"
|
||||||
|
import * as path from "path"
|
||||||
|
import * as url from "url"
|
||||||
|
import * as WebSocket from "ws"
|
||||||
|
import {
|
||||||
|
Application,
|
||||||
|
ApplicationsResponse,
|
||||||
|
ClientMessage,
|
||||||
|
RecentResponse,
|
||||||
|
ServerMessage,
|
||||||
|
SessionError,
|
||||||
|
SessionResponse,
|
||||||
|
} from "../../common/api"
|
||||||
|
import { ApiEndpoint, HttpCode, HttpError } from "../../common/http"
|
||||||
|
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http"
|
||||||
|
import { findApplications, findWhitelistedApplications, Vscode } from "./bin"
|
||||||
|
import { VscodeHttpProvider } from "./vscode"
|
||||||
|
|
||||||
|
interface VsRecents {
|
||||||
|
[key: string]: (string | { configURIPath: string })[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type VsSettings = [string, string][]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API HTTP provider.
|
||||||
|
*/
|
||||||
|
export class ApiHttpProvider extends HttpProvider {
|
||||||
|
private readonly ws = new WebSocket.Server({ noServer: true })
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
options: HttpProviderOptions,
|
||||||
|
private readonly server: HttpServer,
|
||||||
|
private readonly vscode: VscodeHttpProvider,
|
||||||
|
private readonly dataDir?: string,
|
||||||
|
) {
|
||||||
|
super(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
||||||
|
this.ensureAuthenticated(request)
|
||||||
|
if (!this.isRoot(route)) {
|
||||||
|
throw new HttpError("Not found", HttpCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (route.base) {
|
||||||
|
case ApiEndpoint.applications:
|
||||||
|
this.ensureMethod(request)
|
||||||
|
return {
|
||||||
|
mime: "application/json",
|
||||||
|
content: {
|
||||||
|
applications: await this.applications(),
|
||||||
|
},
|
||||||
|
} as HttpResponse<ApplicationsResponse>
|
||||||
|
case ApiEndpoint.process:
|
||||||
|
return this.process(request)
|
||||||
|
case ApiEndpoint.recent:
|
||||||
|
this.ensureMethod(request)
|
||||||
|
return {
|
||||||
|
mime: "application/json",
|
||||||
|
content: await this.recent(),
|
||||||
|
} as HttpResponse<RecentResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpError("Not found", HttpCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleWebSocket(
|
||||||
|
route: Route,
|
||||||
|
request: http.IncomingMessage,
|
||||||
|
socket: net.Socket,
|
||||||
|
head: Buffer,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.authenticated(request)) {
|
||||||
|
throw new Error("not authenticated")
|
||||||
|
}
|
||||||
|
switch (route.base) {
|
||||||
|
case ApiEndpoint.status:
|
||||||
|
return this.handleStatusSocket(request, socket, head)
|
||||||
|
case ApiEndpoint.run:
|
||||||
|
return this.handleRunSocket(route, request, socket, head)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpError("Not found", HttpCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStatusSocket(request: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> {
|
||||||
|
const getMessageResponse = async (event: "health"): Promise<ServerMessage> => {
|
||||||
|
switch (event) {
|
||||||
|
case "health":
|
||||||
|
return { event, connections: await this.server.getConnections() }
|
||||||
|
default:
|
||||||
|
throw new Error("unexpected message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<WebSocket>((resolve) => {
|
||||||
|
this.ws.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
const send = (event: ServerMessage): void => {
|
||||||
|
ws.send(JSON.stringify(event))
|
||||||
|
}
|
||||||
|
ws.on("message", (data) => {
|
||||||
|
logger.trace("got message", field("message", data))
|
||||||
|
try {
|
||||||
|
const message: ClientMessage = JSON.parse(data.toString())
|
||||||
|
getMessageResponse(message.event).then(send)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error.message, field("message", data))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A socket that connects to the process.
|
||||||
|
*/
|
||||||
|
private async handleRunSocket(
|
||||||
|
_route: Route,
|
||||||
|
request: http.IncomingMessage,
|
||||||
|
socket: net.Socket,
|
||||||
|
head: Buffer,
|
||||||
|
): Promise<void> {
|
||||||
|
logger.debug("connecting to process")
|
||||||
|
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||||
|
this.ws.handleUpgrade(request, socket, head, (socket) => {
|
||||||
|
socket.binaryType = "arraybuffer"
|
||||||
|
|
||||||
|
socket.on("error", (error) => {
|
||||||
|
socket.close(SessionError.FailedToStart)
|
||||||
|
logger.error("got error while connecting socket", field("error", error))
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
resolve(socket as WebSocket)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug("connected to process")
|
||||||
|
|
||||||
|
// Send ready message.
|
||||||
|
ws.send(
|
||||||
|
Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
protocol: "TODO",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return whitelisted applications.
|
||||||
|
*/
|
||||||
|
public async applications(): Promise<ReadonlyArray<Application>> {
|
||||||
|
return findWhitelistedApplications()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return installed applications.
|
||||||
|
*/
|
||||||
|
public async installedApplications(): Promise<ReadonlyArray<Application>> {
|
||||||
|
return findApplications()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle /process endpoint.
|
||||||
|
*/
|
||||||
|
private async process(request: http.IncomingMessage): Promise<HttpResponse> {
|
||||||
|
this.ensureMethod(request, ["DELETE", "POST"])
|
||||||
|
|
||||||
|
const data = await this.getData(request)
|
||||||
|
if (!data) {
|
||||||
|
throw new HttpError("No data was provided", HttpCode.BadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: Application = JSON.parse(data)
|
||||||
|
|
||||||
|
switch (request.method) {
|
||||||
|
case "DELETE":
|
||||||
|
if (parsed.pid) {
|
||||||
|
await this.killProcess(parsed.pid)
|
||||||
|
} else if (parsed.path) {
|
||||||
|
await this.killProcess(parsed.path)
|
||||||
|
} else {
|
||||||
|
throw new Error("No pid or path was provided")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mime: "application/json",
|
||||||
|
code: HttpCode.Ok,
|
||||||
|
}
|
||||||
|
case "POST": {
|
||||||
|
if (!parsed.exec) {
|
||||||
|
throw new Error("No exec was provided")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mime: "application/json",
|
||||||
|
content: {
|
||||||
|
created: true,
|
||||||
|
pid: await this.spawnProcess(parsed.exec),
|
||||||
|
},
|
||||||
|
} as HttpResponse<SessionResponse>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpError("Not found", HttpCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill a process identified by pid or path if a web app.
|
||||||
|
*/
|
||||||
|
public async killProcess(pid: number | string): Promise<void> {
|
||||||
|
if (typeof pid === "string") {
|
||||||
|
switch (pid) {
|
||||||
|
case Vscode.path:
|
||||||
|
await this.vscode.dispose()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Process "${pid}" does not exist`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
process.kill(pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a process and return the pid.
|
||||||
|
*/
|
||||||
|
public async spawnProcess(exec: string): Promise<number> {
|
||||||
|
const proc = cp.spawn(exec, {
|
||||||
|
shell: process.env.SHELL || true,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on("error", (error) => logger.error("process errored", field("pid", proc.pid), field("error", error)))
|
||||||
|
proc.on("exit", () => logger.debug("process exited", field("pid", proc.pid)))
|
||||||
|
|
||||||
|
logger.debug("started process", field("pid", proc.pid))
|
||||||
|
|
||||||
|
return proc.pid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return VS Code's recent paths.
|
||||||
|
*/
|
||||||
|
public async recent(): Promise<RecentResponse> {
|
||||||
|
try {
|
||||||
|
if (!this.dataDir) {
|
||||||
|
throw new Error("data directory is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: VsSettings = JSON.parse(await fs.readFile(path.join(this.dataDir, "User/state/global.json"), "utf8"))
|
||||||
|
const setting = Array.isArray(state) && state.find((item) => item[0] === "recently.opened")
|
||||||
|
if (!setting) {
|
||||||
|
return { paths: [], workspaces: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathPromises: { [key: string]: Promise<string> } = {}
|
||||||
|
const workspacePromises: { [key: string]: Promise<string> } = {}
|
||||||
|
Object.values(JSON.parse(setting[1]) as VsRecents).forEach((recents) => {
|
||||||
|
recents.forEach((recent) => {
|
||||||
|
try {
|
||||||
|
const target = typeof recent === "string" ? pathPromises : workspacePromises
|
||||||
|
const pathname = url.parse(typeof recent === "string" ? recent : recent.configURIPath).pathname
|
||||||
|
if (pathname && !target[pathname]) {
|
||||||
|
target[pathname] = new Promise<string>((resolve) => {
|
||||||
|
fs.stat(pathname)
|
||||||
|
.then(() => resolve(pathname))
|
||||||
|
.catch(() => resolve())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug("invalid path", field("path", recent))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const [paths, workspaces] = await Promise.all([
|
||||||
|
Promise.all(Object.values(pathPromises)),
|
||||||
|
Promise.all(Object.values(workspacePromises)),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
paths: paths.filter((p) => !!p),
|
||||||
|
workspaces: workspaces.filter((p) => !!p),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { paths: [], workspaces: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For these, just return the error message since they'll be requested as
|
||||||
|
* JSON.
|
||||||
|
*/
|
||||||
|
public async getErrorRoot(_route: Route, _title: string, _header: string, error: string): Promise<HttpResponse> {
|
||||||
|
return {
|
||||||
|
mime: "application/json",
|
||||||
|
content: JSON.stringify({ error }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/node/app/bin.ts
Normal file
30
src/node/app/bin.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as fs from "fs"
|
||||||
|
import * as path from "path"
|
||||||
|
import { Application } from "../../common/api"
|
||||||
|
|
||||||
|
const getVscodeVersion = (): string => {
|
||||||
|
try {
|
||||||
|
return require(path.resolve(__dirname, "../../../lib/vscode/package.json")).version
|
||||||
|
} catch (error) {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Vscode: Application = {
|
||||||
|
categories: ["Editor"],
|
||||||
|
icon: fs.readFileSync(path.resolve(__dirname, "../../../lib/vscode/resources/linux/code.png")).toString("base64"),
|
||||||
|
installed: true,
|
||||||
|
name: "VS Code",
|
||||||
|
path: "/",
|
||||||
|
version: getVscodeVersion(),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findApplications = async (): Promise<ReadonlyArray<Application>> => {
|
||||||
|
const apps: Application[] = [Vscode]
|
||||||
|
|
||||||
|
return apps.sort((a, b): number => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findWhitelistedApplications = async (): Promise<ReadonlyArray<Application>> => {
|
||||||
|
return [Vscode]
|
||||||
|
}
|
||||||
147
src/node/app/dashboard.ts
Normal file
147
src/node/app/dashboard.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import * as http from "http"
|
||||||
|
import * as querystring from "querystring"
|
||||||
|
import { Application } from "../../common/api"
|
||||||
|
import { HttpCode, HttpError } from "../../common/http"
|
||||||
|
import { normalize } from "../../common/util"
|
||||||
|
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
||||||
|
import { ApiHttpProvider } from "./api"
|
||||||
|
import { UpdateHttpProvider } from "./update"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard HTTP provider.
|
||||||
|
*/
|
||||||
|
export class DashboardHttpProvider extends HttpProvider {
|
||||||
|
public constructor(
|
||||||
|
options: HttpProviderOptions,
|
||||||
|
private readonly api: ApiHttpProvider,
|
||||||
|
private readonly update: UpdateHttpProvider,
|
||||||
|
) {
|
||||||
|
super(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
||||||
|
if (!this.isRoot(route)) {
|
||||||
|
throw new HttpError("Not found", HttpCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (route.base) {
|
||||||
|
case "/spawn": {
|
||||||
|
this.ensureAuthenticated(request)
|
||||||
|
this.ensureMethod(request, "POST")
|
||||||
|
const data = await this.getData(request)
|
||||||
|
const app = data ? querystring.parse(data) : {}
|
||||||
|
if (app.path) {
|
||||||
|
return { redirect: Array.isArray(app.path) ? app.path[0] : app.path }
|
||||||
|
}
|
||||||
|
if (!app.exec) {
|
||||||
|
throw new Error("No exec was provided")
|
||||||
|
}
|
||||||
|
this.api.spawnProcess(Array.isArray(app.exec) ? app.exec[0] : app.exec)
|
||||||
|
return { redirect: this.options.base }
|
||||||
|
}
|
||||||
|
case "/app":
|
||||||
|
case "/": {
|
||||||
|
this.ensureMethod(request)
|
||||||
|
if (!this.authenticated(request)) {
|
||||||
|
return { redirect: "/login", query: { to: this.options.base } }
|
||||||
|
}
|
||||||
|
return route.base === "/" ? this.getRoot(route) : this.getAppRoot(route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpError("Not found", HttpCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRoot(route: Route): Promise<HttpResponse> {
|
||||||
|
const base = this.base(route)
|
||||||
|
const apps = await this.api.installedApplications()
|
||||||
|
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/home.html")
|
||||||
|
response.content = response.content
|
||||||
|
.replace(/{{UPDATE:NAME}}/, await this.getUpdate(base))
|
||||||
|
.replace(
|
||||||
|
/{{APP_LIST:EDITORS}}/,
|
||||||
|
this.getAppRows(
|
||||||
|
base,
|
||||||
|
apps.filter((app) => app.categories && app.categories.includes("Editor")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/{{APP_LIST:OTHER}}/,
|
||||||
|
this.getAppRows(
|
||||||
|
base,
|
||||||
|
apps.filter((app) => !app.categories || !app.categories.includes("Editor")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return this.replaceTemplates(route, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAppRoot(route: Route): Promise<HttpResponse> {
|
||||||
|
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html")
|
||||||
|
return this.replaceTemplates(route, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAppRows(base: string, apps: ReadonlyArray<Application>): string {
|
||||||
|
return apps.length > 0
|
||||||
|
? apps.map((app) => this.getAppRow(base, app)).join("\n")
|
||||||
|
: `<div class="none">No applications found.</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAppRow(base: string, app: Application): string {
|
||||||
|
return `<form class="block-row${app.exec ? " -x11" : ""}" method="post" action="${normalize(
|
||||||
|
`${base}${this.options.base}/spawn`,
|
||||||
|
)}">
|
||||||
|
<button class="item -row -link">
|
||||||
|
<input type="hidden" name="path" value="${app.path || ""}">
|
||||||
|
<input type="hidden" name="exec" value="${app.exec || ""}">
|
||||||
|
${
|
||||||
|
app.icon
|
||||||
|
? `<img class="icon" src="data:image/png;base64,${app.icon}"></img>`
|
||||||
|
: `<span class="icon -missing"></span>`
|
||||||
|
}
|
||||||
|
<span class="name">${app.name}</span>
|
||||||
|
</button>
|
||||||
|
</form>`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUpdate(base: string): Promise<string> {
|
||||||
|
if (!this.update.enabled) {
|
||||||
|
return `<div class="block-row"><div class="item"><div class="sub">Updates are disabled</div></div></div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (this.update.isLatestVersion(update)) {
|
||||||
|
return `<div class="block-row">
|
||||||
|
<div class="item">
|
||||||
|
Latest: ${update.version}
|
||||||
|
<div class="sub">Up to date</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
${humanize(update.checked)}
|
||||||
|
<a class="sub -link" href="${base}/update/check?to=${this.options.base}">Check now</a>
|
||||||
|
</div>
|
||||||
|
<div class="item" >Current: ${this.update.currentVersion}</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="block-row">
|
||||||
|
<div class="item">
|
||||||
|
Latest: ${update.version}
|
||||||
|
<div class="sub">Out of date</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
${humanize(update.checked)}
|
||||||
|
<a class="sub -link" href="${base}/update?to=${this.options.base}">Update now</a>
|
||||||
|
</div>
|
||||||
|
<div class="item" >Current: ${this.update.currentVersion}</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export class ProxyHttpProvider extends HttpProvider {
|
|||||||
const port = route.base.replace(/^\//, "")
|
const port = route.base.replace(/^\//, "")
|
||||||
return {
|
return {
|
||||||
proxy: {
|
proxy: {
|
||||||
strip: `${route.providerBase}/${port}`,
|
base: `${this.options.base}/${port}`,
|
||||||
port,
|
port,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ export class ProxyHttpProvider extends HttpProvider {
|
|||||||
const port = route.base.replace(/^\//, "")
|
const port = route.base.replace(/^\//, "")
|
||||||
return {
|
return {
|
||||||
proxy: {
|
proxy: {
|
||||||
strip: `${route.providerBase}/${port}`,
|
base: `${this.options.base}/${port}`,
|
||||||
port,
|
port,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { HttpProvider, HttpResponse, Route } from "../http"
|
|||||||
import { pathToFsPath } from "../util"
|
import { pathToFsPath } from "../util"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static file HTTP provider. Static requests do not require authentication if
|
* Static file HTTP provider. Regular static requests (the path is the request
|
||||||
* the resource is in the application's directory except requests to serve a
|
* itself) do not require authentication and they only allow access to resources
|
||||||
* directory as a tar which always requires authentication.
|
* within the application. Requests for tars (the path is in a query parameter)
|
||||||
|
* do require permissions and can access any directory.
|
||||||
*/
|
*/
|
||||||
export class StaticHttpProvider extends HttpProvider {
|
export class StaticHttpProvider extends HttpProvider {
|
||||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
||||||
@@ -21,7 +22,7 @@ export class StaticHttpProvider extends HttpProvider {
|
|||||||
return this.getTarredResource(request, pathToFsPath(route.query.tar))
|
return this.getTarredResource(request, pathToFsPath(route.query.tar))
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.getReplacedResource(request, route)
|
const response = await this.getReplacedResource(route)
|
||||||
if (!this.isDev) {
|
if (!this.isDev) {
|
||||||
response.cache = true
|
response.cache = true
|
||||||
}
|
}
|
||||||
@@ -31,25 +32,17 @@ export class StaticHttpProvider extends HttpProvider {
|
|||||||
/**
|
/**
|
||||||
* Return a resource with variables replaced where necessary.
|
* Return a resource with variables replaced where necessary.
|
||||||
*/
|
*/
|
||||||
protected async getReplacedResource(request: http.IncomingMessage, route: Route): Promise<HttpResponse> {
|
protected async getReplacedResource(route: Route): Promise<HttpResponse> {
|
||||||
// The first part is always the commit (for caching purposes).
|
// The first part is always the commit (for caching purposes).
|
||||||
const split = route.requestPath.split("/").slice(1)
|
const split = route.requestPath.split("/").slice(1)
|
||||||
|
|
||||||
const resourcePath = path.resolve("/", ...split)
|
|
||||||
|
|
||||||
// Make sure it's in code-server or a plugin.
|
|
||||||
const validPaths = [this.rootPath, process.env.PLUGIN_DIR]
|
|
||||||
if (!validPaths.find((p) => p && resourcePath.startsWith(p))) {
|
|
||||||
this.ensureAuthenticated(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (split[split.length - 1]) {
|
switch (split[split.length - 1]) {
|
||||||
case "manifest.json": {
|
case "manifest.json": {
|
||||||
const response = await this.getUtf8Resource(resourcePath)
|
const response = await this.getUtf8Resource(this.rootPath, ...split)
|
||||||
return this.replaceTemplates(route, response)
|
return this.replaceTemplates(route, response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.getResource(resourcePath)
|
return this.getResource(this.rootPath, ...split)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { field, logger } from "@coder/logger"
|
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 http from "http"
|
||||||
import * as https from "https"
|
import * as https from "https"
|
||||||
|
import * as os from "os"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import * as semver from "semver"
|
import * as semver from "semver"
|
||||||
|
import { Readable, Writable } from "stream"
|
||||||
|
import * as tar from "tar-fs"
|
||||||
import * as url from "url"
|
import * as url from "url"
|
||||||
|
import * as util from "util"
|
||||||
|
import * as zlib from "zlib"
|
||||||
import { HttpCode, HttpError } from "../../common/http"
|
import { HttpCode, HttpError } from "../../common/http"
|
||||||
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
||||||
import { settings as globalSettings, SettingsProvider, UpdateSettings } from "../settings"
|
import { settings as globalSettings, SettingsProvider, UpdateSettings } from "../settings"
|
||||||
|
import { tmpdir } from "../util"
|
||||||
|
import { ipcMain } from "../wrapper"
|
||||||
|
|
||||||
export interface Update {
|
export interface Update {
|
||||||
checked: number
|
checked: number
|
||||||
@@ -18,7 +27,7 @@ export interface LatestResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP provider for checking updates (does not download/install them).
|
* Update HTTP provider.
|
||||||
*/
|
*/
|
||||||
export class UpdateHttpProvider extends HttpProvider {
|
export class UpdateHttpProvider extends HttpProvider {
|
||||||
private update?: Promise<Update>
|
private update?: Promise<Update>
|
||||||
@@ -32,6 +41,12 @@ export class UpdateHttpProvider extends HttpProvider {
|
|||||||
* that fulfills `LatestResponse`.
|
* that fulfills `LatestResponse`.
|
||||||
*/
|
*/
|
||||||
private readonly latestUrl = "https://api.github.com/repos/cdr/code-server/releases/latest",
|
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
|
* Update information will be stored here. If not provided, the global
|
||||||
* settings will be used.
|
* settings will be used.
|
||||||
@@ -49,30 +64,66 @@ export class UpdateHttpProvider extends HttpProvider {
|
|||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
throw new HttpError("Not found", HttpCode.NotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.enabled) {
|
|
||||||
throw new Error("update checks are disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (route.base) {
|
switch (route.base) {
|
||||||
case "/check":
|
case "/check":
|
||||||
case "/": {
|
this.getUpdate(true)
|
||||||
const update = await this.getUpdate(route.base === "/check")
|
if (route.query && route.query.to) {
|
||||||
return {
|
return {
|
||||||
content: {
|
redirect: Array.isArray(route.query.to) ? route.query.to[0] : route.query.to,
|
||||||
...update,
|
query: { to: undefined },
|
||||||
isLatest: this.isLatestVersion(update),
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
return this.getRoot(route, request)
|
||||||
|
case "/apply":
|
||||||
|
return this.tryUpdate(route, request)
|
||||||
|
case "/":
|
||||||
|
return this.getRoot(route, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
throw new HttpError("Not found", HttpCode.NotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getRoot(
|
||||||
|
route: Route,
|
||||||
|
request: http.IncomingMessage,
|
||||||
|
errorOrUpdate?: Update | Error,
|
||||||
|
): Promise<HttpResponse> {
|
||||||
|
if (request.headers["content-type"] === "application/json") {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return {
|
||||||
|
content: {
|
||||||
|
isLatest: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 ? `<div class="error">${errorOrUpdate.message}</div>` : "")
|
||||||
|
return this.replaceTemplates(route, response)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query for and return the latest update.
|
* Query for and return the latest update.
|
||||||
*/
|
*/
|
||||||
public async getUpdate(force?: boolean): Promise<Update> {
|
public async getUpdate(force?: boolean): Promise<Update> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
throw new Error("updates are not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
// Don't run multiple requests at a time.
|
// Don't run multiple requests at a time.
|
||||||
if (!this.update) {
|
if (!this.update) {
|
||||||
this.update = this._getUpdate(force)
|
this.update = this._getUpdate(force)
|
||||||
@@ -120,6 +171,128 @@ export class UpdateHttpProvider extends HttpProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getUpdateHtml(): Promise<string> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return "Updates are disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = await this.getUpdate()
|
||||||
|
if (this.isLatestVersion(update)) {
|
||||||
|
return "No update available"
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<button type="submit" class="apply -button">Update to ${update.version}</button>`
|
||||||
|
}
|
||||||
|
|
||||||
|
public async tryUpdate(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
||||||
|
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<void> {
|
||||||
|
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<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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an update return the name for the packaged archived.
|
||||||
|
*/
|
||||||
|
public 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}.tar.gz`
|
||||||
|
}
|
||||||
|
|
||||||
private async request(uri: string): Promise<Buffer> {
|
private async request(uri: string): Promise<Buffer> {
|
||||||
const response = await this.requestResponse(uri)
|
const response = await this.requestResponse(uri)
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
WorkbenchOptions,
|
WorkbenchOptions,
|
||||||
} from "../../../lib/vscode/src/vs/server/ipc"
|
} from "../../../lib/vscode/src/vs/server/ipc"
|
||||||
import { HttpCode, HttpError } from "../../common/http"
|
import { HttpCode, HttpError } from "../../common/http"
|
||||||
import { arrayify, generateUuid } from "../../common/util"
|
import { generateUuid } from "../../common/util"
|
||||||
import { Args } from "../cli"
|
import { Args } from "../cli"
|
||||||
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
||||||
import { settings } from "../settings"
|
import { settings } from "../settings"
|
||||||
@@ -131,7 +131,7 @@ export class VscodeHttpProvider extends HttpProvider {
|
|||||||
if (!this.isRoot(route)) {
|
if (!this.isRoot(route)) {
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
throw new HttpError("Not found", HttpCode.NotFound)
|
||||||
} else if (!this.authenticated(request)) {
|
} else if (!this.authenticated(request)) {
|
||||||
return { redirect: "/login", query: { to: route.providerBase } }
|
return { redirect: "/login", query: { to: this.options.base } }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await this.getRoot(request, route)
|
return await this.getRoot(request, route)
|
||||||
@@ -183,10 +183,11 @@ export class VscodeHttpProvider extends HttpProvider {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
settings.write({
|
if (startPath) {
|
||||||
lastVisited: startPath || lastVisited, // If startpath is undefined, then fallback to lastVisited
|
settings.write({
|
||||||
query: route.query,
|
lastVisited: startPath,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.isDev) {
|
if (!this.isDev) {
|
||||||
response.content = response.content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "")
|
response.content = response.content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "")
|
||||||
@@ -200,6 +201,8 @@ export class VscodeHttpProvider extends HttpProvider {
|
|||||||
.replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`)
|
.replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`)
|
||||||
.replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`)
|
.replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`)
|
||||||
return this.replaceTemplates<Options>(route, response, {
|
return this.replaceTemplates<Options>(route, response, {
|
||||||
|
base: this.base(route),
|
||||||
|
commit: this.options.commit,
|
||||||
disableTelemetry: !!this.args["disable-telemetry"],
|
disableTelemetry: !!this.args["disable-telemetry"],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -221,7 +224,8 @@ export class VscodeHttpProvider extends HttpProvider {
|
|||||||
}
|
}
|
||||||
for (let i = 0; i < startPaths.length; ++i) {
|
for (let i = 0; i < startPaths.length; ++i) {
|
||||||
const startPath = startPaths[i]
|
const startPath = startPaths[i]
|
||||||
const url = arrayify(startPath && startPath.url).find((p) => !!p)
|
const url =
|
||||||
|
startPath && (typeof startPath.url === "string" ? [startPath.url] : startPath.url || []).find((p) => !!p)
|
||||||
if (startPath && url) {
|
if (startPath && url) {
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -125,11 +125,7 @@ const options: Options<Required<Args>> = {
|
|||||||
"extra-builtin-extensions-dir": { type: "string[]", path: true },
|
"extra-builtin-extensions-dir": { type: "string[]", path: true },
|
||||||
"list-extensions": { type: "boolean", description: "List installed VS Code extensions." },
|
"list-extensions": { type: "boolean", description: "List installed VS Code extensions." },
|
||||||
force: { type: "boolean", description: "Avoid prompts when installing VS Code extensions." },
|
force: { type: "boolean", description: "Avoid prompts when installing VS Code extensions." },
|
||||||
"install-extension": {
|
"install-extension": { type: "string[]", description: "Install or update a VS Code extension by id or vsix." },
|
||||||
type: "string[]",
|
|
||||||
description:
|
|
||||||
"Install or update a VS Code extension by id or vsix. The identifier of an extension is `${publisher}.${name}`. To install a specific version provide `@${version}`. For example: 'vscode.csharp@1.2.3'.",
|
|
||||||
},
|
|
||||||
"uninstall-extension": { type: "string[]", description: "Uninstall a VS Code extension by id." },
|
"uninstall-extension": { type: "string[]", description: "Uninstall a VS Code extension by id." },
|
||||||
"show-versions": { type: "boolean", description: "Show VS Code extension versions." },
|
"show-versions": { type: "boolean", description: "Show VS Code extension versions." },
|
||||||
"proxy-domain": { type: "string[]", description: "Domain used for proxying ports." },
|
"proxy-domain": { type: "string[]", description: "Domain used for proxying ports." },
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { field, logger } from "@coder/logger"
|
|||||||
import * as cp from "child_process"
|
import * as cp from "child_process"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { CliMessage } from "../../lib/vscode/src/vs/server/ipc"
|
import { CliMessage } from "../../lib/vscode/src/vs/server/ipc"
|
||||||
|
import { ApiHttpProvider } from "./app/api"
|
||||||
|
import { DashboardHttpProvider } from "./app/dashboard"
|
||||||
import { LoginHttpProvider } from "./app/login"
|
import { LoginHttpProvider } from "./app/login"
|
||||||
import { ProxyHttpProvider } from "./app/proxy"
|
import { ProxyHttpProvider } from "./app/proxy"
|
||||||
import { StaticHttpProvider } from "./app/static"
|
import { StaticHttpProvider } from "./app/static"
|
||||||
@@ -9,10 +11,8 @@ import { UpdateHttpProvider } from "./app/update"
|
|||||||
import { VscodeHttpProvider } from "./app/vscode"
|
import { VscodeHttpProvider } from "./app/vscode"
|
||||||
import { Args, bindAddrFromAllSources, optionDescriptions, parse, readConfigFile, setDefaults } from "./cli"
|
import { Args, bindAddrFromAllSources, optionDescriptions, parse, readConfigFile, setDefaults } from "./cli"
|
||||||
import { AuthType, HttpServer, HttpServerOptions } from "./http"
|
import { AuthType, HttpServer, HttpServerOptions } from "./http"
|
||||||
import { loadPlugins } from "./plugin"
|
import { generateCertificate, hash, open, humanPath } from "./util"
|
||||||
import { generateCertificate, hash, humanPath, open } from "./util"
|
|
||||||
import { ipcMain, wrap } from "./wrapper"
|
import { ipcMain, wrap } from "./wrapper"
|
||||||
import { plural } from "../common/util"
|
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
logger.error(`Uncaught exception: ${error.message}`)
|
logger.error(`Uncaught exception: ${error.message}`)
|
||||||
@@ -73,19 +73,15 @@ const main = async (args: Args, cliArgs: Args, configArgs: Args): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const httpServer = new HttpServer(options)
|
const httpServer = new HttpServer(options)
|
||||||
httpServer.registerHttpProvider(["/", "/vscode"], VscodeHttpProvider, args)
|
const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args)
|
||||||
httpServer.registerHttpProvider("/update", UpdateHttpProvider, false)
|
const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"])
|
||||||
|
const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, false)
|
||||||
httpServer.registerHttpProvider("/proxy", ProxyHttpProvider)
|
httpServer.registerHttpProvider("/proxy", ProxyHttpProvider)
|
||||||
httpServer.registerHttpProvider("/login", LoginHttpProvider, args.config!, envPassword)
|
httpServer.registerHttpProvider("/login", LoginHttpProvider, args.config!, envPassword)
|
||||||
httpServer.registerHttpProvider("/static", StaticHttpProvider)
|
httpServer.registerHttpProvider("/static", StaticHttpProvider)
|
||||||
|
httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update)
|
||||||
|
|
||||||
await loadPlugins(httpServer, args)
|
ipcMain().onDispose(() => httpServer.dispose())
|
||||||
|
|
||||||
ipcMain().onDispose(() => {
|
|
||||||
httpServer.dispose().then((errors) => {
|
|
||||||
errors.forEach((error) => logger.error(error.message))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(`code-server ${version} ${commit}`)
|
logger.info(`code-server ${version} ${commit}`)
|
||||||
const serverAddress = await httpServer.listen()
|
const serverAddress = await httpServer.listen()
|
||||||
@@ -114,7 +110,7 @@ const main = async (args: Args, cliArgs: Args, configArgs: Args): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (httpServer.proxyDomains.size > 0) {
|
if (httpServer.proxyDomains.size > 0) {
|
||||||
logger.info(` - ${plural(httpServer.proxyDomains.size, "Proxying the following domain")}:`)
|
logger.info(` - Proxying the following domain${httpServer.proxyDomains.size === 1 ? "" : "s"}:`)
|
||||||
httpServer.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`))
|
httpServer.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
132
src/node/http.ts
132
src/node/http.ts
@@ -12,7 +12,7 @@ import { Readable } from "stream"
|
|||||||
import * as tls from "tls"
|
import * as tls from "tls"
|
||||||
import * as url from "url"
|
import * as url from "url"
|
||||||
import { HttpCode, HttpError } from "../common/http"
|
import { HttpCode, HttpError } from "../common/http"
|
||||||
import { arrayify, normalize, Options, plural, split, trimSlashes } from "../common/util"
|
import { normalize, Options, plural, split } from "../common/util"
|
||||||
import { SocketProxyProvider } from "./socket"
|
import { SocketProxyProvider } from "./socket"
|
||||||
import { getMediaMime, paths } from "./util"
|
import { getMediaMime, paths } from "./util"
|
||||||
|
|
||||||
@@ -36,13 +36,9 @@ export type Query = { [key: string]: string | string[] | undefined }
|
|||||||
|
|
||||||
export interface ProxyOptions {
|
export interface ProxyOptions {
|
||||||
/**
|
/**
|
||||||
* A path to strip from from the beginning of the request before proxying
|
* A base path to strip from from the request before proxying if necessary.
|
||||||
*/
|
*/
|
||||||
strip?: string
|
base?: string
|
||||||
/**
|
|
||||||
* A path to add to the beginning of the request before proxying.
|
|
||||||
*/
|
|
||||||
prepend?: string
|
|
||||||
/**
|
/**
|
||||||
* The port to proxy.
|
* The port to proxy.
|
||||||
*/
|
*/
|
||||||
@@ -83,8 +79,9 @@ export interface HttpResponse<T = string | Buffer | object> {
|
|||||||
*/
|
*/
|
||||||
mime?: string
|
mime?: string
|
||||||
/**
|
/**
|
||||||
* Redirect to this path. This is constructed against the site base (not the
|
* Redirect to this path. Will rewrite against the base path but NOT the
|
||||||
* provider's base).
|
* provider endpoint so you must include it. This allows redirecting outside
|
||||||
|
* of your endpoint.
|
||||||
*/
|
*/
|
||||||
redirect?: string
|
redirect?: string
|
||||||
/**
|
/**
|
||||||
@@ -136,16 +133,12 @@ export interface HttpServerOptions {
|
|||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
/**
|
/**
|
||||||
* Provider base path part (for /provider/base/path it would be /provider).
|
* Base path part (in /test/path it would be "/test").
|
||||||
*/
|
|
||||||
providerBase: string
|
|
||||||
/**
|
|
||||||
* Base path part (for /provider/base/path it would be /base).
|
|
||||||
*/
|
*/
|
||||||
base: string
|
base: string
|
||||||
/**
|
/**
|
||||||
* Remaining part of the route after factoring out the base and provider base
|
* Remaining part of the route (in /test/path it would be "/path"). It can be
|
||||||
* (for /provider/base/path it would be /path). It can be blank.
|
* blank.
|
||||||
*/
|
*/
|
||||||
requestPath: string
|
requestPath: string
|
||||||
/**
|
/**
|
||||||
@@ -168,6 +161,7 @@ interface ProviderRoute extends Route {
|
|||||||
|
|
||||||
export interface HttpProviderOptions {
|
export interface HttpProviderOptions {
|
||||||
readonly auth: AuthType
|
readonly auth: AuthType
|
||||||
|
readonly base: string
|
||||||
readonly commit: string
|
readonly commit: string
|
||||||
readonly password?: string
|
readonly password?: string
|
||||||
}
|
}
|
||||||
@@ -181,7 +175,7 @@ export abstract class HttpProvider {
|
|||||||
|
|
||||||
public constructor(protected readonly options: HttpProviderOptions) {}
|
public constructor(protected readonly options: HttpProviderOptions) {}
|
||||||
|
|
||||||
public async dispose(): Promise<void> {
|
public dispose(): void {
|
||||||
// No default behavior.
|
// No default behavior.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +203,11 @@ export abstract class HttpProvider {
|
|||||||
/**
|
/**
|
||||||
* Get the base relative to the provided route. For each slash we need to go
|
* Get the base relative to the provided route. For each slash we need to go
|
||||||
* up a directory. For example:
|
* up a directory. For example:
|
||||||
* / => .
|
* / => ./
|
||||||
* /foo => .
|
* /foo => ./
|
||||||
* /foo/ => ./..
|
* /foo/ => ./../
|
||||||
* /foo/bar => ./..
|
* /foo/bar => ./../
|
||||||
* /foo/bar/ => ./../..
|
* /foo/bar/ => ./../../
|
||||||
*/
|
*/
|
||||||
public base(route: Route): string {
|
public base(route: Route): string {
|
||||||
const depth = (route.originalPath.match(/\//g) || []).length
|
const depth = (route.originalPath.match(/\//g) || []).length
|
||||||
@@ -235,23 +229,30 @@ export abstract class HttpProvider {
|
|||||||
/**
|
/**
|
||||||
* Replace common templates strings.
|
* Replace common templates strings.
|
||||||
*/
|
*/
|
||||||
|
protected replaceTemplates(route: Route, response: HttpStringFileResponse, sessionId?: string): HttpStringFileResponse
|
||||||
protected replaceTemplates<T extends object>(
|
protected replaceTemplates<T extends object>(
|
||||||
route: Route,
|
route: Route,
|
||||||
response: HttpStringFileResponse,
|
response: HttpStringFileResponse,
|
||||||
extraOptions?: Omit<T, "base" | "csStaticBase" | "logLevel">,
|
options: T,
|
||||||
|
): HttpStringFileResponse
|
||||||
|
protected replaceTemplates(
|
||||||
|
route: Route,
|
||||||
|
response: HttpStringFileResponse,
|
||||||
|
sessionIdOrOptions?: string | object,
|
||||||
): HttpStringFileResponse {
|
): HttpStringFileResponse {
|
||||||
const base = this.base(route)
|
if (typeof sessionIdOrOptions === "undefined" || typeof sessionIdOrOptions === "string") {
|
||||||
const options: Options = {
|
sessionIdOrOptions = {
|
||||||
base,
|
base: this.base(route),
|
||||||
csStaticBase: base + "/static/" + this.options.commit + this.rootPath,
|
commit: this.options.commit,
|
||||||
logLevel: logger.level,
|
logLevel: logger.level,
|
||||||
...extraOptions,
|
sessionID: sessionIdOrOptions,
|
||||||
|
} as Options
|
||||||
}
|
}
|
||||||
response.content = response.content
|
response.content = response.content
|
||||||
|
.replace(/{{COMMIT}}/g, this.options.commit)
|
||||||
.replace(/{{TO}}/g, Array.isArray(route.query.to) ? route.query.to[0] : route.query.to || "/dashboard")
|
.replace(/{{TO}}/g, Array.isArray(route.query.to) ? route.query.to[0] : route.query.to || "/dashboard")
|
||||||
.replace(/{{BASE}}/g, options.base)
|
.replace(/{{BASE}}/g, this.base(route))
|
||||||
.replace(/{{CS_STATIC_BASE}}/g, options.csStaticBase)
|
.replace(/"{{OPTIONS}}"/, `'${JSON.stringify(sessionIdOrOptions)}'`)
|
||||||
.replace(/"{{OPTIONS}}"/, `'${JSON.stringify(options)}'`)
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +281,7 @@ export abstract class HttpProvider {
|
|||||||
* Helper to error on invalid methods (default GET).
|
* Helper to error on invalid methods (default GET).
|
||||||
*/
|
*/
|
||||||
protected ensureMethod(request: http.IncomingMessage, method?: string | string[]): void {
|
protected ensureMethod(request: http.IncomingMessage, method?: string | string[]): void {
|
||||||
const check = arrayify(method || "GET")
|
const check = Array.isArray(method) ? method : [method || "GET"]
|
||||||
if (!request.method || !check.includes(request.method)) {
|
if (!request.method || !check.includes(request.method)) {
|
||||||
throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest)
|
throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest)
|
||||||
}
|
}
|
||||||
@@ -474,7 +475,7 @@ export class HttpServer {
|
|||||||
this.proxyDomains = new Set((options.proxyDomains || []).map((d) => d.replace(/^\*\./, "")))
|
this.proxyDomains = new Set((options.proxyDomains || []).map((d) => d.replace(/^\*\./, "")))
|
||||||
this.heart = new Heart(path.join(paths.data, "heartbeat"), async () => {
|
this.heart = new Heart(path.join(paths.data, "heartbeat"), async () => {
|
||||||
const connections = await this.getConnections()
|
const connections = await this.getConnections()
|
||||||
logger.trace(plural(connections, `${connections} active connection`))
|
logger.trace(`${connections} active connection${plural(connections)}`)
|
||||||
return connections !== 0
|
return connections !== 0
|
||||||
})
|
})
|
||||||
this.protocol = this.options.cert ? "https" : "http"
|
this.protocol = this.options.cert ? "https" : "http"
|
||||||
@@ -501,15 +502,9 @@ export class HttpServer {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public dispose(): void {
|
||||||
* Stop and dispose everything. Return an array of disposal errors.
|
|
||||||
*/
|
|
||||||
public async dispose(): Promise<Error[]> {
|
|
||||||
this.socketProvider.stop()
|
this.socketProvider.stop()
|
||||||
const providers = Array.from(this.providers.values())
|
this.providers.forEach((p) => p.dispose())
|
||||||
// Catch so all the errors can be seen rather than just the first one.
|
|
||||||
const responses = await Promise.all<Error | undefined>(providers.map((p) => p.dispose().catch((e) => e)))
|
|
||||||
return responses.filter<Error>((r): r is Error => typeof r !== "undefined")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getConnections(): Promise<number> {
|
public async getConnections(): Promise<number> {
|
||||||
@@ -523,51 +518,41 @@ export class HttpServer {
|
|||||||
/**
|
/**
|
||||||
* Register a provider for a top-level endpoint.
|
* Register a provider for a top-level endpoint.
|
||||||
*/
|
*/
|
||||||
public registerHttpProvider<T extends HttpProvider>(endpoint: string | string[], provider: HttpProvider0<T>): T
|
public registerHttpProvider<T extends HttpProvider>(endpoint: string, provider: HttpProvider0<T>): T
|
||||||
public registerHttpProvider<A1, T extends HttpProvider>(
|
public registerHttpProvider<A1, T extends HttpProvider>(endpoint: string, provider: HttpProvider1<A1, T>, a1: A1): T
|
||||||
endpoint: string | string[],
|
|
||||||
provider: HttpProvider1<A1, T>,
|
|
||||||
a1: A1,
|
|
||||||
): T
|
|
||||||
public registerHttpProvider<A1, A2, T extends HttpProvider>(
|
public registerHttpProvider<A1, A2, T extends HttpProvider>(
|
||||||
endpoint: string | string[],
|
endpoint: string,
|
||||||
provider: HttpProvider2<A1, A2, T>,
|
provider: HttpProvider2<A1, A2, T>,
|
||||||
a1: A1,
|
a1: A1,
|
||||||
a2: A2,
|
a2: A2,
|
||||||
): T
|
): T
|
||||||
public registerHttpProvider<A1, A2, A3, T extends HttpProvider>(
|
public registerHttpProvider<A1, A2, A3, T extends HttpProvider>(
|
||||||
endpoint: string | string[],
|
endpoint: string,
|
||||||
provider: HttpProvider3<A1, A2, A3, T>,
|
provider: HttpProvider3<A1, A2, A3, T>,
|
||||||
a1: A1,
|
a1: A1,
|
||||||
a2: A2,
|
a2: A2,
|
||||||
a3: A3,
|
a3: A3,
|
||||||
): T
|
): T
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
public registerHttpProvider(endpoint: string | string[], provider: any, ...args: any[]): void {
|
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`)
|
||||||
|
}
|
||||||
|
if (/\//.test(endpoint)) {
|
||||||
|
throw new Error(`Only top-level endpoints are supported (got ${endpoint})`)
|
||||||
|
}
|
||||||
const p = new provider(
|
const p = new provider(
|
||||||
{
|
{
|
||||||
auth: this.options.auth || AuthType.None,
|
auth: this.options.auth || AuthType.None,
|
||||||
|
base: `/${endpoint}`,
|
||||||
commit: this.options.commit,
|
commit: this.options.commit,
|
||||||
password: this.options.password,
|
password: this.options.password,
|
||||||
},
|
},
|
||||||
...args,
|
...args,
|
||||||
)
|
)
|
||||||
const endpoints = arrayify(endpoint).map(trimSlashes)
|
this.providers.set(`/${endpoint}`, p)
|
||||||
endpoints.forEach((endpoint) => {
|
return p
|
||||||
if (/\//.test(endpoint)) {
|
|
||||||
throw new Error(`Only top-level endpoints are supported (got ${endpoint})`)
|
|
||||||
}
|
|
||||||
const existingProvider = this.providers.get(`/${endpoint}`)
|
|
||||||
this.providers.set(`/${endpoint}`, p)
|
|
||||||
if (existingProvider) {
|
|
||||||
logger.debug(`Overridding existing /${endpoint} provider`)
|
|
||||||
// If the existing provider isn't registered elsewhere we can dispose.
|
|
||||||
if (!Array.from(this.providers.values()).find((p) => p === existingProvider)) {
|
|
||||||
logger.debug(`Disposing existing /${endpoint} provider`)
|
|
||||||
existingProvider.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -657,17 +642,15 @@ export class HttpServer {
|
|||||||
e = new HttpError("Not found", HttpCode.NotFound)
|
e = new HttpError("Not found", HttpCode.NotFound)
|
||||||
}
|
}
|
||||||
const code = typeof e.code === "number" ? e.code : HttpCode.ServerError
|
const code = typeof e.code === "number" ? e.code : HttpCode.ServerError
|
||||||
logger.debug("Request error", field("url", request.url), field("code", code), field("error", error))
|
logger.debug("Request error", field("url", request.url), field("code", code))
|
||||||
if (code >= HttpCode.ServerError) {
|
if (code >= HttpCode.ServerError) {
|
||||||
logger.error(error.stack)
|
logger.error(error.stack)
|
||||||
}
|
}
|
||||||
if (request.headers["content-type"] === "application/json") {
|
if (request.headers["content-type"] === "application/json") {
|
||||||
write({
|
write({
|
||||||
code,
|
code,
|
||||||
mime: "application/json",
|
|
||||||
content: {
|
content: {
|
||||||
error: e.message,
|
error: e.message,
|
||||||
...(e.details || {}),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -776,7 +759,7 @@ export class HttpServer {
|
|||||||
// that by shifting the next base out of the request path.
|
// that by shifting the next base out of the request path.
|
||||||
let provider = this.providers.get(base)
|
let provider = this.providers.get(base)
|
||||||
if (base !== "/" && provider) {
|
if (base !== "/" && provider) {
|
||||||
return { ...parse(requestPath), providerBase: base, fullPath, query: parsedUrl.query, provider, originalPath }
|
return { ...parse(requestPath), fullPath, query: parsedUrl.query, provider, originalPath }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to the top-level provider.
|
// Fall back to the top-level provider.
|
||||||
@@ -784,7 +767,7 @@ export class HttpServer {
|
|||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw new Error(`No provider for ${base}`)
|
throw new Error(`No provider for ${base}`)
|
||||||
}
|
}
|
||||||
return { base, providerBase: "/", fullPath, requestPath, query: parsedUrl.query, provider, originalPath }
|
return { base, fullPath, requestPath, query: parsedUrl.query, provider, originalPath }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -823,11 +806,10 @@ export class HttpServer {
|
|||||||
// sure how best to get this information to the `proxyRes` event handler.
|
// sure how best to get this information to the `proxyRes` event handler.
|
||||||
// For now I'm sticking it on the request object which is passed through to
|
// For now I'm sticking it on the request object which is passed through to
|
||||||
// the event.
|
// the event.
|
||||||
;(request as ProxyRequest).base = options.strip
|
;(request as ProxyRequest).base = options.base
|
||||||
|
|
||||||
const isHttp = response instanceof http.ServerResponse
|
const isHttp = response instanceof http.ServerResponse
|
||||||
const base = options.strip ? route.fullPath.replace(options.strip, "") : route.fullPath
|
const path = options.base ? route.fullPath.replace(options.base, "") : route.fullPath
|
||||||
const path = normalize("/" + (options.prepend || "") + "/" + base, true)
|
|
||||||
const proxyOptions: proxy.ServerOptions = {
|
const proxyOptions: proxy.ServerOptions = {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ignorePath: true,
|
ignorePath: true,
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { field, logger } from "@coder/logger"
|
|
||||||
import * as fs from "fs"
|
|
||||||
import * as path from "path"
|
|
||||||
import * as util from "util"
|
|
||||||
import { Args } from "./cli"
|
|
||||||
import { HttpServer } from "./http"
|
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
||||||
|
|
||||||
export type Activate = (httpServer: HttpServer, args: Args) => void
|
|
||||||
|
|
||||||
export interface Plugin {
|
|
||||||
activate: Activate
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Intercept imports so we can inject code-server when the plugin tries to
|
|
||||||
* import it.
|
|
||||||
*/
|
|
||||||
const originalLoad = require("module")._load
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
require("module")._load = function (request: string, parent: object, isMain: boolean): any {
|
|
||||||
return originalLoad.apply(this, [request.replace(/^code-server/, path.resolve(__dirname, "../..")), parent, isMain])
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadPlugin = async (pluginPath: string, httpServer: HttpServer, args: Args): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const plugin: Plugin = require(pluginPath)
|
|
||||||
plugin.activate(httpServer, args)
|
|
||||||
logger.debug("Loaded plugin", field("name", path.basename(pluginPath)))
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== "MODULE_NOT_FOUND") {
|
|
||||||
logger.warn(error.message)
|
|
||||||
} else {
|
|
||||||
logger.error(error.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const _loadPlugins = async (httpServer: HttpServer, args: Args): Promise<void> => {
|
|
||||||
const pluginPath = path.resolve(__dirname, "../../plugins")
|
|
||||||
const files = await util.promisify(fs.readdir)(pluginPath, {
|
|
||||||
withFileTypes: true,
|
|
||||||
})
|
|
||||||
await Promise.all(files.map((file) => loadPlugin(path.join(pluginPath, file.name), httpServer, args)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadPlugins = async (httpServer: HttpServer, args: Args): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await _loadPlugins(httpServer, args)
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== "ENOENT") {
|
|
||||||
logger.warn(error.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.PLUGIN_DIR) {
|
|
||||||
await loadPlugin(process.env.PLUGIN_DIR, httpServer, args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import * as fs from "fs-extra"
|
|||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { extend, paths } from "./util"
|
import { extend, paths } from "./util"
|
||||||
import { logger } from "@coder/logger"
|
import { logger } from "@coder/logger"
|
||||||
import { Route } from "./http"
|
|
||||||
|
|
||||||
export type Settings = { [key: string]: Settings | string | boolean | number }
|
export type Settings = { [key: string]: Settings | string | boolean | number }
|
||||||
|
|
||||||
@@ -30,13 +29,11 @@ export class SettingsProvider<T> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Write settings combined with current settings. On failure log a warning.
|
* Write settings combined with current settings. On failure log a warning.
|
||||||
* Settings can be shallow or deep merged.
|
* Objects will be merged and everything else will be replaced.
|
||||||
*/
|
*/
|
||||||
public async write(settings: Partial<T>, shallow = true): Promise<void> {
|
public async write(settings: Partial<T>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const oldSettings = await this.read()
|
await fs.writeFile(this.settingsPath, JSON.stringify(extend(await this.read(), settings), null, 2))
|
||||||
const nextSettings = shallow ? Object.assign({}, oldSettings, settings) : extend(oldSettings, settings)
|
|
||||||
await fs.writeFile(this.settingsPath, JSON.stringify(nextSettings, null, 2))
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(error.message)
|
logger.warn(error.message)
|
||||||
}
|
}
|
||||||
@@ -58,7 +55,6 @@ export interface CoderSettings extends UpdateSettings {
|
|||||||
url: string
|
url: string
|
||||||
workspace: boolean
|
workspace: boolean
|
||||||
}
|
}
|
||||||
query: Route["query"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { field, logger } from "@coder/logger"
|
import { logger, field } from "@coder/logger"
|
||||||
import * as cp from "child_process"
|
import * as cp from "child_process"
|
||||||
import * as path from "path"
|
|
||||||
import * as rfs from "rotating-file-stream"
|
|
||||||
import { Emitter } from "../common/emitter"
|
import { Emitter } from "../common/emitter"
|
||||||
import { paths } from "./util"
|
|
||||||
|
|
||||||
interface HandshakeMessage {
|
interface HandshakeMessage {
|
||||||
type: "handshake"
|
type: "handshake"
|
||||||
@@ -143,17 +140,8 @@ export interface WrapperOptions {
|
|||||||
export class WrapperProcess {
|
export class WrapperProcess {
|
||||||
private process?: cp.ChildProcess
|
private process?: cp.ChildProcess
|
||||||
private started?: Promise<void>
|
private started?: Promise<void>
|
||||||
private readonly logStdoutStream: rfs.RotatingFileStream
|
|
||||||
private readonly logStderrStream: rfs.RotatingFileStream
|
|
||||||
|
|
||||||
public constructor(private currentVersion: string, private readonly options?: WrapperOptions) {
|
public constructor(private currentVersion: string, private readonly options?: WrapperOptions) {
|
||||||
const opts = {
|
|
||||||
size: "10M",
|
|
||||||
maxFiles: 10,
|
|
||||||
}
|
|
||||||
this.logStdoutStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stdout.log"), opts)
|
|
||||||
this.logStderrStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stderr.log"), opts)
|
|
||||||
|
|
||||||
ipcMain().onDispose(() => {
|
ipcMain().onDispose(() => {
|
||||||
if (this.process) {
|
if (this.process) {
|
||||||
this.process.removeAllListeners()
|
this.process.removeAllListeners()
|
||||||
@@ -188,15 +176,6 @@ export class WrapperProcess {
|
|||||||
public start(): Promise<void> {
|
public start(): Promise<void> {
|
||||||
if (!this.started) {
|
if (!this.started) {
|
||||||
this.started = this.spawn().then((child) => {
|
this.started = this.spawn().then((child) => {
|
||||||
// Log both to stdout and to the log directory.
|
|
||||||
if (child.stdout) {
|
|
||||||
child.stdout.pipe(this.logStdoutStream)
|
|
||||||
child.stdout.pipe(process.stdout)
|
|
||||||
}
|
|
||||||
if (child.stderr) {
|
|
||||||
child.stderr.pipe(this.logStderrStream)
|
|
||||||
child.stderr.pipe(process.stderr)
|
|
||||||
}
|
|
||||||
logger.debug(`spawned inner process ${child.pid}`)
|
logger.debug(`spawned inner process ${child.pid}`)
|
||||||
ipcMain()
|
ipcMain()
|
||||||
.handshake(child)
|
.handshake(child)
|
||||||
@@ -226,7 +205,7 @@ export class WrapperProcess {
|
|||||||
CODE_SERVER_PARENT_PID: process.pid.toString(),
|
CODE_SERVER_PARENT_PID: process.pid.toString(),
|
||||||
NODE_OPTIONS: nodeOptions,
|
NODE_OPTIONS: nodeOptions,
|
||||||
},
|
},
|
||||||
stdio: ["ipc"],
|
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,33 +2,40 @@ import * as assert from "assert"
|
|||||||
import * as fs from "fs-extra"
|
import * as fs from "fs-extra"
|
||||||
import * as http from "http"
|
import * as http from "http"
|
||||||
import * as path from "path"
|
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 { LatestResponse, UpdateHttpProvider } from "../src/node/app/update"
|
||||||
import { AuthType } from "../src/node/http"
|
import { AuthType } from "../src/node/http"
|
||||||
import { SettingsProvider, UpdateSettings } from "../src/node/settings"
|
import { SettingsProvider, UpdateSettings } from "../src/node/settings"
|
||||||
import { tmpdir } from "../src/node/util"
|
import { tmpdir } from "../src/node/util"
|
||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
|
const archivePath = path.join(tmpdir, "tests/updates/code-server-loose-source")
|
||||||
let version = "1.0.0"
|
let version = "1.0.0"
|
||||||
let spy: string[] = []
|
let spy: string[] = []
|
||||||
const server = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => {
|
const server = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => {
|
||||||
if (!request.url) {
|
if (!request.url) {
|
||||||
throw new Error("no url")
|
throw new Error("no url")
|
||||||
}
|
}
|
||||||
|
|
||||||
spy.push(request.url)
|
spy.push(request.url)
|
||||||
|
response.writeHead(200)
|
||||||
// Return the latest version.
|
|
||||||
if (request.url === "/latest") {
|
if (request.url === "/latest") {
|
||||||
const latest: LatestResponse = {
|
const latest: LatestResponse = {
|
||||||
name: version,
|
name: version,
|
||||||
}
|
}
|
||||||
response.writeHead(200)
|
|
||||||
return response.end(JSON.stringify(latest))
|
return response.end(JSON.stringify(latest))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anything else is a 404.
|
const path = archivePath + (request.url.endsWith(".tar.gz") ? ".tar.gz" : ".zip")
|
||||||
response.writeHead(404)
|
|
||||||
response.end("not found")
|
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)
|
||||||
})
|
})
|
||||||
|
|
||||||
const jsonPath = path.join(tmpdir, "tests/updates/update.json")
|
const jsonPath = path.join(tmpdir, "tests/updates/update.json")
|
||||||
@@ -44,10 +51,12 @@ describe("update", () => {
|
|||||||
_provider = new UpdateHttpProvider(
|
_provider = new UpdateHttpProvider(
|
||||||
{
|
{
|
||||||
auth: AuthType.None,
|
auth: AuthType.None,
|
||||||
|
base: "/update",
|
||||||
commit: "test",
|
commit: "test",
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
`http://${address.address}:${address.port}/latest`,
|
`http://${address.address}:${address.port}/latest`,
|
||||||
|
`http://${address.address}:${address.port}/download/{{VERSION}}/{{RELEASE_NAME}}`,
|
||||||
settings,
|
settings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -63,8 +72,32 @@ describe("update", () => {
|
|||||||
host: "localhost",
|
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.remove(path.join(tmpdir, "tests/updates"))
|
||||||
await fs.mkdirp(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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
@@ -152,15 +185,53 @@ describe("update", () => {
|
|||||||
assert.equal(p.isLatestVersion(update), true)
|
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 () => {
|
it("should not reject if unable to fetch", async () => {
|
||||||
const options = {
|
const options = {
|
||||||
auth: AuthType.None,
|
auth: AuthType.None,
|
||||||
|
base: "/update",
|
||||||
commit: "test",
|
commit: "test",
|
||||||
}
|
}
|
||||||
let provider = new UpdateHttpProvider(options, true, "invalid", settings)
|
let provider = new UpdateHttpProvider(options, true, "invalid", "invalid", settings)
|
||||||
await assert.doesNotReject(() => provider.getUpdate(true))
|
await assert.doesNotReject(() => provider.getUpdate(true))
|
||||||
|
|
||||||
provider = new UpdateHttpProvider(options, true, "http://probably.invalid.dev.localhost/latest", settings)
|
provider = new UpdateHttpProvider(
|
||||||
|
options,
|
||||||
|
true,
|
||||||
|
"http://probably.invalid.dev.localhost/latest",
|
||||||
|
"http://probably.invalid.dev.localhost/download",
|
||||||
|
settings,
|
||||||
|
)
|
||||||
await assert.doesNotReject(() => provider.getUpdate(true))
|
await assert.doesNotReject(() => provider.getUpdate(true))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
13
yarn.lock
13
yarn.lock
@@ -792,10 +792,10 @@
|
|||||||
lodash "^4.17.13"
|
lodash "^4.17.13"
|
||||||
to-fast-properties "^2.0.0"
|
to-fast-properties "^2.0.0"
|
||||||
|
|
||||||
"@coder/logger@1.1.16":
|
"@coder/logger@1.1.11":
|
||||||
version "1.1.16"
|
version "1.1.11"
|
||||||
resolved "https://registry.yarnpkg.com/@coder/logger/-/logger-1.1.16.tgz#ee5b1b188f680733f35c11b065bbd139d618c1e1"
|
resolved "https://registry.yarnpkg.com/@coder/logger/-/logger-1.1.11.tgz#e6f36dba9436ae61e66e3f66787d75c768617605"
|
||||||
integrity sha512-X6VB1++IkosYY6amRAiMvuvCf12NA4+ooX+gOuu5bJIkdjmh4Lz7QpJcWRdgxesvo1msriDDr9E/sDbIWf6vsQ==
|
integrity sha512-EEh1dqSU0AaqjjjMsVqumgZGbrZimKFKIb4t5E6o3FLfVUxJCReSME78Yj2N1xWUVAHMnqafDCxLostpuIotzw==
|
||||||
|
|
||||||
"@iarna/toml@^2.2.0":
|
"@iarna/toml@^2.2.0":
|
||||||
version "2.2.5"
|
version "2.2.5"
|
||||||
@@ -6144,11 +6144,6 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
|
|||||||
hash-base "^3.0.0"
|
hash-base "^3.0.0"
|
||||||
inherits "^2.0.1"
|
inherits "^2.0.1"
|
||||||
|
|
||||||
rotating-file-stream@^2.1.1:
|
|
||||||
version "2.1.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/rotating-file-stream/-/rotating-file-stream-2.1.3.tgz#4b3cc8f56ae70b3e30ccdb4ee6b14d95e66b02bb"
|
|
||||||
integrity sha512-zZ4Tkngxispo7DgiTqX0s4ChLtM3qET6iYsDA9tmgDEqJ3BFgRq/ZotsKEDAYQt9pAn9JwwqT27CSwQt3CTxNg==
|
|
||||||
|
|
||||||
run-async@^2.4.0:
|
run-async@^2.4.0:
|
||||||
version "2.4.1"
|
version "2.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
|
resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
|
||||||
|
|||||||
Reference in New Issue
Block a user