mirror of
https://github.com/coder/code-server.git
synced 2024-12-22 09:11:34 +03:00
Implement new structure
This commit is contained in:
parent
ef8da3864f
commit
b29346ecdf
42
.drone.yml
42
.drone.yml
@ -6,6 +6,11 @@ platform:
|
||||
arch: amd64
|
||||
|
||||
steps:
|
||||
- name: submodules
|
||||
image: alpine/git
|
||||
commands:
|
||||
- git submodule update --init
|
||||
|
||||
- name: cache:restore
|
||||
image: node:12
|
||||
commands:
|
||||
@ -69,7 +74,7 @@ steps:
|
||||
- name: publish:gcs
|
||||
image: plugins/gcs
|
||||
settings:
|
||||
source: gcs_bucket
|
||||
source: binary-upload
|
||||
target: codesrv-ci.cdr.sh/
|
||||
token:
|
||||
from_secret: gcs-token
|
||||
@ -85,6 +90,11 @@ platform:
|
||||
arch: amd64
|
||||
|
||||
steps:
|
||||
- name: submodules
|
||||
image: alpine/git
|
||||
commands:
|
||||
- git submodule update --init
|
||||
|
||||
- name: cache:restore
|
||||
image: node:12-alpine
|
||||
commands:
|
||||
@ -133,7 +143,7 @@ steps:
|
||||
- name: publish:gcs
|
||||
image: plugins/gcs
|
||||
settings:
|
||||
source: gcs_bucket
|
||||
source: binary-upload
|
||||
target: codesrv-ci.cdr.sh/
|
||||
token:
|
||||
from_secret: gcs-token
|
||||
@ -149,6 +159,12 @@ platform:
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: submodules
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add git
|
||||
- git submodule update --init
|
||||
|
||||
- name: cache:restore
|
||||
image: node:12
|
||||
commands:
|
||||
@ -211,7 +227,7 @@ steps:
|
||||
- name: publish:gcs
|
||||
image: plugins/gcs
|
||||
settings:
|
||||
source: gcs_bucket
|
||||
source: binary-upload
|
||||
target: codesrv-ci.cdr.sh/
|
||||
token:
|
||||
from_secret: gcs-token
|
||||
@ -227,6 +243,12 @@ platform:
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: submodules
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add git
|
||||
- git submodule update --init
|
||||
|
||||
- name: cache:restore
|
||||
image: node:12-alpine
|
||||
commands:
|
||||
@ -275,7 +297,7 @@ steps:
|
||||
- name: publish:gcs
|
||||
image: plugins/gcs
|
||||
settings:
|
||||
source: gcs_bucket
|
||||
source: binary-upload
|
||||
target: codesrv-ci.cdr.sh/
|
||||
token:
|
||||
from_secret: gcs-token
|
||||
@ -291,6 +313,12 @@ platform:
|
||||
arch: arm
|
||||
|
||||
steps:
|
||||
- name: submodules
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add git
|
||||
- git submodule update --init
|
||||
|
||||
- name: cache:restore
|
||||
image: node:12
|
||||
commands:
|
||||
@ -360,6 +388,12 @@ platform:
|
||||
arch: arm
|
||||
|
||||
steps:
|
||||
- name: submodules
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add git
|
||||
- git submodule update --init
|
||||
|
||||
- name: cache:restore
|
||||
image: node:12-alpine
|
||||
commands:
|
||||
|
6
.editorconfig
Normal file
6
.editorconfig
Normal file
@ -0,0 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
trim_trailing_whitespace = true
|
||||
indent_size = 2
|
39
.eslintrc.yaml
Normal file
39
.eslintrc.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
parser: "@typescript-eslint/parser"
|
||||
env:
|
||||
browser: true
|
||||
es6: true # Map, etc.
|
||||
mocha: true
|
||||
node: true
|
||||
|
||||
parserOptions:
|
||||
ecmaVersion: 2018
|
||||
sourceType: module
|
||||
ecmaFeatures:
|
||||
jsx: true
|
||||
|
||||
extends:
|
||||
- eslint:recommended
|
||||
- plugin:@typescript-eslint/recommended
|
||||
- plugin:import/recommended
|
||||
- plugin:import/typescript
|
||||
- plugin:react/recommended
|
||||
- plugin:prettier/recommended
|
||||
- prettier # Removes eslint rules that conflict with prettier.
|
||||
- prettier/@typescript-eslint # Remove conflicts again.
|
||||
|
||||
plugins:
|
||||
- react-hooks
|
||||
|
||||
# Need to set this explicitly for the eslint-plugin-react.
|
||||
settings:
|
||||
react:
|
||||
version: detect
|
||||
|
||||
rules:
|
||||
# For overloads.
|
||||
no-dupe-class-members: off
|
||||
|
||||
# https://www.npmjs.com/package/eslint-plugin-react-hooks
|
||||
react-hooks/rules-of-hooks: error
|
||||
|
||||
react/prop-types: off # We use Typescript to verify prop types.
|
17
.gitignore
vendored
17
.gitignore
vendored
@ -1,5 +1,14 @@
|
||||
*.tsbuildinfo
|
||||
.cache
|
||||
binaries
|
||||
binary-upload
|
||||
build
|
||||
cache-upload
|
||||
dist
|
||||
dist-build
|
||||
node_modules
|
||||
/build
|
||||
/release
|
||||
/binaries
|
||||
/lib
|
||||
out
|
||||
out-build
|
||||
release
|
||||
source
|
||||
yarn-cache
|
||||
|
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "lib/vscode"]
|
||||
path = lib/vscode
|
||||
url = https://github.com/microsoft/vscode
|
7
.prettierrc.yaml
Normal file
7
.prettierrc.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
printWidth: 120
|
||||
semi: false
|
||||
tabWidth: 2
|
||||
singleQuote: false
|
||||
trailingComma: es5
|
||||
useTabs: false
|
||||
arrowParens: always
|
2
.stylelintrc.yaml
Normal file
2
.stylelintrc.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
extends:
|
||||
- stylelint-config-standard
|
34
Dockerfile
34
Dockerfile
@ -4,40 +4,40 @@ ARG githubToken
|
||||
|
||||
# Install VS Code's deps. These are the only two it seems we need.
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libxkbfile-dev \
|
||||
libsecret-1-dev
|
||||
libxkbfile-dev \
|
||||
libsecret-1-dev
|
||||
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
|
||||
RUN yarn \
|
||||
&& DRONE_TAG="$tag" MINIFY=true BINARY=true GITHUB_TOKEN="$githubToken" ./scripts/ci.bash \
|
||||
&& rm -r /src/build \
|
||||
&& rm -r /src/source
|
||||
&& DRONE_TAG="$tag" MINIFY=true STRIP_BIN_TARGET=true GITHUB_TOKEN="$githubToken" ./scripts/ci.bash \
|
||||
&& rm -r /src/build \
|
||||
&& rm -r /src/source
|
||||
|
||||
# We deploy with Ubuntu so that devs have a familiar environment.
|
||||
FROM ubuntu:18.04
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
openssl \
|
||||
net-tools \
|
||||
git \
|
||||
locales \
|
||||
sudo \
|
||||
dumb-init \
|
||||
vim \
|
||||
curl \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
openssl \
|
||||
net-tools \
|
||||
git \
|
||||
locales \
|
||||
sudo \
|
||||
dumb-init \
|
||||
vim \
|
||||
curl \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN locale-gen en_US.UTF-8
|
||||
# We cannot use update-locale because docker will not use the env variables
|
||||
# configured in /etc/default/locale so we need to set it manually.
|
||||
ENV LC_ALL=en_US.UTF-8 \
|
||||
SHELL=/bin/bash
|
||||
SHELL=/bin/bash
|
||||
|
||||
RUN adduser --gecos '' --disabled-password coder && \
|
||||
echo "coder ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/nopasswd
|
||||
echo "coder ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/nopasswd
|
||||
|
||||
USER coder
|
||||
# Create first so these directories will be owned by coder instead of root
|
||||
|
98
README.md
98
README.md
@ -10,14 +10,19 @@ docker run -it -p 127.0.0.1:8080:8080 -v "$PWD:/home/coder/project" codercom/cod
|
||||
```
|
||||
|
||||
- **Consistent environment:** Code on your Chromebook, tablet, and laptop with a
|
||||
consistent dev environment. develop more easily for Linux if you have a
|
||||
Windows or Mac, and pick up where you left off when switching workstations.
|
||||
consistent dev environment. Develop more easily for Linux if you have a
|
||||
Windows or Mac and pick up where you left off when switching workstations.
|
||||
- **Server-powered:** Take advantage of large cloud servers to speed up tests,
|
||||
compilations, downloads, and more. Preserve battery life when you're on the go
|
||||
since all intensive computation runs on your server.
|
||||
|
||||
![Screenshot](/doc/assets/ide.gif)
|
||||
|
||||
## VS Code
|
||||
|
||||
- See [our VS Code readme](./src/vscode) for more information about how
|
||||
code-server and VS Code work together.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Requirements
|
||||
@ -25,7 +30,8 @@ docker run -it -p 127.0.0.1:8080:8080 -v "$PWD:/home/coder/project" codercom/cod
|
||||
- 64-bit host.
|
||||
- At least 1GB of RAM.
|
||||
- 2 cores or more are recommended (1 core works but not optimally).
|
||||
- Secure connection over HTTPS or localhost (required for service workers).
|
||||
- Secure connection over HTTPS or localhost (required for service workers and
|
||||
clipboard support).
|
||||
- For Linux: GLIBC 2.17 or later and GLIBCXX 3.4.15 or later.
|
||||
- Docker (for Docker versions of `code-server`).
|
||||
|
||||
@ -37,12 +43,6 @@ Use [sshcode](https://github.com/codercom/sshcode) for a simple setup.
|
||||
|
||||
See the Docker one-liner mentioned above. Dockerfile is at [/Dockerfile](/Dockerfile).
|
||||
|
||||
To debug Golang using the
|
||||
[ms-vscode-go extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.Go),
|
||||
you need to add `--security-opt seccomp=unconfined` to your `docker run`
|
||||
arguments when launching code-server with Docker. See
|
||||
[#725](https://github.com/cdr/code-server/issues/725) for details.
|
||||
|
||||
### Digital Ocean
|
||||
|
||||
[![Create a Droplet](./doc/assets/droplet.svg)](https://marketplace.digitalocean.com/apps/code-server?action=deploy)
|
||||
@ -59,18 +59,18 @@ arguments when launching code-server with Docker. See
|
||||
|
||||
### Build
|
||||
|
||||
See
|
||||
[VS Code's prerequisites](https://github.com/Microsoft/vscode/wiki/How-to-Contribute#prerequisites)
|
||||
before building.
|
||||
- [VS Code prerequisites](https://github.com/Microsoft/vscode/wiki/How-to-Contribute#prerequisites)
|
||||
|
||||
```shell
|
||||
export OUT=/path/to/output/build # Optional if only building. Required if also developing.
|
||||
yarn build $vscodeVersion $codeServerVersion # See scripts/ci.bash for the VS Code version to use.
|
||||
# The code-server version can be anything you want.
|
||||
node /path/to/output/build/out/vs/server/main.js # You can run the built JavaScript with Node.
|
||||
yarn binary $vscodeVersion $codeServerVersion # Or you can package it into a binary.
|
||||
yarn
|
||||
yarn build
|
||||
node build/out/entry.js # You can run the built JavaScript with Node.
|
||||
yarn binary # Or you can package it into a binary.
|
||||
```
|
||||
|
||||
If changes are made to the patch and you've built previously you must manually
|
||||
reset VS Code then run `yarn patch:apply`.
|
||||
|
||||
## Security
|
||||
|
||||
### Authentication
|
||||
@ -98,32 +98,11 @@ for free.
|
||||
Do not expose `code-server` to the open internet without SSL, whether built-in
|
||||
or through a proxy.
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Creating custom VS Code extensions and debugging them doesn't work.
|
||||
- Extension profiling and tips are currently disabled.
|
||||
|
||||
## Future
|
||||
|
||||
- **Stay up to date!** Get notified about new releases of code-server.
|
||||
- **Stay up to date!** Get notified about new releases of `code-server`.
|
||||
![Screenshot](/doc/assets/release.gif)
|
||||
- Windows support.
|
||||
- Electron and Chrome OS applications to bridge the gap between local<->remote.
|
||||
- Run VS Code unit tests against our builds to ensure features work as expected.
|
||||
|
||||
## Extensions
|
||||
|
||||
code-server does not provide access to the official
|
||||
[Visual Studio Marketplace](https://marketplace.visualstudio.com/vscode). Instead,
|
||||
Coder has created a custom extension marketplace that we manage for open-source
|
||||
extensions. If you want to use an extension with code-server that we do not have
|
||||
in our marketplace please look for a release in the extension’s repository,
|
||||
contact us to see if we have one in the works or, if you build an extension
|
||||
locally from open source, you can copy it to the `extensions` folder. If you
|
||||
build one locally from open-source please contribute it to the project and let
|
||||
us know so we can give you props! If you have your own custom marketplace, it is
|
||||
possible to point code-server to it by setting the `SERVICE_URL` and `ITEM_URL`
|
||||
environment variables.
|
||||
|
||||
## Telemetry
|
||||
|
||||
@ -134,51 +113,18 @@ data collected to improve code-server.
|
||||
|
||||
### Development
|
||||
|
||||
See
|
||||
[VS Code's prerequisites](https://github.com/Microsoft/vscode/wiki/How-to-Contribute#prerequisites)
|
||||
before developing.
|
||||
- [VS Code prerequisites](https://github.com/Microsoft/vscode/wiki/How-to-Contribute#prerequisites)
|
||||
|
||||
```shell
|
||||
git clone https://github.com/microsoft/vscode
|
||||
cd vscode
|
||||
git checkout ${vscodeVersion} # See scripts/ci.bash for the version to use.
|
||||
yarn
|
||||
git clone https://github.com/cdr/code-server src/vs/server
|
||||
cd src/vs/server
|
||||
yarn
|
||||
yarn patch:apply
|
||||
yarn watch
|
||||
# Wait for the initial compilation to complete (it will say "Finished compilation").
|
||||
# Run the next command in another shell.
|
||||
yarn start
|
||||
# Visit http://localhost:8080
|
||||
yarn watch # Visit http://localhost:8080 once completed.
|
||||
```
|
||||
|
||||
If you run into issues about a different version of Node being used, try running
|
||||
`npm rebuild` in the VS Code directory.
|
||||
|
||||
### Upgrading VS Code
|
||||
|
||||
We patch VS Code to provide and fix some functionality. As the web portion of VS
|
||||
Code matures, we'll be able to shrink and maybe even entirely eliminate our
|
||||
patch. In the meantime, however, upgrading the VS Code version requires ensuring
|
||||
that the patch still applies and has the intended effects.
|
||||
|
||||
To generate a new patch, **stage all the changes** you want to be included in
|
||||
the patch in the VS Code source, then run `yarn patch:generate` in this
|
||||
directory.
|
||||
|
||||
Our changes include:
|
||||
|
||||
- Allow multiple extension directories (both user and built-in).
|
||||
- Modify the loader, websocket, webview, service worker, and asset requests to
|
||||
use the URL of the page as a base (and TLS if necessary for the websocket).
|
||||
- Send client-side telemetry through the server.
|
||||
- Make changing the display language work.
|
||||
- Make it possible for us to load code on the client.
|
||||
- Make extensions work in the browser.
|
||||
- Fix getting permanently disconnected when you sleep or hibernate for a while.
|
||||
- Make it possible to automatically update the binary.
|
||||
If changes are made to the patch and you've built previously you must manually
|
||||
reset VS Code then run `yarn patch:apply`.
|
||||
|
||||
## License
|
||||
|
||||
|
1
lib/vscode
Submodule
1
lib/vscode
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 26076a4de974ead31f97692a0d32f90d735645c0
|
7
main.js
7
main.js
@ -1,7 +0,0 @@
|
||||
// Once our entry file is loaded we no longer need nbin to bypass normal Node
|
||||
// execution. We can still shim the fs into the binary even when bypassing. This
|
||||
// will ensure for example that a spawn like `${process.argv[0]} -e` will work
|
||||
// while still allowing us to access files within the binary.
|
||||
process.env.NBIN_BYPASS = true;
|
||||
|
||||
require("../../bootstrap-amd").load("vs/server/src/node/cli");
|
62
package.json
62
package.json
@ -1,42 +1,70 @@
|
||||
{
|
||||
"name": "code-server",
|
||||
"license": "MIT",
|
||||
"version": "2.1.0",
|
||||
"scripts": {
|
||||
"i": "yarn install --ignore-scripts",
|
||||
"preinstall": "./scripts/preinstall.sh",
|
||||
"postinstall": "./scripts/postinstall.sh",
|
||||
"patch:generate": "cd ./lib/vscode && git diff HEAD > ../../scripts/vscode.patch",
|
||||
"patch:apply": "cd ./lib/vscode && git apply ../../scripts/vscode.patch",
|
||||
"test": "mocha -r ts-node/register ./test/*.test.ts",
|
||||
"lint:js": "eslint {src,test,scripts} --ext .ts,.tsx",
|
||||
"lint:css": "stylelint 'src/**/*.css'",
|
||||
"lint": "./scripts/lint.sh",
|
||||
"watch": "yarn runner watch",
|
||||
"runner": "cd ./scripts && node --max-old-space-size=32384 -r ts-node/register ./build.ts",
|
||||
"start": "nodemon --watch ../../../out --verbose ../../../out/vs/server/main.js",
|
||||
"test": "./scripts/test.sh",
|
||||
"watch": "cd ../../../ && yarn watch",
|
||||
"build": "yarn && yarn runner build",
|
||||
"package": "yarn runner package",
|
||||
"build": "yarn runner build",
|
||||
"binary": "yarn runner binary",
|
||||
"patch:generate": "cd ../../../ && git diff --staged > ./src/vs/server/scripts/vscode.patch",
|
||||
"patch:apply": "cd ../../../ && git apply ./src/vs/server/scripts/vscode.patch"
|
||||
"package": "yarn runner package"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@coder/nbin": "^1.2.7",
|
||||
"@types/fs-extra": "^8.0.1",
|
||||
"@types/node": "^10.12.12",
|
||||
"@types/mocha": "^5.2.7",
|
||||
"@types/node": "^12.12.7",
|
||||
"@types/parcel-bundler": "^1.12.1",
|
||||
"@types/pem": "^1.9.5",
|
||||
"@types/react": "^16.9.18",
|
||||
"@types/react-dom": "^16.9.5",
|
||||
"@types/react-router-dom": "^5.1.3",
|
||||
"@types/safe-compare": "^1.1.0",
|
||||
"@types/tar-fs": "^1.16.1",
|
||||
"@types/tar-stream": "^1.6.1",
|
||||
"fs-extra": "^8.1.0",
|
||||
"nodemon": "^1.19.1",
|
||||
"@types/ws": "^6.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^2.0.0",
|
||||
"@typescript-eslint/parser": "^2.0.0",
|
||||
"eslint": "^6.2.0",
|
||||
"eslint-config-prettier": "^6.0.0",
|
||||
"eslint-plugin-import": "^2.18.2",
|
||||
"eslint-plugin-prettier": "^3.1.0",
|
||||
"eslint-plugin-react": "^7.14.3",
|
||||
"eslint-plugin-react-hooks": "^1.7.0",
|
||||
"leaked-handles": "^5.2.0",
|
||||
"mocha": "^6.2.0",
|
||||
"parcel-bundler": "^1.12.4",
|
||||
"prettier": "^1.18.2",
|
||||
"stylelint": "^13.0.0",
|
||||
"stylelint-config-standard": "^19.0.0",
|
||||
"ts-node": "^8.4.1",
|
||||
"typescript": "3.6"
|
||||
"typescript": "3.7.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/node": "^10.12.12",
|
||||
"safe-buffer": "^5.1.1"
|
||||
"@types/node": "^12.12.7",
|
||||
"safe-buffer": "^5.1.1",
|
||||
"vfile-message": "^2.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coder/logger": "^1.1.12",
|
||||
"@coder/node-browser": "^1.0.6",
|
||||
"@coder/requirefs": "^1.0.6",
|
||||
"@coder/logger": "1.1.11",
|
||||
"fs-extra": "^8.1.0",
|
||||
"httpolyglot": "^0.1.2",
|
||||
"pem": "^1.14.2",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"safe-compare": "^1.1.4",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tar-stream": "^2.1.0",
|
||||
"util": "^0.12.1"
|
||||
"ws": "^7.2.0"
|
||||
}
|
||||
}
|
||||
|
736
scripts/build.ts
736
scripts/build.ts
@ -1,391 +1,451 @@
|
||||
import { Binary } from "@coder/nbin";
|
||||
import * as cp from "child_process";
|
||||
// import * as crypto from "crypto";
|
||||
import * as fs from "fs-extra";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import * as util from "util";
|
||||
import { Binary } from "@coder/nbin"
|
||||
import * as cp from "child_process"
|
||||
import * as fs from "fs-extra"
|
||||
import * as os from "os"
|
||||
import Bundler from "parcel-bundler"
|
||||
import * as path from "path"
|
||||
import * as util from "util"
|
||||
|
||||
enum Task {
|
||||
/**
|
||||
* Use before running anything that only works inside VS Code.
|
||||
*/
|
||||
EnsureInVscode = "ensure-in-vscode",
|
||||
Binary = "binary",
|
||||
Package = "package",
|
||||
Build = "build",
|
||||
Binary = "binary",
|
||||
Package = "package",
|
||||
Build = "build",
|
||||
Watch = "watch",
|
||||
}
|
||||
|
||||
class Builder {
|
||||
private readonly rootPath = path.resolve(__dirname, "..");
|
||||
private readonly outPath = process.env.OUT || this.rootPath;
|
||||
private _target?: "darwin" | "alpine" | "linux";
|
||||
private currentTask?: Task;
|
||||
private readonly rootPath = path.resolve(__dirname, "..")
|
||||
private readonly vscodeSourcePath = path.join(this.rootPath, "lib/vscode")
|
||||
private readonly binariesPath = path.join(this.rootPath, "binaries")
|
||||
private readonly buildPath = path.join(this.rootPath, "build")
|
||||
private readonly codeServerVersion: string
|
||||
private _target?: "darwin" | "alpine" | "linux"
|
||||
private currentTask?: Task
|
||||
|
||||
public run(task: Task | undefined, args: string[]): void {
|
||||
this.currentTask = task;
|
||||
this.doRun(task, args).catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
public constructor() {
|
||||
this.ensureArgument("rootPath", this.rootPath)
|
||||
this.codeServerVersion = this.ensureArgument(
|
||||
"codeServerVersion",
|
||||
process.env.VERSION || require(path.join(this.rootPath, "package.json")).version
|
||||
)
|
||||
}
|
||||
|
||||
private async task<T>(message: string, fn: () => Promise<T>): Promise<T> {
|
||||
const time = Date.now();
|
||||
this.log(`${message}...`, true);
|
||||
try {
|
||||
const t = await fn();
|
||||
process.stdout.write(`took ${Date.now() - time}ms\n`);
|
||||
return t;
|
||||
} catch (error) {
|
||||
process.stdout.write("failed\n");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
public run(task: Task | undefined): void {
|
||||
this.currentTask = task
|
||||
this.doRun(task).catch((error) => {
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes to stdout with an optional newline.
|
||||
*/
|
||||
private log(message: string, skipNewline: boolean = false): void {
|
||||
process.stdout.write(`[${this.currentTask || "default"}] ${message}`);
|
||||
if (!skipNewline) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
private async task<T>(message: string, fn: () => Promise<T>): Promise<T> {
|
||||
const time = Date.now()
|
||||
this.log(`${message}...`, true)
|
||||
try {
|
||||
const t = await fn()
|
||||
process.stdout.write(`took ${Date.now() - time}ms\n`)
|
||||
return t
|
||||
} catch (error) {
|
||||
process.stdout.write("failed\n")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async doRun(task: Task | undefined, args: string[]): Promise<void> {
|
||||
if (!task) {
|
||||
throw new Error("No task provided");
|
||||
}
|
||||
/**
|
||||
* Writes to stdout with an optional newline.
|
||||
*/
|
||||
private log(message: string, skipNewline = false): void {
|
||||
process.stdout.write(`[${this.currentTask || "default"}] ${message}`)
|
||||
if (!skipNewline) {
|
||||
process.stdout.write("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (task === Task.EnsureInVscode) {
|
||||
return process.exit(this.isInVscode(this.rootPath) ? 0 : 1);
|
||||
}
|
||||
private async doRun(task: Task | undefined): Promise<void> {
|
||||
if (!task) {
|
||||
throw new Error("No task provided")
|
||||
}
|
||||
|
||||
// If we're inside VS Code assume we want to develop. In that case we should
|
||||
// set an OUT directory and not build in this directory, otherwise when you
|
||||
// build/watch VS Code the build directory will be included.
|
||||
if (this.isInVscode(this.outPath)) {
|
||||
throw new Error("Should not build inside VS Code; set the OUT environment variable");
|
||||
}
|
||||
const arch = this.ensureArgument("arch", os.arch().replace(/^x/, "x86_"))
|
||||
const target = this.ensureArgument("target", await this.target())
|
||||
const binaryName = `code-server-${this.codeServerVersion}-${target}-${arch}`
|
||||
|
||||
this.ensureArgument("rootPath", this.rootPath);
|
||||
this.ensureArgument("outPath", this.outPath);
|
||||
switch (task) {
|
||||
case Task.Watch:
|
||||
return this.watch()
|
||||
case Task.Binary:
|
||||
return this.binary(binaryName)
|
||||
case Task.Package:
|
||||
return this.package(binaryName)
|
||||
case Task.Build:
|
||||
return this.build()
|
||||
default:
|
||||
throw new Error(`No task matching "${task}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const arch = this.ensureArgument("arch", os.arch().replace(/^x/, "x86_"));
|
||||
const target = this.ensureArgument("target", await this.target());
|
||||
const vscodeVersion = this.ensureArgument("vscodeVersion", args[0]);
|
||||
const codeServerVersion = this.ensureArgument("codeServerVersion", args[1]);
|
||||
/**
|
||||
* Get the target of the system.
|
||||
*/
|
||||
private async target(): Promise<"darwin" | "alpine" | "linux"> {
|
||||
if (!this._target) {
|
||||
if (os.platform() === "darwin" || (process.env.OSTYPE && /^darwin/.test(process.env.OSTYPE))) {
|
||||
this._target = "darwin"
|
||||
} else {
|
||||
// Alpine's ldd doesn't have a version flag but if you use an invalid flag
|
||||
// (like --version) it outputs the version to stderr and exits with 1.
|
||||
const result = await util
|
||||
.promisify(cp.exec)("ldd --version")
|
||||
.catch((error) => ({ stderr: error.message, stdout: "" }))
|
||||
if (/musl/.test(result.stderr) || /musl/.test(result.stdout)) {
|
||||
this._target = "alpine"
|
||||
} else {
|
||||
this._target = "linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._target
|
||||
}
|
||||
|
||||
const vscodeSourcePath = path.join(this.outPath, "source", `vscode-${vscodeVersion}-source`);
|
||||
const binariesPath = path.join(this.outPath, "binaries");
|
||||
const binaryName = `code-server${codeServerVersion}-vsc${vscodeVersion}-${target}-${arch}`;
|
||||
const finalBuildPath = path.join(this.outPath, "build", `${binaryName}-built`);
|
||||
/**
|
||||
* Make sure the argument is set. Display the value if it is.
|
||||
*/
|
||||
private ensureArgument(name: string, arg?: string): string {
|
||||
if (!arg) {
|
||||
throw new Error(`${name} is missing`)
|
||||
}
|
||||
this.log(`${name} is "${arg}"`)
|
||||
return arg
|
||||
}
|
||||
|
||||
switch (task) {
|
||||
case Task.Binary:
|
||||
return this.binary(finalBuildPath, binariesPath, binaryName);
|
||||
case Task.Package:
|
||||
return this.package(vscodeSourcePath, binariesPath, binaryName);
|
||||
case Task.Build:
|
||||
return this.build(vscodeSourcePath, vscodeVersion, codeServerVersion, finalBuildPath);
|
||||
default:
|
||||
throw new Error(`No task matching "${task}"`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Build VS Code and code-server.
|
||||
*/
|
||||
private async build(): Promise<void> {
|
||||
process.env.NODE_OPTIONS = "--max-old-space-size=32384 " + (process.env.NODE_OPTIONS || "")
|
||||
process.env.NODE_ENV = "production"
|
||||
|
||||
/**
|
||||
* Get the target of the system.
|
||||
*/
|
||||
private async target(): Promise<"darwin" | "alpine" | "linux"> {
|
||||
if (!this._target) {
|
||||
if (os.platform() === "darwin" || (process.env.OSTYPE && /^darwin/.test(process.env.OSTYPE))) {
|
||||
this._target = "darwin";
|
||||
} else {
|
||||
// Alpine's ldd doesn't have a version flag but if you use an invalid flag
|
||||
// (like --version) it outputs the version to stderr and exits with 1.
|
||||
const result = await util.promisify(cp.exec)("ldd --version")
|
||||
.catch((error) => ({ stderr: error.message, stdout: "" }));
|
||||
if (/musl/.test(result.stderr) || /musl/.test(result.stdout)) {
|
||||
this._target = "alpine";
|
||||
} else {
|
||||
this._target = "linux";
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._target;
|
||||
}
|
||||
await this.task("cleaning up old build", async () => {
|
||||
if (!process.env.SKIP_VSCODE) {
|
||||
return fs.remove(this.buildPath)
|
||||
}
|
||||
// If skipping VS Code, keep the existing build if any.
|
||||
try {
|
||||
const files = await fs.readdir(this.buildPath)
|
||||
return Promise.all(files.filter((f) => f !== "lib").map((f) => fs.remove(path.join(this.buildPath, f))))
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Make sure the argument is set. Display the value if it is.
|
||||
*/
|
||||
private ensureArgument(name: string, arg?: string): string {
|
||||
if (!arg) {
|
||||
this.log(`${name} is missing`);
|
||||
throw new Error("Usage: <vscodeVersion> <codeServerVersion>");
|
||||
}
|
||||
this.log(`${name} is "${arg}"`);
|
||||
return arg;
|
||||
}
|
||||
const commit = require(path.join(this.vscodeSourcePath, "build/lib/util")).getVersion(this.rootPath) as string
|
||||
if (!process.env.SKIP_VSCODE) {
|
||||
await this.buildVscode(commit)
|
||||
} else {
|
||||
this.log("skipping vs code build")
|
||||
}
|
||||
await this.buildCodeServer(commit)
|
||||
|
||||
/**
|
||||
* Return true if it looks like we're inside VS Code. This is used to prevent
|
||||
* accidentally building inside VS Code while developing which causes issues
|
||||
* because the watcher will try compiling those built files.
|
||||
*/
|
||||
private isInVscode(pathToCheck: string): boolean {
|
||||
let inside = false;
|
||||
const maybeVsCode = path.join(pathToCheck, "../../../");
|
||||
try {
|
||||
// If it has a package.json with the right name it's probably VS Code.
|
||||
inside = require(path.join(maybeVsCode, "package.json")).name === "code-oss-dev";
|
||||
} catch (error) {}
|
||||
this.log(
|
||||
inside
|
||||
? `Running inside VS Code ([${maybeVsCode}]${path.relative(maybeVsCode, pathToCheck)})`
|
||||
: "Not running inside VS Code"
|
||||
);
|
||||
return inside;
|
||||
}
|
||||
this.log(`final build: ${this.buildPath}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build code-server within VS Code.
|
||||
*/
|
||||
private async build(vscodeSourcePath: string, vscodeVersion: string, codeServerVersion: string, finalBuildPath: string): Promise<void> {
|
||||
// Install dependencies (should be cached by CI).
|
||||
await this.task("Installing code-server dependencies", async () => {
|
||||
await util.promisify(cp.exec)("yarn", { cwd: this.rootPath });
|
||||
});
|
||||
private async buildCodeServer(commit: string): Promise<void> {
|
||||
await this.task("building code-server", async () => {
|
||||
return util.promisify(cp.exec)("tsc --outDir ./out-build --tsBuildInfoFile ./.prod.tsbuildinfo", {
|
||||
cwd: this.rootPath,
|
||||
})
|
||||
})
|
||||
|
||||
// Download and prepare VS Code if necessary (should be cached by CI).
|
||||
if (fs.existsSync(vscodeSourcePath)) {
|
||||
this.log("Using existing VS Code clone");
|
||||
} else {
|
||||
await this.task("Cloning VS Code", () => {
|
||||
return util.promisify(cp.exec)(
|
||||
"git clone https://github.com/microsoft/vscode"
|
||||
+ ` --quiet --branch "${vscodeVersion}"`
|
||||
+ ` --single-branch --depth=1 "${vscodeSourcePath}"`);
|
||||
});
|
||||
}
|
||||
await this.task("bundling code-server", async () => {
|
||||
return this.createBundler("dist-build", commit).bundle()
|
||||
})
|
||||
|
||||
await this.task("Installing VS Code dependencies", () => {
|
||||
return util.promisify(cp.exec)("yarn", { cwd: vscodeSourcePath });
|
||||
});
|
||||
await this.task("copying code-server into build directory", async () => {
|
||||
await fs.mkdirp(this.buildPath)
|
||||
await Promise.all([
|
||||
fs.copy(path.join(this.rootPath, "out-build"), path.join(this.buildPath, "out")),
|
||||
fs.copy(path.join(this.rootPath, "dist-build"), path.join(this.buildPath, "dist")),
|
||||
// For source maps and images.
|
||||
fs.copy(path.join(this.rootPath, "src"), path.join(this.buildPath, "src")),
|
||||
])
|
||||
})
|
||||
|
||||
if (fs.existsSync(path.join(vscodeSourcePath, ".build/extensions"))) {
|
||||
this.log("Using existing built-in-extensions");
|
||||
} else {
|
||||
await this.task("Building default extensions", () => {
|
||||
return util.promisify(cp.exec)(
|
||||
"yarn gulp compile-extensions-build --max-old-space-size=32384",
|
||||
{ cwd: vscodeSourcePath },
|
||||
);
|
||||
});
|
||||
}
|
||||
await this.copyDependencies("code-server", this.rootPath, this.buildPath)
|
||||
}
|
||||
|
||||
// Clean before patching or it could fail if already patched.
|
||||
await this.task("Patching VS Code", async () => {
|
||||
await util.promisify(cp.exec)("git reset --hard", { cwd: vscodeSourcePath });
|
||||
await util.promisify(cp.exec)("git clean -fd", { cwd: vscodeSourcePath });
|
||||
await util.promisify(cp.exec)(`git apply ${this.rootPath}/scripts/vscode.patch`, { cwd: vscodeSourcePath });
|
||||
});
|
||||
private async buildVscode(commit: string): Promise<void> {
|
||||
await this.task("building vs code", () => {
|
||||
return util.promisify(cp.exec)("yarn gulp compile-build", { cwd: this.vscodeSourcePath })
|
||||
})
|
||||
|
||||
const serverPath = path.join(vscodeSourcePath, "src/vs/server");
|
||||
await this.task("Copying code-server into VS Code", async () => {
|
||||
await fs.remove(serverPath);
|
||||
await fs.mkdirp(serverPath);
|
||||
await Promise.all(["main.js", "node_modules", "src", "typings"].map((fileName) => {
|
||||
return fs.copy(path.join(this.rootPath, fileName), path.join(serverPath, fileName));
|
||||
}));
|
||||
});
|
||||
await this.task("building builtin extensions", async () => {
|
||||
const exists = await fs.pathExists(path.join(this.vscodeSourcePath, ".build/extensions"))
|
||||
if (exists) {
|
||||
process.stdout.write("already built, skipping...")
|
||||
} else {
|
||||
await util.promisify(cp.exec)("yarn gulp compile-extensions-build", { cwd: this.vscodeSourcePath })
|
||||
}
|
||||
})
|
||||
|
||||
await this.task("Building VS Code", () => {
|
||||
return util.promisify(cp.exec)("yarn gulp compile-build --max-old-space-size=32384", { cwd: vscodeSourcePath });
|
||||
});
|
||||
await this.task("optimizing vs code", async () => {
|
||||
return util.promisify(cp.exec)("yarn gulp optimize --gulpfile ./coder.js", { cwd: this.vscodeSourcePath })
|
||||
})
|
||||
|
||||
await this.task("Optimizing VS Code", async () => {
|
||||
await fs.copyFile(path.join(this.rootPath, "scripts/optimize.js"), path.join(vscodeSourcePath, "coder.js"));
|
||||
await util.promisify(cp.exec)(`yarn gulp optimize --max-old-space-size=32384 --gulpfile ./coder.js`, { cwd: vscodeSourcePath });
|
||||
});
|
||||
if (process.env.MINIFY) {
|
||||
await this.task("minifying vs code", () => {
|
||||
return util.promisify(cp.exec)("yarn gulp minify --gulpfile ./coder.js", { cwd: this.vscodeSourcePath })
|
||||
})
|
||||
}
|
||||
|
||||
const { productJson, packageJson } = await this.task("Generating final package.json and product.json", async () => {
|
||||
const merge = async (name: string, extraJson: { [key: string]: string } = {}): Promise<{ [key: string]: string }> => {
|
||||
const [aJson, bJson] = (await Promise.all([
|
||||
fs.readFile(path.join(vscodeSourcePath, `${name}.json`), "utf8"),
|
||||
fs.readFile(path.join(this.rootPath, `scripts/${name}.json`), "utf8"),
|
||||
])).map((raw) => {
|
||||
const json = JSON.parse(raw);
|
||||
delete json.scripts;
|
||||
delete json.dependencies;
|
||||
delete json.devDependencies;
|
||||
delete json.optionalDependencies;
|
||||
return json;
|
||||
});
|
||||
const { productJson, packageJson } = await this.task("generating vs code product configuration", async () => {
|
||||
const merge = async (name: string, json: { [key: string]: string } = {}): Promise<{ [key: string]: string }> => {
|
||||
return {
|
||||
...JSON.parse(await fs.readFile(path.join(this.vscodeSourcePath, `${name}.json`), "utf8")),
|
||||
...json,
|
||||
}
|
||||
}
|
||||
|
||||
return { ...aJson, ...bJson, ...extraJson };
|
||||
};
|
||||
const date = new Date().toISOString()
|
||||
const [packageJson, productJson] = await Promise.all([merge("package", {}), merge("product", { commit, date })])
|
||||
|
||||
const date = new Date().toISOString();
|
||||
const commit = require(path.join(vscodeSourcePath, "build/lib/util")).getVersion(this.rootPath);
|
||||
return { productJson, packageJson }
|
||||
})
|
||||
|
||||
const [productJson, packageJson] = await Promise.all([
|
||||
merge("product", { commit, date }),
|
||||
merge("package", { codeServerVersion: `${codeServerVersion}-vsc${vscodeVersion}` }),
|
||||
]);
|
||||
await this.task("inserting vs code product configuration", async () => {
|
||||
const filePath = path.join(this.vscodeSourcePath, "out-build/vs/platform/product/common/product.js")
|
||||
return fs.writeFile(
|
||||
filePath,
|
||||
(await fs.readFile(filePath, "utf8")).replace(
|
||||
"{ /*BUILD->INSERT_PRODUCT_CONFIGURATION*/}",
|
||||
JSON.stringify({
|
||||
version: packageJson.version,
|
||||
...productJson,
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// We could do this before the optimization but then it'd be copied into
|
||||
// three files and unused in two which seems like a waste of bytes.
|
||||
const apiPath = path.join(vscodeSourcePath, "out-vscode/vs/workbench/workbench.web.api.js");
|
||||
await fs.writeFile(apiPath, (await fs.readFile(apiPath, "utf8")).replace('{ /*BUILD->INSERT_PRODUCT_CONFIGURATION*/}', JSON.stringify({
|
||||
version: packageJson.version,
|
||||
codeServerVersion: packageJson.codeServerVersion,
|
||||
...productJson,
|
||||
})));
|
||||
const vscodeBuildPath = path.join(this.buildPath, "lib/vscode")
|
||||
await this.task("copying vs code into build directory", async () => {
|
||||
await fs.mkdirp(vscodeBuildPath)
|
||||
await Promise.all([
|
||||
(async (): Promise<void> => {
|
||||
await fs.move(
|
||||
path.join(this.vscodeSourcePath, `out-vscode${process.env.MINIFY ? "-min" : ""}`),
|
||||
path.join(vscodeBuildPath, "out")
|
||||
)
|
||||
await fs.remove(path.join(vscodeBuildPath, "out/vs/server/browser/workbench.html"))
|
||||
await fs.move(
|
||||
path.join(vscodeBuildPath, "out/vs/server/browser/workbench-build.html"),
|
||||
path.join(vscodeBuildPath, "out/vs/server/browser/workbench.html")
|
||||
)
|
||||
})(),
|
||||
await fs.copy(path.join(this.vscodeSourcePath, ".build/extensions"), path.join(vscodeBuildPath, "extensions")),
|
||||
])
|
||||
})
|
||||
|
||||
return { productJson, packageJson };
|
||||
});
|
||||
await this.copyDependencies("vs code", this.vscodeSourcePath, vscodeBuildPath)
|
||||
|
||||
if (process.env.MINIFY) {
|
||||
await this.task("Minifying VS Code", () => {
|
||||
return util.promisify(cp.exec)("yarn gulp minify --max-old-space-size=32384 --gulpfile ./coder.js", { cwd: vscodeSourcePath });
|
||||
});
|
||||
}
|
||||
await this.task("writing final vs code product.json", () => {
|
||||
return fs.writeFile(path.join(vscodeBuildPath, "product.json"), JSON.stringify(productJson, null, 2))
|
||||
})
|
||||
}
|
||||
|
||||
const finalServerPath = path.join(finalBuildPath, "out/vs/server");
|
||||
await this.task("Copying into final build directory", async () => {
|
||||
await fs.remove(finalBuildPath);
|
||||
await fs.mkdirp(finalBuildPath);
|
||||
await Promise.all([
|
||||
fs.copy(path.join(vscodeSourcePath, "remote/node_modules"), path.join(finalBuildPath, "node_modules")),
|
||||
fs.copy(path.join(vscodeSourcePath, ".build/extensions"), path.join(finalBuildPath, "extensions")),
|
||||
fs.copy(path.join(vscodeSourcePath, `out-vscode${process.env.MINIFY ? "-min" : ""}`), path.join(finalBuildPath, "out")).then(() => {
|
||||
return Promise.all([
|
||||
fs.remove(path.join(finalServerPath, "node_modules")).then(() => {
|
||||
return fs.copy(path.join(serverPath, "node_modules"), path.join(finalServerPath, "node_modules"));
|
||||
}),
|
||||
fs.copy(path.join(finalServerPath, "src/browser/workbench-build.html"), path.join(finalServerPath, "src/browser/workbench.html")),
|
||||
]);
|
||||
}),
|
||||
]);
|
||||
});
|
||||
private async copyDependencies(name: string, sourcePath: string, buildPath: string): Promise<void> {
|
||||
await this.task(`copying ${name} dependencies`, async () => {
|
||||
return Promise.all(
|
||||
["node_modules", "package.json", "yarn.lock"].map((fileName) => {
|
||||
return fs.copy(path.join(sourcePath, fileName), path.join(buildPath, fileName))
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
if (process.env.MINIFY) {
|
||||
await this.task("Restricting to production dependencies", async () => {
|
||||
await Promise.all(["package.json", "yarn.lock"].map((fileName) => {
|
||||
Promise.all([
|
||||
fs.copy(path.join(this.rootPath, fileName), path.join(finalServerPath, fileName)),
|
||||
fs.copy(path.join(path.join(vscodeSourcePath, "remote"), fileName), path.join(finalBuildPath, fileName)),
|
||||
]);
|
||||
}));
|
||||
if (process.env.MINIFY) {
|
||||
await this.task(`restricting ${name} to production dependencies`, async () => {
|
||||
return util.promisify(cp.exec)("yarn --production --ignore-scripts", { cwd: buildPath })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([finalServerPath, finalBuildPath].map((cwd) => {
|
||||
return util.promisify(cp.exec)("yarn --production", { cwd });
|
||||
}));
|
||||
/**
|
||||
* Bundles the built code into a binary.
|
||||
*/
|
||||
private async binary(binaryName: string): Promise<void> {
|
||||
const bin = new Binary({
|
||||
mainFile: path.join(this.buildPath, "out/node/entry.js"),
|
||||
target: await this.target(),
|
||||
})
|
||||
|
||||
await Promise.all(["package.json", "yarn.lock"].map((fileName) => {
|
||||
return Promise.all([
|
||||
fs.remove(path.join(finalServerPath, fileName)),
|
||||
fs.remove(path.join(finalBuildPath, fileName)),
|
||||
]);
|
||||
}));
|
||||
});
|
||||
}
|
||||
bin.writeFiles(path.join(this.buildPath, "**"))
|
||||
|
||||
await this.task("Writing final package.json and product.json", () => {
|
||||
return Promise.all([
|
||||
fs.writeFile(path.join(finalBuildPath, "package.json"), JSON.stringify(packageJson, null, 2)),
|
||||
fs.writeFile(path.join(finalBuildPath, "product.json"), JSON.stringify(productJson, null, 2)),
|
||||
]);
|
||||
});
|
||||
await fs.mkdirp(this.binariesPath)
|
||||
|
||||
// Prevent needless cache changes.
|
||||
await this.task("Cleaning for smaller cache", () => {
|
||||
return Promise.all([
|
||||
fs.remove(serverPath),
|
||||
fs.remove(path.join(vscodeSourcePath, "out-vscode")),
|
||||
fs.remove(path.join(vscodeSourcePath, "out-vscode-min")),
|
||||
fs.remove(path.join(vscodeSourcePath, "out-build")),
|
||||
util.promisify(cp.exec)("git reset --hard", { cwd: vscodeSourcePath }).then(() => {
|
||||
return util.promisify(cp.exec)("git clean -fd", { cwd: vscodeSourcePath });
|
||||
}),
|
||||
]);
|
||||
});
|
||||
const binaryPath = path.join(this.binariesPath, binaryName)
|
||||
await fs.writeFile(binaryPath, await bin.build())
|
||||
await fs.chmod(binaryPath, "755")
|
||||
|
||||
// Prepend code to the target which enables finding files within the binary.
|
||||
const prependLoader = async (relativeFilePath: string): Promise<void> => {
|
||||
const filePath = path.join(finalBuildPath, relativeFilePath);
|
||||
const shim = `
|
||||
if (!global.NBIN_LOADED) {
|
||||
try {
|
||||
const nbin = require("nbin");
|
||||
nbin.shimNativeFs("${finalBuildPath}");
|
||||
global.NBIN_LOADED = true;
|
||||
const path = require("path");
|
||||
const rg = require("vscode-ripgrep");
|
||||
rg.binaryRgPath = rg.rgPath;
|
||||
rg.rgPath = path.join(require("os").tmpdir(), "code-server", path.basename(rg.binaryRgPath));
|
||||
} catch (error) { /* Not in the binary. */ }
|
||||
}
|
||||
`;
|
||||
await fs.writeFile(filePath, shim + (await fs.readFile(filePath, "utf8")));
|
||||
};
|
||||
this.log(`binary: ${binaryPath}`)
|
||||
}
|
||||
|
||||
await this.task("Prepending nbin loader", () => {
|
||||
return Promise.all([
|
||||
prependLoader("out/vs/server/main.js"),
|
||||
prependLoader("out/bootstrap-fork.js"),
|
||||
prependLoader("extensions/node_modules/typescript/lib/tsserver.js"),
|
||||
]);
|
||||
});
|
||||
/**
|
||||
* Package the binary into a release archive.
|
||||
*/
|
||||
private async package(binaryName: string): Promise<void> {
|
||||
const releasePath = path.join(this.rootPath, "release")
|
||||
const archivePath = path.join(releasePath, binaryName)
|
||||
|
||||
this.log(`Final build: ${finalBuildPath}`);
|
||||
}
|
||||
await fs.remove(archivePath)
|
||||
await fs.mkdirp(archivePath)
|
||||
|
||||
/**
|
||||
* Bundles the built code into a binary.
|
||||
*/
|
||||
private async binary(targetPath: string, binariesPath: string, binaryName: string): Promise<void> {
|
||||
const bin = new Binary({
|
||||
mainFile: path.join(targetPath, "out/vs/server/main.js"),
|
||||
target: await this.target(),
|
||||
});
|
||||
await fs.copyFile(path.join(this.binariesPath, binaryName), path.join(archivePath, "code-server"))
|
||||
await fs.copyFile(path.join(this.rootPath, "README.md"), path.join(archivePath, "README.md"))
|
||||
await fs.copyFile(path.join(this.vscodeSourcePath, "LICENSE.txt"), path.join(archivePath, "LICENSE.txt"))
|
||||
await fs.copyFile(
|
||||
path.join(this.vscodeSourcePath, "ThirdPartyNotices.txt"),
|
||||
path.join(archivePath, "ThirdPartyNotices.txt")
|
||||
)
|
||||
|
||||
bin.writeFiles(path.join(targetPath, "**"));
|
||||
if ((await this.target()) === "darwin") {
|
||||
await util.promisify(cp.exec)(`zip -r "${binaryName}.zip" "${binaryName}"`, { cwd: releasePath })
|
||||
this.log(`archive: ${archivePath}.zip`)
|
||||
} else {
|
||||
await util.promisify(cp.exec)(`tar -czf "${binaryName}.tar.gz" "${binaryName}"`, { cwd: releasePath })
|
||||
this.log(`archive: ${archivePath}.tar.gz`)
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdirp(binariesPath);
|
||||
private async watch(): Promise<void> {
|
||||
let server: cp.ChildProcess | undefined
|
||||
const restartServer = (): void => {
|
||||
if (server) {
|
||||
server.kill()
|
||||
}
|
||||
const s = cp.fork(path.join(this.rootPath, "out/node/entry.js"))
|
||||
console.log(`[server] spawned process ${s.pid}`)
|
||||
s.on("exit", () => console.log(`[server] process ${s.pid} exited`))
|
||||
server = s
|
||||
}
|
||||
|
||||
const binaryPath = path.join(binariesPath, binaryName);
|
||||
await fs.writeFile(binaryPath, await bin.build());
|
||||
await fs.chmod(binaryPath, "755");
|
||||
const vscode = cp.spawn("yarn", ["watch"], { cwd: this.vscodeSourcePath })
|
||||
const tsc = cp.spawn("tsc", ["--watch", "--pretty", "--preserveWatchOutput"], { cwd: this.rootPath })
|
||||
const bundler = this.createBundler()
|
||||
|
||||
this.log(`Binary: ${binaryPath}`);
|
||||
}
|
||||
const cleanup = (code?: number | null): void => {
|
||||
this.log("killing vs code watcher")
|
||||
vscode.removeAllListeners()
|
||||
vscode.kill()
|
||||
|
||||
/**
|
||||
* Package the binary into a release archive.
|
||||
*/
|
||||
private async package(vscodeSourcePath: string, binariesPath: string, binaryName: string): Promise<void> {
|
||||
const releasePath = path.join(this.outPath, "release");
|
||||
const archivePath = path.join(releasePath, binaryName);
|
||||
this.log("killing tsc")
|
||||
tsc.removeAllListeners()
|
||||
tsc.kill()
|
||||
|
||||
await fs.remove(archivePath);
|
||||
await fs.mkdirp(archivePath);
|
||||
if (server) {
|
||||
this.log("killing server")
|
||||
server.removeAllListeners()
|
||||
server.kill()
|
||||
}
|
||||
|
||||
await fs.copyFile(path.join(binariesPath, binaryName), path.join(archivePath, "code-server"));
|
||||
await fs.copyFile(path.join(this.rootPath, "README.md"), path.join(archivePath, "README.md"));
|
||||
await fs.copyFile(path.join(vscodeSourcePath, "LICENSE.txt"), path.join(archivePath, "LICENSE.txt"));
|
||||
await fs.copyFile(path.join(vscodeSourcePath, "ThirdPartyNotices.txt"), path.join(archivePath, "ThirdPartyNotices.txt"));
|
||||
this.log("killing bundler")
|
||||
process.exit(code || 0)
|
||||
}
|
||||
|
||||
if ((await this.target()) === "darwin") {
|
||||
await util.promisify(cp.exec)(`zip -r "${binaryName}.zip" "${binaryName}"`, { cwd: releasePath });
|
||||
this.log(`Archive: ${archivePath}.zip`);
|
||||
} else {
|
||||
await util.promisify(cp.exec)(`tar -czf "${binaryName}.tar.gz" "${binaryName}"`, { cwd: releasePath });
|
||||
this.log(`Archive: ${archivePath}.tar.gz`);
|
||||
}
|
||||
}
|
||||
process.on("SIGINT", () => cleanup())
|
||||
process.on("SIGTERM", () => cleanup())
|
||||
|
||||
vscode.on("exit", (code) => {
|
||||
this.log("vs code watcher terminated unexpectedly")
|
||||
cleanup(code)
|
||||
})
|
||||
tsc.on("exit", (code) => {
|
||||
this.log("tsc terminated unexpectedly")
|
||||
cleanup(code)
|
||||
})
|
||||
const bundle = bundler.bundle().catch(() => {
|
||||
this.log("parcel watcher terminated unexpectedly")
|
||||
cleanup(1)
|
||||
})
|
||||
bundler.on("buildEnd", () => {
|
||||
console.log("[parcel] bundled")
|
||||
})
|
||||
|
||||
vscode.stderr.on("data", (d) => process.stderr.write(d))
|
||||
tsc.stderr.on("data", (d) => process.stderr.write(d))
|
||||
|
||||
// From https://github.com/chalk/ansi-regex
|
||||
const pattern = [
|
||||
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
|
||||
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))",
|
||||
].join("|")
|
||||
const re = new RegExp(pattern, "g")
|
||||
|
||||
/**
|
||||
* Split stdout on newlines and strip ANSI codes.
|
||||
*/
|
||||
const onLine = (proc: cp.ChildProcess, callback: (strippedLine: string, originalLine: string) => void): void => {
|
||||
let buffer = ""
|
||||
if (!proc.stdout) {
|
||||
throw new Error("no stdout")
|
||||
}
|
||||
proc.stdout.setEncoding("utf8")
|
||||
proc.stdout.on("data", (d) => {
|
||||
const data = buffer + d
|
||||
const split = data.split("\n")
|
||||
const last = split.length - 1
|
||||
|
||||
for (let i = 0; i < last; ++i) {
|
||||
callback(split[i].replace(re, ""), split[i])
|
||||
}
|
||||
|
||||
// The last item will either be an empty string (the data ended with a
|
||||
// newline) or a partial line (did not end with a newline) and we must
|
||||
// wait to parse it until we get a full line.
|
||||
buffer = split[last]
|
||||
})
|
||||
}
|
||||
|
||||
let startingVscode = false
|
||||
onLine(vscode, (line, original) => {
|
||||
console.log("[vscode]", original)
|
||||
// Wait for watch-client since "Finished compilation" will appear multiple
|
||||
// times before the client starts building.
|
||||
if (!startingVscode && line.includes("Starting watch-client")) {
|
||||
startingVscode = true
|
||||
} else if (startingVscode && line.includes("Finished compilation") && process.env.AUTO_PATCH) {
|
||||
cp.exec("yarn patch:generate", { cwd: this.rootPath }, (error, _, stderr) => {
|
||||
if (error || stderr) {
|
||||
console.error(error ? error.message : stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onLine(tsc, (line, original) => {
|
||||
// tsc outputs blank lines; skip them.
|
||||
if (line !== "") {
|
||||
console.log("[tsc]", original)
|
||||
}
|
||||
if (line.includes("Watching for file changes")) {
|
||||
bundle.then(restartServer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private createBundler(out = "dist", commit?: string): Bundler {
|
||||
return new Bundler(path.join(this.rootPath, "src/browser/index.tsx"), {
|
||||
cache: true,
|
||||
cacheDir: path.join(this.rootPath, ".cache"),
|
||||
detailedReport: true,
|
||||
minify: !!process.env.MINIFY,
|
||||
hmr: false,
|
||||
logLevel: 1,
|
||||
outDir: path.join(this.rootPath, out),
|
||||
publicUrl: `/static-${commit}/dist`,
|
||||
target: "browser",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const builder = new Builder();
|
||||
builder.run(process.argv[2] as Task, process.argv.slice(3));
|
||||
const builder = new Builder()
|
||||
builder.run(process.argv[2] as Task)
|
||||
|
@ -8,46 +8,48 @@ set -eu
|
||||
|
||||
# Try restoring from each argument in turn until we get something.
|
||||
restore() {
|
||||
for branch in "$@" ; do
|
||||
if [ -n "$branch" ] ; then
|
||||
cache_path="https://codesrv-ci.cdr.sh/cache/$branch/$tar.tar.gz"
|
||||
if wget "$cache_path" ; then
|
||||
tar xzvf "$tar.tar.gz"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
for branch in "$@" ; do
|
||||
if [ -n "$branch" ] ; then
|
||||
cache_path="https://codesrv-ci.cdr.sh/cache/$branch/$tar.tar.gz"
|
||||
if wget "$cache_path" ; then
|
||||
tar xzvf "$tar.tar.gz"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# We need to cache the built-in extensions and Node modules. Everything inside
|
||||
# the cache-upload directory will be uploaded as-is to the code-server bucket.
|
||||
package() {
|
||||
mkdir -p "cache-upload/cache/$1"
|
||||
tar czfv "cache-upload/cache/$1/$tar.tar.gz" node_modules source yarn-cache
|
||||
mkdir -p "cache-upload/cache/$1"
|
||||
tar czfv "cache-upload/cache/$1/$tar.tar.gz" node_modules yarn-cache \
|
||||
lib/vscode/.build \
|
||||
lib/vscode/node_modules
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$(dirname "$0")/.."
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Get the branch for this build.
|
||||
branch=${DRONE_BRANCH:-${DRONE_SOURCE_BRANCH:-${DRONE_TAG:-}}}
|
||||
# Get the branch for this build.
|
||||
branch=${DRONE_BRANCH:-${DRONE_SOURCE_BRANCH:-${DRONE_TAG:-}}}
|
||||
|
||||
# The cache will be named based on the arch, platform, and libc.
|
||||
arch=$DRONE_STAGE_ARCH
|
||||
platform=${PLATFORM:-linux}
|
||||
case $DRONE_STAGE_NAME in
|
||||
*alpine*) libc=musl ;;
|
||||
* ) libc=glibc ;;
|
||||
esac
|
||||
# The cache will be named based on the arch, platform, and libc.
|
||||
arch=$DRONE_STAGE_ARCH
|
||||
platform=${PLATFORM:-linux}
|
||||
case $DRONE_STAGE_NAME in
|
||||
*alpine*) libc=musl ;;
|
||||
* ) libc=glibc ;;
|
||||
esac
|
||||
|
||||
tar="$platform-$arch-$libc"
|
||||
tar="$platform-$arch-$libc"
|
||||
|
||||
# The action is determined by the name of the step.
|
||||
case $DRONE_STEP_NAME in
|
||||
*restore*) restore "$branch" "$DRONE_REPO_BRANCH" ;;
|
||||
*rebuild*|*package*) package "$branch" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
# The action is determined by the name of the step.
|
||||
case $DRONE_STEP_NAME in
|
||||
*restore*) restore "$branch" "$DRONE_REPO_BRANCH" ;;
|
||||
*rebuild*|*package*) package "$branch" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
101
scripts/ci.bash
101
scripts/ci.bash
@ -3,71 +3,62 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
function target() {
|
||||
local os=$(uname | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$os" == "linux" ]]; then
|
||||
# Using the same strategy to detect Alpine as build.ts.
|
||||
local ldd_output=$(ldd --version 2>&1 || true)
|
||||
if echo "$ldd_output" | grep -iq musl; then
|
||||
os="alpine"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "${os}-$(uname -m)"
|
||||
}
|
||||
|
||||
function main() {
|
||||
cd "$(dirname "${0}")/.."
|
||||
cd "$(dirname "${0}")/.."
|
||||
|
||||
# Get the version information. If a specific version wasn't set, generate it
|
||||
# from the tag and VS Code version.
|
||||
local vscode_version=${VSCODE_VERSION:-1.41.1}
|
||||
local code_server_version=${VERSION:-${TRAVIS_TAG:-${DRONE_TAG:-daily}}}
|
||||
local code_server_version=${VERSION:-${TRAVIS_TAG:-${DRONE_TAG:-}}}
|
||||
if [[ -z $code_server_version ]] ; then
|
||||
code_server_version=$(grep version ./package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[:space:]')
|
||||
fi
|
||||
export VERSION=$code_server_version
|
||||
|
||||
# Remove everything that isn't the current VS Code source for caching
|
||||
# (otherwise the cache will contain old versions).
|
||||
if [[ -d "source/vscode-$vscode_version-source" ]] ; then
|
||||
mv "source/vscode-$vscode_version-source" "vscode-$vscode_version-source"
|
||||
fi
|
||||
rm -rf source/vscode-*-source
|
||||
if [[ -d "vscode-$vscode_version-source" ]] ; then
|
||||
mv "vscode-$vscode_version-source" "source/vscode-$vscode_version-source"
|
||||
fi
|
||||
YARN_CACHE_FOLDER="$(pwd)/yarn-cache"
|
||||
export YARN_CACHE_FOLDER
|
||||
|
||||
YARN_CACHE_FOLDER="$(pwd)/yarn-cache"
|
||||
export YARN_CACHE_FOLDER
|
||||
# Always minify and package on tags since that's when releases are pushed.
|
||||
if [[ -n ${DRONE_TAG:-} || -n ${TRAVIS_TAG:-} ]] ; then
|
||||
export MINIFY="true"
|
||||
export PACKAGE="true"
|
||||
fi
|
||||
|
||||
# Always minify and package on tags since that's when releases are pushed.
|
||||
if [[ -n ${DRONE_TAG:-} || -n ${TRAVIS_TAG:-} ]] ; then
|
||||
export MINIFY="true"
|
||||
export PACKAGE="true"
|
||||
fi
|
||||
if [[ -z ${SKIP_YARN:-} ]] ; then
|
||||
yarn
|
||||
fi
|
||||
|
||||
function run-yarn() {
|
||||
yarn "$1" "$vscode_version" "$code_server_version"
|
||||
}
|
||||
yarn build
|
||||
yarn binary
|
||||
if [[ -n ${PACKAGE:-} ]] ; then
|
||||
yarn package
|
||||
fi
|
||||
|
||||
run-yarn build
|
||||
run-yarn binary
|
||||
if [[ -n ${PACKAGE:-} ]] ; then
|
||||
run-yarn package
|
||||
fi
|
||||
cd binaries
|
||||
|
||||
# In this case provide a plainly named "code-server" binary.
|
||||
if [[ -n ${BINARY:-} ]] ; then
|
||||
mv binaries/code-server*-vsc* binaries/code-server
|
||||
fi
|
||||
if [[ -n ${STRIP_BIN_TARGET:-} ]] ; then
|
||||
# In this case provide plainly named binaries.
|
||||
for binary in code-server* ; do
|
||||
echo "Moving $binary to code-server"
|
||||
mv "$binary" code-server
|
||||
done
|
||||
elif [[ -n ${DRONE_TAG:-} || -n ${TRAVIS_TAG:-} ]] ; then
|
||||
# Prepare directory for uploading binaries on release.
|
||||
for binary in code-server* ; do
|
||||
mkdir -p "../binary-upload"
|
||||
|
||||
# Prepare GCS bucket directory on release.
|
||||
if [[ -n ${DRONE_TAG:-} || -n ${TRAVIS_TAG:-} ]] ; then
|
||||
local gcp_dir="gcs_bucket/releases/$code_server_version/$(target)"
|
||||
local prefix="code-server-$code_server_version-"
|
||||
local target="${binary#$prefix}"
|
||||
if [[ $target == "linux-x86_64" ]] ; then
|
||||
echo "Copying $binary to ../binary-upload/latest-linux"
|
||||
cp "$binary" "../binary-upload/latest-linux"
|
||||
fi
|
||||
|
||||
mkdir -p "$gcp_dir"
|
||||
mv binaries/code-server*-vsc* "$gcp_dir"
|
||||
if [[ "$(target)" == "linux-x86_64" ]] ; then
|
||||
mv binaries/code-server*-vsc* "gcs_bucket/latest-linux"
|
||||
fi
|
||||
fi
|
||||
local gcp_dir
|
||||
gcp_dir="../binary-upload/releases/$code_server_version/$target"
|
||||
mkdir -p "$gcp_dir"
|
||||
|
||||
echo "Copying $binary to $gcp_dir/code-server"
|
||||
cp "$binary" "$gcp_dir/code-server"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
@ -2,24 +2,24 @@
|
||||
FROM ubuntu:18.04
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
openssl \
|
||||
net-tools \
|
||||
git \
|
||||
locales \
|
||||
sudo \
|
||||
dumb-init \
|
||||
vim \
|
||||
curl \
|
||||
wget
|
||||
openssl \
|
||||
net-tools \
|
||||
git \
|
||||
locales \
|
||||
sudo \
|
||||
dumb-init \
|
||||
vim \
|
||||
curl \
|
||||
wget
|
||||
|
||||
RUN locale-gen en_US.UTF-8
|
||||
# We cannot use update-locale because docker will not use the env variables
|
||||
# configured in /etc/default/locale so we need to set it manually.
|
||||
ENV LC_ALL=en_US.UTF-8 \
|
||||
SHELL=/bin/bash
|
||||
SHELL=/bin/bash
|
||||
|
||||
RUN adduser --gecos '' --disabled-password coder && \
|
||||
echo "coder ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/nopasswd
|
||||
echo "coder ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/nopasswd
|
||||
|
||||
USER coder
|
||||
# Create first so these directories will be owned by coder instead of root
|
||||
|
11
scripts/lint.sh
Executable file
11
scripts/lint.sh
Executable file
@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
# lint.sh -- Lint CSS and JS files.
|
||||
|
||||
set -eu
|
||||
|
||||
main() {
|
||||
yarn lint:css "$@"
|
||||
yarn lint:js "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
@ -1,71 +0,0 @@
|
||||
// This must be ran from VS Code's root.
|
||||
const gulp = require("gulp");
|
||||
const path = require("path");
|
||||
const _ = require("underscore");
|
||||
const buildfile = require("./src/buildfile");
|
||||
const common = require("./build/lib/optimize");
|
||||
const util = require("./build/lib/util");
|
||||
const deps = require("./build/dependencies");
|
||||
|
||||
const vscodeEntryPoints = _.flatten([
|
||||
buildfile.entrypoint("vs/workbench/workbench.web.api"),
|
||||
buildfile.entrypoint("vs/server/src/node/cli"),
|
||||
buildfile.base,
|
||||
buildfile.workbenchWeb,
|
||||
buildfile.workerExtensionHost,
|
||||
buildfile.keyboardMaps,
|
||||
buildfile.entrypoint('vs/platform/files/node/watcher/unix/watcherApp', ["vs/css", "vs/nls"]),
|
||||
buildfile.entrypoint('vs/platform/files/node/watcher/nsfw/watcherApp', ["vs/css", "vs/nls"]),
|
||||
buildfile.entrypoint('vs/workbench/services/extensions/node/extensionHostProcess', ["vs/css", "vs/nls"]),
|
||||
]);
|
||||
|
||||
const vscodeResources = [
|
||||
"out-build/vs/server/main.js",
|
||||
"out-build/vs/server/src/node/uriTransformer.js",
|
||||
"!out-build/vs/server/doc/**",
|
||||
"out-build/vs/server/src/media/*",
|
||||
"out-build/vs/workbench/services/extensions/worker/extensionHostWorkerMain.js",
|
||||
"out-build/bootstrap.js",
|
||||
"out-build/bootstrap-fork.js",
|
||||
"out-build/bootstrap-amd.js",
|
||||
"out-build/paths.js",
|
||||
'out-build/vs/**/*.{svg,png,html}',
|
||||
"!out-build/vs/code/browser/workbench/*.html",
|
||||
'!out-build/vs/code/electron-browser/**',
|
||||
"out-build/vs/base/common/performance.js",
|
||||
"out-build/vs/base/node/languagePacks.js",
|
||||
"out-build/vs/base/browser/ui/octiconLabel/octicons/**",
|
||||
"out-build/vs/base/browser/ui/codiconLabel/codicon/**",
|
||||
"out-build/vs/workbench/browser/media/*-theme.css",
|
||||
"out-build/vs/workbench/contrib/debug/**/*.json",
|
||||
"out-build/vs/workbench/contrib/externalTerminal/**/*.scpt",
|
||||
"out-build/vs/workbench/contrib/webview/browser/pre/*.js",
|
||||
"out-build/vs/**/markdown.css",
|
||||
"out-build/vs/workbench/contrib/tasks/**/*.json",
|
||||
"out-build/vs/platform/files/**/*.md",
|
||||
"!**/test/**"
|
||||
];
|
||||
|
||||
const rootPath = __dirname;
|
||||
const nodeModules = ["electron", "original-fs"]
|
||||
.concat(_.uniq(deps.getProductionDependencies(rootPath).map((d) => d.name)))
|
||||
.concat(_.uniq(deps.getProductionDependencies(path.join(rootPath, "src/vs/server")).map((d) => d.name)))
|
||||
.concat(Object.keys(process.binding("natives")).filter((n) => !/^_|\//.test(n)));
|
||||
|
||||
gulp.task("optimize", gulp.series(
|
||||
util.rimraf("out-vscode"),
|
||||
common.optimizeTask({
|
||||
src: "out-build",
|
||||
entryPoints: vscodeEntryPoints,
|
||||
resources: vscodeResources,
|
||||
loaderConfig: common.loaderConfig(nodeModules),
|
||||
out: "out-vscode",
|
||||
inlineAmdImages: true,
|
||||
bundleInfo: undefined
|
||||
}),
|
||||
));
|
||||
|
||||
gulp.task("minify", gulp.series(
|
||||
util.rimraf("out-vscode-min"),
|
||||
common.minifyTask("out-vscode")
|
||||
));
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "code-server",
|
||||
"main": "out/vs/server/main",
|
||||
"desktopName": "code-server-url-handler.desktop"
|
||||
}
|
10
scripts/postinstall.sh
Executable file
10
scripts/postinstall.sh
Executable file
@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env sh
|
||||
# postinstall.sh - Does nothing at the moment.
|
||||
|
||||
set -eu
|
||||
|
||||
main() {
|
||||
cd "$(dirname "${0}")/.."
|
||||
}
|
||||
|
||||
main "$@"
|
21
scripts/preinstall.sh
Executable file
21
scripts/preinstall.sh
Executable file
@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env sh
|
||||
# preinstall.sh -- Prepare VS Code.
|
||||
|
||||
set -eu
|
||||
|
||||
main() {
|
||||
cd "$(dirname "${0}")/.."
|
||||
|
||||
# Ensure submodules are cloned and up to date.
|
||||
git submodule update --init
|
||||
|
||||
# Try patching but don't worry too much if it fails. It's possible VS Code has
|
||||
# already been patched.
|
||||
yarn patch:apply || echo "Unable to patch; assuming already patched"
|
||||
|
||||
# Install VS Code dependencies.
|
||||
cd ./lib/vscode
|
||||
yarn
|
||||
}
|
||||
|
||||
main "$@"
|
@ -1,21 +0,0 @@
|
||||
{
|
||||
"nameShort": "code-server",
|
||||
"nameLong": "code-server",
|
||||
"applicationName": "code-server",
|
||||
"dataFolderName": ".code-server",
|
||||
"win32MutexName": "codeserver",
|
||||
"win32DirName": "Code Server",
|
||||
"win32NameVersion": "Code Server",
|
||||
"win32RegValueName": "CodeServer",
|
||||
"win32AppId": "",
|
||||
"win32x64AppId": "",
|
||||
"win32UserAppId": "",
|
||||
"win32x64UserAppId": "",
|
||||
"win32AppUserModelId": "CodeServer",
|
||||
"win32ShellNameShort": "C&ode Server",
|
||||
"darwinBundleIdentifier": "com.code.server",
|
||||
"linuxIconName": "com.code.server",
|
||||
"urlProtocol": "code-server",
|
||||
"updateUrl": "https://api.github.com/repos/cdr/code-server/releases",
|
||||
"quality": "latest"
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
# test.sh -- Simple test for CI.
|
||||
# We'll have more involved tests eventually. This just ensures the binary has
|
||||
# been built and runs.
|
||||
|
||||
set -eu
|
||||
|
||||
main() {
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
version=$(./binaries/code-server* --version | head -1)
|
||||
echo "Got '$version' for the version"
|
||||
case $version in
|
||||
*-vsc1.41.1) exit 0 ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
@ -1,17 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitAny": true,
|
||||
"experimentalDecorators": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"target": "esnext"
|
||||
}
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./out",
|
||||
"declaration": true,
|
||||
"experimentalDecorators": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts"
|
||||
]
|
||||
}
|
||||
|
2787
scripts/vscode.patch
2787
scripts/vscode.patch
File diff suppressed because it is too large
Load Diff
@ -1,369 +1,82 @@
|
||||
import * as vscode from "vscode";
|
||||
import { CoderApi, VSCodeApi } from "../../typings/api";
|
||||
import { createCSSRule } from "vs/base/browser/dom";
|
||||
import { Emitter, Event } from "vs/base/common/event";
|
||||
import { IDisposable } from "vs/base/common/lifecycle";
|
||||
import { URI } from "vs/base/common/uri";
|
||||
import { generateUuid } from "vs/base/common/uuid";
|
||||
import { localize } from "vs/nls";
|
||||
import { SyncActionDescriptor } from "vs/platform/actions/common/actions";
|
||||
import { CommandsRegistry, ICommandService } from "vs/platform/commands/common/commands";
|
||||
import { IConfigurationService } from "vs/platform/configuration/common/configuration";
|
||||
import { IContextMenuService } from "vs/platform/contextview/browser/contextView";
|
||||
import { FileDeleteOptions, FileOpenOptions, FileOverwriteOptions, FileSystemProviderCapabilities, FileType, FileWriteOptions, IFileChange, IFileService, IFileSystemProvider, IStat, IWatchOptions } from "vs/platform/files/common/files";
|
||||
import { IInstantiationService, ServiceIdentifier } from "vs/platform/instantiation/common/instantiation";
|
||||
import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection";
|
||||
import { INotificationService } from "vs/platform/notification/common/notification";
|
||||
import { Registry } from "vs/platform/registry/common/platform";
|
||||
import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from "vs/workbench/services/statusbar/common/statusbar";
|
||||
import { IStorageService } from "vs/platform/storage/common/storage";
|
||||
import { ITelemetryService } from "vs/platform/telemetry/common/telemetry";
|
||||
import { IThemeService } from "vs/platform/theme/common/themeService";
|
||||
import { IWorkspaceContextService } from "vs/platform/workspace/common/workspace";
|
||||
import * as extHostTypes from "vs/workbench/api/common/extHostTypes";
|
||||
import { CustomTreeView, CustomTreeViewPane } from "vs/workbench/browser/parts/views/customView";
|
||||
import { ViewContainerViewlet } from "vs/workbench/browser/parts/views/viewsViewlet";
|
||||
import { Extensions as ViewletExtensions, ShowViewletAction, ViewletDescriptor, ViewletRegistry } from "vs/workbench/browser/viewlet";
|
||||
import { Extensions as ActionExtensions, IWorkbenchActionRegistry } from "vs/workbench/common/actions";
|
||||
import { Extensions as ViewsExtensions, ITreeItem, ITreeViewDataProvider, ITreeViewDescriptor, IViewContainersRegistry, IViewsRegistry, TreeItemCollapsibleState } from "vs/workbench/common/views";
|
||||
import { IEditorGroupsService } from "vs/workbench/services/editor/common/editorGroupsService";
|
||||
import { IEditorService } from "vs/workbench/services/editor/common/editorService";
|
||||
import { IExtensionService } from "vs/workbench/services/extensions/common/extensions";
|
||||
import { IWorkbenchLayoutService } from "vs/workbench/services/layout/browser/layoutService";
|
||||
import { IViewletService } from "vs/workbench/services/viewlet/browser/viewlet";
|
||||
import { Application, ApplicationsResponse, CreateSessionResponse, FilesResponse, RecentResponse } from "../common/api"
|
||||
import { ApiEndpoint, HttpCode, HttpError } from "../common/http"
|
||||
|
||||
export interface AuthBody {
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side implementation of VS Code's API.
|
||||
* TODO: Views aren't quite working.
|
||||
* TODO: Implement menu items for views (for item actions).
|
||||
* TODO: File system provider doesn't work.
|
||||
* Set authenticated status.
|
||||
*/
|
||||
export const vscodeApi = (serviceCollection: ServiceCollection): VSCodeApi => {
|
||||
const getService = <T>(id: ServiceIdentifier<T>): T => serviceCollection.get<T>(id) as T;
|
||||
const commandService = getService(ICommandService);
|
||||
const notificationService = getService(INotificationService);
|
||||
const fileService = getService(IFileService);
|
||||
const viewsRegistry = Registry.as<IViewsRegistry>(ViewsExtensions.ViewsRegistry);
|
||||
const statusbarService = getService(IStatusbarService);
|
||||
|
||||
// It would be nice to just export what VS Code creates but it looks to me
|
||||
// that it assumes it's running in the extension host and wouldn't work here.
|
||||
// It is probably possible to create an extension host that runs in the
|
||||
// browser's main thread, but I'm not sure how much jank that would require.
|
||||
// We could have a web worker host but we want DOM access.
|
||||
return {
|
||||
EventEmitter: <any>Emitter, // It can take T so T | undefined should work.
|
||||
FileSystemError: extHostTypes.FileSystemError,
|
||||
FileType,
|
||||
StatusBarAlignment: extHostTypes.StatusBarAlignment,
|
||||
ThemeColor: extHostTypes.ThemeColor,
|
||||
TreeItemCollapsibleState: extHostTypes.TreeItemCollapsibleState,
|
||||
Uri: URI,
|
||||
commands: {
|
||||
executeCommand: <T = any>(commandId: string, ...args: any[]): Promise<T | undefined> => {
|
||||
return commandService.executeCommand(commandId, ...args);
|
||||
},
|
||||
registerCommand: (id: string, command: (...args: any[]) => any): IDisposable => {
|
||||
return CommandsRegistry.registerCommand(id, command);
|
||||
},
|
||||
},
|
||||
window: {
|
||||
createStatusBarItem(alignmentOrOptions?: extHostTypes.StatusBarAlignment | vscode.window.StatusBarItemOptions, priority?: number): StatusBarEntry {
|
||||
return new StatusBarEntry(statusbarService, alignmentOrOptions, priority);
|
||||
},
|
||||
registerTreeDataProvider: <T>(id: string, dataProvider: vscode.TreeDataProvider<T>): IDisposable => {
|
||||
const tree = new TreeViewDataProvider(dataProvider);
|
||||
const view = viewsRegistry.getView(id);
|
||||
(view as ITreeViewDescriptor).treeView.dataProvider = tree;
|
||||
return {
|
||||
dispose: () => tree.dispose(),
|
||||
};
|
||||
},
|
||||
showErrorMessage: async (message: string): Promise<string | undefined> => {
|
||||
notificationService.error(message);
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
registerFileSystemProvider: (scheme: string, provider: vscode.FileSystemProvider): IDisposable => {
|
||||
return fileService.registerProvider(scheme, new FileSystemProvider(provider));
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
export function setAuthed(authed: boolean): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).setAuthed(authed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Coder API. This should only provide functionality that can't be made
|
||||
* available through the VS Code API.
|
||||
* Try making a request. Throw an error if the request is anything except OK.
|
||||
* Also set authed to false if the request returns unauthorized.
|
||||
*/
|
||||
export const coderApi = (serviceCollection: ServiceCollection): CoderApi => {
|
||||
const getService = <T>(id: ServiceIdentifier<T>): T => serviceCollection.get<T>(id) as T;
|
||||
return {
|
||||
registerView: (viewId, viewName, containerId, containerName, icon): void => {
|
||||
const cssClass = `extensionViewlet-${containerId}`;
|
||||
const id = `workbench.view.extension.${containerId}`;
|
||||
class CustomViewlet extends ViewContainerViewlet {
|
||||
public constructor(
|
||||
@IConfigurationService configurationService: IConfigurationService,
|
||||
@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,
|
||||
@ITelemetryService telemetryService: ITelemetryService,
|
||||
@IWorkspaceContextService contextService: IWorkspaceContextService,
|
||||
@IStorageService storageService: IStorageService,
|
||||
@IEditorService _editorService: IEditorService,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@IContextMenuService contextMenuService: IContextMenuService,
|
||||
@IExtensionService extensionService: IExtensionService,
|
||||
) {
|
||||
super(id, `${id}.state`, true, configurationService, layoutService, telemetryService, storageService, instantiationService, themeService, contextMenuService, extensionService, contextService);
|
||||
}
|
||||
}
|
||||
|
||||
Registry.as<ViewletRegistry>(ViewletExtensions.Viewlets).registerViewlet(
|
||||
ViewletDescriptor.create(CustomViewlet as any, id, containerName, cssClass, undefined, URI.parse(icon)),
|
||||
);
|
||||
|
||||
Registry.as<IWorkbenchActionRegistry>(ActionExtensions.WorkbenchActions).registerWorkbenchAction(
|
||||
SyncActionDescriptor.create(OpenCustomViewletAction as any, id, localize("showViewlet", "Show {0}", containerName)),
|
||||
"View: Show {0}",
|
||||
localize("view", "View"),
|
||||
);
|
||||
|
||||
// Generate CSS to show the icon in the activity bar.
|
||||
const iconClass = `.monaco-workbench .activitybar .monaco-action-bar .action-label.${cssClass}`;
|
||||
createCSSRule(iconClass, `-webkit-mask: url('${icon}') no-repeat 50% 50%`);
|
||||
|
||||
const container = Registry.as<IViewContainersRegistry>(ViewsExtensions.ViewContainersRegistry).registerViewContainer(containerId);
|
||||
Registry.as<IViewsRegistry>(ViewsExtensions.ViewsRegistry).registerViews([{
|
||||
id: viewId,
|
||||
name: viewName,
|
||||
ctorDescriptor: { ctor: CustomTreeViewPane },
|
||||
treeView: getService(IInstantiationService).createInstance(CustomTreeView as any, viewId, container),
|
||||
}] as ITreeViewDescriptor[], container);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
class OpenCustomViewletAction extends ShowViewletAction {
|
||||
public constructor(
|
||||
id: string, label: string,
|
||||
@IViewletService viewletService: IViewletService,
|
||||
@IEditorGroupsService editorGroupService: IEditorGroupsService,
|
||||
@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,
|
||||
) {
|
||||
super(id, label, id, viewletService, editorGroupService, layoutService);
|
||||
}
|
||||
const tryRequest = async (endpoint: string, options?: RequestInit): Promise<Response> => {
|
||||
const response = await fetch("/api" + endpoint + "/", options)
|
||||
if (response.status === HttpCode.Unauthorized) {
|
||||
setAuthed(false)
|
||||
}
|
||||
if (response.status !== HttpCode.Ok) {
|
||||
const text = await response.text()
|
||||
throw new HttpError(text || response.statusText || "unknown error", response.status)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
class FileSystemProvider implements IFileSystemProvider {
|
||||
private readonly _onDidChange = new Emitter<IFileChange[]>();
|
||||
|
||||
public readonly onDidChangeFile: Event<IFileChange[]> = this._onDidChange.event;
|
||||
|
||||
public readonly capabilities: FileSystemProviderCapabilities;
|
||||
public readonly onDidChangeCapabilities: Event<void> = Event.None;
|
||||
|
||||
public constructor(private readonly provider: vscode.FileSystemProvider) {
|
||||
this.capabilities = FileSystemProviderCapabilities.Readonly;
|
||||
}
|
||||
|
||||
public watch(resource: URI, opts: IWatchOptions): IDisposable {
|
||||
return this.provider.watch(resource, opts);
|
||||
}
|
||||
|
||||
public async stat(resource: URI): Promise<IStat> {
|
||||
return this.provider.stat(resource);
|
||||
}
|
||||
|
||||
public async readFile(resource: URI): Promise<Uint8Array> {
|
||||
return this.provider.readFile(resource);
|
||||
}
|
||||
|
||||
public async writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
|
||||
return this.provider.writeFile(resource, content, opts);
|
||||
}
|
||||
|
||||
public async delete(resource: URI, opts: FileDeleteOptions): Promise<void> {
|
||||
return this.provider.delete(resource, opts);
|
||||
}
|
||||
|
||||
public mkdir(_resource: URI): Promise<void> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
public async readdir(resource: URI): Promise<[string, FileType][]> {
|
||||
return this.provider.readDirectory(resource);
|
||||
}
|
||||
|
||||
public async rename(resource: URI, target: URI, opts: FileOverwriteOptions): Promise<void> {
|
||||
return this.provider.rename(resource, target, opts);
|
||||
}
|
||||
|
||||
public async copy(resource: URI, target: URI, opts: FileOverwriteOptions): Promise<void> {
|
||||
return this.provider.copy!(resource, target, opts);
|
||||
}
|
||||
|
||||
public open(_resource: URI, _opts: FileOpenOptions): Promise<number> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
public close(_fd: number): Promise<void> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
public read(_fd: number, _pos: number, _data: Uint8Array, _offset: number, _length: number): Promise<number> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
public write(_fd: number, _pos: number, _data: Uint8Array, _offset: number, _length: number): Promise<number> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
/**
|
||||
* Try authenticating.
|
||||
*/
|
||||
export const authenticate = async (body?: AuthBody): Promise<void> => {
|
||||
let formBody: URLSearchParams | undefined
|
||||
if (body) {
|
||||
formBody = new URLSearchParams()
|
||||
formBody.append("password", body.password)
|
||||
}
|
||||
const response = await tryRequest(ApiEndpoint.login, {
|
||||
method: "POST",
|
||||
body: formBody,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
},
|
||||
})
|
||||
const json = await response.json()
|
||||
if (json && json.success) {
|
||||
setAuthed(true)
|
||||
}
|
||||
}
|
||||
|
||||
class TreeViewDataProvider<T> implements ITreeViewDataProvider {
|
||||
private readonly root = Symbol("root");
|
||||
private readonly values = new Map<string, T>();
|
||||
private readonly children = new Map<T | Symbol, ITreeItem[]>();
|
||||
|
||||
public constructor(private readonly provider: vscode.TreeDataProvider<T>) {}
|
||||
|
||||
public async getChildren(item?: ITreeItem): Promise<ITreeItem[]> {
|
||||
const value = item && this.itemToValue(item);
|
||||
const children = await Promise.all(
|
||||
(await this.provider.getChildren(value) || [])
|
||||
.map(async (childValue) => {
|
||||
const treeItem = await this.provider.getTreeItem(childValue);
|
||||
const handle = this.createHandle(treeItem);
|
||||
this.values.set(handle, childValue);
|
||||
return {
|
||||
handle,
|
||||
collapsibleState: TreeItemCollapsibleState.Collapsed,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
this.clear(value || this.root, item);
|
||||
this.children.set(value || this.root, children);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
private itemToValue(item: ITreeItem): T {
|
||||
if (!this.values.has(item.handle)) {
|
||||
throw new Error(`No element found with handle ${item.handle}`);
|
||||
}
|
||||
return this.values.get(item.handle)!;
|
||||
}
|
||||
|
||||
private clear(value: T | Symbol, item?: ITreeItem): void {
|
||||
if (this.children.has(value)) {
|
||||
this.children.get(value)!.map((c) => this.clear(this.itemToValue(c), c));
|
||||
this.children.delete(value);
|
||||
}
|
||||
if (item) {
|
||||
this.values.delete(item.handle);
|
||||
}
|
||||
}
|
||||
|
||||
private createHandle(item: vscode.TreeItem): string {
|
||||
return item.id
|
||||
? `coder-tree-item-id/${item.id}`
|
||||
: `coder-tree-item-uuid/${generateUuid()}`;
|
||||
}
|
||||
export const getFiles = async (): Promise<FilesResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.files)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
interface IStatusBarEntry extends IStatusbarEntry {
|
||||
alignment: StatusbarAlignment;
|
||||
priority?: number;
|
||||
export const getRecent = async (): Promise<RecentResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.recent)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
class StatusBarEntry implements vscode.StatusBarItem {
|
||||
private static ID = 0;
|
||||
|
||||
private _id: number;
|
||||
private entry: IStatusBarEntry;
|
||||
private visible?: boolean;
|
||||
private disposed?: boolean;
|
||||
private statusId: string;
|
||||
private statusName: string;
|
||||
private accessor?: IStatusbarEntryAccessor;
|
||||
private timeout: any;
|
||||
|
||||
constructor(private readonly statusbarService: IStatusbarService, alignmentOrOptions?: extHostTypes.StatusBarAlignment | vscode.window.StatusBarItemOptions, priority?: number) {
|
||||
this._id = StatusBarEntry.ID--;
|
||||
if (alignmentOrOptions && typeof alignmentOrOptions !== "number") {
|
||||
this.statusId = alignmentOrOptions.id;
|
||||
this.statusName = alignmentOrOptions.name;
|
||||
this.entry = {
|
||||
alignment: alignmentOrOptions.alignment === extHostTypes.StatusBarAlignment.Right
|
||||
? StatusbarAlignment.RIGHT : StatusbarAlignment.LEFT,
|
||||
priority,
|
||||
text: "",
|
||||
};
|
||||
} else {
|
||||
this.statusId = "web-api";
|
||||
this.statusName = "Web API";
|
||||
this.entry = {
|
||||
alignment: alignmentOrOptions === extHostTypes.StatusBarAlignment.Right
|
||||
? StatusbarAlignment.RIGHT : StatusbarAlignment.LEFT,
|
||||
priority,
|
||||
text: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public get alignment(): extHostTypes.StatusBarAlignment {
|
||||
return this.entry.alignment === StatusbarAlignment.RIGHT
|
||||
? extHostTypes.StatusBarAlignment.Right : extHostTypes.StatusBarAlignment.Left;
|
||||
}
|
||||
|
||||
public get id(): number { return this._id; }
|
||||
public get priority(): number | undefined { return this.entry.priority; }
|
||||
public get text(): string { return this.entry.text; }
|
||||
public get tooltip(): string | undefined { return this.entry.tooltip; }
|
||||
public get color(): string | extHostTypes.ThemeColor | undefined { return this.entry.color; }
|
||||
public get command(): string | undefined { return this.entry.command; }
|
||||
|
||||
public set text(text: string) { this.update({ text }); }
|
||||
public set tooltip(tooltip: string | undefined) { this.update({ tooltip }); }
|
||||
public set color(color: string | extHostTypes.ThemeColor | undefined) { this.update({ color }); }
|
||||
public set command(command: string | undefined) { this.update({ command }); }
|
||||
|
||||
public show(): void {
|
||||
this.visible = true;
|
||||
this.update();
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
clearTimeout(this.timeout);
|
||||
this.visible = false;
|
||||
if (this.accessor) {
|
||||
this.accessor.dispose();
|
||||
this.accessor = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private update(values?: Partial<IStatusBarEntry>): void {
|
||||
this.entry = { ...this.entry, ...values };
|
||||
if (this.disposed || !this.visible) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = setTimeout(() => {
|
||||
if (!this.accessor) {
|
||||
this.accessor = this.statusbarService.addEntry(this.entry, this.statusId, this.statusName, this.entry.alignment, this.priority);
|
||||
} else {
|
||||
this.accessor.update(this.entry);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.hide();
|
||||
this.disposed = true;
|
||||
}
|
||||
export const getApplications = async (): Promise<ApplicationsResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.applications)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const getSession = async (app: Application): Promise<CreateSessionResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.session, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(app),
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const killSession = async (app: Application): Promise<Response> => {
|
||||
return tryRequest(ApiEndpoint.session, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(app),
|
||||
})
|
||||
}
|
||||
|
18
src/browser/app.css
Normal file
18
src/browser/app.css
Normal file
@ -0,0 +1,18 @@
|
||||
html,
|
||||
body,
|
||||
#root,
|
||||
iframe {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #272727;
|
||||
margin: 0;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
37
src/browser/app.tsx
Normal file
37
src/browser/app.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { Application } from "../common/api"
|
||||
import { Route, Switch } from "react-router-dom"
|
||||
import { HttpError } from "../common/http"
|
||||
import { Modal } from "./components/modal"
|
||||
import { getOptions } from "../common/util"
|
||||
|
||||
const App: React.FunctionComponent = () => {
|
||||
const [authed, setAuthed] = React.useState<boolean>(false)
|
||||
const [app, setApp] = React.useState<Application>()
|
||||
const [error, setError] = React.useState<HttpError | Error | string>()
|
||||
|
||||
React.useEffect(() => {
|
||||
getOptions()
|
||||
}, [])
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).setAuthed = setAuthed
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch>
|
||||
<Route path="/vscode" render={(): React.ReactElement => <iframe id="iframe" src="/vscode-embed"></iframe>} />
|
||||
<Route
|
||||
path="/"
|
||||
render={(): React.ReactElement => (
|
||||
<Modal app={app} setApp={setApp} authed={authed} error={error} setError={setError} />
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
@ -1,133 +0,0 @@
|
||||
import { Emitter } from "vs/base/common/event";
|
||||
import { URI } from "vs/base/common/uri";
|
||||
import { localize } from "vs/nls";
|
||||
import { Extensions, IConfigurationRegistry } from "vs/platform/configuration/common/configurationRegistry";
|
||||
import { registerSingleton } from "vs/platform/instantiation/common/extensions";
|
||||
import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection";
|
||||
import { ILocalizationsService } from "vs/platform/localizations/common/localizations";
|
||||
import { INotificationService, Severity } from "vs/platform/notification/common/notification";
|
||||
import { Registry } from "vs/platform/registry/common/platform";
|
||||
import { PersistentConnectionEventType } from "vs/platform/remote/common/remoteAgentConnection";
|
||||
import { ITelemetryService } from "vs/platform/telemetry/common/telemetry";
|
||||
import { coderApi, vscodeApi } from "vs/server/src/browser/api";
|
||||
import { INodeProxyService, NodeProxyChannelClient } from "vs/server/src/common/nodeProxy";
|
||||
import { TelemetryChannelClient } from "vs/server/src/common/telemetry";
|
||||
import { split } from "vs/server/src/common/util";
|
||||
import "vs/workbench/contrib/localizations/browser/localizations.contribution";
|
||||
import { LocalizationsService } from "vs/workbench/services/localizations/electron-browser/localizationsService";
|
||||
import { IRemoteAgentService } from "vs/workbench/services/remote/common/remoteAgentService";
|
||||
|
||||
class TelemetryService extends TelemetryChannelClient {
|
||||
public constructor(
|
||||
@IRemoteAgentService remoteAgentService: IRemoteAgentService,
|
||||
) {
|
||||
super(remoteAgentService.getConnection()!.getChannel("telemetry"));
|
||||
}
|
||||
}
|
||||
|
||||
const TELEMETRY_SECTION_ID = "telemetry";
|
||||
|
||||
Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfiguration({
|
||||
"id": TELEMETRY_SECTION_ID,
|
||||
"order": 110,
|
||||
"type": "object",
|
||||
"title": localize("telemetryConfigurationTitle", "Telemetry"),
|
||||
"properties": {
|
||||
"telemetry.enableTelemetry": {
|
||||
"type": "boolean",
|
||||
"description": localize("telemetry.enableTelemetry", "Enable usage data and errors to be sent to a Microsoft online service."),
|
||||
"default": true,
|
||||
"tags": ["usesOnlineServices"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
class NodeProxyService extends NodeProxyChannelClient implements INodeProxyService {
|
||||
private readonly _onClose = new Emitter<void>();
|
||||
public readonly onClose = this._onClose.event;
|
||||
private readonly _onDown = new Emitter<void>();
|
||||
public readonly onDown = this._onDown.event;
|
||||
private readonly _onUp = new Emitter<void>();
|
||||
public readonly onUp = this._onUp.event;
|
||||
|
||||
public constructor(
|
||||
@IRemoteAgentService remoteAgentService: IRemoteAgentService,
|
||||
) {
|
||||
super(remoteAgentService.getConnection()!.getChannel("nodeProxy"));
|
||||
remoteAgentService.getConnection()!.onDidStateChange((state) => {
|
||||
switch (state.type) {
|
||||
case PersistentConnectionEventType.ConnectionGain:
|
||||
return this._onUp.fire();
|
||||
case PersistentConnectionEventType.ConnectionLost:
|
||||
return this._onDown.fire();
|
||||
case PersistentConnectionEventType.ReconnectionPermanentFailure:
|
||||
return this._onClose.fire();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerSingleton(ILocalizationsService, LocalizationsService);
|
||||
registerSingleton(INodeProxyService, NodeProxyService);
|
||||
registerSingleton(ITelemetryService, TelemetryService);
|
||||
|
||||
/**
|
||||
* This is called by vs/workbench/browser/web.main.ts after the workbench has
|
||||
* been initialized so we can initialize our own client-side code.
|
||||
*/
|
||||
export const initialize = async (services: ServiceCollection): Promise<void> => {
|
||||
const target = window as any;
|
||||
target.ide = coderApi(services);
|
||||
target.vscode = vscodeApi(services);
|
||||
|
||||
const event = new CustomEvent("ide-ready");
|
||||
(event as any).ide = target.ide;
|
||||
(event as any).vscode = target.vscode;
|
||||
window.dispatchEvent(event);
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
(services.get(INotificationService) as INotificationService).notify({
|
||||
severity: Severity.Warning,
|
||||
message: "code-server is being accessed over an insecure domain. Some functionality may not work as expected.",
|
||||
actions: {
|
||||
primary: [{
|
||||
id: "understand",
|
||||
label: "I understand",
|
||||
tooltip: "",
|
||||
class: undefined,
|
||||
enabled: true,
|
||||
checked: true,
|
||||
dispose: () => undefined,
|
||||
run: () => {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}],
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export interface Query {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the URL modified with the specified query variables. It's pretty
|
||||
* stupid so it probably doesn't cover any edge cases. Undefined values will
|
||||
* unset existing values. Doesn't allow duplicates.
|
||||
*/
|
||||
export const withQuery = (url: string, replace: Query): string => {
|
||||
const uri = URI.parse(url);
|
||||
const query = { ...replace };
|
||||
uri.query.split("&").forEach((kv) => {
|
||||
const [key, value] = split(kv, "=");
|
||||
if (!(key in query)) {
|
||||
query[key] = value;
|
||||
}
|
||||
});
|
||||
return uri.with({
|
||||
query: Object.keys(query)
|
||||
.filter((k) => typeof query[k] !== "undefined")
|
||||
.map((k) => `${k}=${query[k]}`).join("&"),
|
||||
}).toString(true);
|
||||
};
|
27
src/browser/components/animate.tsx
Normal file
27
src/browser/components/animate.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
|
||||
export interface DelayProps {
|
||||
readonly show: boolean
|
||||
readonly delay: number
|
||||
}
|
||||
|
||||
export const Animate: React.FunctionComponent<DelayProps> = (props) => {
|
||||
const [timer, setTimer] = React.useState<NodeJS.Timeout>()
|
||||
const [mount, setMount] = React.useState<boolean>(false)
|
||||
const [visible, setVisible] = React.useState<boolean>(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (!props.show) {
|
||||
setVisible(false)
|
||||
setTimer(setTimeout(() => setMount(false), props.delay))
|
||||
} else {
|
||||
setTimer(setTimeout(() => setVisible(true), props.delay))
|
||||
setMount(true)
|
||||
}
|
||||
}, [props])
|
||||
|
||||
return mount ? <div className={`animate -${visible ? "show" : "hide"}`}>{props.children}</div> : null
|
||||
}
|
28
src/browser/components/error.css
Normal file
28
src/browser/components/error.css
Normal file
@ -0,0 +1,28 @@
|
||||
.field-error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.request-error {
|
||||
align-items: center;
|
||||
color: rgba(0, 0, 0, 0.37);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.request-error > .close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.request-error + .request-error {
|
||||
border-top: 1px solid #b6b6b6;
|
||||
}
|
48
src/browser/components/error.tsx
Normal file
48
src/browser/components/error.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { HttpError } from "../../common/http"
|
||||
|
||||
export interface ErrorProps {
|
||||
error: HttpError | Error | string
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* An error to be displayed in a section where a request has failed.
|
||||
*/
|
||||
export const RequestError: React.FunctionComponent<ErrorProps> = (props) => {
|
||||
return (
|
||||
<div className="request-error">
|
||||
<div className="error">{typeof props.error === "string" ? props.error : props.error.message}</div>
|
||||
{props.onClose ? (
|
||||
<button className="close" onClick={props.onClose}>
|
||||
Go Back
|
||||
</button>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a more human/natural/useful message for some error codes resulting
|
||||
* from a form submission.
|
||||
*/
|
||||
const humanizeFormError = (error: HttpError | Error | string): string => {
|
||||
if (typeof error === "string") {
|
||||
return error
|
||||
}
|
||||
switch ((error as HttpError).code) {
|
||||
case 401:
|
||||
return "Wrong password"
|
||||
default:
|
||||
return error.message
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error to be displayed underneath a field.
|
||||
*/
|
||||
export const FieldError: React.FunctionComponent<ErrorProps> = (props) => {
|
||||
return <div className="field-error">{humanizeFormError(props.error)}</div>
|
||||
}
|
108
src/browser/components/list.css
Normal file
108
src/browser/components/list.css
Normal file
@ -0,0 +1,108 @@
|
||||
.app-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0 -10px; /* To counter app padding. */
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-loader {
|
||||
align-items: center;
|
||||
color: #b6b6b6;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-loader > .loader {
|
||||
color: #b6b6b6;
|
||||
}
|
||||
|
||||
.app-row {
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-row > .launch,
|
||||
.app-row > .kill {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
margin: 1px 0;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.app-row > .launch {
|
||||
border-radius: 50px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-row > .launch:hover,
|
||||
.app-row > .kill:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.app-row .icon {
|
||||
height: 1em;
|
||||
margin-right: 5px;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.app-row .icon.-missing {
|
||||
background-color: #eee;
|
||||
color: #b6b6b6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-row .icon.-missing::after {
|
||||
content: "?";
|
||||
font-size: 0.7em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.app-row.-selected {
|
||||
background-color: #bcc6fa;
|
||||
}
|
||||
|
||||
.app-loader > .opening {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.app-loader > .app-row {
|
||||
color: #000;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-loader > .cancel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-list + .app-list {
|
||||
border-top: 1px solid #b6b6b6;
|
||||
margin-top: 1em;
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.app-list > .header {
|
||||
color: #b6b6b6;
|
||||
font-size: 1em;
|
||||
margin-bottom: 1em;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.app-list > .loader {
|
||||
color: #b6b6b6;
|
||||
}
|
169
src/browser/components/list.tsx
Normal file
169
src/browser/components/list.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import * as React from "react"
|
||||
import { Application, isExecutableApplication, isRunningApplication } from "../../common/api"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { getSession, killSession } from "../api"
|
||||
import { RequestError } from "../components/error"
|
||||
|
||||
export const AppDetails: React.FunctionComponent<Application> = (props) => {
|
||||
return (
|
||||
<>
|
||||
{props.icon ? (
|
||||
<img className="icon" src={`data:image/png;base64,${props.icon}`}></img>
|
||||
) : (
|
||||
<div className="icon -missing"></div>
|
||||
)}
|
||||
<div className="name">{props.name}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppRowProps {
|
||||
readonly app: Application
|
||||
onKilled(app: Application): void
|
||||
open(app: Application): void
|
||||
}
|
||||
|
||||
export const AppRow: React.FunctionComponent<AppRowProps> = (props) => {
|
||||
const [killing, setKilling] = React.useState<boolean>(false)
|
||||
const [error, setError] = React.useState<HttpError>()
|
||||
|
||||
function kill(): void {
|
||||
if (isRunningApplication(props.app)) {
|
||||
setKilling(true)
|
||||
killSession(props.app)
|
||||
.then(() => {
|
||||
setKilling(false)
|
||||
props.onKilled(props.app)
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(error)
|
||||
setKilling(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-row">
|
||||
<button className="launch" onClick={(): void => props.open(props.app)}>
|
||||
<AppDetails {...props.app} />
|
||||
</button>
|
||||
{isRunningApplication(props.app) && !killing ? (
|
||||
<button className="kill" onClick={(): void => kill()}>
|
||||
{error ? error.message : killing ? "..." : "kill"}
|
||||
</button>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppListProps {
|
||||
readonly header: string
|
||||
readonly apps?: ReadonlyArray<Application>
|
||||
open(app: Application): void
|
||||
onKilled(app: Application): void
|
||||
}
|
||||
|
||||
export const AppList: React.FunctionComponent<AppListProps> = (props) => {
|
||||
return (
|
||||
<div className="app-list">
|
||||
<h2 className="header">{props.header}</h2>
|
||||
{props.apps && props.apps.length > 0 ? (
|
||||
props.apps.map((app, i) => <AppRow key={i} app={app} {...props} />)
|
||||
) : props.apps ? (
|
||||
<RequestError error={`No ${props.header.toLowerCase()} found`} />
|
||||
) : (
|
||||
<div className="loader">loading...</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ApplicationSection {
|
||||
readonly apps?: ReadonlyArray<Application>
|
||||
readonly header: string
|
||||
}
|
||||
|
||||
export interface AppLoaderProps {
|
||||
readonly app?: Application
|
||||
setApp(app?: Application): void
|
||||
getApps(): Promise<ReadonlyArray<ApplicationSection>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Display provided applications or sessions and allow opening them.
|
||||
*/
|
||||
export const AppLoader: React.FunctionComponent<AppLoaderProps> = (props) => {
|
||||
const [apps, setApps] = React.useState<ReadonlyArray<ApplicationSection>>()
|
||||
const [error, setError] = React.useState<HttpError>()
|
||||
|
||||
const refresh = (): void => {
|
||||
props
|
||||
.getApps()
|
||||
.then(setApps)
|
||||
.catch((e) => setError(e.message))
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
refresh()
|
||||
}, [props])
|
||||
|
||||
function open(app: Application): void {
|
||||
props.setApp(app)
|
||||
if (!isRunningApplication(app) && isExecutableApplication(app)) {
|
||||
getSession(app)
|
||||
.then((session) => {
|
||||
props.setApp({ ...app, ...session })
|
||||
})
|
||||
.catch(setError)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
props.setApp(undefined)
|
||||
return (
|
||||
<RequestError
|
||||
error={error}
|
||||
onClose={(): void => {
|
||||
setError(undefined)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (props.app && !props.app.loaded) {
|
||||
return (
|
||||
<div className="app-loader">
|
||||
<div className="opening">Opening</div>
|
||||
<div className="app-row">
|
||||
<AppDetails {...props.app} />
|
||||
</div>
|
||||
<button
|
||||
className="cancel"
|
||||
onClick={(): void => {
|
||||
props.setApp(undefined)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!apps) {
|
||||
return (
|
||||
<div className="app-loader">
|
||||
<div className="loader">loading</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{apps.map((section, i) => (
|
||||
<AppList key={i} open={open} onKilled={refresh} {...section} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
147
src/browser/components/modal.css
Normal file
147
src/browser/components/modal.css
Normal file
@ -0,0 +1,147 @@
|
||||
.modal-bar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.animate > .modal-bar {
|
||||
transform: translateY(-100%);
|
||||
transition: transform 200ms;
|
||||
}
|
||||
|
||||
.animate.-show > .modal-bar {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-bar > .bar {
|
||||
background-color: #fcfcfc;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
box-sizing: border-box;
|
||||
color: #101010;
|
||||
display: flex;
|
||||
font-size: 0.8em;
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
pointer-events: initial;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .close {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .open > .button {
|
||||
background-color: transparent;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #101010;
|
||||
color: #101010;
|
||||
cursor: pointer;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .open > .button:hover {
|
||||
background-color: #bcc6fa;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 9999999;
|
||||
}
|
||||
|
||||
.modal-container > .modal {
|
||||
background: #fcfcfc;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
max-width: 664px;
|
||||
padding: 20px 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .sidebar {
|
||||
border-right: 1.5px solid rgba(0, 0, 0, 0.37);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .sidebar > .links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .sidebar > .links > .link {
|
||||
color: rgba(0, 0, 0, 0.37);
|
||||
font-size: 1.4em;
|
||||
height: 31px;
|
||||
margin-bottom: 20px;
|
||||
padding: 0 35px;
|
||||
text-decoration: none;
|
||||
transition: 150ms color ease, 150ms height ease, 150ms margin-bottom ease;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .sidebar > .footer > .close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .sidebar > .footer > .close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .links > .link[aria-current="page"] {
|
||||
color: rgba(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
.modal-container > .modal > .content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
padding: 0 20px;
|
||||
}
|
192
src/browser/components/modal.tsx
Normal file
192
src/browser/components/modal.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import { logger } from "@coder/logger"
|
||||
import * as React from "react"
|
||||
import { NavLink, Route, RouteComponentProps, Switch } from "react-router-dom"
|
||||
import { Application, isExecutableApplication } from "../../common/api"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { RequestError } from "../components/error"
|
||||
import { Browse } from "../pages/browse"
|
||||
import { Home } from "../pages/home"
|
||||
import { Login } from "../pages/login"
|
||||
import { Open } from "../pages/open"
|
||||
import { Recent } from "../pages/recent"
|
||||
import { Animate } from "./animate"
|
||||
|
||||
export interface ModalProps {
|
||||
app?: Application
|
||||
authed: boolean
|
||||
error?: HttpError | Error | string
|
||||
setApp(app?: Application): void
|
||||
setError(error?: HttpError | Error | string): void
|
||||
}
|
||||
|
||||
export const Modal: React.FunctionComponent<ModalProps> = (props) => {
|
||||
const [showModal, setShowModal] = React.useState<boolean>(false)
|
||||
const [showBar, setShowBar] = React.useState<boolean>(true)
|
||||
|
||||
const setApp = (app: Application): void => {
|
||||
setShowModal(false)
|
||||
props.setApp(app)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
// Show the bar when hovering around the top area for a while.
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
const hover = (clientY: number): void => {
|
||||
if (clientY > 30 && timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
} else if (clientY <= 30 && !timeout) {
|
||||
timeout = setTimeout(() => setShowBar(true), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const iframe =
|
||||
props.app && !isExecutableApplication(props.app) && (document.getElementById("iframe") as HTMLIFrameElement)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const postIframeMessage = (message: any): void => {
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(message, window.location.origin)
|
||||
} else {
|
||||
logger.warn("Tried to post message to missing iframe")
|
||||
}
|
||||
}
|
||||
|
||||
const onHover = (event: MouseEvent | MessageEvent): void => {
|
||||
hover((event as MessageEvent).data ? (event as MessageEvent).data.clientY : (event as MouseEvent).clientY)
|
||||
}
|
||||
|
||||
const onIframeLoaded = (): void => {
|
||||
if (props.app) {
|
||||
setApp({ ...props.app, loaded: true })
|
||||
}
|
||||
}
|
||||
|
||||
// No need to track the mouse if we don't have a hidden bar.
|
||||
const hasHiddenBar = !props.error && !showModal && props.app && !showBar
|
||||
|
||||
if (props.app && !isExecutableApplication(props.app)) {
|
||||
// Once the iframe reports it has loaded, tell it to bind mousemove and
|
||||
// start listening for that instead.
|
||||
if (!props.app.loaded) {
|
||||
window.addEventListener("message", onIframeLoaded)
|
||||
} else if (hasHiddenBar) {
|
||||
postIframeMessage({ bind: "mousemove", prop: "clientY" })
|
||||
window.removeEventListener("message", onIframeLoaded)
|
||||
window.addEventListener("message", onHover)
|
||||
}
|
||||
} else if (hasHiddenBar) {
|
||||
document.addEventListener("mousemove", onHover)
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
document.removeEventListener("mousemove", onHover)
|
||||
window.removeEventListener("message", onHover)
|
||||
window.removeEventListener("message", onIframeLoaded)
|
||||
if (props.app && !isExecutableApplication(props.app)) {
|
||||
postIframeMessage({ unbind: "mousemove" })
|
||||
}
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [showBar, props.error, showModal, props.app])
|
||||
|
||||
return props.error || showModal || !props.app || !props.app.loaded ? (
|
||||
<div className="modal-container">
|
||||
<div className="modal">
|
||||
{props.authed && (!props.app || props.app.loaded) ? (
|
||||
<aside className="sidebar">
|
||||
<nav className="links">
|
||||
{!props.authed ? (
|
||||
<NavLink className="link" to="/login">
|
||||
Login
|
||||
</NavLink>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
{props.authed ? (
|
||||
<NavLink className="link" exact to="/recent/">
|
||||
Recent
|
||||
</NavLink>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
{props.authed ? (
|
||||
<NavLink className="link" exact to="/open/">
|
||||
Open
|
||||
</NavLink>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
{props.authed ? (
|
||||
<NavLink className="link" exact to="/browse/">
|
||||
Browse
|
||||
</NavLink>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</nav>
|
||||
<div className="footer">
|
||||
{props.app && props.app.loaded && !props.error ? (
|
||||
<button className="close" onClick={(): void => setShowModal(false)}>
|
||||
Close
|
||||
</button>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
{props.error ? (
|
||||
<RequestError
|
||||
error={props.error}
|
||||
onClose={(): void => {
|
||||
props.setApp(undefined)
|
||||
props.setError(undefined)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="content">
|
||||
<Switch>
|
||||
<Route path="/login" component={Login} />
|
||||
<Route
|
||||
path="/recent"
|
||||
render={(p: RouteComponentProps): React.ReactElement => (
|
||||
<Recent app={props.app} setApp={setApp} {...p} />
|
||||
)}
|
||||
/>
|
||||
<Route path="/browse" component={Browse} />
|
||||
<Route
|
||||
path="/open"
|
||||
render={(p: RouteComponentProps): React.ReactElement => <Open app={props.app} setApp={setApp} {...p} />}
|
||||
/>
|
||||
<Route path="/" component={Home} />
|
||||
</Switch>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Animate show={showBar} delay={200}>
|
||||
<div className="modal-bar">
|
||||
<div className="bar">
|
||||
<div className="content">
|
||||
<div className="help">
|
||||
Hover at the top {/*or press <strong>Ctrl+Shift+G</strong>*/} to display this menu.
|
||||
</div>
|
||||
</div>
|
||||
<div className="open">
|
||||
<button className="button" onClick={(): void => setShowModal(true)}>
|
||||
Open Modal
|
||||
</button>
|
||||
</div>
|
||||
<button className="close" onClick={(): void => setShowBar(false)}>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Animate>
|
||||
)
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
import { Emitter } from "vs/base/common/event";
|
||||
import { createDecorator } from "vs/platform/instantiation/common/instantiation";
|
||||
import { ExtHostNodeProxyShape, MainContext, MainThreadNodeProxyShape } from "vs/workbench/api/common/extHost.protocol";
|
||||
import { IExtHostRpcService } from "vs/workbench/api/common/extHostRpcService";
|
||||
|
||||
export class ExtHostNodeProxy implements ExtHostNodeProxyShape {
|
||||
_serviceBrand: any;
|
||||
|
||||
private readonly _onMessage = new Emitter<string>();
|
||||
public readonly onMessage = this._onMessage.event;
|
||||
private readonly _onClose = new Emitter<void>();
|
||||
public readonly onClose = this._onClose.event;
|
||||
private readonly _onDown = new Emitter<void>();
|
||||
public readonly onDown = this._onDown.event;
|
||||
private readonly _onUp = new Emitter<void>();
|
||||
public readonly onUp = this._onUp.event;
|
||||
|
||||
private readonly proxy: MainThreadNodeProxyShape;
|
||||
|
||||
constructor(@IExtHostRpcService rpc: IExtHostRpcService) {
|
||||
this.proxy = rpc.getProxy(MainContext.MainThreadNodeProxy);
|
||||
}
|
||||
|
||||
public $onMessage(message: string): void {
|
||||
this._onMessage.fire(message);
|
||||
}
|
||||
|
||||
public $onClose(): void {
|
||||
this._onClose.fire();
|
||||
}
|
||||
|
||||
public $onUp(): void {
|
||||
this._onUp.fire();
|
||||
}
|
||||
|
||||
public $onDown(): void {
|
||||
this._onDown.fire();
|
||||
}
|
||||
|
||||
public send(message: string): void {
|
||||
this.proxy.$send(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IExtHostNodeProxy extends ExtHostNodeProxy { }
|
||||
export const IExtHostNodeProxy = createDecorator<IExtHostNodeProxy>("IExtHostNodeProxy");
|
19
src/browser/index.html
Normal file
19
src/browser/index.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!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="font-src 'self'; connect-src 'self'; default-src ws: wss:; style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"> -->
|
||||
<title>code-server</title>
|
||||
<link rel="icon" href="/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
|
||||
<link rel="manifest" href="/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
|
||||
<link rel="apple-touch-icon" href="/static-{{COMMIT}}/src/browser/media/code-server.png" />
|
||||
<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans&display=swap" rel="stylesheet" />
|
||||
<link href="/static-{{COMMIT}}/dist/index.css" rel="stylesheet">
|
||||
<meta id="coder-options" data-settings="{{OPTIONS}}">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">{{COMPONENT}}</div>
|
||||
<script src="/static-{{COMMIT}}/dist/index.js"></script>
|
||||
</body>
|
||||
</html>
|
18
src/browser/index.tsx
Normal file
18
src/browser/index.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
import * as ReactDOM from "react-dom"
|
||||
import App from "./app"
|
||||
import { BrowserRouter } from "react-router-dom"
|
||||
|
||||
import "./app.css"
|
||||
import "./pages/home.css"
|
||||
import "./pages/login.css"
|
||||
import "./components/error.css"
|
||||
import "./components/list.css"
|
||||
import "./components/modal.css"
|
||||
|
||||
ReactDOM.hydrate(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>,
|
||||
document.getElementById("root")
|
||||
)
|
@ -1,31 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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="default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'unsafe-inline'; manifest-src 'self'; img-src 'self';">
|
||||
<title>Authenticate: code-server</title>
|
||||
<link rel="icon" href="./static/out/vs/server/src/media/favicon.ico" type="image/x-icon" />
|
||||
<link rel="manifest" href="./manifest.json" crossorigin="use-credentials">
|
||||
<link rel="apple-touch-icon" href="./static/out/vs/server/src/media/code-server.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<link href="./static/out/vs/server/src/media/login.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<form class="login-form" method="post">
|
||||
<h4 class="title">code-server</h4>
|
||||
<h2 class="subtitle">
|
||||
Enter server password
|
||||
</h2>
|
||||
<div class="field">
|
||||
<!-- The onfocus code places the cursor at the end of the value. -->
|
||||
<input name="password" type="password" class="input" value=""
|
||||
required autofocus
|
||||
onfocus="const value=this.value;this.value='';this.value=value;">
|
||||
</div>
|
||||
<button class="button" type="submit">
|
||||
<span class="label">Enter IDE</span>
|
||||
</button>
|
||||
<div class="error-display" style="display:none">{{ERROR}}</div>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
@ -1,37 +0,0 @@
|
||||
import { IDisposable } from "vs/base/common/lifecycle";
|
||||
import { INodeProxyService } from "vs/server/src/common/nodeProxy";
|
||||
import { ExtHostContext, IExtHostContext, MainContext, MainThreadNodeProxyShape } from "vs/workbench/api/common/extHost.protocol";
|
||||
import { extHostNamedCustomer } from "vs/workbench/api/common/extHostCustomers";
|
||||
|
||||
@extHostNamedCustomer(MainContext.MainThreadNodeProxy)
|
||||
export class MainThreadNodeProxy implements MainThreadNodeProxyShape {
|
||||
private disposed = false;
|
||||
private disposables = <IDisposable[]>[];
|
||||
|
||||
constructor(
|
||||
extHostContext: IExtHostContext,
|
||||
@INodeProxyService private readonly proxyService: INodeProxyService,
|
||||
) {
|
||||
if (!extHostContext.remoteAuthority) { // HACK: A terrible way to detect if running in the worker.
|
||||
const proxy = extHostContext.getProxy(ExtHostContext.ExtHostNodeProxy);
|
||||
this.disposables = [
|
||||
this.proxyService.onMessage((message: string) => proxy.$onMessage(message)),
|
||||
this.proxyService.onClose(() => proxy.$onClose()),
|
||||
this.proxyService.onDown(() => proxy.$onDown()),
|
||||
this.proxyService.onUp(() => proxy.$onUp()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$send(message: string): void {
|
||||
if (!this.disposed) {
|
||||
this.proxyService.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposables.forEach((d) => d.dispose());
|
||||
this.disposables = [];
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
13
src/browser/media/manifest.json
Normal file
13
src/browser/media/manifest.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "code-server",
|
||||
"short_name": "code-server",
|
||||
"start_url": "../../../..",
|
||||
"display": "fullscreen",
|
||||
"background-color": "#fff",
|
||||
"description": "Run editors on a remote server.",
|
||||
"icons": [{
|
||||
"src": "./code-server.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
}]
|
||||
}
|
34
src/browser/pages/browse.tsx
Normal file
34
src/browser/pages/browse.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { RouteComponentProps } from "react-router"
|
||||
import { FilesResponse } from "../../common/api"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { getFiles } from "../api"
|
||||
import { RequestError } from "../components/error"
|
||||
|
||||
/**
|
||||
* File browser.
|
||||
*/
|
||||
export const Browse: React.FunctionComponent<RouteComponentProps> = (props) => {
|
||||
const [response, setResponse] = React.useState<FilesResponse>()
|
||||
const [error, setError] = React.useState<HttpError>()
|
||||
|
||||
React.useEffect(() => {
|
||||
getFiles()
|
||||
.then(setResponse)
|
||||
.catch((e) => setError(e.message))
|
||||
}, [props])
|
||||
|
||||
return (
|
||||
<>
|
||||
{error || (response && response.files.length === 0) ? (
|
||||
<RequestError error={error || "Empty directory"} />
|
||||
) : (
|
||||
<ul>
|
||||
{((response && response.files) || []).map((f, i) => (
|
||||
<li key={i}>{f.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
8
src/browser/pages/home.css
Normal file
8
src/browser/pages/home.css
Normal file
@ -0,0 +1,8 @@
|
||||
.orientation-guide {
|
||||
align-items: center;
|
||||
color: #b6b6b6;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
22
src/browser/pages/home.tsx
Normal file
22
src/browser/pages/home.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { RouteComponentProps } from "react-router"
|
||||
import { authenticate } from "../api"
|
||||
|
||||
export const Home: React.FunctionComponent<RouteComponentProps> = (props) => {
|
||||
React.useEffect(() => {
|
||||
authenticate()
|
||||
.then(() => {
|
||||
// TEMP: Always redirect to VS Code.
|
||||
props.history.push("./vscode/")
|
||||
})
|
||||
.catch(() => {
|
||||
props.history.push("./login/")
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="orientation-guide">
|
||||
<div className="welcome">Welcome to code-server.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
35
src/browser/pages/login.css
Normal file
35
src/browser/pages/login.css
Normal file
@ -0,0 +1,35 @@
|
||||
.login-form {
|
||||
align-items: center;
|
||||
color: rgba(0, 0, 0, 0.37);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-form > .field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-form > .field-error {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.login-form > .field > .input {
|
||||
border: 1px solid #b6b6b6;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-form > .field > .submit {
|
||||
background-color: transparent;
|
||||
border: 1px solid #b6b6b6;
|
||||
box-sizing: border-box;
|
||||
margin-left: -1px;
|
||||
padding: 10px 20px;
|
||||
}
|
55
src/browser/pages/login.tsx
Normal file
55
src/browser/pages/login.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import { RouteComponentProps } from "react-router"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { authenticate } from "../api"
|
||||
import { FieldError } from "../components/error"
|
||||
|
||||
/**
|
||||
* Login page. Will redirect on success.
|
||||
*/
|
||||
export const Login: React.FunctionComponent<RouteComponentProps> = (props) => {
|
||||
const [password, setPassword] = React.useState<string>("")
|
||||
const [error, setError] = React.useState<HttpError>()
|
||||
|
||||
function redirect(): void {
|
||||
// TEMP: Always redirect to VS Code.
|
||||
console.log("is authed")
|
||||
props.history.push("../vscode/")
|
||||
// const params = new URLSearchParams(window.location.search)
|
||||
// props.history.push(params.get("to") || "/")
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault()
|
||||
authenticate({ password })
|
||||
.then(redirect)
|
||||
.catch(setError)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
authenticate()
|
||||
.then(redirect)
|
||||
.catch(() => {
|
||||
// Do nothing; we're already at the login page.
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<input
|
||||
autoFocus
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="password"
|
||||
autoComplete="current-password"
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => setPassword(event.target.value)}
|
||||
/>
|
||||
<button className="submit" type="submit">
|
||||
Log In
|
||||
</button>
|
||||
</div>
|
||||
{error ? <FieldError error={error} /> : undefined}
|
||||
</form>
|
||||
)
|
||||
}
|
29
src/browser/pages/open.tsx
Normal file
29
src/browser/pages/open.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import { Application } from "../../common/api"
|
||||
import { getApplications } from "../api"
|
||||
import { ApplicationSection, AppLoader } from "../components/list"
|
||||
|
||||
export interface OpenProps {
|
||||
app?: Application
|
||||
setApp(app: Application): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Display recently used applications.
|
||||
*/
|
||||
export const Open: React.FunctionComponent<OpenProps> = (props) => {
|
||||
return (
|
||||
<AppLoader
|
||||
getApps={async (): Promise<ReadonlyArray<ApplicationSection>> => {
|
||||
const response = await getApplications()
|
||||
return [
|
||||
{
|
||||
header: "Applications",
|
||||
apps: response && response.applications,
|
||||
},
|
||||
]
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
33
src/browser/pages/recent.tsx
Normal file
33
src/browser/pages/recent.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { Application } from "../../common/api"
|
||||
import { getRecent } from "../api"
|
||||
import { ApplicationSection, AppLoader } from "../components/list"
|
||||
|
||||
export interface RecentProps {
|
||||
app?: Application
|
||||
setApp(app: Application): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Display recently used applications.
|
||||
*/
|
||||
export const Recent: React.FunctionComponent<RecentProps> = (props) => {
|
||||
return (
|
||||
<AppLoader
|
||||
getApps={async (): Promise<ReadonlyArray<ApplicationSection>> => {
|
||||
const response = await getRecent()
|
||||
return [
|
||||
{
|
||||
header: "Running Applications",
|
||||
apps: response && response.running,
|
||||
},
|
||||
{
|
||||
header: "Recent Applications",
|
||||
apps: response && response.recent,
|
||||
},
|
||||
]
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
208
src/browser/socket.ts
Normal file
208
src/browser/socket.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { field, logger, Logger } from "@coder/logger"
|
||||
import { Emitter } from "../common/emitter"
|
||||
import { generateUuid } from "../common/util"
|
||||
|
||||
const decoder = new TextDecoder("utf8")
|
||||
export const decode = (buffer: string | ArrayBuffer): string => {
|
||||
return typeof buffer !== "string" ? decoder.decode(buffer) : buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* A web socket that reconnects itself when it closes. Sending messages while
|
||||
* disconnected will throw an error.
|
||||
*/
|
||||
export class ReconnectingSocket {
|
||||
protected readonly _onMessage = new Emitter<string | ArrayBuffer>()
|
||||
public readonly onMessage = this._onMessage.event
|
||||
protected readonly _onDisconnect = new Emitter<number | undefined>()
|
||||
public readonly onDisconnect = this._onDisconnect.event
|
||||
protected readonly _onClose = new Emitter<number | undefined>()
|
||||
public readonly onClose = this._onClose.event
|
||||
protected readonly _onConnect = new Emitter<void>()
|
||||
public readonly onConnect = this._onConnect.event
|
||||
|
||||
// This helps distinguish messages between sockets.
|
||||
private readonly logger: Logger
|
||||
|
||||
private socket?: WebSocket
|
||||
private connecting?: Promise<void>
|
||||
private closed = false
|
||||
private readonly openTimeout = 10000
|
||||
|
||||
// Every time the socket fails to connect, the retry will be increasingly
|
||||
// delayed up to a maximum.
|
||||
private readonly retryBaseDelay = 1000
|
||||
private readonly retryMaxDelay = 10000
|
||||
private retryDelay?: number
|
||||
private readonly retryDelayFactor = 1.5
|
||||
|
||||
// The socket must be connected for this amount of time before resetting the
|
||||
// retry delay. This prevents rapid retries when the socket does connect but
|
||||
// is closed shortly after.
|
||||
private resetRetryTimeout?: NodeJS.Timeout
|
||||
private readonly resetRetryDelay = 10000
|
||||
|
||||
private _binaryType: typeof WebSocket.prototype.binaryType = "arraybuffer"
|
||||
|
||||
public constructor(private customPath?: string, public readonly id: string = generateUuid(4)) {
|
||||
// On Firefox the socket seems to somehow persist a page reload so the close
|
||||
// event runs and we see "attempting to reconnect".
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", () => this.close())
|
||||
}
|
||||
this.logger = logger.named(this.id)
|
||||
}
|
||||
|
||||
public set binaryType(b: typeof WebSocket.prototype.binaryType) {
|
||||
this._binaryType = b
|
||||
if (this.socket) {
|
||||
this.socket.binaryType = b
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently close the connection. Will not attempt to reconnect. Will
|
||||
* remove event listeners.
|
||||
*/
|
||||
public close(code?: number): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (code) {
|
||||
this.logger.info(`closing with code ${code}`)
|
||||
}
|
||||
|
||||
if (this.resetRetryTimeout) {
|
||||
clearTimeout(this.resetRetryTimeout)
|
||||
}
|
||||
|
||||
this.closed = true
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.close()
|
||||
} else {
|
||||
this._onClose.emit(code)
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._onMessage.dispose()
|
||||
this._onDisconnect.dispose()
|
||||
this._onClose.dispose()
|
||||
this._onConnect.dispose()
|
||||
this.logger.debug("disposed handlers")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message on the socket. Logs an error if currently disconnected.
|
||||
*/
|
||||
public send(message: string | ArrayBuffer): void {
|
||||
this.logger.trace(() => ["sending message", field("message", decode(message))])
|
||||
if (!this.socket) {
|
||||
return logger.error("tried to send message on closed socket")
|
||||
}
|
||||
this.socket.send(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the socket. Can also be called to wait until the connection is
|
||||
* established in the case of disconnections. Multiple calls will be handled
|
||||
* correctly.
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (!this.connecting) {
|
||||
this.connecting = new Promise((resolve, reject) => {
|
||||
const tryConnect = (): void => {
|
||||
if (this.closed) {
|
||||
return reject(new Error("disconnected")) // Don't keep trying if we've closed permanently.
|
||||
}
|
||||
if (typeof this.retryDelay === "undefined") {
|
||||
this.retryDelay = 0
|
||||
} else {
|
||||
this.retryDelay = this.retryDelay * this.retryDelayFactor || this.retryBaseDelay
|
||||
if (this.retryDelay > this.retryMaxDelay) {
|
||||
this.retryDelay = this.retryMaxDelay
|
||||
}
|
||||
}
|
||||
this._connect()
|
||||
.then((socket) => {
|
||||
this.logger.info("connected")
|
||||
this.socket = socket
|
||||
this.socket.binaryType = this._binaryType
|
||||
if (this.resetRetryTimeout) {
|
||||
clearTimeout(this.resetRetryTimeout)
|
||||
}
|
||||
this.resetRetryTimeout = setTimeout(() => (this.retryDelay = undefined), this.resetRetryDelay)
|
||||
this.connecting = undefined
|
||||
this._onConnect.emit()
|
||||
resolve()
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error(`failed to connect: ${error.message}`)
|
||||
tryConnect()
|
||||
})
|
||||
}
|
||||
tryConnect()
|
||||
})
|
||||
}
|
||||
return this.connecting
|
||||
}
|
||||
|
||||
private async _connect(): Promise<WebSocket> {
|
||||
const socket = await new Promise<WebSocket>((resolve, _reject) => {
|
||||
if (this.retryDelay) {
|
||||
this.logger.info(`retrying in ${this.retryDelay}ms...`)
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.logger.info("connecting...")
|
||||
const socket = new WebSocket(
|
||||
`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${this.customPath || location.pathname}${
|
||||
location.search ? `?${location.search}` : ""
|
||||
}`
|
||||
)
|
||||
|
||||
const reject = (): void => {
|
||||
_reject(new Error("socket closed"))
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
socket.removeEventListener("open", open)
|
||||
socket.removeEventListener("close", reject)
|
||||
_reject(new Error("timeout"))
|
||||
}, this.openTimeout)
|
||||
|
||||
const open = (): void => {
|
||||
clearTimeout(timeout)
|
||||
socket.removeEventListener("close", reject)
|
||||
resolve(socket)
|
||||
}
|
||||
|
||||
socket.addEventListener("open", open)
|
||||
socket.addEventListener("close", reject)
|
||||
}, this.retryDelay)
|
||||
})
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
this.logger.trace(() => ["got message", field("message", decode(event.data))])
|
||||
this._onMessage.emit(event.data)
|
||||
})
|
||||
socket.addEventListener("close", (event) => {
|
||||
this.socket = undefined
|
||||
if (!this.closed) {
|
||||
this._onDisconnect.emit(event.code)
|
||||
// It might be closed in the event handler.
|
||||
if (!this.closed) {
|
||||
this.logger.info("connection closed; attempting to reconnect")
|
||||
this.connect()
|
||||
}
|
||||
} else {
|
||||
this._onClose.emit(event.code)
|
||||
this.logger.info("connection closed permanently")
|
||||
}
|
||||
})
|
||||
|
||||
return socket
|
||||
}
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<!-- Disable pinch zooming -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||
|
||||
<!-- Workbench Configuration -->
|
||||
<meta id="vscode-workbench-web-configuration" data-settings="{{WORKBENCH_WEB_CONFIGURATION}}">
|
||||
|
||||
<!-- Workarounds/Hacks (remote user data uri) -->
|
||||
<meta id="vscode-remote-user-data-uri" data-settings="{{REMOTE_USER_DATA_URI}}">
|
||||
<!-- NOTE@coder: Added the commit for use in caching, the product for the
|
||||
extensions gallery URL, and nls for language support. -->
|
||||
<meta id="vscode-remote-commit" data-settings="{{COMMIT}}">
|
||||
<meta id="vscode-remote-product-configuration" data-settings="{{PRODUCT_CONFIGURATION}}">
|
||||
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}">
|
||||
|
||||
<!-- Workbench Icon/Manifest/CSS -->
|
||||
<link rel="icon" href="./static-{{COMMIT}}/out/vs/server/src/media/favicon.ico" type="image/x-icon" />
|
||||
<link rel="manifest" href="./manifest.json" crossorigin="use-credentials">
|
||||
<link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="./static-{{COMMIT}}/out/vs/workbench/workbench.web.api.css">
|
||||
<link rel="apple-touch-icon" href="./static-{{COMMIT}}/out/vs/server/src/media/code-server.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
|
||||
<!-- Prefetch to avoid waterfall -->
|
||||
<link rel="prefetch" href="./static-{{COMMIT}}/node_modules/semver-umd/lib/semver-umd.js">
|
||||
</head>
|
||||
|
||||
<body aria-label="">
|
||||
</body>
|
||||
|
||||
<!-- Startup (do not modify order of script tags!) -->
|
||||
<!-- NOTE:coder: Modified to work against the current path and use the commit for caching. -->
|
||||
<script>
|
||||
// NOTE: Changes to inline scripts require update of content security policy
|
||||
const basePath = window.location.pathname.replace(/\/+$/, '');
|
||||
const base = window.location.origin + basePath;
|
||||
const el = document.getElementById('vscode-remote-commit');
|
||||
const commit = el ? el.getAttribute('data-settings') : "";
|
||||
const staticBase = base + '/static-' + commit;
|
||||
let nlsConfig;
|
||||
try {
|
||||
nlsConfig = JSON.parse(document.getElementById('vscode-remote-nls-configuration').getAttribute('data-settings'));
|
||||
if (nlsConfig._resolvedLanguagePackCoreLocation) {
|
||||
const bundles = Object.create(null);
|
||||
nlsConfig.loadBundle = (bundle, language, cb) => {
|
||||
let result = bundles[bundle];
|
||||
if (result) {
|
||||
return cb(undefined, result);
|
||||
}
|
||||
// FIXME: Only works if path separators are /.
|
||||
const path = nlsConfig._resolvedLanguagePackCoreLocation
|
||||
+ '/' + bundle.replace(/\//g, '!') + '.nls.json';
|
||||
fetch(`${base}/resource/?path=${encodeURIComponent(path)}`)
|
||||
.then((response) => response.json())
|
||||
.then((json) => {
|
||||
bundles[bundle] = json;
|
||||
cb(undefined, json);
|
||||
})
|
||||
.catch(cb);
|
||||
};
|
||||
}
|
||||
} catch (error) { /* Probably fine. */ }
|
||||
self.require = {
|
||||
baseUrl: `${staticBase}/out`,
|
||||
paths: {
|
||||
'vscode-textmate': `${staticBase}/node_modules/vscode-textmate/release/main`,
|
||||
'onigasm-umd': `${staticBase}/node_modules/onigasm-umd/release/main`,
|
||||
'xterm': `${staticBase}/node_modules/xterm/lib/xterm.js`,
|
||||
'xterm-addon-search': `${staticBase}/node_modules/xterm-addon-search/lib/xterm-addon-search.js`,
|
||||
'xterm-addon-web-links': `${staticBase}/node_modules/xterm-addon-web-links/lib/xterm-addon-web-links.js`,
|
||||
'xterm-addon-webgl': `${staticBase}/node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js`,
|
||||
'semver-umd': `${staticBase}/node_modules/semver-umd/lib/semver-umd.js`,
|
||||
},
|
||||
'vs/nls': nlsConfig,
|
||||
};
|
||||
</script>
|
||||
<script src="./static-{{COMMIT}}/out/vs/loader.js"></script>
|
||||
<script src="./static-{{COMMIT}}/out/vs/workbench/workbench.web.api.nls.js"></script>
|
||||
<script src="./static-{{COMMIT}}/out/vs/workbench/workbench.web.api.js"></script>
|
||||
<!-- TODO@coder: This errors with multiple anonymous define calls (one is
|
||||
workbench.js and one is semver-umd.js). For now use the same method found in
|
||||
workbench-dev.html. Appears related to the timing of the script load events. -->
|
||||
<!-- <script src="./static-{{COMMIT}}/out/vs/workbench/workbench.js"></script> -->
|
||||
<script>
|
||||
// NOTE: Changes to inline scripts require update of content security policy
|
||||
require(['vs/code/browser/workbench/workbench'], function() {});
|
||||
</script>
|
||||
</html>
|
@ -1,53 +0,0 @@
|
||||
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<!-- Disable pinch zooming -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||
|
||||
<!-- Workbench Configuration -->
|
||||
<meta id="vscode-workbench-web-configuration" data-settings="{{WORKBENCH_WEB_CONFIGURATION}}">
|
||||
|
||||
<!-- Workarounds/Hacks (remote user data uri) -->
|
||||
<meta id="vscode-remote-user-data-uri" data-settings="{{REMOTE_USER_DATA_URI}}">
|
||||
<!-- NOTE@coder: Added the commit for use in caching, the product for the
|
||||
extensions gallery URL, and nls for language support. -->
|
||||
<meta id="vscode-remote-commit" data-settings="{{COMMIT}}">
|
||||
<meta id="vscode-remote-product-configuration" data-settings="{{PRODUCT_CONFIGURATION}}">
|
||||
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}">
|
||||
|
||||
<!-- Workbench Icon/Manifest/CSS -->
|
||||
<link rel="icon" href="./static/out/vs/server/src/media/favicon.ico" type="image/x-icon" />
|
||||
<link rel="manifest" href="./manifest.json" crossorigin="use-credentials">
|
||||
</head>
|
||||
|
||||
<body aria-label="">
|
||||
</body>
|
||||
|
||||
<!-- Startup (do not modify order of script tags!) -->
|
||||
<script>
|
||||
const basePath = window.location.pathname.replace(/\/+$/, '');
|
||||
const base = window.location.origin + basePath;
|
||||
const el = document.getElementById('vscode-remote-commit');
|
||||
const commit = el ? el.getAttribute('data-settings') : "";
|
||||
const staticBase = base + '/static-' + commit;
|
||||
self.require = {
|
||||
baseUrl: `${staticBase}/out`,
|
||||
paths: {
|
||||
'vscode-textmate': `${staticBase}/node_modules/vscode-textmate/release/main`,
|
||||
'onigasm-umd': `${staticBase}/node_modules/onigasm-umd/release/main`,
|
||||
'xterm': `${staticBase}/node_modules/xterm/lib/xterm.js`,
|
||||
'xterm-addon-search': `${staticBase}/node_modules/xterm-addon-search/lib/xterm-addon-search.js`,
|
||||
'xterm-addon-web-links': `${staticBase}/node_modules/xterm-addon-web-links/lib/xterm-addon-web-links.js`,
|
||||
'xterm-addon-webgl': `${staticBase}/node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js`,
|
||||
'semver-umd': `${staticBase}/node_modules/semver-umd/lib/semver-umd.js`,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<script src="./static/out/vs/loader.js"></script>
|
||||
<script>
|
||||
require(['vs/code/browser/workbench/workbench'], function() {});
|
||||
</script>
|
||||
</html>
|
@ -1,57 +0,0 @@
|
||||
import { URI } from "vs/base/common/uri";
|
||||
import { IExtensionDescription } from "vs/platform/extensions/common/extensions";
|
||||
import { ILogService } from "vs/platform/log/common/log";
|
||||
import { Client } from "vs/server/node_modules/@coder/node-browser/out/client/client";
|
||||
import { fromTar } from "vs/server/node_modules/@coder/requirefs/out/requirefs";
|
||||
import { ExtensionActivationTimesBuilder } from "vs/workbench/api/common/extHostExtensionActivator";
|
||||
import { IExtHostNodeProxy } from "./extHostNodeProxy";
|
||||
|
||||
export const loadCommonJSModule = async <T>(
|
||||
module: IExtensionDescription,
|
||||
activationTimesBuilder: ExtensionActivationTimesBuilder,
|
||||
nodeProxy: IExtHostNodeProxy,
|
||||
logService: ILogService,
|
||||
vscode: any,
|
||||
): Promise<T> => {
|
||||
const fetchUri = URI.from({
|
||||
scheme: self.location.protocol.replace(":", ""),
|
||||
authority: self.location.host,
|
||||
path: `${self.location.pathname.replace(/\/static.*\/out\/vs\/workbench\/services\/extensions\/worker\/extensionHostWorkerMain.js$/, "")}/tar`,
|
||||
query: `path=${encodeURIComponent(module.extensionLocation.path)}`,
|
||||
});
|
||||
const response = await fetch(fetchUri.toString(true));
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to download extension "${module.extensionLocation.path}"`);
|
||||
}
|
||||
const client = new Client(nodeProxy, { logger: logService });
|
||||
const init = await client.handshake();
|
||||
const buffer = new Uint8Array(await response.arrayBuffer());
|
||||
const rfs = fromTar(buffer);
|
||||
(<any>self).global = self;
|
||||
rfs.provide("vscode", vscode);
|
||||
Object.keys(client.modules).forEach((key) => {
|
||||
const mod = (client.modules as any)[key];
|
||||
if (key === "process") {
|
||||
(<any>self).process = mod;
|
||||
(<any>self).process.env = init.env;
|
||||
return;
|
||||
}
|
||||
|
||||
rfs.provide(key, mod);
|
||||
switch (key) {
|
||||
case "buffer":
|
||||
(<any>self).Buffer = mod.Buffer;
|
||||
break;
|
||||
case "timers":
|
||||
(<any>self).setImmediate = mod.setImmediate;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
activationTimesBuilder.codeLoadingStart();
|
||||
return rfs.require(".");
|
||||
} finally {
|
||||
activationTimesBuilder.codeLoadingStop();
|
||||
}
|
||||
};
|
78
src/common/api.ts
Normal file
78
src/common/api.ts
Normal file
@ -0,0 +1,78 @@
|
||||
export interface Application {
|
||||
readonly comment?: string
|
||||
readonly directory?: string
|
||||
readonly exec?: string
|
||||
readonly icon?: string
|
||||
readonly loaded?: boolean
|
||||
readonly name: string
|
||||
readonly path: string
|
||||
readonly sessionId?: string
|
||||
}
|
||||
|
||||
export interface ApplicationsResponse {
|
||||
readonly applications: ReadonlyArray<Application>
|
||||
}
|
||||
|
||||
export enum SessionError {
|
||||
NotFound = 4000,
|
||||
FailedToStart,
|
||||
Starting,
|
||||
InvalidState,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface CreateSessionResponse {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export interface ExecutableApplication extends Application {
|
||||
exec: string
|
||||
}
|
||||
|
||||
export const isExecutableApplication = (app: Application): app is ExecutableApplication => {
|
||||
return !!(app as ExecutableApplication).exec
|
||||
}
|
||||
|
||||
export interface RunningApplication extends ExecutableApplication {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export const isRunningApplication = (app: Application): app is RunningApplication => {
|
||||
return !!(app as RunningApplication).sessionId
|
||||
}
|
||||
|
||||
export interface RecentResponse {
|
||||
readonly recent: ReadonlyArray<Application>
|
||||
readonly running: ReadonlyArray<RunningApplication>
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
readonly type: "file" | "directory"
|
||||
readonly name: string
|
||||
readonly size: number
|
||||
}
|
||||
|
||||
export interface FilesResponse {
|
||||
files: FileEntry[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
40
src/common/emitter.ts
Normal file
40
src/common/emitter.ts
Normal file
@ -0,0 +1,40 @@
|
||||
export interface Disposable {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export interface Event<T> {
|
||||
(listener: (value: T) => void): Disposable
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitter typecasts for a single event type.
|
||||
*/
|
||||
export class Emitter<T> {
|
||||
private listeners: Array<(value: T) => void> = []
|
||||
|
||||
public get event(): Event<T> {
|
||||
return (cb: (value: T) => void): Disposable => {
|
||||
this.listeners.push(cb)
|
||||
|
||||
return {
|
||||
dispose: (): void => {
|
||||
const i = this.listeners.indexOf(cb)
|
||||
if (i !== -1) {
|
||||
this.listeners.splice(i, 1)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event with a value.
|
||||
*/
|
||||
public emit(value: T): void {
|
||||
this.listeners.forEach((cb) => cb(value))
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.listeners = []
|
||||
}
|
||||
}
|
24
src/common/http.ts
Normal file
24
src/common/http.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export enum HttpCode {
|
||||
Ok = 200,
|
||||
Redirect = 302,
|
||||
NotFound = 404,
|
||||
BadRequest = 400,
|
||||
Unauthorized = 401,
|
||||
LargePayload = 413,
|
||||
ServerError = 500,
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
public constructor(message: string, public readonly code: number) {
|
||||
super(message)
|
||||
this.name = this.constructor.name
|
||||
}
|
||||
}
|
||||
|
||||
export enum ApiEndpoint {
|
||||
applications = "/applications",
|
||||
files = "/files",
|
||||
login = "/login",
|
||||
recent = "/recent",
|
||||
session = "/session",
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import { Event } from "vs/base/common/event";
|
||||
import { IChannel, IServerChannel } from "vs/base/parts/ipc/common/ipc";
|
||||
import { createDecorator } from "vs/platform/instantiation/common/instantiation";
|
||||
import { ReadWriteConnection } from "vs/server/node_modules/@coder/node-browser/out/common/connection";
|
||||
|
||||
export const INodeProxyService = createDecorator<INodeProxyService>("nodeProxyService");
|
||||
|
||||
export interface INodeProxyService extends ReadWriteConnection {
|
||||
_serviceBrand: any;
|
||||
send(message: string): void;
|
||||
onMessage: Event<string>;
|
||||
onUp: Event<void>;
|
||||
onClose: Event<void>;
|
||||
onDown: Event<void>;
|
||||
}
|
||||
|
||||
export class NodeProxyChannel implements IServerChannel {
|
||||
constructor(private service: INodeProxyService) {}
|
||||
|
||||
listen(_: unknown, event: string): Event<any> {
|
||||
switch (event) {
|
||||
case "onMessage": return this.service.onMessage;
|
||||
}
|
||||
throw new Error(`Invalid listen ${event}`);
|
||||
}
|
||||
|
||||
async call(_: unknown, command: string, args?: any): Promise<any> {
|
||||
switch (command) {
|
||||
case "send": return this.service.send(args[0]);
|
||||
}
|
||||
throw new Error(`Invalid call ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeProxyChannelClient {
|
||||
_serviceBrand: any;
|
||||
|
||||
public readonly onMessage: Event<string>;
|
||||
|
||||
constructor(private readonly channel: IChannel) {
|
||||
this.onMessage = this.channel.listen<string>("onMessage");
|
||||
}
|
||||
|
||||
public send(data: string): void {
|
||||
this.channel.call("send", [data]);
|
||||
}
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
import { ITelemetryData } from "vs/base/common/actions";
|
||||
import { Event } from "vs/base/common/event";
|
||||
import { IChannel, IServerChannel } from "vs/base/parts/ipc/common/ipc";
|
||||
import { ClassifiedEvent, GDPRClassification, StrictPropertyCheck } from "vs/platform/telemetry/common/gdprTypings";
|
||||
import { ITelemetryInfo, ITelemetryService } from "vs/platform/telemetry/common/telemetry";
|
||||
|
||||
export class TelemetryChannel implements IServerChannel {
|
||||
constructor(private service: ITelemetryService) {}
|
||||
|
||||
listen(_: unknown, event: string): Event<any> {
|
||||
throw new Error(`Invalid listen ${event}`);
|
||||
}
|
||||
|
||||
call(_: unknown, command: string, args?: any): Promise<any> {
|
||||
switch (command) {
|
||||
case "publicLog": return this.service.publicLog(args[0], args[1], args[2]);
|
||||
case "publicLog2": return this.service.publicLog2(args[0], args[1], args[2]);
|
||||
case "setEnabled": return Promise.resolve(this.service.setEnabled(args[0]));
|
||||
case "getTelemetryInfo": return this.service.getTelemetryInfo();
|
||||
}
|
||||
throw new Error(`Invalid call ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class TelemetryChannelClient implements ITelemetryService {
|
||||
_serviceBrand: any;
|
||||
|
||||
constructor(private readonly channel: IChannel) {}
|
||||
|
||||
public publicLog(eventName: string, data?: ITelemetryData, anonymizeFilePaths?: boolean): Promise<void> {
|
||||
return this.channel.call("publicLog", [eventName, data, anonymizeFilePaths]);
|
||||
}
|
||||
|
||||
public publicLog2<E extends ClassifiedEvent<T> = never, T extends GDPRClassification<T> = never>(eventName: string, data?: StrictPropertyCheck<T, E>, anonymizeFilePaths?: boolean): Promise<void> {
|
||||
return this.channel.call("publicLog2", [eventName, data, anonymizeFilePaths]);
|
||||
}
|
||||
|
||||
public setEnabled(value: boolean): void {
|
||||
this.channel.call("setEnable", [value]);
|
||||
}
|
||||
|
||||
public getTelemetryInfo(): Promise<ITelemetryInfo> {
|
||||
return this.channel.call("getTelemetryInfo");
|
||||
}
|
||||
|
||||
public get isOptedIn(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
@ -1,10 +1,48 @@
|
||||
import { logger } from "@coder/logger"
|
||||
|
||||
export interface Options {
|
||||
logLevel?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a string up to the delimiter. If the delimiter doesn't exist the first
|
||||
* item will have all the text and the second item will be an empty string.
|
||||
*/
|
||||
export const split = (str: string, delimiter: string): [string, string] => {
|
||||
const index = str.indexOf(delimiter);
|
||||
return index !== -1
|
||||
? [str.substring(0, index).trim(), str.substring(index + 1)]
|
||||
: [str, ""];
|
||||
};
|
||||
const index = str.indexOf(delimiter)
|
||||
return index !== -1 ? [str.substring(0, index).trim(), str.substring(index + 1)] : [str, ""]
|
||||
}
|
||||
|
||||
export const plural = (count: number): string => (count === 1 ? "" : "s")
|
||||
|
||||
export const generateUuid = (length = 24): string => {
|
||||
const possible = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
return Array(length)
|
||||
.fill(1)
|
||||
.map(() => possible[Math.floor(Math.random() * possible.length)])
|
||||
.join("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get options embedded in the HTML from the server.
|
||||
*/
|
||||
export const getOptions = <T extends Options>(): T => {
|
||||
const el = document.getElementById("coder-options")
|
||||
try {
|
||||
if (!el) {
|
||||
throw new Error("no options element")
|
||||
}
|
||||
const value = el.getAttribute("data-settings")
|
||||
if (!value) {
|
||||
throw new Error("no options value")
|
||||
}
|
||||
const options = JSON.parse(value)
|
||||
if (typeof options.logLevel !== "undefined") {
|
||||
logger.level = options.logLevel
|
||||
}
|
||||
return options
|
||||
} catch (error) {
|
||||
logger.warn(error.message)
|
||||
return {} as T
|
||||
}
|
||||
}
|
||||
|
@ -1,94 +0,0 @@
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*, *:before, *:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: #FFFFFF;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-family: "monospace";
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 18px 80px 10px rgba(69, 65, 78, 0.08);
|
||||
color: #575962;
|
||||
margin-top: -10%;
|
||||
max-width: 328px;
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-form > .title {
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1.5px;
|
||||
line-height: 15px;
|
||||
margin-bottom: 0px;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.login-form > .subtitle {
|
||||
font-size: 19px;
|
||||
font-weight: bold;
|
||||
line-height: 25px;
|
||||
margin-bottom: 45px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-form > .field {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: #797E84;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.login-form > .field > .input {
|
||||
background: none !important;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 2px;
|
||||
padding: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-form > .button {
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 12px 17px 2px rgba(171,173,163,0.14), 0 5px 22px 4px rgba(171,173,163,0.12), 0 7px 8px -4px rgba(171,173,163,0.2);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
padding: 15px 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-form > .button:hover {
|
||||
background-color: rgb(0, 122, 204);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.error-display {
|
||||
box-sizing: border-box;
|
||||
color: #bb2d0f;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 12px;
|
||||
padding: 20px 8px 0;
|
||||
text-align: center;
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "code-server",
|
||||
"short_name": "code-server",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background-color": "#fff",
|
||||
"description": "Run VS Code on a remote server.",
|
||||
"icons": [{
|
||||
"src": "./code-server.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
}]
|
||||
}
|
159
src/node/api/server.ts
Normal file
159
src/node/api/server.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { field, logger } from "@coder/logger"
|
||||
import * as http from "http"
|
||||
import * as net from "net"
|
||||
import * as querystring from "querystring"
|
||||
import * as ws from "ws"
|
||||
import { ApplicationsResponse, ClientMessage, FilesResponse, LoginResponse, ServerMessage } from "../../common/api"
|
||||
import { ApiEndpoint, HttpCode } from "../../common/http"
|
||||
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, PostData } from "../http"
|
||||
import { hash } from "../util"
|
||||
|
||||
interface LoginPayload extends PostData {
|
||||
password?: string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* API HTTP provider.
|
||||
*/
|
||||
export class ApiHttpProvider extends HttpProvider {
|
||||
private readonly ws = new ws.Server({ noServer: true })
|
||||
|
||||
public constructor(private readonly server: HttpServer, options: HttpProviderOptions) {
|
||||
super(options)
|
||||
}
|
||||
|
||||
public async handleRequest(
|
||||
base: string,
|
||||
_requestPath: string,
|
||||
_query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage
|
||||
): Promise<HttpResponse | undefined> {
|
||||
switch (base) {
|
||||
case ApiEndpoint.login:
|
||||
if (request.method === "POST") {
|
||||
return this.login(request)
|
||||
}
|
||||
break
|
||||
default:
|
||||
if (!this.authenticated(request)) {
|
||||
return { code: HttpCode.Unauthorized }
|
||||
}
|
||||
switch (base) {
|
||||
case ApiEndpoint.applications:
|
||||
return this.applications()
|
||||
case ApiEndpoint.files:
|
||||
return this.files()
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
public async handleWebSocket(
|
||||
_base: string,
|
||||
_requestPath: string,
|
||||
_query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage,
|
||||
socket: net.Socket,
|
||||
head: Buffer
|
||||
): Promise<true> {
|
||||
if (!this.authenticated(request)) {
|
||||
throw new Error("not authenticated")
|
||||
}
|
||||
await new Promise<ws>((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())
|
||||
this.getMessageResponse(message.event).then(send)
|
||||
} catch (error) {
|
||||
logger.error(error.message, field("message", data))
|
||||
}
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private async getMessageResponse(event: "health"): Promise<ServerMessage> {
|
||||
switch (event) {
|
||||
case "health":
|
||||
return { event, connections: await this.server.getConnections() }
|
||||
default:
|
||||
throw new Error("unexpected message")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return OK and a cookie if the user is authenticated otherwise return
|
||||
* unauthorized.
|
||||
*/
|
||||
private async login(request: http.IncomingMessage): Promise<HttpResponse<LoginResponse>> {
|
||||
const ok = (password: string | true): HttpResponse<LoginResponse> => {
|
||||
return {
|
||||
content: {
|
||||
success: true,
|
||||
},
|
||||
cookie: typeof password === "string" ? { key: "key", value: password } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Already authenticated via cookies?
|
||||
const providedPassword = this.authenticated(request)
|
||||
if (providedPassword) {
|
||||
return ok(providedPassword)
|
||||
}
|
||||
|
||||
const data = await this.getData(request)
|
||||
const payload: LoginPayload = data ? querystring.parse(data) : {}
|
||||
const password = this.authenticated(request, {
|
||||
key: typeof payload.password === "string" ? [hash(payload.password)] : undefined,
|
||||
})
|
||||
if (password) {
|
||||
return ok(password)
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Failed login attempt",
|
||||
JSON.stringify({
|
||||
xForwardedFor: request.headers["x-forwarded-for"],
|
||||
remoteAddress: request.connection.remoteAddress,
|
||||
userAgent: request.headers["user-agent"],
|
||||
timestamp: Math.floor(new Date().getTime() / 1000),
|
||||
})
|
||||
)
|
||||
|
||||
return { code: HttpCode.Unauthorized }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return files at the requested directory.
|
||||
*/
|
||||
private async files(): Promise<HttpResponse<FilesResponse>> {
|
||||
return {
|
||||
content: {
|
||||
files: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return available applications.
|
||||
*/
|
||||
private async applications(): Promise<HttpResponse<ApplicationsResponse>> {
|
||||
return {
|
||||
content: {
|
||||
applications: [
|
||||
{
|
||||
name: "VS Code",
|
||||
path: "/vscode",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
37
src/node/app/server.tsx
Normal file
37
src/node/app/server.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { logger } from "@coder/logger"
|
||||
import * as React from "react"
|
||||
import * as ReactDOMServer from "react-dom/server"
|
||||
import * as ReactRouterDOM from "react-router-dom"
|
||||
import App from "../../browser/app"
|
||||
import { HttpProvider, HttpResponse } from "../http"
|
||||
|
||||
/**
|
||||
* Top-level and fallback HTTP provider.
|
||||
*/
|
||||
export class MainHttpProvider extends HttpProvider {
|
||||
public async handleRequest(base: string, requestPath: string): Promise<HttpResponse | undefined> {
|
||||
if (base === "/static") {
|
||||
const response = await this.getResource(this.rootPath, requestPath)
|
||||
response.cache = true
|
||||
return response
|
||||
}
|
||||
|
||||
const response = await this.getUtf8Resource(this.rootPath, "src/browser/index.html")
|
||||
response.content = response.content
|
||||
.replace(/{{COMMIT}}/g, "") // TODO
|
||||
.replace(/"{{OPTIONS}}"/g, `'${JSON.stringify({ logLevel: logger.level })}'`)
|
||||
.replace(
|
||||
/{{COMPONENT}}/g,
|
||||
ReactDOMServer.renderToString(
|
||||
<ReactRouterDOM.StaticRouter location={base}>
|
||||
<App />
|
||||
</ReactRouterDOM.StaticRouter>
|
||||
)
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
public async handleWebSocket(): Promise<undefined> {
|
||||
return undefined
|
||||
}
|
||||
}
|
@ -1,343 +0,0 @@
|
||||
import * as path from "path";
|
||||
import { VSBuffer, VSBufferReadableStream } from "vs/base/common/buffer";
|
||||
import { Emitter, Event } from "vs/base/common/event";
|
||||
import { IDisposable } from "vs/base/common/lifecycle";
|
||||
import { OS } from "vs/base/common/platform";
|
||||
import { ReadableStreamEventPayload } from "vs/base/common/stream";
|
||||
import { URI, UriComponents } from "vs/base/common/uri";
|
||||
import { transformOutgoingURIs } from "vs/base/common/uriIpc";
|
||||
import { IServerChannel } from "vs/base/parts/ipc/common/ipc";
|
||||
import { IDiagnosticInfo } from "vs/platform/diagnostics/common/diagnostics";
|
||||
import { IEnvironmentService } from "vs/platform/environment/common/environment";
|
||||
import { ExtensionIdentifier, IExtensionDescription } from "vs/platform/extensions/common/extensions";
|
||||
import { FileDeleteOptions, FileOpenOptions, FileOverwriteOptions, FileReadStreamOptions, FileType, FileWriteOptions, IStat, IWatchOptions } from "vs/platform/files/common/files";
|
||||
import { createReadStream } from "vs/platform/files/common/io";
|
||||
import { DiskFileSystemProvider } from "vs/platform/files/node/diskFileSystemProvider";
|
||||
import { ILogService } from "vs/platform/log/common/log";
|
||||
import product from "vs/platform/product/common/product";
|
||||
import { IRemoteAgentEnvironment, RemoteAgentConnectionContext } from "vs/platform/remote/common/remoteAgentEnvironment";
|
||||
import { ITelemetryService } from "vs/platform/telemetry/common/telemetry";
|
||||
import { INodeProxyService } from "vs/server/src/common/nodeProxy";
|
||||
import { getTranslations } from "vs/server/src/node/nls";
|
||||
import { getUriTransformer, localRequire } from "vs/server/src/node/util";
|
||||
import { IFileChangeDto } from "vs/workbench/api/common/extHost.protocol";
|
||||
import { ExtensionScanner, ExtensionScannerInput } from "vs/workbench/services/extensions/node/extensionPoints";
|
||||
|
||||
/**
|
||||
* Extend the file provider to allow unwatching.
|
||||
*/
|
||||
class Watcher extends DiskFileSystemProvider {
|
||||
public readonly watches = new Map<number, IDisposable>();
|
||||
|
||||
public dispose(): void {
|
||||
this.watches.forEach((w) => w.dispose());
|
||||
this.watches.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public _watch(req: number, resource: URI, opts: IWatchOptions): void {
|
||||
this.watches.set(req, this.watch(resource, opts));
|
||||
}
|
||||
|
||||
public unwatch(req: number): void {
|
||||
this.watches.get(req)!.dispose();
|
||||
this.watches.delete(req);
|
||||
}
|
||||
}
|
||||
|
||||
export class FileProviderChannel implements IServerChannel<RemoteAgentConnectionContext>, IDisposable {
|
||||
private readonly provider: DiskFileSystemProvider;
|
||||
private readonly watchers = new Map<string, Watcher>();
|
||||
|
||||
public constructor(
|
||||
private readonly environmentService: IEnvironmentService,
|
||||
private readonly logService: ILogService,
|
||||
) {
|
||||
this.provider = new DiskFileSystemProvider(this.logService);
|
||||
}
|
||||
|
||||
public listen(context: RemoteAgentConnectionContext, event: string, args?: any): Event<any> {
|
||||
switch (event) {
|
||||
case "filechange": return this.filechange(context, args[0]);
|
||||
case "readFileStream": return this.readFileStream(args[0], args[1]);
|
||||
}
|
||||
|
||||
throw new Error(`Invalid listen "${event}"`);
|
||||
}
|
||||
|
||||
private filechange(context: RemoteAgentConnectionContext, session: string): Event<IFileChangeDto[]> {
|
||||
const emitter = new Emitter<IFileChangeDto[]>({
|
||||
onFirstListenerAdd: () => {
|
||||
const provider = new Watcher(this.logService);
|
||||
this.watchers.set(session, provider);
|
||||
const transformer = getUriTransformer(context.remoteAuthority);
|
||||
provider.onDidChangeFile((events) => {
|
||||
emitter.fire(events.map((event) => ({
|
||||
...event,
|
||||
resource: transformer.transformOutgoing(event.resource),
|
||||
})));
|
||||
});
|
||||
provider.onDidErrorOccur((event) => this.logService.error(event));
|
||||
},
|
||||
onLastListenerRemove: () => {
|
||||
this.watchers.get(session)!.dispose();
|
||||
this.watchers.delete(session);
|
||||
},
|
||||
});
|
||||
|
||||
return emitter.event;
|
||||
}
|
||||
|
||||
private readFileStream(resource: UriComponents, opts: FileReadStreamOptions): Event<ReadableStreamEventPayload<VSBuffer>> {
|
||||
let fileStream: VSBufferReadableStream | undefined;
|
||||
const emitter = new Emitter<ReadableStreamEventPayload<VSBuffer>>({
|
||||
onFirstListenerAdd: () => {
|
||||
if (!fileStream) {
|
||||
fileStream = createReadStream(this.provider, this.transform(resource), {
|
||||
...opts,
|
||||
bufferSize: 64 * 1024, // From DiskFileSystemProvider
|
||||
});
|
||||
fileStream.on("data", (data) => emitter.fire(data));
|
||||
fileStream.on("error", (error) => emitter.fire(error));
|
||||
fileStream.on("end", () => emitter.fire("end"));
|
||||
}
|
||||
},
|
||||
onLastListenerRemove: () => fileStream && fileStream.destroy(),
|
||||
});
|
||||
|
||||
return emitter.event;
|
||||
}
|
||||
|
||||
public call(_: unknown, command: string, args?: any): Promise<any> {
|
||||
switch (command) {
|
||||
case "stat": return this.stat(args[0]);
|
||||
case "open": return this.open(args[0], args[1]);
|
||||
case "close": return this.close(args[0]);
|
||||
case "read": return this.read(args[0], args[1], args[2]);
|
||||
case "readFile": return this.readFile(args[0]);
|
||||
case "write": return this.write(args[0], args[1], args[2], args[3], args[4]);
|
||||
case "writeFile": return this.writeFile(args[0], args[1], args[2]);
|
||||
case "delete": return this.delete(args[0], args[1]);
|
||||
case "mkdir": return this.mkdir(args[0]);
|
||||
case "readdir": return this.readdir(args[0]);
|
||||
case "rename": return this.rename(args[0], args[1], args[2]);
|
||||
case "copy": return this.copy(args[0], args[1], args[2]);
|
||||
case "watch": return this.watch(args[0], args[1], args[2], args[3]);
|
||||
case "unwatch": return this.unwatch(args[0], args[1]);
|
||||
}
|
||||
|
||||
throw new Error(`Invalid call "${command}"`);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.watchers.forEach((w) => w.dispose());
|
||||
this.watchers.clear();
|
||||
}
|
||||
|
||||
private async stat(resource: UriComponents): Promise<IStat> {
|
||||
return this.provider.stat(this.transform(resource));
|
||||
}
|
||||
|
||||
private async open(resource: UriComponents, opts: FileOpenOptions): Promise<number> {
|
||||
return this.provider.open(this.transform(resource), opts);
|
||||
}
|
||||
|
||||
private async close(fd: number): Promise<void> {
|
||||
return this.provider.close(fd);
|
||||
}
|
||||
|
||||
private async read(fd: number, pos: number, length: number): Promise<[VSBuffer, number]> {
|
||||
const buffer = VSBuffer.alloc(length);
|
||||
const bytesRead = await this.provider.read(fd, pos, buffer.buffer, 0, length);
|
||||
return [buffer, bytesRead];
|
||||
}
|
||||
|
||||
private async readFile(resource: UriComponents): Promise<VSBuffer> {
|
||||
return VSBuffer.wrap(await this.provider.readFile(this.transform(resource)));
|
||||
}
|
||||
|
||||
private write(fd: number, pos: number, buffer: VSBuffer, offset: number, length: number): Promise<number> {
|
||||
return this.provider.write(fd, pos, buffer.buffer, offset, length);
|
||||
}
|
||||
|
||||
private writeFile(resource: UriComponents, buffer: VSBuffer, opts: FileWriteOptions): Promise<void> {
|
||||
return this.provider.writeFile(this.transform(resource), buffer.buffer, opts);
|
||||
}
|
||||
|
||||
private async delete(resource: UriComponents, opts: FileDeleteOptions): Promise<void> {
|
||||
return this.provider.delete(this.transform(resource), opts);
|
||||
}
|
||||
|
||||
private async mkdir(resource: UriComponents): Promise<void> {
|
||||
return this.provider.mkdir(this.transform(resource));
|
||||
}
|
||||
|
||||
private async readdir(resource: UriComponents): Promise<[string, FileType][]> {
|
||||
return this.provider.readdir(this.transform(resource));
|
||||
}
|
||||
|
||||
private async rename(resource: UriComponents, target: UriComponents, opts: FileOverwriteOptions): Promise<void> {
|
||||
return this.provider.rename(this.transform(resource), URI.from(target), opts);
|
||||
}
|
||||
|
||||
private copy(resource: UriComponents, target: UriComponents, opts: FileOverwriteOptions): Promise<void> {
|
||||
return this.provider.copy(this.transform(resource), URI.from(target), opts);
|
||||
}
|
||||
|
||||
private async watch(session: string, req: number, resource: UriComponents, opts: IWatchOptions): Promise<void> {
|
||||
this.watchers.get(session)!._watch(req, this.transform(resource), opts);
|
||||
}
|
||||
|
||||
private async unwatch(session: string, req: number): Promise<void> {
|
||||
this.watchers.get(session)!.unwatch(req);
|
||||
}
|
||||
|
||||
private transform(resource: UriComponents): URI {
|
||||
// Used for walkthrough content.
|
||||
if (/^\/static[^/]*\//.test(resource.path)) {
|
||||
return URI.file(this.environmentService.appRoot + resource.path.replace(/^\/static[^/]*\//, "/"));
|
||||
// Used by the webview service worker to load resources.
|
||||
} else if (resource.path === "/vscode-resource" && resource.query) {
|
||||
try {
|
||||
const query = JSON.parse(resource.query);
|
||||
if (query.requestResourcePath) {
|
||||
return URI.file(query.requestResourcePath);
|
||||
}
|
||||
} catch (error) { /* Carry on. */ }
|
||||
}
|
||||
return URI.from(resource);
|
||||
}
|
||||
}
|
||||
|
||||
export class ExtensionEnvironmentChannel implements IServerChannel {
|
||||
public constructor(
|
||||
private readonly environment: IEnvironmentService,
|
||||
private readonly log: ILogService,
|
||||
private readonly telemetry: ITelemetryService,
|
||||
private readonly connectionToken: string,
|
||||
) {}
|
||||
|
||||
public listen(_: unknown, event: string): Event<any> {
|
||||
throw new Error(`Invalid listen "${event}"`);
|
||||
}
|
||||
|
||||
public async call(context: any, command: string, args?: any): Promise<any> {
|
||||
switch (command) {
|
||||
case "getEnvironmentData":
|
||||
return transformOutgoingURIs(
|
||||
await this.getEnvironmentData(args.language),
|
||||
getUriTransformer(context.remoteAuthority),
|
||||
);
|
||||
case "getDiagnosticInfo": return this.getDiagnosticInfo();
|
||||
case "disableTelemetry": return this.disableTelemetry();
|
||||
}
|
||||
throw new Error(`Invalid call "${command}"`);
|
||||
}
|
||||
|
||||
private async getEnvironmentData(locale: string): Promise<IRemoteAgentEnvironment> {
|
||||
return {
|
||||
pid: process.pid,
|
||||
connectionToken: this.connectionToken,
|
||||
appRoot: URI.file(this.environment.appRoot),
|
||||
appSettingsHome: this.environment.appSettingsHome,
|
||||
settingsPath: this.environment.machineSettingsHome,
|
||||
logsPath: URI.file(this.environment.logsPath),
|
||||
extensionsPath: URI.file(this.environment.extensionsPath!),
|
||||
extensionHostLogsPath: URI.file(path.join(this.environment.logsPath, "extension-host")),
|
||||
globalStorageHome: URI.file(this.environment.globalStorageHome),
|
||||
userHome: URI.file(this.environment.userHome),
|
||||
extensions: await this.scanExtensions(locale),
|
||||
os: OS,
|
||||
};
|
||||
}
|
||||
|
||||
private async scanExtensions(locale: string): Promise<IExtensionDescription[]> {
|
||||
const translations = await getTranslations(locale, this.environment.userDataPath);
|
||||
|
||||
const scanMultiple = (isBuiltin: boolean, isUnderDevelopment: boolean, paths: string[]): Promise<IExtensionDescription[][]> => {
|
||||
return Promise.all(paths.map((path) => {
|
||||
return ExtensionScanner.scanExtensions(new ExtensionScannerInput(
|
||||
product.version,
|
||||
product.commit,
|
||||
locale,
|
||||
!!process.env.VSCODE_DEV,
|
||||
path,
|
||||
isBuiltin,
|
||||
isUnderDevelopment,
|
||||
translations,
|
||||
), this.log);
|
||||
}));
|
||||
};
|
||||
|
||||
const scanBuiltin = async (): Promise<IExtensionDescription[][]> => {
|
||||
return scanMultiple(true, false, [this.environment.builtinExtensionsPath, ...this.environment.extraBuiltinExtensionPaths]);
|
||||
};
|
||||
|
||||
const scanInstalled = async (): Promise<IExtensionDescription[][]> => {
|
||||
return scanMultiple(false, true, [this.environment.extensionsPath!, ...this.environment.extraExtensionPaths]);
|
||||
};
|
||||
|
||||
return Promise.all([scanBuiltin(), scanInstalled()]).then((allExtensions) => {
|
||||
const uniqueExtensions = new Map<string, IExtensionDescription>();
|
||||
allExtensions.forEach((multipleExtensions) => {
|
||||
multipleExtensions.forEach((extensions) => {
|
||||
extensions.forEach((extension) => {
|
||||
const id = ExtensionIdentifier.toKey(extension.identifier);
|
||||
if (uniqueExtensions.has(id)) {
|
||||
const oldPath = uniqueExtensions.get(id)!.extensionLocation.fsPath;
|
||||
const newPath = extension.extensionLocation.fsPath;
|
||||
this.log.warn(`${oldPath} has been overridden ${newPath}`);
|
||||
}
|
||||
uniqueExtensions.set(id, extension);
|
||||
});
|
||||
});
|
||||
});
|
||||
return Array.from(uniqueExtensions.values());
|
||||
});
|
||||
}
|
||||
|
||||
private getDiagnosticInfo(): Promise<IDiagnosticInfo> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
private async disableTelemetry(): Promise<void> {
|
||||
this.telemetry.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeProxyService implements INodeProxyService {
|
||||
public _serviceBrand = undefined;
|
||||
|
||||
public readonly server: import("@coder/node-browser/out/server/server").Server;
|
||||
|
||||
private readonly _onMessage = new Emitter<string>();
|
||||
public readonly onMessage = this._onMessage.event;
|
||||
private readonly _$onMessage = new Emitter<string>();
|
||||
public readonly $onMessage = this._$onMessage.event;
|
||||
public readonly _onDown = new Emitter<void>();
|
||||
public readonly onDown = this._onDown.event;
|
||||
public readonly _onUp = new Emitter<void>();
|
||||
public readonly onUp = this._onUp.event;
|
||||
|
||||
// Unused because the server connection will never permanently close.
|
||||
private readonly _onClose = new Emitter<void>();
|
||||
public readonly onClose = this._onClose.event;
|
||||
|
||||
public constructor() {
|
||||
// TODO: down/up
|
||||
const { Server } = localRequire<typeof import("@coder/node-browser/out/server/server")>("@coder/node-browser/out/server/server");
|
||||
this.server = new Server({
|
||||
onMessage: this.$onMessage,
|
||||
onClose: this.onClose,
|
||||
onDown: this.onDown,
|
||||
onUp: this.onUp,
|
||||
send: (message: string): void => {
|
||||
this._onMessage.fire(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public send(message: string): void {
|
||||
this._$onMessage.fire(message);
|
||||
}
|
||||
}
|
299
src/node/cli.ts
299
src/node/cli.ts
@ -1,299 +0,0 @@
|
||||
import * as cp from "child_process";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { setUnexpectedErrorHandler } from "vs/base/common/errors";
|
||||
import { main as vsCli } from "vs/code/node/cliProcessMain";
|
||||
import { validatePaths } from "vs/code/node/paths";
|
||||
import { ParsedArgs } from "vs/platform/environment/common/environment";
|
||||
import { buildHelpMessage, buildVersionMessage, Option as VsOption, OPTIONS, OptionDescriptions } from "vs/platform/environment/node/argv";
|
||||
import { parseMainProcessArgv } from "vs/platform/environment/node/argvHelper";
|
||||
import product from "vs/platform/product/common/product";
|
||||
import { ipcMain } from "vs/server/src/node/ipc";
|
||||
import { enableCustomMarketplace } from "vs/server/src/node/marketplace";
|
||||
import { MainServer } from "vs/server/src/node/server";
|
||||
import { AuthType, buildAllowedMessage, enumToArray, FormatType, generateCertificate, generatePassword, localRequire, open, unpackExecutables } from "vs/server/src/node/util";
|
||||
|
||||
const { logger } = localRequire<typeof import("@coder/logger/out/index")>("@coder/logger/out/index");
|
||||
setUnexpectedErrorHandler((error) => logger.warn(error.message));
|
||||
|
||||
interface Args extends ParsedArgs {
|
||||
auth?: AuthType;
|
||||
"base-path"?: string;
|
||||
cert?: string;
|
||||
"cert-key"?: string;
|
||||
format?: string;
|
||||
host?: string;
|
||||
open?: boolean;
|
||||
port?: string;
|
||||
socket?: string;
|
||||
}
|
||||
|
||||
// @ts-ignore: Force `keyof Args` to work.
|
||||
interface Option extends VsOption {
|
||||
id: keyof Args;
|
||||
}
|
||||
|
||||
const getArgs = (): Args => {
|
||||
// Remove options that won't work or don't make sense.
|
||||
for (let key in OPTIONS) {
|
||||
switch (key) {
|
||||
case "add":
|
||||
case "diff":
|
||||
case "file-uri":
|
||||
case "folder-uri":
|
||||
case "goto":
|
||||
case "new-window":
|
||||
case "reuse-window":
|
||||
case "wait":
|
||||
case "disable-gpu":
|
||||
// TODO: pretty sure these don't work but not 100%.
|
||||
case "prof-startup":
|
||||
case "inspect-extensions":
|
||||
case "inspect-brk-extensions":
|
||||
delete OPTIONS[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const options = OPTIONS as OptionDescriptions<Required<Args>>;
|
||||
options["base-path"] = { type: "string", cat: "o", description: "Base path of the URL at which code-server is hosted (used for login redirects)." };
|
||||
options["cert"] = { type: "string", cat: "o", description: "Path to certificate. If the path is omitted, both this and --cert-key will be generated." };
|
||||
options["cert-key"] = { type: "string", cat: "o", description: "Path to the certificate's key if one was provided." };
|
||||
options["format"] = { type: "string", cat: "o", description: `Format for the version. ${buildAllowedMessage(FormatType)}.` };
|
||||
options["host"] = { type: "string", cat: "o", description: "Host for the server." };
|
||||
options["auth"] = { type: "string", cat: "o", description: `The type of authentication to use. ${buildAllowedMessage(AuthType)}.` };
|
||||
options["open"] = { type: "boolean", cat: "o", description: "Open in the browser on startup." };
|
||||
options["port"] = { type: "string", cat: "o", description: "Port for the main server." };
|
||||
options["socket"] = { type: "string", cat: "o", description: "Listen on a socket instead of host:port." };
|
||||
|
||||
const args = parseMainProcessArgv(process.argv);
|
||||
if (!args["user-data-dir"]) {
|
||||
args["user-data-dir"] = path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local/share"), "code-server");
|
||||
}
|
||||
if (!args["extensions-dir"]) {
|
||||
args["extensions-dir"] = path.join(args["user-data-dir"], "extensions");
|
||||
}
|
||||
|
||||
if (!args.verbose && !args.log && process.env.LOG_LEVEL) {
|
||||
args.log = process.env.LOG_LEVEL;
|
||||
}
|
||||
|
||||
return validatePaths(args);
|
||||
};
|
||||
|
||||
const startVscode = async (args: Args): Promise<void | void[]> => {
|
||||
const extra = args["_"] || [];
|
||||
const options = {
|
||||
auth: args.auth || AuthType.Password,
|
||||
basePath: args["base-path"],
|
||||
cert: args.cert,
|
||||
certKey: args["cert-key"],
|
||||
openUri: extra.length > 1 ? extra[extra.length - 1] : undefined,
|
||||
host: args.host,
|
||||
password: process.env.PASSWORD,
|
||||
};
|
||||
|
||||
if (enumToArray(AuthType).filter((t) => t === options.auth).length === 0) {
|
||||
throw new Error(`'${options.auth}' is not a valid authentication type.`);
|
||||
} else if (options.auth === "password" && !options.password) {
|
||||
options.password = await generatePassword();
|
||||
}
|
||||
|
||||
if (!options.certKey && typeof options.certKey !== "undefined") {
|
||||
throw new Error(`--cert-key cannot be blank`);
|
||||
} else if (options.certKey && !options.cert) {
|
||||
throw new Error(`--cert-key was provided but --cert was not`);
|
||||
} if (!options.cert && typeof options.cert !== "undefined") {
|
||||
const { cert, certKey } = await generateCertificate();
|
||||
options.cert = cert;
|
||||
options.certKey = certKey;
|
||||
}
|
||||
|
||||
enableCustomMarketplace();
|
||||
|
||||
const server = new MainServer({
|
||||
...options,
|
||||
port: typeof args.port !== "undefined" ? parseInt(args.port, 10) : 8080,
|
||||
socket: args.socket,
|
||||
}, args);
|
||||
|
||||
const [serverAddress, /* ignore */] = await Promise.all([
|
||||
server.listen(),
|
||||
unpackExecutables(),
|
||||
]);
|
||||
logger.info(`Server listening on ${serverAddress}`);
|
||||
|
||||
if (options.auth === "password" && !process.env.PASSWORD) {
|
||||
logger.info(` - Password is ${options.password}`);
|
||||
logger.info(" - To use your own password, set the PASSWORD environment variable");
|
||||
if (!args.auth) {
|
||||
logger.info(" - To disable use `--auth none`");
|
||||
}
|
||||
} else if (options.auth === "password") {
|
||||
logger.info(" - Using custom password for authentication");
|
||||
} else {
|
||||
logger.info(" - No authentication");
|
||||
}
|
||||
|
||||
if (server.protocol === "https") {
|
||||
logger.info(
|
||||
args.cert
|
||||
? ` - Using provided certificate${args["cert-key"] ? " and key" : ""} for HTTPS`
|
||||
: ` - Using generated certificate and key for HTTPS`,
|
||||
);
|
||||
} else {
|
||||
logger.info(" - Not serving HTTPS");
|
||||
}
|
||||
|
||||
if (!server.options.socket && args.open) {
|
||||
// The web socket doesn't seem to work if browsing with 0.0.0.0.
|
||||
const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost");
|
||||
await open(openAddress).catch(console.error);
|
||||
logger.info(` - Opened ${openAddress}`);
|
||||
}
|
||||
};
|
||||
|
||||
const startCli = (args: Args): boolean | Promise<void> => {
|
||||
if (args.help) {
|
||||
const executable = `${product.applicationName}${os.platform() === "win32" ? ".exe" : ""}`;
|
||||
console.log(buildHelpMessage(product.nameLong, executable, product.codeServerVersion, OPTIONS, false));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.version) {
|
||||
if (args.format === "json") {
|
||||
console.log(JSON.stringify({
|
||||
codeServerVersion: product.codeServerVersion,
|
||||
commit: product.commit,
|
||||
vscodeVersion: product.version,
|
||||
}));
|
||||
} else {
|
||||
buildVersionMessage(product.codeServerVersion, product.commit).split("\n").map((line) => logger.info(line));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const shouldSpawnCliProcess = (): boolean => {
|
||||
return !!args["install-source"]
|
||||
|| !!args["list-extensions"]
|
||||
|| !!args["install-extension"]
|
||||
|| !!args["uninstall-extension"]
|
||||
|| !!args["locate-extension"]
|
||||
|| !!args["telemetry"];
|
||||
};
|
||||
|
||||
if (shouldSpawnCliProcess()) {
|
||||
enableCustomMarketplace();
|
||||
return vsCli(args);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export class WrapperProcess {
|
||||
private process?: cp.ChildProcess;
|
||||
private started?: Promise<void>;
|
||||
private currentVersion = product.codeServerVersion;
|
||||
|
||||
public constructor(private readonly args: Args) {
|
||||
ipcMain.onMessage(async (message) => {
|
||||
switch (message.type) {
|
||||
case "relaunch":
|
||||
logger.info(`Relaunching: ${this.currentVersion} -> ${message.version}`);
|
||||
this.currentVersion = message.version;
|
||||
this.started = undefined;
|
||||
if (this.process) {
|
||||
this.process.removeAllListeners();
|
||||
this.process.kill();
|
||||
}
|
||||
try {
|
||||
await this.start();
|
||||
} catch (error) {
|
||||
logger.error(error.message);
|
||||
process.exit(typeof error.code === "number" ? error.code : 1);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
logger.error(`Unrecognized message ${message}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public start(): Promise<void> {
|
||||
if (!this.started) {
|
||||
const child = this.spawn();
|
||||
this.started = ipcMain.handshake(child).then(() => {
|
||||
child.once("exit", (code) => exit(code!));
|
||||
});
|
||||
this.process = child;
|
||||
}
|
||||
return this.started;
|
||||
}
|
||||
|
||||
private spawn(): cp.ChildProcess {
|
||||
// Flags to pass along to the Node binary. We use the environment variable
|
||||
// since otherwise the code-server binary will swallow them.
|
||||
const maxMemory = this.args["max-memory"] || 2048;
|
||||
let nodeOptions = `${process.env.NODE_OPTIONS || ""} ${this.args["js-flags"] || ""}`;
|
||||
if (!/max_old_space_size=(\d+)/g.exec(nodeOptions)) {
|
||||
nodeOptions += ` --max_old_space_size=${maxMemory}`;
|
||||
}
|
||||
|
||||
// If we're using loose files then we need to specify the path. If we're in
|
||||
// the binary we need to let the binary determine the path (via nbin) since
|
||||
// it could be different between binaries which presents a problem when
|
||||
// upgrading (different version numbers or different staging directories).
|
||||
const isBinary = (global as any).NBIN_LOADED;
|
||||
return cp.spawn(process.argv[0], process.argv.slice(isBinary ? 2 : 1), {
|
||||
env: {
|
||||
...process.env,
|
||||
LAUNCH_VSCODE: "true",
|
||||
NBIN_BYPASS: undefined,
|
||||
VSCODE_PARENT_PID: process.pid.toString(),
|
||||
NODE_OPTIONS: nodeOptions,
|
||||
},
|
||||
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const main = async(): Promise<boolean | void | void[]> => {
|
||||
const args = getArgs();
|
||||
if (process.env.LAUNCH_VSCODE) {
|
||||
await ipcMain.handshake();
|
||||
return startVscode(args);
|
||||
}
|
||||
return startCli(args) || new WrapperProcess(args).start();
|
||||
};
|
||||
|
||||
const exit = process.exit;
|
||||
process.exit = function (code?: number) {
|
||||
const err = new Error(`process.exit() was prevented: ${code || "unknown code"}.`);
|
||||
console.warn(err.stack);
|
||||
} as (code?: number) => never;
|
||||
|
||||
// Copy the extension host behavior of killing oneself if the parent dies. This
|
||||
// also exists in bootstrap-fork.js but spawning with that won't work because we
|
||||
// override process.exit.
|
||||
if (typeof process.env.VSCODE_PARENT_PID !== "undefined") {
|
||||
const parentPid = parseInt(process.env.VSCODE_PARENT_PID, 10);
|
||||
setInterval(() => {
|
||||
try {
|
||||
process.kill(parentPid, 0); // Throws an exception if the process doesn't exist anymore.
|
||||
} catch (e) {
|
||||
exit();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// It's possible that the pipe has closed (for example if you run code-server
|
||||
// --version | head -1). Assume that means we're done.
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.on("error", () => exit());
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logger.error(error.message);
|
||||
exit(typeof error.code === "number" ? error.code : 1);
|
||||
});
|
@ -1,156 +0,0 @@
|
||||
import * as cp from "child_process";
|
||||
import { getPathFromAmdModule } from "vs/base/common/amd";
|
||||
import { VSBuffer } from "vs/base/common/buffer";
|
||||
import { Emitter } from "vs/base/common/event";
|
||||
import { ISocket } from "vs/base/parts/ipc/common/ipc.net";
|
||||
import { NodeSocket } from "vs/base/parts/ipc/node/ipc.net";
|
||||
import { IEnvironmentService } from "vs/platform/environment/common/environment";
|
||||
import { ILogService } from "vs/platform/log/common/log";
|
||||
import { getNlsConfiguration } from "vs/server/src/node/nls";
|
||||
import { Protocol } from "vs/server/src/node/protocol";
|
||||
import { uriTransformerPath } from "vs/server/src/node/util";
|
||||
import { IExtHostReadyMessage } from "vs/workbench/services/extensions/common/extensionHostProtocol";
|
||||
|
||||
export abstract class Connection {
|
||||
private readonly _onClose = new Emitter<void>();
|
||||
public readonly onClose = this._onClose.event;
|
||||
private disposed = false;
|
||||
private _offline: number | undefined;
|
||||
|
||||
public constructor(protected protocol: Protocol, public readonly token: string) {}
|
||||
|
||||
public get offline(): number | undefined {
|
||||
return this._offline;
|
||||
}
|
||||
|
||||
public reconnect(socket: ISocket, buffer: VSBuffer): void {
|
||||
this._offline = undefined;
|
||||
this.doReconnect(socket, buffer);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (!this.disposed) {
|
||||
this.disposed = true;
|
||||
this.doDispose();
|
||||
this._onClose.fire();
|
||||
}
|
||||
}
|
||||
|
||||
protected setOffline(): void {
|
||||
if (!this._offline) {
|
||||
this._offline = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the connection on a new socket.
|
||||
*/
|
||||
protected abstract doReconnect(socket: ISocket, buffer: VSBuffer): void;
|
||||
protected abstract doDispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for all the IPC channels.
|
||||
*/
|
||||
export class ManagementConnection extends Connection {
|
||||
public constructor(protected protocol: Protocol, token: string) {
|
||||
super(protocol, token);
|
||||
protocol.onClose(() => this.dispose()); // Explicit close.
|
||||
protocol.onSocketClose(() => this.setOffline()); // Might reconnect.
|
||||
}
|
||||
|
||||
protected doDispose(): void {
|
||||
this.protocol.sendDisconnect();
|
||||
this.protocol.dispose();
|
||||
this.protocol.getSocket().end();
|
||||
}
|
||||
|
||||
protected doReconnect(socket: ISocket, buffer: VSBuffer): void {
|
||||
this.protocol.beginAcceptReconnection(socket, buffer);
|
||||
this.protocol.endAcceptReconnection();
|
||||
}
|
||||
}
|
||||
|
||||
export class ExtensionHostConnection extends Connection {
|
||||
private process?: cp.ChildProcess;
|
||||
|
||||
public constructor(
|
||||
locale:string, protocol: Protocol, buffer: VSBuffer, token: string,
|
||||
private readonly log: ILogService,
|
||||
private readonly environment: IEnvironmentService,
|
||||
) {
|
||||
super(protocol, token);
|
||||
this.protocol.dispose();
|
||||
this.spawn(locale, buffer).then((p) => this.process = p);
|
||||
this.protocol.getUnderlyingSocket().pause();
|
||||
}
|
||||
|
||||
protected doDispose(): void {
|
||||
if (this.process) {
|
||||
this.process.kill();
|
||||
}
|
||||
this.protocol.getSocket().end();
|
||||
}
|
||||
|
||||
protected doReconnect(socket: ISocket, buffer: VSBuffer): void {
|
||||
// This is just to set the new socket.
|
||||
this.protocol.beginAcceptReconnection(socket, null);
|
||||
this.protocol.dispose();
|
||||
this.sendInitMessage(buffer);
|
||||
}
|
||||
|
||||
private sendInitMessage(buffer: VSBuffer): void {
|
||||
const socket = this.protocol.getUnderlyingSocket();
|
||||
socket.pause();
|
||||
this.process!.send({ // Process must be set at this point.
|
||||
type: "VSCODE_EXTHOST_IPC_SOCKET",
|
||||
initialDataChunk: (buffer.buffer as Buffer).toString("base64"),
|
||||
skipWebSocketFrames: this.protocol.getSocket() instanceof NodeSocket,
|
||||
}, socket);
|
||||
}
|
||||
|
||||
private async spawn(locale: string, buffer: VSBuffer): Promise<cp.ChildProcess> {
|
||||
const config = await getNlsConfiguration(locale, this.environment.userDataPath);
|
||||
const proc = cp.fork(
|
||||
getPathFromAmdModule(require, "bootstrap-fork"),
|
||||
[ "--type=extensionHost", `--uriTransformerPath=${uriTransformerPath}` ],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
AMD_ENTRYPOINT: "vs/workbench/services/extensions/node/extensionHostProcess",
|
||||
PIPE_LOGGING: "true",
|
||||
VERBOSE_LOGGING: "true",
|
||||
VSCODE_EXTHOST_WILL_SEND_SOCKET: "true",
|
||||
VSCODE_HANDLES_UNCAUGHT_ERRORS: "true",
|
||||
VSCODE_LOG_STACK: "false",
|
||||
VSCODE_LOG_LEVEL: this.environment.verbose ? "trace" : this.environment.log,
|
||||
VSCODE_NLS_CONFIG: JSON.stringify(config),
|
||||
},
|
||||
silent: true,
|
||||
},
|
||||
);
|
||||
|
||||
proc.on("error", () => this.dispose());
|
||||
proc.on("exit", () => this.dispose());
|
||||
proc.stdout.setEncoding("utf8").on("data", (d) => this.log.info("Extension host stdout", d));
|
||||
proc.stderr.setEncoding("utf8").on("data", (d) => this.log.error("Extension host stderr", d));
|
||||
proc.on("message", (event) => {
|
||||
if (event && event.type === "__$console") {
|
||||
const severity = (<any>this.log)[event.severity] ? event.severity : "info";
|
||||
(<any>this.log)[severity]("Extension host", event.arguments);
|
||||
}
|
||||
if (event && event.type === "VSCODE_EXTHOST_DISCONNECTED") {
|
||||
this.setOffline();
|
||||
}
|
||||
});
|
||||
|
||||
const listen = (message: IExtHostReadyMessage) => {
|
||||
if (message.type === "VSCODE_EXTHOST_IPC_READY") {
|
||||
proc.removeListener("message", listen);
|
||||
this.sendInitMessage(buffer);
|
||||
}
|
||||
};
|
||||
|
||||
return proc.on("message", listen);
|
||||
}
|
||||
}
|
87
src/node/entry.ts
Normal file
87
src/node/entry.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { logger } from "@coder/logger"
|
||||
import { ApiHttpProvider } from "./api/server"
|
||||
import { MainHttpProvider } from "./app/server"
|
||||
import { AuthType, HttpServer } from "./http"
|
||||
import { generateCertificate, generatePassword, hash, open } from "./util"
|
||||
import { VscodeHttpProvider } from "./vscode/server"
|
||||
import { ipcMain, wrap } from "./wrapper"
|
||||
|
||||
export interface Args {
|
||||
auth?: AuthType
|
||||
"base-path"?: string
|
||||
cert?: string
|
||||
"cert-key"?: string
|
||||
format?: string
|
||||
host?: string
|
||||
open?: boolean
|
||||
port?: string
|
||||
socket?: string
|
||||
_?: string[]
|
||||
}
|
||||
|
||||
const main = async (args: Args = {}): Promise<void> => {
|
||||
// Spawn the main HTTP server.
|
||||
const options = {
|
||||
basePath: args["base-path"],
|
||||
cert: args.cert,
|
||||
certKey: args["cert-key"],
|
||||
host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"),
|
||||
port: typeof args.port !== "undefined" ? parseInt(args.port, 10) : 8080,
|
||||
socket: args.socket,
|
||||
}
|
||||
if (!options.cert && typeof options.cert !== "undefined") {
|
||||
const { cert, certKey } = await generateCertificate()
|
||||
options.cert = cert
|
||||
options.certKey = certKey
|
||||
}
|
||||
const httpServer = new HttpServer(options)
|
||||
|
||||
// Register all the providers.
|
||||
// TODO: Might be cleaner to be able to register with just the class name
|
||||
// then let HttpServer instantiate with the common arguments.
|
||||
const auth = args.auth || AuthType.Password
|
||||
const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword()))
|
||||
const password = originalPassword && hash(originalPassword)
|
||||
httpServer.registerHttpProvider("/", new MainHttpProvider({ base: "/", auth, password }))
|
||||
httpServer.registerHttpProvider("/api", new ApiHttpProvider(httpServer, { base: "/", auth, password }))
|
||||
httpServer.registerHttpProvider(
|
||||
"/vscode-embed",
|
||||
new VscodeHttpProvider([], { base: "/vscode-embed", auth, password })
|
||||
)
|
||||
|
||||
ipcMain.onDispose(() => httpServer.dispose())
|
||||
|
||||
const serverAddress = await httpServer.listen()
|
||||
logger.info(`Server listening on ${serverAddress}`)
|
||||
|
||||
if (auth === AuthType.Password && !process.env.PASSWORD) {
|
||||
logger.info(` - Password is ${originalPassword}`)
|
||||
logger.info(" - To use your own password, set the PASSWORD environment variable")
|
||||
if (!args.auth) {
|
||||
logger.info(" - To disable use `--auth none`")
|
||||
}
|
||||
} else if (auth === AuthType.Password) {
|
||||
logger.info(" - Using custom password for authentication")
|
||||
} else {
|
||||
logger.info(" - No authentication")
|
||||
}
|
||||
|
||||
if (httpServer.protocol === "https") {
|
||||
logger.info(
|
||||
args.cert
|
||||
? ` - Using provided certificate${args["cert-key"] ? " and key" : ""} for HTTPS`
|
||||
: ` - Using generated certificate and key for HTTPS`
|
||||
)
|
||||
} else {
|
||||
logger.info(" - Not serving HTTPS")
|
||||
}
|
||||
|
||||
if (serverAddress && !options.socket && args.open) {
|
||||
// The web socket doesn't seem to work if browsing with 0.0.0.0.
|
||||
const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost")
|
||||
await open(openAddress).catch(console.error)
|
||||
logger.info(` - Opened ${openAddress}`)
|
||||
}
|
||||
}
|
||||
|
||||
wrap(main)
|
579
src/node/http.ts
Normal file
579
src/node/http.ts
Normal file
@ -0,0 +1,579 @@
|
||||
import { logger } from "@coder/logger"
|
||||
import * as fs from "fs-extra"
|
||||
import * as http from "http"
|
||||
import * as httpolyglot from "httpolyglot"
|
||||
import * as https from "https"
|
||||
import * as net from "net"
|
||||
import * as path from "path"
|
||||
import * as querystring from "querystring"
|
||||
import safeCompare from "safe-compare"
|
||||
import { Readable } from "stream"
|
||||
import * as tarFs from "tar-fs"
|
||||
import * as tls from "tls"
|
||||
import * as url from "url"
|
||||
import { HttpCode, HttpError } from "../common/http"
|
||||
import { plural, split } from "../common/util"
|
||||
import { getMediaMime, normalize, xdgLocalDir } from "./util"
|
||||
|
||||
export type Cookies = { [key: string]: string[] | undefined }
|
||||
export type PostData = { [key: string]: string | string[] | undefined }
|
||||
|
||||
interface AuthPayload extends Cookies {
|
||||
key?: string[]
|
||||
}
|
||||
|
||||
export enum AuthType {
|
||||
Password = "password",
|
||||
None = "none",
|
||||
}
|
||||
|
||||
export type Query = { [key: string]: string | string[] | undefined }
|
||||
|
||||
export interface HttpResponse<T = string | Buffer | object> {
|
||||
/*
|
||||
* Whether to set cache-control headers for this response.
|
||||
*/
|
||||
cache?: boolean
|
||||
/**
|
||||
* If the code cannot be determined automatically set it here. The
|
||||
* defaults are 302 for redirects and 200 for successful requests. For errors
|
||||
* you should throw an HttpError and include the code there. If you
|
||||
* use Error it will default to 404 for ENOENT and EISDIR and 500 otherwise.
|
||||
*/
|
||||
code?: number
|
||||
/**
|
||||
* Content to write in the response. Mutually exclusive with stream.
|
||||
*/
|
||||
content?: T
|
||||
/**
|
||||
* Cookie to write with the response.
|
||||
*/
|
||||
cookie?: { key: string; value: string }
|
||||
/**
|
||||
* Used to automatically determine the appropriate mime type.
|
||||
*/
|
||||
filePath?: string
|
||||
/**
|
||||
* Additional headers to include.
|
||||
*/
|
||||
headers?: http.OutgoingHttpHeaders
|
||||
/**
|
||||
* If the mime type cannot be determined automatically set it here.
|
||||
*/
|
||||
mime?: string
|
||||
/**
|
||||
* Redirect to this path. Will rewrite against the base path but NOT the
|
||||
* provider endpoint so you must include it. This allows redirecting outside
|
||||
* of your endpoint. Use `withBase()` to redirect within your endpoint.
|
||||
*/
|
||||
redirect?: string
|
||||
/**
|
||||
* Stream this to the response. Mutually exclusive with content.
|
||||
*/
|
||||
stream?: Readable
|
||||
/**
|
||||
* Query variables to add in addition to current ones when redirecting. Use
|
||||
* `undefined` to remove a query variable.
|
||||
*/
|
||||
query?: Query
|
||||
}
|
||||
|
||||
/**
|
||||
* Use when you need to run search and replace on a file's content before
|
||||
* sending it.
|
||||
*/
|
||||
export interface HttpStringFileResponse extends HttpResponse {
|
||||
content: string
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export interface HttpServerOptions {
|
||||
readonly basePath?: string
|
||||
readonly cert?: string
|
||||
readonly certKey?: string
|
||||
readonly host?: string
|
||||
readonly port?: number
|
||||
readonly socket?: string
|
||||
}
|
||||
|
||||
interface ProviderRoute {
|
||||
base: string
|
||||
requestPath: string
|
||||
query: querystring.ParsedUrlQuery
|
||||
provider: HttpProvider
|
||||
fullPath: string
|
||||
originalPath: string
|
||||
}
|
||||
|
||||
export interface HttpProviderOptions {
|
||||
readonly base: string
|
||||
readonly auth: AuthType
|
||||
readonly password: string | false
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides HTTP responses. This abstract class provides some helpers for
|
||||
* interpreting, creating, and authenticating responses.
|
||||
*/
|
||||
export abstract class HttpProvider {
|
||||
protected readonly rootPath = path.resolve(__dirname, "../..")
|
||||
|
||||
public constructor(private readonly options: HttpProviderOptions) {}
|
||||
|
||||
public dispose(): void {
|
||||
// No default behavior.
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle web sockets on the registered endpoint.
|
||||
*/
|
||||
public abstract handleWebSocket(
|
||||
base: string,
|
||||
requestPath: string,
|
||||
query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage,
|
||||
socket: net.Socket,
|
||||
head: Buffer
|
||||
): Promise<true | undefined>
|
||||
|
||||
/**
|
||||
* Handle requests to the registered endpoint.
|
||||
*/
|
||||
public abstract handleRequest(
|
||||
base: string,
|
||||
requestPath: string,
|
||||
query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage
|
||||
): Promise<HttpResponse | undefined>
|
||||
|
||||
/**
|
||||
* Return the specified path with the base path prepended.
|
||||
*/
|
||||
protected withBase(path: string): string {
|
||||
return normalize(`${this.options.base}/${path}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file resource.
|
||||
* TODO: Would a stream be faster, at least for large files?
|
||||
*/
|
||||
protected async getResource(...parts: string[]): Promise<HttpResponse> {
|
||||
const filePath = path.join(...parts)
|
||||
return { content: await fs.readFile(filePath), filePath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file resource as a string.
|
||||
*/
|
||||
protected async getUtf8Resource(...parts: string[]): Promise<HttpStringFileResponse> {
|
||||
const filePath = path.join(...parts)
|
||||
return { content: await fs.readFile(filePath, "utf8"), filePath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tar up and stream a directory.
|
||||
*/
|
||||
protected async getTarredResource(...parts: string[]): Promise<HttpResponse> {
|
||||
const filePath = path.join(...parts)
|
||||
return { stream: tarFs.pack(filePath), filePath, mime: "application/tar", cache: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to error on anything that's not a GET.
|
||||
*/
|
||||
protected ensureGet(request: http.IncomingMessage): void {
|
||||
if (request.method !== "GET") {
|
||||
throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to error if not authorized.
|
||||
*/
|
||||
protected ensureAuthenticated(request: http.IncomingMessage): void {
|
||||
if (!this.authenticated(request)) {
|
||||
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the first query value or the default if there isn't one.
|
||||
*/
|
||||
protected queryOrDefault(value: string | string[] | undefined, def: string): string {
|
||||
if (Array.isArray(value)) {
|
||||
value = value[0]
|
||||
}
|
||||
return typeof value !== "undefined" ? value : def
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the provided password value if the payload contains the right
|
||||
* password otherwise return false. If no payload is specified use cookies.
|
||||
*/
|
||||
protected authenticated(request: http.IncomingMessage, payload?: AuthPayload): string | boolean {
|
||||
switch (this.options.auth) {
|
||||
case AuthType.None:
|
||||
return true
|
||||
case AuthType.Password:
|
||||
if (typeof payload === "undefined") {
|
||||
payload = this.parseCookies<AuthPayload>(request)
|
||||
}
|
||||
if (this.options.password && payload.key) {
|
||||
for (let i = 0; i < payload.key.length; ++i) {
|
||||
if (safeCompare(payload.key[i], this.options.password)) {
|
||||
return payload.key[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
throw new Error(`Unsupported auth type ${this.options.auth}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse POST data.
|
||||
*/
|
||||
protected getData(request: http.IncomingMessage): Promise<string | undefined> {
|
||||
return request.method === "POST" || request.method === "DELETE"
|
||||
? new Promise<string>((resolve, reject) => {
|
||||
let body = ""
|
||||
const onEnd = (): void => {
|
||||
off() // eslint-disable-line @typescript-eslint/no-use-before-define
|
||||
resolve(body || undefined)
|
||||
}
|
||||
const onError = (error: Error): void => {
|
||||
off() // eslint-disable-line @typescript-eslint/no-use-before-define
|
||||
reject(error)
|
||||
}
|
||||
const onData = (d: Buffer): void => {
|
||||
body += d
|
||||
if (body.length > 1e6) {
|
||||
onError(new HttpError("Payload is too large", HttpCode.LargePayload))
|
||||
request.connection.destroy()
|
||||
}
|
||||
}
|
||||
const off = (): void => {
|
||||
request.off("error", onError)
|
||||
request.off("data", onError)
|
||||
request.off("end", onEnd)
|
||||
}
|
||||
request.on("error", onError)
|
||||
request.on("data", onData)
|
||||
request.on("end", onEnd)
|
||||
})
|
||||
: Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cookies.
|
||||
*/
|
||||
protected parseCookies<T extends Cookies>(request: http.IncomingMessage): T {
|
||||
const cookies: { [key: string]: string[] } = {}
|
||||
if (request.headers.cookie) {
|
||||
request.headers.cookie.split(";").forEach((keyValue) => {
|
||||
const [key, value] = split(keyValue, "=")
|
||||
if (!cookies[key]) {
|
||||
cookies[key] = []
|
||||
}
|
||||
cookies[key].push(decodeURI(value))
|
||||
})
|
||||
}
|
||||
return cookies as T
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a heartbeat using a local file to indicate activity.
|
||||
*/
|
||||
export class Heart {
|
||||
private heartbeatTimer?: NodeJS.Timeout
|
||||
private heartbeatInterval = 60000
|
||||
private lastHeartbeat = 0
|
||||
|
||||
public constructor(private readonly heartbeatPath: string, private readonly isActive: () => Promise<boolean>) {}
|
||||
|
||||
/**
|
||||
* Write to the heartbeat file if we haven't already done so within the
|
||||
* timeout and start or reset a timer that keeps running as long as there is
|
||||
* activity. Failures are logged as warnings.
|
||||
*/
|
||||
public beat(): void {
|
||||
const now = Date.now()
|
||||
if (now - this.lastHeartbeat >= this.heartbeatInterval) {
|
||||
logger.trace("heartbeat")
|
||||
fs.outputFile(this.heartbeatPath, "").catch((error) => {
|
||||
logger.warn(error.message)
|
||||
})
|
||||
this.lastHeartbeat = now
|
||||
if (typeof this.heartbeatTimer !== "undefined") {
|
||||
clearTimeout(this.heartbeatTimer)
|
||||
}
|
||||
this.heartbeatTimer = setTimeout(() => {
|
||||
this.isActive().then((active) => {
|
||||
if (active) {
|
||||
this.beat()
|
||||
}
|
||||
})
|
||||
}, this.heartbeatInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An HTTP server. Its main role is to route incoming HTTP requests to the
|
||||
* appropriate provider for that endpoint then write out the response. It also
|
||||
* covers some common use cases like redirects and caching.
|
||||
*/
|
||||
export class HttpServer {
|
||||
protected readonly server: http.Server | https.Server
|
||||
private listenPromise: Promise<string | null> | undefined
|
||||
public readonly protocol: "http" | "https"
|
||||
private readonly providers = new Map<string, HttpProvider>()
|
||||
private readonly options: HttpServerOptions
|
||||
private readonly heart: Heart
|
||||
|
||||
public constructor(options: HttpServerOptions) {
|
||||
this.heart = new Heart(path.join(xdgLocalDir, "heartbeat"), async () => {
|
||||
const connections = await this.getConnections()
|
||||
logger.trace(`${connections} active connection${plural(connections)}`)
|
||||
return connections !== 0
|
||||
})
|
||||
this.options = {
|
||||
...options,
|
||||
basePath: options.basePath ? options.basePath.replace(/\/+$/, "") : "",
|
||||
}
|
||||
this.protocol = this.options.cert ? "https" : "http"
|
||||
if (this.protocol === "https") {
|
||||
this.server = httpolyglot.createServer(
|
||||
{
|
||||
cert: this.options.cert && fs.readFileSync(this.options.cert),
|
||||
key: this.options.certKey && fs.readFileSync(this.options.certKey),
|
||||
},
|
||||
this.onRequest
|
||||
)
|
||||
} else {
|
||||
this.server = http.createServer(this.onRequest)
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.providers.forEach((p) => p.dispose())
|
||||
}
|
||||
|
||||
public async getConnections(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server.getConnections((error, count) => {
|
||||
return error ? reject(error) : resolve(count)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a provider for a top-level endpoint.
|
||||
*/
|
||||
public registerHttpProvider<T extends HttpProvider>(endpoint: string, provider: T): void {
|
||||
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})`)
|
||||
}
|
||||
this.providers.set(`/${endpoint}`, provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening on the specified port.
|
||||
*/
|
||||
public listen(): Promise<string | null> {
|
||||
if (!this.listenPromise) {
|
||||
this.listenPromise = new Promise((resolve, reject) => {
|
||||
this.server.on("error", reject)
|
||||
this.server.on("upgrade", this.onUpgrade)
|
||||
const onListen = (): void => resolve(this.address())
|
||||
if (this.options.socket) {
|
||||
this.server.listen(this.options.socket, onListen)
|
||||
} else {
|
||||
this.server.listen(this.options.port, this.options.host, onListen)
|
||||
}
|
||||
})
|
||||
}
|
||||
return this.listenPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* The *local* address of the server.
|
||||
*/
|
||||
public address(): string | null {
|
||||
const address = this.server.address()
|
||||
const endpoint =
|
||||
typeof address !== "string" && address !== null
|
||||
? (address.address === "::" ? "localhost" : address.address) + ":" + address.port
|
||||
: address
|
||||
return endpoint && `${this.protocol}://${endpoint}`
|
||||
}
|
||||
|
||||
private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> => {
|
||||
try {
|
||||
this.heart.beat()
|
||||
const route = this.parseUrl(request)
|
||||
const payload =
|
||||
this.maybeRedirect(request, route) ||
|
||||
(await route.provider.handleRequest(route.base, route.requestPath, route.query, request))
|
||||
if (!payload) {
|
||||
throw new HttpError("Not found", HttpCode.NotFound)
|
||||
}
|
||||
const basePath = this.options.basePath || "/"
|
||||
response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, {
|
||||
"Content-Type": payload.mime || getMediaMime(payload.filePath),
|
||||
...(payload.redirect
|
||||
? {
|
||||
Location: this.constructRedirect(
|
||||
request.headers.host as string,
|
||||
route.fullPath,
|
||||
normalize(`${basePath}/${payload.redirect}`) + "/",
|
||||
{ ...route.query, ...(payload.query || {}) }
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(request.headers["service-worker"] ? { "Service-Worker-Allowed": basePath } : {}),
|
||||
...(payload.cache ? { "Cache-Control": "public, max-age=31536000" } : {}),
|
||||
...(payload.cookie
|
||||
? {
|
||||
"Set-Cookie": `${payload.cookie.key}=${payload.cookie.value}; Path=${basePath}; HttpOnly; SameSite=strict`,
|
||||
}
|
||||
: {}),
|
||||
...payload.headers,
|
||||
})
|
||||
if (payload.stream) {
|
||||
payload.stream.on("error", (error: NodeJS.ErrnoException) => {
|
||||
response.writeHead(error.code === "ENOENT" ? HttpCode.NotFound : HttpCode.ServerError)
|
||||
response.end(error.message)
|
||||
})
|
||||
payload.stream.pipe(response)
|
||||
} else if (typeof payload.content === "string" || payload.content instanceof Buffer) {
|
||||
response.end(payload.content)
|
||||
} else if (payload.content && typeof payload.content === "object") {
|
||||
response.end(JSON.stringify(payload.content))
|
||||
} else {
|
||||
response.end()
|
||||
}
|
||||
} catch (error) {
|
||||
let e = error
|
||||
if (error.code === "ENOENT" || error.code === "EISDIR") {
|
||||
e = new HttpError("Not found", HttpCode.NotFound)
|
||||
} else {
|
||||
logger.error(error.stack)
|
||||
}
|
||||
response.writeHead(typeof e.code === "number" ? e.code : HttpCode.ServerError)
|
||||
response.end(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return any necessary redirection before delegating to a provider.
|
||||
*/
|
||||
private maybeRedirect(request: http.IncomingMessage, route: ProviderRoute): HttpResponse | undefined {
|
||||
// Redirect to HTTPS.
|
||||
if (this.options.cert && !(request.connection as tls.TLSSocket).encrypted) {
|
||||
return { redirect: route.fullPath }
|
||||
}
|
||||
// Redirect indexes to a trailing slash so relative paths will operate
|
||||
// against the provider.
|
||||
if (route.requestPath === "/index.html" && !route.originalPath.endsWith("/")) {
|
||||
return { redirect: route.fullPath } // Redirect always includes a trailing slash.
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private onUpgrade = async (request: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> => {
|
||||
try {
|
||||
this.heart.beat()
|
||||
socket.on("error", () => socket.destroy())
|
||||
|
||||
if (this.options.cert && !(socket as tls.TLSSocket).encrypted) {
|
||||
throw new HttpError("HTTP websocket", HttpCode.BadRequest)
|
||||
}
|
||||
|
||||
if (!request.headers.upgrade || request.headers.upgrade.toLowerCase() !== "websocket") {
|
||||
throw new HttpError("HTTP/1.1 400 Bad Request", HttpCode.BadRequest)
|
||||
}
|
||||
|
||||
const { base, requestPath, query, provider } = this.parseUrl(request)
|
||||
if (!provider) {
|
||||
throw new HttpError("Not found", HttpCode.NotFound)
|
||||
}
|
||||
|
||||
if (!(await provider.handleWebSocket(base, requestPath, query, request, socket, head))) {
|
||||
throw new HttpError("Not found", HttpCode.NotFound)
|
||||
}
|
||||
} catch (error) {
|
||||
socket.destroy(error)
|
||||
logger.warn(`discarding socket connection: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a request URL so we can route it.
|
||||
*/
|
||||
private parseUrl(request: http.IncomingMessage): ProviderRoute {
|
||||
const parse = (fullPath: string): { base: string; requestPath: string } => {
|
||||
const match = fullPath.match(/^(\/?[^/]*)(.*)$/)
|
||||
let [, /* ignore */ base, requestPath] = match ? match.map((p) => p.replace(/\/+$/, "")) : ["", "", ""]
|
||||
if (base.indexOf(".") !== -1) {
|
||||
// Assume it's a file at the root.
|
||||
requestPath = base
|
||||
base = "/"
|
||||
} else if (base === "") {
|
||||
// Happens if it's a plain `domain.com`.
|
||||
base = "/"
|
||||
}
|
||||
requestPath = requestPath || "/index.html"
|
||||
// Allow for a versioned static endpoint. This lets us cache every static
|
||||
// resource underneath the path based on the version without any work and
|
||||
// without adding query parameters which have their own issues.
|
||||
if (/^\/static-/.test(base)) {
|
||||
base = "/static"
|
||||
}
|
||||
|
||||
return { base, requestPath }
|
||||
}
|
||||
|
||||
const parsedUrl = request.url ? url.parse(request.url, true) : { query: {}, pathname: "" }
|
||||
const originalPath = parsedUrl.pathname || ""
|
||||
const fullPath = normalize(originalPath)
|
||||
const { base, requestPath } = parse(fullPath)
|
||||
|
||||
// Providers match on the path after their base so we need to account for
|
||||
// that by shifting the next base out of the request path.
|
||||
let provider = this.providers.get(base)
|
||||
if (base !== "/" && provider) {
|
||||
return { ...parse(requestPath), fullPath, query: parsedUrl.query, provider, originalPath }
|
||||
}
|
||||
|
||||
// Fall back to the top-level provider.
|
||||
provider = this.providers.get("/")
|
||||
if (!provider) {
|
||||
throw new Error(`No provider for ${base}`)
|
||||
}
|
||||
return { base, fullPath, requestPath, query: parsedUrl.query, provider, originalPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the request URL with the specified base and new path.
|
||||
*/
|
||||
private constructRedirect(host: string, oldPath: string, newPath: string, query: Query): string {
|
||||
if (oldPath && oldPath !== "/" && !query.to && /\/login(\/|$)/.test(newPath) && !/\/login(\/|$)/.test(oldPath)) {
|
||||
query.to = oldPath
|
||||
}
|
||||
Object.keys(query).forEach((key) => {
|
||||
if (typeof query[key] === "undefined") {
|
||||
delete query[key]
|
||||
}
|
||||
})
|
||||
return (
|
||||
`${this.protocol}://${host}${newPath}` + (Object.keys(query).length > 0 ? `?${querystring.stringify(query)}` : "")
|
||||
)
|
||||
}
|
||||
}
|
@ -1,124 +0,0 @@
|
||||
import * as appInsights from "applicationinsights";
|
||||
import * as https from "https";
|
||||
import * as http from "http";
|
||||
import * as os from "os";
|
||||
|
||||
class Channel {
|
||||
public get _sender() {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
public get _buffer() {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public setUseDiskRetryCaching(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
public send(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
public triggerSend(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
}
|
||||
|
||||
export class TelemetryClient {
|
||||
public context: any = undefined;
|
||||
public commonProperties: any = undefined;
|
||||
public config: any = {};
|
||||
|
||||
public channel: any = new Channel();
|
||||
|
||||
public addTelemetryProcessor(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public clearTelemetryProcessors(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public runTelemetryProcessors(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackTrace(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackMetric(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackException(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackRequest(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackDependency(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public track(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackNodeHttpRequestSync(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackNodeHttpRequest(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackNodeHttpDependency(): void {
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
public trackEvent(options: appInsights.Contracts.EventTelemetry): void {
|
||||
if (!options.properties) {
|
||||
options.properties = {};
|
||||
}
|
||||
if (!options.measurements) {
|
||||
options.measurements = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const cpus = os.cpus();
|
||||
options.measurements.cores = cpus.length;
|
||||
options.properties["common.cpuModel"] = cpus[0].model;
|
||||
} catch (error) {}
|
||||
|
||||
try {
|
||||
options.measurements.memoryFree = os.freemem();
|
||||
options.measurements.memoryTotal = os.totalmem();
|
||||
} catch (error) {}
|
||||
|
||||
try {
|
||||
options.properties["common.shell"] = os.userInfo().shell;
|
||||
options.properties["common.release"] = os.release();
|
||||
options.properties["common.arch"] = os.arch();
|
||||
} catch (error) {}
|
||||
|
||||
try {
|
||||
const url = process.env.TELEMETRY_URL || "https://v1.telemetry.coder.com/track";
|
||||
const request = (/^http:/.test(url) ? http : https).request(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
request.on("error", () => { /* We don"t care. */ });
|
||||
request.write(JSON.stringify(options));
|
||||
request.end();
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
public flush(options: { callback: (v: string) => void }): void {
|
||||
if (options.callback) {
|
||||
options.callback("");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
import * as cp from "child_process";
|
||||
import { Emitter } from "vs/base/common/event";
|
||||
|
||||
enum ControlMessage {
|
||||
okToChild = "ok>",
|
||||
okFromChild = "ok<",
|
||||
}
|
||||
|
||||
interface RelaunchMessage {
|
||||
type: "relaunch";
|
||||
version: string;
|
||||
}
|
||||
|
||||
export type Message = RelaunchMessage;
|
||||
|
||||
class IpcMain {
|
||||
protected readonly _onMessage = new Emitter<Message>();
|
||||
public readonly onMessage = this._onMessage.event;
|
||||
|
||||
public handshake(child?: cp.ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const target = child || process;
|
||||
if (!target.send) {
|
||||
throw new Error("Not spawned with IPC enabled");
|
||||
}
|
||||
target.on("message", (message) => {
|
||||
if (message === child ? ControlMessage.okFromChild : ControlMessage.okToChild) {
|
||||
target.removeAllListeners();
|
||||
target.on("message", (msg) => this._onMessage.fire(msg));
|
||||
if (child) {
|
||||
target.send!(ControlMessage.okToChild);
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
if (child) {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
const error = new Error(`Unexpected exit with code ${code}`);
|
||||
(error as any).code = code;
|
||||
reject(error);
|
||||
});
|
||||
} else {
|
||||
target.send(ControlMessage.okFromChild);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public relaunch(version: string): void {
|
||||
this.send({ type: "relaunch", version });
|
||||
}
|
||||
|
||||
private send(message: Message): void {
|
||||
if (!process.send) {
|
||||
throw new Error("Not a child process with IPC enabled");
|
||||
}
|
||||
process.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const ipcMain = new IpcMain();
|
@ -1,176 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as util from "util";
|
||||
import { CancellationToken } from "vs/base/common/cancellation";
|
||||
import { mkdirp } from "vs/base/node/pfs";
|
||||
import * as vszip from "vs/base/node/zip";
|
||||
import * as nls from "vs/nls";
|
||||
import product from "vs/platform/product/common/product";
|
||||
import { localRequire } from "vs/server/src/node/util";
|
||||
|
||||
const tarStream = localRequire<typeof import("tar-stream")>("tar-stream/index");
|
||||
|
||||
// We will be overriding these, so keep a reference to the original.
|
||||
const vszipExtract = vszip.extract;
|
||||
const vszipBuffer = vszip.buffer;
|
||||
|
||||
export interface IExtractOptions {
|
||||
overwrite?: boolean;
|
||||
/**
|
||||
* Source path within the TAR/ZIP archive. Only the files
|
||||
* contained in this path will be extracted.
|
||||
*/
|
||||
sourcePath?: string;
|
||||
}
|
||||
|
||||
export interface IFile {
|
||||
path: string;
|
||||
contents?: Buffer | string;
|
||||
localPath?: string;
|
||||
}
|
||||
|
||||
export const tar = async (tarPath: string, files: IFile[]): Promise<string> => {
|
||||
const pack = tarStream.pack();
|
||||
const chunks: Buffer[] = [];
|
||||
const ended = new Promise<Buffer>((resolve) => {
|
||||
pack.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
pack.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
pack.entry({ name: file.path }, file.contents);
|
||||
}
|
||||
pack.finalize();
|
||||
await util.promisify(fs.writeFile)(tarPath, await ended);
|
||||
return tarPath;
|
||||
};
|
||||
|
||||
export const extract = async (archivePath: string, extractPath: string, options: IExtractOptions = {}, token: CancellationToken): Promise<void> => {
|
||||
try {
|
||||
await extractTar(archivePath, extractPath, options, token);
|
||||
} catch (error) {
|
||||
if (error.toString().includes("Invalid tar header")) {
|
||||
await vszipExtract(archivePath, extractPath, options, token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buffer = (targetPath: string, filePath: string): Promise<Buffer> => {
|
||||
return new Promise<Buffer>(async (resolve, reject) => {
|
||||
try {
|
||||
let done: boolean = false;
|
||||
await extractAssets(targetPath, new RegExp(filePath), (assetPath: string, data: Buffer) => {
|
||||
if (path.normalize(assetPath) === path.normalize(filePath)) {
|
||||
done = true;
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
if (!done) {
|
||||
throw new Error("couldn't find asset " + filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.toString().includes("Invalid tar header")) {
|
||||
vszipBuffer(targetPath, filePath).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const extractAssets = async (tarPath: string, match: RegExp, callback: (path: string, data: Buffer) => void): Promise<void> => {
|
||||
return new Promise<void>((resolve, reject): void => {
|
||||
const extractor = tarStream.extract();
|
||||
const fail = (error: Error) => {
|
||||
extractor.destroy();
|
||||
reject(error);
|
||||
};
|
||||
extractor.once("error", fail);
|
||||
extractor.on("entry", async (header, stream, next) => {
|
||||
const name = header.name;
|
||||
if (match.test(name)) {
|
||||
extractData(stream).then((data) => {
|
||||
callback(name, data);
|
||||
next();
|
||||
}).catch(fail);
|
||||
} else {
|
||||
stream.on("end", () => next());
|
||||
stream.resume(); // Just drain it.
|
||||
}
|
||||
});
|
||||
extractor.on("finish", resolve);
|
||||
fs.createReadStream(tarPath).pipe(extractor);
|
||||
});
|
||||
};
|
||||
|
||||
const extractData = (stream: NodeJS.ReadableStream): Promise<Buffer> => {
|
||||
return new Promise((resolve, reject): void => {
|
||||
const fileData: Buffer[] = [];
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(Buffer.concat(fileData)));
|
||||
stream.on("data", (data) => fileData.push(data));
|
||||
});
|
||||
};
|
||||
|
||||
const extractTar = async (tarPath: string, targetPath: string, options: IExtractOptions = {}, token: CancellationToken): Promise<void> => {
|
||||
return new Promise<void>((resolve, reject): void => {
|
||||
const sourcePathRegex = new RegExp(options.sourcePath ? `^${options.sourcePath}` : "");
|
||||
const extractor = tarStream.extract();
|
||||
const fail = (error: Error) => {
|
||||
extractor.destroy();
|
||||
reject(error);
|
||||
};
|
||||
extractor.once("error", fail);
|
||||
extractor.on("entry", async (header, stream, next) => {
|
||||
const nextEntry = (): void => {
|
||||
stream.on("end", () => next());
|
||||
stream.resume();
|
||||
};
|
||||
|
||||
const rawName = path.normalize(header.name);
|
||||
if (token.isCancellationRequested || !sourcePathRegex.test(rawName)) {
|
||||
return nextEntry();
|
||||
}
|
||||
|
||||
const fileName = rawName.replace(sourcePathRegex, "");
|
||||
const targetFileName = path.join(targetPath, fileName);
|
||||
if (/\/$/.test(fileName)) {
|
||||
return mkdirp(targetFileName).then(nextEntry);
|
||||
}
|
||||
|
||||
const dirName = path.dirname(fileName);
|
||||
const targetDirName = path.join(targetPath, dirName);
|
||||
if (targetDirName.indexOf(targetPath) !== 0) {
|
||||
return fail(new Error(nls.localize("invalid file", "Error extracting {0}. Invalid file.", fileName)));
|
||||
}
|
||||
|
||||
await mkdirp(targetDirName, undefined);
|
||||
|
||||
const fstream = fs.createWriteStream(targetFileName, { mode: header.mode });
|
||||
fstream.once("close", () => next());
|
||||
fstream.once("error", fail);
|
||||
stream.pipe(fstream);
|
||||
});
|
||||
extractor.once("finish", resolve);
|
||||
fs.createReadStream(tarPath).pipe(extractor);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Override original functionality so we can use a custom marketplace with
|
||||
* either tars or zips.
|
||||
*/
|
||||
export const enableCustomMarketplace = (): void => {
|
||||
(<any>product).extensionsGallery = { // Use `any` to override readonly.
|
||||
serviceUrl: process.env.SERVICE_URL || "https://v1.extapi.coder.com",
|
||||
itemUrl: process.env.ITEM_URL || "",
|
||||
controlUrl: "",
|
||||
recommendationsUrl: "",
|
||||
...(product.extensionsGallery || {}),
|
||||
};
|
||||
|
||||
const target = vszip as typeof vszip;
|
||||
target.zip = tar;
|
||||
target.extract = extract;
|
||||
target.buffer = buffer;
|
||||
};
|
@ -1,86 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as util from "util";
|
||||
import { getPathFromAmdModule } from "vs/base/common/amd";
|
||||
import * as lp from "vs/base/node/languagePacks";
|
||||
import product from "vs/platform/product/common/product";
|
||||
import { Translations } from "vs/workbench/services/extensions/common/extensionPoints";
|
||||
|
||||
const configurations = new Map<string, Promise<lp.NLSConfiguration>>();
|
||||
const metadataPath = path.join(getPathFromAmdModule(require, ""), "nls.metadata.json");
|
||||
|
||||
export const isInternalConfiguration = (config: lp.NLSConfiguration): config is lp.InternalNLSConfiguration => {
|
||||
return config && !!(<lp.InternalNLSConfiguration>config)._languagePackId;
|
||||
};
|
||||
|
||||
const DefaultConfiguration = {
|
||||
locale: "en",
|
||||
availableLanguages: {},
|
||||
};
|
||||
|
||||
export const getNlsConfiguration = async (locale: string, userDataPath: string): Promise<lp.NLSConfiguration> => {
|
||||
const id = `${locale}: ${userDataPath}`;
|
||||
if (!configurations.has(id)) {
|
||||
configurations.set(id, new Promise(async (resolve) => {
|
||||
const config = product.commit && await util.promisify(fs.exists)(metadataPath)
|
||||
? await lp.getNLSConfiguration(product.commit, userDataPath, metadataPath, locale)
|
||||
: DefaultConfiguration;
|
||||
if (isInternalConfiguration(config)) {
|
||||
config._languagePackSupport = true;
|
||||
}
|
||||
// If the configuration has no results keep trying since code-server
|
||||
// doesn't restart when a language is installed so this result would
|
||||
// persist (the plugin might not be installed yet or something).
|
||||
if (config.locale !== "en" && config.locale !== "en-us" && Object.keys(config.availableLanguages).length === 0) {
|
||||
configurations.delete(id);
|
||||
}
|
||||
resolve(config);
|
||||
}));
|
||||
}
|
||||
return configurations.get(id)!;
|
||||
};
|
||||
|
||||
export const getTranslations = async (locale: string, userDataPath: string): Promise<Translations> => {
|
||||
const config = await getNlsConfiguration(locale, userDataPath);
|
||||
if (isInternalConfiguration(config)) {
|
||||
try {
|
||||
return JSON.parse(await util.promisify(fs.readFile)(config._translationsConfigFile, "utf8"));
|
||||
} catch (error) { /* Nothing yet. */}
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const getLocaleFromConfig = async (userDataPath: string): Promise<string> => {
|
||||
let locale = "en";
|
||||
try {
|
||||
const localeConfigUri = path.join(userDataPath, "User/locale.json");
|
||||
const content = stripComments(await util.promisify(fs.readFile)(localeConfigUri, "utf8"));
|
||||
locale = JSON.parse(content).locale;
|
||||
} catch (error) { /* Ignore. */ }
|
||||
return locale;
|
||||
};
|
||||
|
||||
// Taken from src/main.js in the main VS Code source.
|
||||
const stripComments = (content: string): string => {
|
||||
const regexp = /("(?:[^\\"]*(?:\\.)?)*")|('(?:[^\\']*(?:\\.)?)*')|(\/\*(?:\r?\n|.)*?\*\/)|(\/{2,}.*?(?:(?:\r?\n)|$))/g;
|
||||
|
||||
return content.replace(regexp, (match, _m1, _m2, m3, m4) => {
|
||||
// Only one of m1, m2, m3, m4 matches
|
||||
if (m3) {
|
||||
// A block comment. Replace with nothing
|
||||
return '';
|
||||
} else if (m4) {
|
||||
// A line comment. If it ends in \r?\n then keep it.
|
||||
const length_1 = m4.length;
|
||||
if (length_1 > 2 && m4[length_1 - 1] === '\n') {
|
||||
return m4[length_1 - 2] === '\r' ? '\r\n' : '\n';
|
||||
}
|
||||
else {
|
||||
return '';
|
||||
}
|
||||
} else {
|
||||
// We match a string
|
||||
return match;
|
||||
}
|
||||
});
|
||||
};
|
@ -1,73 +0,0 @@
|
||||
import * as net from "net";
|
||||
import { VSBuffer } from "vs/base/common/buffer";
|
||||
import { PersistentProtocol } from "vs/base/parts/ipc/common/ipc.net";
|
||||
import { NodeSocket, WebSocketNodeSocket } from "vs/base/parts/ipc/node/ipc.net";
|
||||
import { AuthRequest, ConnectionTypeRequest, HandshakeMessage } from "vs/platform/remote/common/remoteAgentConnection";
|
||||
|
||||
export interface SocketOptions {
|
||||
readonly reconnectionToken: string;
|
||||
readonly reconnection: boolean;
|
||||
readonly skipWebSocketFrames: boolean;
|
||||
}
|
||||
|
||||
export class Protocol extends PersistentProtocol {
|
||||
public constructor(socket: net.Socket, public readonly options: SocketOptions) {
|
||||
super(
|
||||
options.skipWebSocketFrames
|
||||
? new NodeSocket(socket)
|
||||
: new WebSocketNodeSocket(new NodeSocket(socket)),
|
||||
);
|
||||
}
|
||||
|
||||
public getUnderlyingSocket(): net.Socket {
|
||||
const socket = this.getSocket();
|
||||
return socket instanceof NodeSocket
|
||||
? socket.socket
|
||||
: (socket as WebSocketNodeSocket).socket.socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a handshake to get a connection request.
|
||||
*/
|
||||
public handshake(): Promise<ConnectionTypeRequest> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = this.onControlMessage((rawMessage) => {
|
||||
try {
|
||||
const message = JSON.parse(rawMessage.toString());
|
||||
switch (message.type) {
|
||||
case "auth": return this.authenticate(message);
|
||||
case "connectionType":
|
||||
handler.dispose();
|
||||
return resolve(message);
|
||||
default: throw new Error("Unrecognized message type");
|
||||
}
|
||||
} catch (error) {
|
||||
handler.dispose();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: This ignores the authentication process entirely for now.
|
||||
*/
|
||||
private authenticate(_message: AuthRequest): void {
|
||||
this.sendMessage({ type: "sign", data: "" });
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: implement.
|
||||
*/
|
||||
public tunnel(): void {
|
||||
throw new Error("Tunnel is not implemented yet");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a handshake message. In the case of the extension host, it just sends
|
||||
* back a debug port.
|
||||
*/
|
||||
public sendMessage(message: HandshakeMessage | { debugPort?: number } ): void {
|
||||
this.sendControl(VSBuffer.fromString(JSON.stringify(message)));
|
||||
}
|
||||
}
|
@ -1,957 +0,0 @@
|
||||
import * as crypto from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as http from "http";
|
||||
import * as https from "https";
|
||||
import * as net from "net";
|
||||
import * as path from "path";
|
||||
import * as querystring from "querystring";
|
||||
import { Readable } from "stream";
|
||||
import * as tls from "tls";
|
||||
import * as url from "url";
|
||||
import * as util from "util";
|
||||
import { Emitter } from "vs/base/common/event";
|
||||
import { sanitizeFilePath } from "vs/base/common/extpath";
|
||||
import { Schemas } from "vs/base/common/network";
|
||||
import { URI, UriComponents } from "vs/base/common/uri";
|
||||
import { generateUuid } from "vs/base/common/uuid";
|
||||
import { getMachineId } from 'vs/base/node/id';
|
||||
import { NLSConfiguration } from "vs/base/node/languagePacks";
|
||||
import { mkdirp, rimraf } from "vs/base/node/pfs";
|
||||
import { ClientConnectionEvent, IPCServer, IServerChannel } from "vs/base/parts/ipc/common/ipc";
|
||||
import { createChannelReceiver } from "vs/base/parts/ipc/node/ipc";
|
||||
import { LogsDataCleaner } from "vs/code/electron-browser/sharedProcess/contrib/logsDataCleaner";
|
||||
import { IConfigurationService } from "vs/platform/configuration/common/configuration";
|
||||
import { ConfigurationService } from "vs/platform/configuration/node/configurationService";
|
||||
import { ExtensionHostDebugBroadcastChannel } from "vs/platform/debug/common/extensionHostDebugIpc";
|
||||
import { IEnvironmentService, ParsedArgs } from "vs/platform/environment/common/environment";
|
||||
import { EnvironmentService } from "vs/platform/environment/node/environmentService";
|
||||
import { ExtensionGalleryService } from "vs/platform/extensionManagement/common/extensionGalleryService";
|
||||
import { IExtensionGalleryService, IExtensionManagementService } from "vs/platform/extensionManagement/common/extensionManagement";
|
||||
import { ExtensionManagementChannel } from "vs/platform/extensionManagement/common/extensionManagementIpc";
|
||||
import { ExtensionManagementService } from "vs/platform/extensionManagement/node/extensionManagementService";
|
||||
import { IFileService } from "vs/platform/files/common/files";
|
||||
import { FileService } from "vs/platform/files/common/fileService";
|
||||
import { DiskFileSystemProvider } from "vs/platform/files/node/diskFileSystemProvider";
|
||||
import { SyncDescriptor } from "vs/platform/instantiation/common/descriptors";
|
||||
import { InstantiationService } from "vs/platform/instantiation/common/instantiationService";
|
||||
import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection";
|
||||
import { ILocalizationsService } from "vs/platform/localizations/common/localizations";
|
||||
import { LocalizationsService } from "vs/platform/localizations/node/localizations";
|
||||
import { getLogLevel, ILogService } from "vs/platform/log/common/log";
|
||||
import { LoggerChannel } from "vs/platform/log/common/logIpc";
|
||||
import { SpdLogService } from "vs/platform/log/node/spdlogService";
|
||||
import product from 'vs/platform/product/common/product';
|
||||
import { IProductService } from "vs/platform/product/common/productService";
|
||||
import { ConnectionType, ConnectionTypeRequest } from "vs/platform/remote/common/remoteAgentConnection";
|
||||
import { RemoteAgentConnectionContext } from "vs/platform/remote/common/remoteAgentEnvironment";
|
||||
import { REMOTE_FILE_SYSTEM_CHANNEL_NAME } from "vs/platform/remote/common/remoteAgentFileSystemChannel";
|
||||
import { IRequestService } from "vs/platform/request/common/request";
|
||||
import { RequestChannel } from "vs/platform/request/common/requestIpc";
|
||||
import { RequestService } from "vs/platform/request/node/requestService";
|
||||
import ErrorTelemetry from "vs/platform/telemetry/browser/errorTelemetry";
|
||||
import { ITelemetryService } from "vs/platform/telemetry/common/telemetry";
|
||||
import { ITelemetryServiceConfig, TelemetryService } from "vs/platform/telemetry/common/telemetryService";
|
||||
import { combinedAppender, LogAppender, NullTelemetryService } from "vs/platform/telemetry/common/telemetryUtils";
|
||||
import { AppInsightsAppender } from "vs/platform/telemetry/node/appInsightsAppender";
|
||||
import { resolveCommonProperties } from "vs/platform/telemetry/node/commonProperties";
|
||||
import { UpdateChannel } from "vs/platform/update/electron-main/updateIpc";
|
||||
import { INodeProxyService, NodeProxyChannel } from "vs/server/src/common/nodeProxy";
|
||||
import { TelemetryChannel } from "vs/server/src/common/telemetry";
|
||||
import { split } from "vs/server/src/common/util";
|
||||
import { ExtensionEnvironmentChannel, FileProviderChannel, NodeProxyService } from "vs/server/src/node/channel";
|
||||
import { Connection, ExtensionHostConnection, ManagementConnection } from "vs/server/src/node/connection";
|
||||
import { TelemetryClient } from "vs/server/src/node/insights";
|
||||
import { getLocaleFromConfig, getNlsConfiguration } from "vs/server/src/node/nls";
|
||||
import { Protocol } from "vs/server/src/node/protocol";
|
||||
import { UpdateService } from "vs/server/src/node/update";
|
||||
import { AuthType, getMediaMime, getUriTransformer, hash, localRequire, tmpdir } from "vs/server/src/node/util";
|
||||
import { RemoteExtensionLogFileName } from "vs/workbench/services/remote/common/remoteAgentService";
|
||||
import { IWorkbenchConstructionOptions } from "vs/workbench/workbench.web.api";
|
||||
|
||||
const tarFs = localRequire<typeof import("tar-fs")>("tar-fs/index");
|
||||
|
||||
export enum HttpCode {
|
||||
Ok = 200,
|
||||
Redirect = 302,
|
||||
NotFound = 404,
|
||||
BadRequest = 400,
|
||||
Unauthorized = 401,
|
||||
LargePayload = 413,
|
||||
ServerError = 500,
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
WORKBENCH_WEB_CONFIGURATION: IWorkbenchConstructionOptions & { folderUri?: UriComponents, workspaceUri?: UriComponents };
|
||||
REMOTE_USER_DATA_URI: UriComponents | URI;
|
||||
PRODUCT_CONFIGURATION: Partial<IProductService>;
|
||||
NLS_CONFIGURATION: NLSConfiguration;
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
cache?: boolean;
|
||||
code?: number;
|
||||
content?: string | Buffer;
|
||||
filePath?: string;
|
||||
headers?: http.OutgoingHttpHeaders;
|
||||
mime?: string;
|
||||
redirect?: string;
|
||||
stream?: Readable;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface AuthPayload {
|
||||
key?: string[];
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
public constructor(message: string, public readonly code: number) {
|
||||
super(message);
|
||||
// @ts-ignore
|
||||
this.name = this.constructor.name;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerOptions {
|
||||
readonly auth: AuthType;
|
||||
readonly basePath?: string;
|
||||
readonly connectionToken?: string;
|
||||
readonly cert?: string;
|
||||
readonly certKey?: string;
|
||||
readonly openUri?: string;
|
||||
readonly host?: string;
|
||||
readonly password?: string;
|
||||
readonly port?: number;
|
||||
readonly socket?: string;
|
||||
}
|
||||
|
||||
export abstract class Server {
|
||||
protected readonly server: http.Server | https.Server;
|
||||
protected rootPath = path.resolve(__dirname, "../../../../..");
|
||||
protected serverRoot = path.join(this.rootPath, "/out/vs/server/src");
|
||||
protected readonly allowedRequestPaths: string[] = [this.rootPath];
|
||||
private listenPromise: Promise<string> | undefined;
|
||||
public readonly protocol: "http" | "https";
|
||||
public readonly options: ServerOptions;
|
||||
|
||||
public constructor(options: ServerOptions) {
|
||||
this.options = {
|
||||
host: options.auth === "password" && options.cert ? "0.0.0.0" : "localhost",
|
||||
...options,
|
||||
basePath: options.basePath ? options.basePath.replace(/\/+$/, "") : "",
|
||||
password: options.password ? hash(options.password) : undefined,
|
||||
};
|
||||
this.protocol = this.options.cert ? "https" : "http";
|
||||
if (this.protocol === "https") {
|
||||
const httpolyglot = localRequire<typeof import("httpolyglot")>("httpolyglot/lib/index");
|
||||
this.server = httpolyglot.createServer({
|
||||
cert: this.options.cert && fs.readFileSync(this.options.cert),
|
||||
key: this.options.certKey && fs.readFileSync(this.options.certKey),
|
||||
}, this.onRequest);
|
||||
} else {
|
||||
this.server = http.createServer(this.onRequest);
|
||||
}
|
||||
}
|
||||
|
||||
public listen(): Promise<string> {
|
||||
if (!this.listenPromise) {
|
||||
this.listenPromise = new Promise((resolve, reject) => {
|
||||
this.server.on("error", reject);
|
||||
this.server.on("upgrade", this.onUpgrade);
|
||||
const onListen = () => resolve(this.address());
|
||||
if (this.options.socket) {
|
||||
this.server.listen(this.options.socket, onListen);
|
||||
} else {
|
||||
this.server.listen(this.options.port, this.options.host, onListen);
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.listenPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* The *local* address of the server.
|
||||
*/
|
||||
public address(): string {
|
||||
const address = this.server.address();
|
||||
const endpoint = typeof address !== "string"
|
||||
? (address.address === "::" ? "localhost" : address.address) + ":" + address.port
|
||||
: address;
|
||||
return `${this.protocol}://${endpoint}`;
|
||||
}
|
||||
|
||||
protected abstract handleWebSocket(
|
||||
socket: net.Socket,
|
||||
parsedUrl: url.UrlWithParsedQuery
|
||||
): Promise<void>;
|
||||
|
||||
protected abstract handleRequest(
|
||||
base: string,
|
||||
requestPath: string,
|
||||
parsedUrl: url.UrlWithParsedQuery,
|
||||
request: http.IncomingMessage,
|
||||
): Promise<Response>;
|
||||
|
||||
protected async getResource(...parts: string[]): Promise<Response> {
|
||||
const filePath = this.ensureAuthorizedFilePath(...parts);
|
||||
return { content: await util.promisify(fs.readFile)(filePath), filePath };
|
||||
}
|
||||
|
||||
protected async getAnyResource(...parts: string[]): Promise<Response> {
|
||||
const filePath = path.join(...parts);
|
||||
return { content: await util.promisify(fs.readFile)(filePath), filePath };
|
||||
}
|
||||
|
||||
protected async getTarredResource(...parts: string[]): Promise<Response> {
|
||||
const filePath = this.ensureAuthorizedFilePath(...parts);
|
||||
return { stream: tarFs.pack(filePath), filePath, mime: "application/tar", cache: true };
|
||||
}
|
||||
|
||||
protected ensureAuthorizedFilePath(...parts: string[]): string {
|
||||
const filePath = path.join(...parts);
|
||||
if (!this.isAllowedRequestPath(filePath)) {
|
||||
throw new HttpError("Unauthorized", HttpCode.Unauthorized);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
protected withBase(request: http.IncomingMessage, path: string): string {
|
||||
const [, query] = request.url ? split(request.url, "?") : [];
|
||||
return `${this.protocol}://${request.headers.host}${this.options.basePath}${path}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
private isAllowedRequestPath(path: string): boolean {
|
||||
for (let i = 0; i < this.allowedRequestPaths.length; ++i) {
|
||||
if (path.indexOf(this.allowedRequestPaths[i]) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> => {
|
||||
try {
|
||||
const parsedUrl = request.url ? url.parse(request.url, true) : { query: {}};
|
||||
const payload = await this.preHandleRequest(request, parsedUrl);
|
||||
response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, {
|
||||
"Content-Type": payload.mime || getMediaMime(payload.filePath),
|
||||
...(payload.redirect ? { Location: this.withBase(request, payload.redirect) } : {}),
|
||||
...(request.headers["service-worker"] ? { "Service-Worker-Allowed": this.options.basePath || "/" } : {}),
|
||||
...(payload.cache ? { "Cache-Control": "public, max-age=31536000" } : {}),
|
||||
...payload.headers,
|
||||
});
|
||||
if (payload.stream) {
|
||||
payload.stream.on("error", (error: NodeJS.ErrnoException) => {
|
||||
response.writeHead(error.code === "ENOENT" ? HttpCode.NotFound : HttpCode.ServerError);
|
||||
response.end(error.message);
|
||||
});
|
||||
payload.stream.pipe(response);
|
||||
} else {
|
||||
response.end(payload.content);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT" || error.code === "EISDIR") {
|
||||
error = new HttpError("Not found", HttpCode.NotFound);
|
||||
}
|
||||
response.writeHead(typeof error.code === "number" ? error.code : HttpCode.ServerError);
|
||||
response.end(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
private async preHandleRequest(request: http.IncomingMessage, parsedUrl: url.UrlWithParsedQuery): Promise<Response> {
|
||||
const secure = (request.connection as tls.TLSSocket).encrypted;
|
||||
if (this.options.cert && !secure) {
|
||||
return { redirect: request.url };
|
||||
}
|
||||
|
||||
const fullPath = decodeURIComponent(parsedUrl.pathname || "/");
|
||||
const match = fullPath.match(/^(\/?[^/]*)(.*)$/);
|
||||
let [/* ignore */, base, requestPath] = match
|
||||
? match.map((p) => p.replace(/\/+$/, ""))
|
||||
: ["", "", ""];
|
||||
if (base.indexOf(".") !== -1) { // Assume it's a file at the root.
|
||||
requestPath = base;
|
||||
base = "/";
|
||||
} else if (base === "") { // Happens if it's a plain `domain.com`.
|
||||
base = "/";
|
||||
}
|
||||
base = path.normalize(base);
|
||||
requestPath = path.normalize(requestPath || "/index.html");
|
||||
|
||||
if (base !== "/login" || this.options.auth !== "password" || requestPath !== "/index.html") {
|
||||
this.ensureGet(request);
|
||||
}
|
||||
|
||||
// Allow for a versioned static endpoint. This lets us cache every static
|
||||
// resource underneath the path based on the version without any work and
|
||||
// without adding query parameters which have their own issues.
|
||||
// REVIEW: Discuss whether this is the best option; this is sort of a quick
|
||||
// hack almost to get caching in the meantime but it does work pretty well.
|
||||
if (/^\/static-/.test(base)) {
|
||||
base = "/static";
|
||||
}
|
||||
|
||||
switch (base) {
|
||||
case "/":
|
||||
switch (requestPath) {
|
||||
// NOTE: This must be served at the correct location based on the
|
||||
// start_url in the manifest.
|
||||
case "/manifest.json":
|
||||
case "/code-server.png":
|
||||
const response = await this.getResource(this.serverRoot, "media", requestPath);
|
||||
response.cache = true;
|
||||
return response;
|
||||
}
|
||||
if (!this.authenticate(request)) {
|
||||
return { redirect: "/login" };
|
||||
}
|
||||
break;
|
||||
case "/static":
|
||||
const response = await this.getResource(this.rootPath, requestPath);
|
||||
response.cache = true;
|
||||
return response;
|
||||
case "/login":
|
||||
if (this.options.auth !== "password" || requestPath !== "/index.html") {
|
||||
throw new HttpError("Not found", HttpCode.NotFound);
|
||||
}
|
||||
return this.tryLogin(request);
|
||||
default:
|
||||
if (!this.authenticate(request)) {
|
||||
throw new HttpError("Unauthorized", HttpCode.Unauthorized);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return this.handleRequest(base, requestPath, parsedUrl, request);
|
||||
}
|
||||
|
||||
private onUpgrade = async (request: http.IncomingMessage, socket: net.Socket): Promise<void> => {
|
||||
try {
|
||||
await this.preHandleWebSocket(request, socket);
|
||||
} catch (error) {
|
||||
socket.destroy();
|
||||
console.error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
private preHandleWebSocket(request: http.IncomingMessage, socket: net.Socket): Promise<void> {
|
||||
socket.on("error", () => socket.destroy());
|
||||
socket.on("end", () => socket.destroy());
|
||||
|
||||
this.ensureGet(request);
|
||||
if (!this.authenticate(request)) {
|
||||
throw new HttpError("Unauthorized", HttpCode.Unauthorized);
|
||||
} else if (!request.headers.upgrade || request.headers.upgrade.toLowerCase() !== "websocket") {
|
||||
throw new Error("HTTP/1.1 400 Bad Request");
|
||||
}
|
||||
|
||||
// This magic value is specified by the websocket spec.
|
||||
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
const reply = crypto.createHash("sha1")
|
||||
.update(<string>request.headers["sec-websocket-key"] + magic)
|
||||
.digest("base64");
|
||||
socket.write([
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${reply}`,
|
||||
].join("\r\n") + "\r\n\r\n");
|
||||
|
||||
const parsedUrl = request.url ? url.parse(request.url, true) : { query: {}};
|
||||
return this.handleWebSocket(socket, parsedUrl);
|
||||
}
|
||||
|
||||
private async tryLogin(request: http.IncomingMessage): Promise<Response> {
|
||||
const redirect = (password: string | true) => {
|
||||
return {
|
||||
redirect: "/",
|
||||
headers: typeof password === "string"
|
||||
? { "Set-Cookie": `key=${password}; Path=${this.options.basePath || "/"}; HttpOnly; SameSite=strict` }
|
||||
: {},
|
||||
};
|
||||
};
|
||||
const providedPassword = this.authenticate(request);
|
||||
if (providedPassword && (request.method === "GET" || request.method === "POST")) {
|
||||
return redirect(providedPassword);
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
const data = await this.getData<LoginPayload>(request);
|
||||
const password = this.authenticate(request, {
|
||||
key: typeof data.password === "string" ? [hash(data.password)] : undefined,
|
||||
});
|
||||
if (password) {
|
||||
return redirect(password);
|
||||
}
|
||||
console.error("Failed login attempt", JSON.stringify({
|
||||
xForwardedFor: request.headers["x-forwarded-for"],
|
||||
remoteAddress: request.connection.remoteAddress,
|
||||
userAgent: request.headers["user-agent"],
|
||||
timestamp: Math.floor(new Date().getTime() / 1000),
|
||||
}));
|
||||
return this.getLogin("Invalid password", data);
|
||||
}
|
||||
this.ensureGet(request);
|
||||
return this.getLogin();
|
||||
}
|
||||
|
||||
private async getLogin(error: string = "", payload?: LoginPayload): Promise<Response> {
|
||||
const filePath = path.join(this.serverRoot, "browser/login.html");
|
||||
const content = (await util.promisify(fs.readFile)(filePath, "utf8"))
|
||||
.replace("{{ERROR}}", error)
|
||||
.replace("display:none", error ? "display:block" : "display:none")
|
||||
.replace('value=""', `value="${payload && payload.password || ""}"`);
|
||||
return { content, filePath };
|
||||
}
|
||||
|
||||
private ensureGet(request: http.IncomingMessage): void {
|
||||
if (request.method !== "GET") {
|
||||
throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest);
|
||||
}
|
||||
}
|
||||
|
||||
private getData<T extends object>(request: http.IncomingMessage): Promise<T> {
|
||||
return request.method === "POST"
|
||||
? new Promise<T>((resolve, reject) => {
|
||||
let body = "";
|
||||
const onEnd = (): void => {
|
||||
off();
|
||||
resolve(querystring.parse(body) as T);
|
||||
};
|
||||
const onError = (error: Error): void => {
|
||||
off();
|
||||
reject(error);
|
||||
};
|
||||
const onData = (d: Buffer): void => {
|
||||
body += d;
|
||||
if (body.length > 1e6) {
|
||||
onError(new HttpError("Payload is too large", HttpCode.LargePayload));
|
||||
request.connection.destroy();
|
||||
}
|
||||
};
|
||||
const off = (): void => {
|
||||
request.off("error", onError);
|
||||
request.off("data", onError);
|
||||
request.off("end", onEnd);
|
||||
};
|
||||
request.on("error", onError);
|
||||
request.on("data", onData);
|
||||
request.on("end", onEnd);
|
||||
})
|
||||
: Promise.resolve({} as T);
|
||||
}
|
||||
|
||||
private authenticate(request: http.IncomingMessage, payload?: AuthPayload): string | boolean {
|
||||
if (this.options.auth === "none") {
|
||||
return true;
|
||||
}
|
||||
const safeCompare = localRequire<typeof import("safe-compare")>("safe-compare/index");
|
||||
if (typeof payload === "undefined") {
|
||||
payload = this.parseCookies<AuthPayload>(request);
|
||||
}
|
||||
if (this.options.password && payload.key) {
|
||||
for (let i = 0; i < payload.key.length; ++i) {
|
||||
if (safeCompare(payload.key[i], this.options.password)) {
|
||||
return payload.key[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private parseCookies<T extends object>(request: http.IncomingMessage): T {
|
||||
const cookies: { [key: string]: string[] } = {};
|
||||
if (request.headers.cookie) {
|
||||
request.headers.cookie.split(";").forEach((keyValue) => {
|
||||
const [key, value] = split(keyValue, "=");
|
||||
if (!cookies[key]) {
|
||||
cookies[key] = [];
|
||||
}
|
||||
cookies[key].push(decodeURI(value));
|
||||
});
|
||||
}
|
||||
return cookies as T;
|
||||
}
|
||||
}
|
||||
|
||||
interface StartPath {
|
||||
path?: string[] | string;
|
||||
workspace?: boolean;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
lastVisited?: StartPath;
|
||||
}
|
||||
|
||||
export class MainServer extends Server {
|
||||
public readonly _onDidClientConnect = new Emitter<ClientConnectionEvent>();
|
||||
public readonly onDidClientConnect = this._onDidClientConnect.event;
|
||||
private readonly ipc = new IPCServer<RemoteAgentConnectionContext>(this.onDidClientConnect);
|
||||
|
||||
private readonly maxExtraOfflineConnections = 0;
|
||||
private readonly connections = new Map<ConnectionType, Map<string, Connection>>();
|
||||
|
||||
private readonly services = new ServiceCollection();
|
||||
private readonly servicesPromise: Promise<void>;
|
||||
|
||||
public readonly _onProxyConnect = new Emitter<net.Socket>();
|
||||
private proxyPipe = path.join(tmpdir, "tls-proxy");
|
||||
private _proxyServer?: Promise<net.Server>;
|
||||
private readonly proxyTimeout = 5000;
|
||||
|
||||
private settings: Settings = {};
|
||||
private heartbeatTimer?: NodeJS.Timeout;
|
||||
private heartbeatInterval = 60000;
|
||||
private lastHeartbeat = 0;
|
||||
|
||||
public constructor(options: ServerOptions, args: ParsedArgs) {
|
||||
super(options);
|
||||
this.servicesPromise = this.initializeServices(args);
|
||||
}
|
||||
|
||||
public async listen(): Promise<string> {
|
||||
const environment = (this.services.get(IEnvironmentService) as EnvironmentService);
|
||||
const [address] = await Promise.all<string>([
|
||||
super.listen(), ...[
|
||||
environment.extensionsPath,
|
||||
].map((p) => mkdirp(p).then(() => p)),
|
||||
]);
|
||||
return address;
|
||||
}
|
||||
|
||||
protected async handleWebSocket(socket: net.Socket, parsedUrl: url.UrlWithParsedQuery): Promise<void> {
|
||||
this.heartbeat();
|
||||
if (!parsedUrl.query.reconnectionToken) {
|
||||
throw new Error("Reconnection token is missing from query parameters");
|
||||
}
|
||||
const protocol = new Protocol(await this.createProxy(socket), {
|
||||
reconnectionToken: <string>parsedUrl.query.reconnectionToken,
|
||||
reconnection: parsedUrl.query.reconnection === "true",
|
||||
skipWebSocketFrames: parsedUrl.query.skipWebSocketFrames === "true",
|
||||
});
|
||||
try {
|
||||
await this.connect(await protocol.handshake(), protocol);
|
||||
} catch (error) {
|
||||
protocol.sendMessage({ type: "error", reason: error.message });
|
||||
protocol.dispose();
|
||||
protocol.getSocket().dispose();
|
||||
}
|
||||
}
|
||||
|
||||
protected async handleRequest(
|
||||
base: string,
|
||||
requestPath: string,
|
||||
parsedUrl: url.UrlWithParsedQuery,
|
||||
request: http.IncomingMessage,
|
||||
): Promise<Response> {
|
||||
this.heartbeat();
|
||||
switch (base) {
|
||||
case "/": return this.getRoot(request, parsedUrl);
|
||||
case "/resource":
|
||||
case "/vscode-remote-resource":
|
||||
if (typeof parsedUrl.query.path === "string") {
|
||||
return this.getAnyResource(parsedUrl.query.path);
|
||||
}
|
||||
break;
|
||||
case "/tar":
|
||||
if (typeof parsedUrl.query.path === "string") {
|
||||
return this.getTarredResource(parsedUrl.query.path);
|
||||
}
|
||||
break;
|
||||
case "/webview":
|
||||
if (/^\/vscode-resource/.test(requestPath)) {
|
||||
return this.getAnyResource(requestPath.replace(/^\/vscode-resource(\/file)?/, ""));
|
||||
}
|
||||
return this.getResource(
|
||||
this.rootPath,
|
||||
"out/vs/workbench/contrib/webview/browser/pre",
|
||||
requestPath
|
||||
);
|
||||
}
|
||||
throw new HttpError("Not found", HttpCode.NotFound);
|
||||
}
|
||||
|
||||
private async getRoot(request: http.IncomingMessage, parsedUrl: url.UrlWithParsedQuery): Promise<Response> {
|
||||
const filePath = path.join(this.serverRoot, "browser/workbench.html");
|
||||
let [content, startPath] = await Promise.all([
|
||||
util.promisify(fs.readFile)(filePath, "utf8"),
|
||||
this.getFirstValidPath([
|
||||
{ path: parsedUrl.query.workspace, workspace: true },
|
||||
{ path: parsedUrl.query.folder, workspace: false },
|
||||
(await this.readSettings()).lastVisited,
|
||||
{ path: this.options.openUri }
|
||||
]),
|
||||
this.servicesPromise,
|
||||
]);
|
||||
|
||||
if (startPath) {
|
||||
this.writeSettings({
|
||||
lastVisited: {
|
||||
path: startPath.uri.fsPath,
|
||||
workspace: startPath.workspace
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const logger = this.services.get(ILogService) as ILogService;
|
||||
logger.info("request.url", `"${request.url}"`);
|
||||
|
||||
const remoteAuthority = request.headers.host as string;
|
||||
const transformer = getUriTransformer(remoteAuthority);
|
||||
|
||||
const environment = this.services.get(IEnvironmentService) as IEnvironmentService;
|
||||
const options: Options = {
|
||||
WORKBENCH_WEB_CONFIGURATION: {
|
||||
workspaceUri: startPath && startPath.workspace ? transformer.transformOutgoing(startPath.uri) : undefined,
|
||||
folderUri: startPath && !startPath.workspace ? transformer.transformOutgoing(startPath.uri) : undefined,
|
||||
remoteAuthority,
|
||||
logLevel: getLogLevel(environment),
|
||||
},
|
||||
REMOTE_USER_DATA_URI: transformer.transformOutgoing(URI.file(environment.userDataPath)),
|
||||
PRODUCT_CONFIGURATION: {
|
||||
extensionsGallery: product.extensionsGallery,
|
||||
},
|
||||
NLS_CONFIGURATION: await getNlsConfiguration(environment.args.locale || await getLocaleFromConfig(environment.userDataPath), environment.userDataPath),
|
||||
};
|
||||
|
||||
content = content.replace(/{{COMMIT}}/g, product.commit || "");
|
||||
for (const key in options) {
|
||||
content = content.replace(`"{{${key}}}"`, `'${JSON.stringify(options[key as keyof Options])}'`);
|
||||
}
|
||||
|
||||
return { content, filePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the first valid path. If `workspace` is undefined then either a
|
||||
* workspace or a directory are acceptable. Otherwise it must be a file if a
|
||||
* workspace or a directory otherwise.
|
||||
*/
|
||||
private async getFirstValidPath(startPaths: Array<StartPath | undefined>): Promise<{ uri: URI, workspace?: boolean} | undefined> {
|
||||
const logger = this.services.get(ILogService) as ILogService;
|
||||
const cwd = process.env.VSCODE_CWD || process.cwd();
|
||||
for (let i = 0; i < startPaths.length; ++i) {
|
||||
const startPath = startPaths[i];
|
||||
if (!startPath) {
|
||||
continue;
|
||||
}
|
||||
const paths = typeof startPath.path === "string" ? [startPath.path] : (startPath.path || []);
|
||||
for (let j = 0; j < paths.length; ++j) {
|
||||
const uri = URI.file(sanitizeFilePath(paths[j], cwd));
|
||||
try {
|
||||
const stat = await util.promisify(fs.stat)(uri.fsPath);
|
||||
if (typeof startPath.workspace === "undefined" || startPath.workspace !== stat.isDirectory()) {
|
||||
return { uri, workspace: !stat.isDirectory() };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async connect(message: ConnectionTypeRequest, protocol: Protocol): Promise<void> {
|
||||
if (product.commit && message.commit !== product.commit) {
|
||||
throw new Error(`Version mismatch (${message.commit} instead of ${product.commit})`);
|
||||
}
|
||||
|
||||
switch (message.desiredConnectionType) {
|
||||
case ConnectionType.ExtensionHost:
|
||||
case ConnectionType.Management:
|
||||
if (!this.connections.has(message.desiredConnectionType)) {
|
||||
this.connections.set(message.desiredConnectionType, new Map());
|
||||
}
|
||||
const connections = this.connections.get(message.desiredConnectionType)!;
|
||||
|
||||
const ok = async () => {
|
||||
return message.desiredConnectionType === ConnectionType.ExtensionHost
|
||||
? { debugPort: await this.getDebugPort() }
|
||||
: { type: "ok" };
|
||||
};
|
||||
|
||||
const token = protocol.options.reconnectionToken;
|
||||
if (protocol.options.reconnection && connections.has(token)) {
|
||||
protocol.sendMessage(await ok());
|
||||
const buffer = protocol.readEntireBuffer();
|
||||
protocol.dispose();
|
||||
return connections.get(token)!.reconnect(protocol.getSocket(), buffer);
|
||||
} else if (protocol.options.reconnection || connections.has(token)) {
|
||||
throw new Error(protocol.options.reconnection
|
||||
? "Unrecognized reconnection token"
|
||||
: "Duplicate reconnection token"
|
||||
);
|
||||
}
|
||||
|
||||
protocol.sendMessage(await ok());
|
||||
|
||||
let connection: Connection;
|
||||
if (message.desiredConnectionType === ConnectionType.Management) {
|
||||
connection = new ManagementConnection(protocol, token);
|
||||
this._onDidClientConnect.fire({
|
||||
protocol, onDidClientDisconnect: connection.onClose,
|
||||
});
|
||||
// TODO: Need a way to match clients with a connection. For now
|
||||
// dispose everything which only works because no extensions currently
|
||||
// utilize long-running proxies.
|
||||
(this.services.get(INodeProxyService) as NodeProxyService)._onUp.fire();
|
||||
connection.onClose(() => (this.services.get(INodeProxyService) as NodeProxyService)._onDown.fire());
|
||||
} else {
|
||||
const buffer = protocol.readEntireBuffer();
|
||||
connection = new ExtensionHostConnection(
|
||||
message.args ? message.args.language : "en",
|
||||
protocol, buffer, token,
|
||||
this.services.get(ILogService) as ILogService,
|
||||
this.services.get(IEnvironmentService) as IEnvironmentService,
|
||||
);
|
||||
}
|
||||
connections.set(token, connection);
|
||||
connection.onClose(() => connections.delete(token));
|
||||
this.disposeOldOfflineConnections(connections);
|
||||
break;
|
||||
case ConnectionType.Tunnel: return protocol.tunnel();
|
||||
default: throw new Error("Unrecognized connection type");
|
||||
}
|
||||
}
|
||||
|
||||
private disposeOldOfflineConnections(connections: Map<string, Connection>): void {
|
||||
const offline = Array.from(connections.values())
|
||||
.filter((connection) => typeof connection.offline !== "undefined");
|
||||
for (let i = 0, max = offline.length - this.maxExtraOfflineConnections; i < max; ++i) {
|
||||
offline[i].dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeServices(args: ParsedArgs): Promise<void> {
|
||||
const environmentService = new EnvironmentService(args, process.execPath);
|
||||
const logService = new SpdLogService(RemoteExtensionLogFileName, environmentService.logsPath, getLogLevel(environmentService));
|
||||
const fileService = new FileService(logService);
|
||||
fileService.registerProvider(Schemas.file, new DiskFileSystemProvider(logService));
|
||||
|
||||
this.allowedRequestPaths.push(
|
||||
path.join(environmentService.userDataPath, "clp"), // Language packs.
|
||||
environmentService.extensionsPath,
|
||||
environmentService.builtinExtensionsPath,
|
||||
...environmentService.extraExtensionPaths,
|
||||
...environmentService.extraBuiltinExtensionPaths,
|
||||
);
|
||||
|
||||
this.ipc.registerChannel("logger", new LoggerChannel(logService));
|
||||
this.ipc.registerChannel(ExtensionHostDebugBroadcastChannel.ChannelName, new ExtensionHostDebugBroadcastChannel());
|
||||
|
||||
this.services.set(ILogService, logService);
|
||||
this.services.set(IEnvironmentService, environmentService);
|
||||
this.services.set(IConfigurationService, new SyncDescriptor(ConfigurationService, [environmentService.machineSettingsResource]));
|
||||
this.services.set(IRequestService, new SyncDescriptor(RequestService));
|
||||
this.services.set(IFileService, fileService);
|
||||
this.services.set(IProductService, { _serviceBrand: undefined, ...product });
|
||||
this.services.set(IExtensionGalleryService, new SyncDescriptor(ExtensionGalleryService));
|
||||
this.services.set(IExtensionManagementService, new SyncDescriptor(ExtensionManagementService));
|
||||
|
||||
if (!environmentService.args["disable-telemetry"]) {
|
||||
this.services.set(ITelemetryService, new SyncDescriptor(TelemetryService, [{
|
||||
appender: combinedAppender(
|
||||
new AppInsightsAppender("code-server", null, () => new TelemetryClient() as any, logService),
|
||||
new LogAppender(logService),
|
||||
),
|
||||
commonProperties: resolveCommonProperties(
|
||||
product.commit, product.codeServerVersion, await getMachineId(),
|
||||
[], environmentService.installSourcePath, "code-server",
|
||||
),
|
||||
piiPaths: this.allowedRequestPaths,
|
||||
} as ITelemetryServiceConfig]));
|
||||
} else {
|
||||
this.services.set(ITelemetryService, NullTelemetryService);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const instantiationService = new InstantiationService(this.services);
|
||||
this.services.set(ILocalizationsService, instantiationService.createInstance(LocalizationsService));
|
||||
this.services.set(INodeProxyService, instantiationService.createInstance(NodeProxyService));
|
||||
|
||||
instantiationService.invokeFunction(() => {
|
||||
instantiationService.createInstance(LogsDataCleaner);
|
||||
const telemetryService = this.services.get(ITelemetryService) as ITelemetryService;
|
||||
this.ipc.registerChannel("extensions", new ExtensionManagementChannel(
|
||||
this.services.get(IExtensionManagementService) as IExtensionManagementService,
|
||||
(context) => getUriTransformer(context.remoteAuthority),
|
||||
));
|
||||
this.ipc.registerChannel("remoteextensionsenvironment", new ExtensionEnvironmentChannel(
|
||||
environmentService, logService, telemetryService, this.options.connectionToken || "",
|
||||
));
|
||||
this.ipc.registerChannel("request", new RequestChannel(this.services.get(IRequestService) as IRequestService));
|
||||
this.ipc.registerChannel("telemetry", new TelemetryChannel(telemetryService));
|
||||
this.ipc.registerChannel("nodeProxy", new NodeProxyChannel(this.services.get(INodeProxyService) as INodeProxyService));
|
||||
this.ipc.registerChannel("localizations", <IServerChannel<any>>createChannelReceiver(this.services.get(ILocalizationsService) as ILocalizationsService));
|
||||
this.ipc.registerChannel("update", new UpdateChannel(instantiationService.createInstance(UpdateService)));
|
||||
this.ipc.registerChannel(REMOTE_FILE_SYSTEM_CHANNEL_NAME, new FileProviderChannel(environmentService, logService));
|
||||
resolve(new ErrorTelemetry(telemetryService));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: implement.
|
||||
*/
|
||||
private async getDebugPort(): Promise<number | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Since we can't pass TLS sockets to children, use this to proxy the socket
|
||||
* and pass a non-TLS socket.
|
||||
*/
|
||||
private createProxy = async (socket: net.Socket): Promise<net.Socket> => {
|
||||
if (!(socket instanceof tls.TLSSocket)) {
|
||||
return socket;
|
||||
}
|
||||
|
||||
await this.startProxyServer();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
listener.dispose();
|
||||
socket.destroy();
|
||||
proxy.destroy();
|
||||
reject(new Error("TLS socket proxy timed out"));
|
||||
}, this.proxyTimeout);
|
||||
|
||||
const listener = this._onProxyConnect.event((connection) => {
|
||||
connection.once("data", (data) => {
|
||||
if (!socket.destroyed && !proxy.destroyed && data.toString() === id) {
|
||||
clearTimeout(timeout);
|
||||
listener.dispose();
|
||||
[[proxy, socket], [socket, proxy]].forEach(([a, b]) => {
|
||||
a.pipe(b);
|
||||
a.on("error", () => b.destroy());
|
||||
a.on("close", () => b.destroy());
|
||||
a.on("end", () => b.end());
|
||||
});
|
||||
resolve(connection);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const id = generateUuid();
|
||||
const proxy = net.connect(this.proxyPipe);
|
||||
proxy.once("connect", () => proxy.write(id));
|
||||
});
|
||||
}
|
||||
|
||||
private async startProxyServer(): Promise<net.Server> {
|
||||
if (!this._proxyServer) {
|
||||
this._proxyServer = new Promise(async (resolve) => {
|
||||
this.proxyPipe = await this.findFreeSocketPath(this.proxyPipe);
|
||||
await mkdirp(tmpdir);
|
||||
await rimraf(this.proxyPipe);
|
||||
const proxyServer = net.createServer((p) => this._onProxyConnect.fire(p));
|
||||
proxyServer.once("listening", resolve);
|
||||
proxyServer.listen(this.proxyPipe);
|
||||
});
|
||||
}
|
||||
return this._proxyServer;
|
||||
}
|
||||
|
||||
private async findFreeSocketPath(basePath: string, maxTries: number = 100): Promise<string> {
|
||||
const canConnect = (path: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(path);
|
||||
socket.once("error", () => resolve(false));
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
let path = basePath;
|
||||
while (await canConnect(path) && i < maxTries) {
|
||||
path = `${basePath}-${++i}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the file path for Coder settings.
|
||||
*/
|
||||
private get settingsPath(): string {
|
||||
const environment = this.services.get(IEnvironmentService) as IEnvironmentService;
|
||||
return path.join(environment.userDataPath, "coder.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read settings from the file. On a failure return last known settings and
|
||||
* log a warning.
|
||||
*
|
||||
*/
|
||||
private async readSettings(): Promise<Settings> {
|
||||
try {
|
||||
const raw = (await util.promisify(fs.readFile)(this.settingsPath, "utf8")).trim();
|
||||
this.settings = raw ? JSON.parse(raw) : {};
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
(this.services.get(ILogService) as ILogService).warn(error.message);
|
||||
}
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write settings combined with current settings. On failure log a warning.
|
||||
*/
|
||||
private async writeSettings(newSettings: Partial<Settings>): Promise<void> {
|
||||
this.settings = { ...this.settings, ...newSettings };
|
||||
try {
|
||||
await util.promisify(fs.writeFile)(this.settingsPath, JSON.stringify(this.settings));
|
||||
} catch (error) {
|
||||
(this.services.get(ILogService) as ILogService).warn(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the file path for the heartbeat file.
|
||||
*/
|
||||
private get heartbeatPath(): string {
|
||||
const environment = this.services.get(IEnvironmentService) as IEnvironmentService;
|
||||
return path.join(environment.userDataPath, "heartbeat");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all online connections regardless of type.
|
||||
*/
|
||||
private get onlineConnections(): Connection[] {
|
||||
const online = <Connection[]>[];
|
||||
this.connections.forEach((connections) => {
|
||||
connections.forEach((connection) => {
|
||||
if (typeof connection.offline === "undefined") {
|
||||
online.push(connection);
|
||||
}
|
||||
});
|
||||
});
|
||||
return online;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to the heartbeat file if we haven't already done so within the
|
||||
* timeout and start or reset a timer that keeps running as long as there are
|
||||
* active connections. Failures are logged as warnings.
|
||||
*/
|
||||
private heartbeat(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastHeartbeat >= this.heartbeatInterval) {
|
||||
util.promisify(fs.writeFile)(this.heartbeatPath, "").catch((error) => {
|
||||
(this.services.get(ILogService) as ILogService).warn(error.message);
|
||||
});
|
||||
this.lastHeartbeat = now;
|
||||
clearTimeout(this.heartbeatTimer!); // We can clear undefined so ! is fine.
|
||||
this.heartbeatTimer = setTimeout(() => {
|
||||
if (this.onlineConnections.length > 0) {
|
||||
this.heartbeat();
|
||||
}
|
||||
}, this.heartbeatInterval);
|
||||
}
|
||||
}
|
||||
}
|
40
src/node/settings.ts
Normal file
40
src/node/settings.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import * as fs from "fs-extra"
|
||||
import { logger } from "@coder/logger"
|
||||
import { extend } from "./util"
|
||||
|
||||
export type Settings = { [key: string]: Settings | string | boolean | number }
|
||||
|
||||
/**
|
||||
* Provides read and write access to settings.
|
||||
*/
|
||||
export class SettingsProvider<T> {
|
||||
public constructor(private readonly settingsPath: string) {}
|
||||
|
||||
/**
|
||||
* Read settings from the file. On a failure return last known settings and
|
||||
* log a warning.
|
||||
*/
|
||||
public async read(): Promise<T> {
|
||||
try {
|
||||
const raw = (await fs.readFile(this.settingsPath, "utf8")).trim()
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
logger.warn(error.message)
|
||||
}
|
||||
}
|
||||
return {} as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Write settings combined with current settings. On failure log a warning.
|
||||
* Objects will be merged and everything else will be replaced.
|
||||
*/
|
||||
public async write(settings: Partial<T>): Promise<void> {
|
||||
try {
|
||||
await fs.writeFile(this.settingsPath, JSON.stringify(extend(this.read(), settings)))
|
||||
} catch (error) {
|
||||
logger.warn(error.message)
|
||||
}
|
||||
}
|
||||
}
|
110
src/node/socket.ts
Normal file
110
src/node/socket.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import * as fs from "fs-extra"
|
||||
import * as net from "net"
|
||||
import * as path from "path"
|
||||
import * as tls from "tls"
|
||||
import { Emitter } from "../common/emitter"
|
||||
import { generateUuid } from "../common/util"
|
||||
import { tmpdir } from "./util"
|
||||
|
||||
/**
|
||||
* Provides a way to proxy a TLS socket. Can be used when you need to pass a
|
||||
* socket to a child process since you can't pass the TLS socket.
|
||||
*/
|
||||
export class SocketProxyProvider {
|
||||
private readonly onProxyConnect = new Emitter<net.Socket>()
|
||||
private proxyPipe = path.join(tmpdir, "tls-proxy")
|
||||
private _proxyServer?: Promise<net.Server>
|
||||
private readonly proxyTimeout = 5000
|
||||
|
||||
/**
|
||||
* Stop the proxy server.
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this._proxyServer) {
|
||||
this._proxyServer.then((server) => server.close())
|
||||
this._proxyServer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a socket proxy for TLS sockets. If it's not a TLS socket the
|
||||
* original socket is returned. This will spawn a proxy server on demand.
|
||||
*/
|
||||
public async createProxy(socket: net.Socket): Promise<net.Socket> {
|
||||
if (!(socket instanceof tls.TLSSocket)) {
|
||||
return socket
|
||||
}
|
||||
|
||||
await this.startProxyServer()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = generateUuid()
|
||||
const proxy = net.connect(this.proxyPipe)
|
||||
proxy.once("connect", () => proxy.write(id))
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
listener.dispose() // eslint-disable-line @typescript-eslint/no-use-before-define
|
||||
socket.destroy()
|
||||
proxy.destroy()
|
||||
reject(new Error("TLS socket proxy timed out"))
|
||||
}, this.proxyTimeout)
|
||||
|
||||
const listener = this.onProxyConnect.event((connection) => {
|
||||
connection.once("data", (data) => {
|
||||
if (!socket.destroyed && !proxy.destroyed && data.toString() === id) {
|
||||
clearTimeout(timeout)
|
||||
listener.dispose()
|
||||
;[
|
||||
[proxy, socket],
|
||||
[socket, proxy],
|
||||
].forEach(([a, b]) => {
|
||||
a.pipe(b)
|
||||
a.on("error", () => b.destroy())
|
||||
a.on("close", () => b.destroy())
|
||||
a.on("end", () => b.end())
|
||||
})
|
||||
resolve(connection)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async startProxyServer(): Promise<net.Server> {
|
||||
if (!this._proxyServer) {
|
||||
this._proxyServer = this.findFreeSocketPath(this.proxyPipe)
|
||||
.then((pipe) => {
|
||||
this.proxyPipe = pipe
|
||||
return Promise.all([fs.mkdirp(tmpdir), fs.remove(this.proxyPipe)])
|
||||
})
|
||||
.then(() => {
|
||||
return new Promise((resolve) => {
|
||||
const proxyServer = net.createServer((p) => this.onProxyConnect.emit(p))
|
||||
proxyServer.once("listening", () => resolve(proxyServer))
|
||||
proxyServer.listen(this.proxyPipe)
|
||||
})
|
||||
})
|
||||
}
|
||||
return this._proxyServer
|
||||
}
|
||||
|
||||
public async findFreeSocketPath(basePath: string, maxTries = 100): Promise<string> {
|
||||
const canConnect = (path: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(path)
|
||||
socket.once("error", () => resolve(false))
|
||||
socket.once("connect", () => {
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let i = 0
|
||||
let path = basePath
|
||||
while ((await canConnect(path)) && i < maxTries) {
|
||||
path = `${basePath}-${++i}`
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
@ -1,141 +0,0 @@
|
||||
import * as cp from "child_process";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import * as util from "util";
|
||||
import { CancellationToken } from "vs/base/common/cancellation";
|
||||
import { URI } from "vs/base/common/uri";
|
||||
import * as pfs from "vs/base/node/pfs";
|
||||
import { IConfigurationService } from "vs/platform/configuration/common/configuration";
|
||||
import { IEnvironmentService } from "vs/platform/environment/common/environment";
|
||||
import { IFileService } from "vs/platform/files/common/files";
|
||||
import { ILogService } from "vs/platform/log/common/log";
|
||||
import product from "vs/platform/product/common/product";
|
||||
import { asJson, IRequestService } from "vs/platform/request/common/request";
|
||||
import { AvailableForDownload, State, UpdateType, StateType } from "vs/platform/update/common/update";
|
||||
import { AbstractUpdateService } from "vs/platform/update/electron-main/abstractUpdateService";
|
||||
import { ipcMain } from "vs/server/src/node/ipc";
|
||||
import { extract } from "vs/server/src/node/marketplace";
|
||||
import { tmpdir } from "vs/server/src/node/util";
|
||||
|
||||
interface IUpdate {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class UpdateService extends AbstractUpdateService {
|
||||
_serviceBrand: any;
|
||||
|
||||
constructor(
|
||||
@IConfigurationService configurationService: IConfigurationService,
|
||||
@IEnvironmentService environmentService: IEnvironmentService,
|
||||
@IRequestService requestService: IRequestService,
|
||||
@ILogService logService: ILogService,
|
||||
@IFileService private readonly fileService: IFileService,
|
||||
) {
|
||||
super(null, configurationService, environmentService, requestService, logService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the currently installed version is the latest.
|
||||
*/
|
||||
public async isLatestVersion(latest?: IUpdate | null): Promise<boolean | undefined> {
|
||||
if (!latest) {
|
||||
latest = await this.getLatestVersion();
|
||||
}
|
||||
if (latest) {
|
||||
const latestMajor = parseInt(latest.name);
|
||||
const currentMajor = parseInt(product.codeServerVersion);
|
||||
// If these are invalid versions we can't compare meaningfully.
|
||||
return isNaN(latestMajor) || isNaN(currentMajor) ||
|
||||
// This can happen when there is a pre-release for a new major version.
|
||||
currentMajor > latestMajor ||
|
||||
// Otherwise assume that if it's not the same then we're out of date.
|
||||
latest.name === product.codeServerVersion;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected buildUpdateFeedUrl(quality: string): string {
|
||||
return `${product.updateUrl}/${quality}`;
|
||||
}
|
||||
|
||||
public async doQuitAndInstall(): Promise<void> {
|
||||
if (this.state.type === StateType.Ready) {
|
||||
ipcMain.relaunch(this.state.update.version);
|
||||
}
|
||||
}
|
||||
|
||||
protected async doCheckForUpdates(context: any): Promise<void> {
|
||||
this.setState(State.CheckingForUpdates(context));
|
||||
try {
|
||||
const update = await this.getLatestVersion();
|
||||
if (!update || await this.isLatestVersion(update)) {
|
||||
this.setState(State.Idle(UpdateType.Archive));
|
||||
} else {
|
||||
this.setState(State.AvailableForDownload({
|
||||
version: update.name,
|
||||
productVersion: update.name,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
this.onRequestError(error, !!context);
|
||||
}
|
||||
}
|
||||
|
||||
private async getLatestVersion(): Promise<IUpdate | null> {
|
||||
const data = await this.requestService.request({
|
||||
url: this.url,
|
||||
headers: { "User-Agent": "code-server" },
|
||||
}, CancellationToken.None);
|
||||
return asJson(data);
|
||||
}
|
||||
|
||||
protected async doDownloadUpdate(state: AvailableForDownload): Promise<void> {
|
||||
this.setState(State.Downloading(state.update));
|
||||
const target = os.platform();
|
||||
const releaseName = await this.buildReleaseName(state.update.version);
|
||||
const url = "https://github.com/cdr/code-server/releases/download/"
|
||||
+ `${state.update.version}/${releaseName}`
|
||||
+ `.${target === "darwin" ? "zip" : "tar.gz"}`;
|
||||
const downloadPath = path.join(tmpdir, `${state.update.version}-archive`);
|
||||
const extractPath = path.join(tmpdir, state.update.version);
|
||||
try {
|
||||
await pfs.mkdirp(tmpdir);
|
||||
const context = await this.requestService.request({ url }, CancellationToken.None, true);
|
||||
await this.fileService.writeFile(URI.file(downloadPath), context.stream);
|
||||
await extract(downloadPath, extractPath, undefined, CancellationToken.None);
|
||||
const newBinary = path.join(extractPath, releaseName, "code-server");
|
||||
if (!pfs.exists(newBinary)) {
|
||||
throw new Error("No code-server binary in extracted archive");
|
||||
}
|
||||
await pfs.unlink(process.argv[0]); // Must unlink first to avoid ETXTBSY.
|
||||
await pfs.move(newBinary, process.argv[0]);
|
||||
this.setState(State.Ready(state.update));
|
||||
} catch (error) {
|
||||
this.onRequestError(error, true);
|
||||
}
|
||||
await Promise.all([downloadPath, extractPath].map((p) => pfs.rimraf(p)));
|
||||
}
|
||||
|
||||
private onRequestError(error: Error, showNotification?: boolean): void {
|
||||
this.logService.error(error);
|
||||
this.setState(State.Idle(UpdateType.Archive, showNotification ? (error.message || error.toString()) : undefined));
|
||||
}
|
||||
|
||||
private async buildReleaseName(release: string): 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${release}-${target}-${arch}`;
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
// This file is included via a regular Node require. I'm not sure how (or if)
|
||||
// we can write this in Typescript and have it compile to non-AMD syntax.
|
||||
module.exports = (remoteAuthority) => {
|
||||
return {
|
||||
transformIncoming: (uri) => {
|
||||
switch (uri.scheme) {
|
||||
case "vscode-remote": return { scheme: "file", path: uri.path };
|
||||
default: return uri;
|
||||
}
|
||||
},
|
||||
transformOutgoing: (uri) => {
|
||||
switch (uri.scheme) {
|
||||
case "file": return { scheme: "vscode-remote", authority: remoteAuthority, path: uri.path };
|
||||
default: return uri;
|
||||
}
|
||||
},
|
||||
transformOutgoingScheme: (scheme) => {
|
||||
switch (scheme) {
|
||||
case "file": return "vscode-remote";
|
||||
default: return scheme;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
316
src/node/util.ts
316
src/node/util.ts
@ -1,144 +1,216 @@
|
||||
import * as cp from "child_process";
|
||||
import * as crypto from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import * as util from "util";
|
||||
import * as rg from "vscode-ripgrep";
|
||||
import * as cp from "child_process"
|
||||
import * as crypto from "crypto"
|
||||
import * as fs from "fs-extra"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import * as util from "util"
|
||||
|
||||
import { getPathFromAmdModule } from "vs/base/common/amd";
|
||||
import { getMediaMime as vsGetMediaMime } from "vs/base/common/mime";
|
||||
import { extname } from "vs/base/common/path";
|
||||
import { URITransformer, IRawURITransformer } from "vs/base/common/uriIpc";
|
||||
import { mkdirp } from "vs/base/node/pfs";
|
||||
export const tmpdir = path.join(os.tmpdir(), "code-server")
|
||||
|
||||
export enum AuthType {
|
||||
Password = "password",
|
||||
None = "none",
|
||||
const getXdgDataDir = (): string => {
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), "AppData/Local"), "code-server/Data")
|
||||
case "darwin":
|
||||
return path.join(
|
||||
process.env.XDG_DATA_HOME || path.join(os.homedir(), "Library/Application Support"),
|
||||
"code-server"
|
||||
)
|
||||
default:
|
||||
return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local/share"), "code-server")
|
||||
}
|
||||
}
|
||||
|
||||
export enum FormatType {
|
||||
Json = "json",
|
||||
export const xdgLocalDir = getXdgDataDir()
|
||||
|
||||
export const generateCertificate = async (): Promise<{ cert: string; certKey: string }> => {
|
||||
const paths = {
|
||||
cert: path.join(tmpdir, "self-signed.cert"),
|
||||
certKey: path.join(tmpdir, "self-signed.key"),
|
||||
}
|
||||
const checks = await Promise.all([fs.pathExists(paths.cert), fs.pathExists(paths.certKey)])
|
||||
if (!checks[0] || !checks[1]) {
|
||||
// Require on demand so openssl isn't required if you aren't going to
|
||||
// generate certificates.
|
||||
const pem = require("pem") as typeof import("pem")
|
||||
const certs = await new Promise<import("pem").CertificateCreationResult>((resolve, reject): void => {
|
||||
pem.createCertificate({ selfSigned: true }, (error, result) => {
|
||||
return error ? reject(error) : resolve(result)
|
||||
})
|
||||
})
|
||||
await fs.mkdirp(tmpdir)
|
||||
await Promise.all([fs.writeFile(paths.cert, certs.certificate), fs.writeFile(paths.certKey, certs.serviceKey)])
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
export const tmpdir = path.join(os.tmpdir(), "code-server");
|
||||
|
||||
export const generateCertificate = async (): Promise<{ cert: string, certKey: string }> => {
|
||||
const paths = {
|
||||
cert: path.join(tmpdir, "self-signed.cert"),
|
||||
certKey: path.join(tmpdir, "self-signed.key"),
|
||||
};
|
||||
|
||||
const exists = await Promise.all([
|
||||
util.promisify(fs.exists)(paths.cert),
|
||||
util.promisify(fs.exists)(paths.certKey),
|
||||
]);
|
||||
|
||||
if (!exists[0] || !exists[1]) {
|
||||
const pem = localRequire<typeof import("pem")>("pem/lib/pem");
|
||||
const certs = await new Promise<import("pem").CertificateCreationResult>((resolve, reject): void => {
|
||||
pem.createCertificate({ selfSigned: true }, (error, result) => {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
await mkdirp(tmpdir);
|
||||
await Promise.all([
|
||||
util.promisify(fs.writeFile)(paths.cert, certs.certificate),
|
||||
util.promisify(fs.writeFile)(paths.certKey, certs.serviceKey),
|
||||
]);
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
|
||||
export const uriTransformerPath = getPathFromAmdModule(require, "vs/server/src/node/uriTransformer");
|
||||
export const getUriTransformer = (remoteAuthority: string): URITransformer => {
|
||||
const rawURITransformerFactory = <any>require.__$__nodeRequire(uriTransformerPath);
|
||||
const rawURITransformer = <IRawURITransformer>rawURITransformerFactory(remoteAuthority);
|
||||
return new URITransformer(rawURITransformer);
|
||||
};
|
||||
|
||||
export const generatePassword = async (length: number = 24): Promise<string> => {
|
||||
const buffer = Buffer.alloc(Math.ceil(length / 2));
|
||||
await util.promisify(crypto.randomFill)(buffer);
|
||||
return buffer.toString("hex").substring(0, length);
|
||||
};
|
||||
export const generatePassword = async (length = 24): Promise<string> => {
|
||||
const buffer = Buffer.alloc(Math.ceil(length / 2))
|
||||
await util.promisify(crypto.randomFill)(buffer)
|
||||
return buffer.toString("hex").substring(0, length)
|
||||
}
|
||||
|
||||
export const hash = (str: string): string => {
|
||||
return crypto.createHash("sha256").update(str).digest("hex");
|
||||
};
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(str)
|
||||
.digest("hex")
|
||||
}
|
||||
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
".aac": "audio/x-aac",
|
||||
".avi": "video/x-msvideo",
|
||||
".bmp": "image/bmp",
|
||||
".css": "text/css",
|
||||
".flv": "video/x-flv",
|
||||
".gif": "image/gif",
|
||||
".html": "text/html",
|
||||
".ico": "image/x-icon",
|
||||
".jpe": "image/jpg",
|
||||
".jpeg": "image/jpg",
|
||||
".jpg": "image/jpg",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".m1v": "video/mpeg",
|
||||
".m2a": "audio/mpeg",
|
||||
".m2v": "video/mpeg",
|
||||
".m3a": "audio/mpeg",
|
||||
".mid": "audio/midi",
|
||||
".midi": "audio/midi",
|
||||
".mk3d": "video/x-matroska",
|
||||
".mks": "video/x-matroska",
|
||||
".mkv": "video/x-matroska",
|
||||
".mov": "video/quicktime",
|
||||
".movie": "video/x-sgi-movie",
|
||||
".mp2": "audio/mpeg",
|
||||
".mp2a": "audio/mpeg",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".mp4a": "audio/mp4",
|
||||
".mp4v": "video/mp4",
|
||||
".mpe": "video/mpeg",
|
||||
".mpeg": "video/mpeg",
|
||||
".mpg": "video/mpeg",
|
||||
".mpg4": "video/mp4",
|
||||
".mpga": "audio/mpeg",
|
||||
".oga": "audio/ogg",
|
||||
".ogg": "audio/ogg",
|
||||
".ogv": "video/ogg",
|
||||
".png": "image/png",
|
||||
".psd": "image/vnd.adobe.photoshop",
|
||||
".qt": "video/quicktime",
|
||||
".spx": "audio/ogg",
|
||||
".svg": "image/svg+xml",
|
||||
".tga": "image/x-tga",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".txt": "text/plain",
|
||||
".wav": "audio/x-wav",
|
||||
".wasm": "application/wasm",
|
||||
".webm": "video/webm",
|
||||
".webp": "image/webp",
|
||||
".wma": "audio/x-ms-wma",
|
||||
".wmv": "video/x-ms-wmv",
|
||||
".woff": "application/font-woff",
|
||||
}
|
||||
|
||||
export const getMediaMime = (filePath?: string): string => {
|
||||
return filePath && (vsGetMediaMime(filePath) || (<{[index: string]: string}>{
|
||||
".css": "text/css",
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
})[extname(filePath)]) || "text/plain";
|
||||
};
|
||||
return (filePath && mimeTypes[path.extname(filePath)]) || "text/plain"
|
||||
}
|
||||
|
||||
export const isWsl = async (): Promise<boolean> => {
|
||||
return process.platform === "linux"
|
||||
&& os.release().toLowerCase().indexOf("microsoft") !== -1
|
||||
|| (await util.promisify(fs.readFile)("/proc/version", "utf8"))
|
||||
.toLowerCase().indexOf("microsoft") !== -1;
|
||||
};
|
||||
return (
|
||||
(process.platform === "linux" &&
|
||||
os
|
||||
.release()
|
||||
.toLowerCase()
|
||||
.indexOf("microsoft") !== -1) ||
|
||||
(await fs.readFile("/proc/version", "utf8")).toLowerCase().indexOf("microsoft") !== -1
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Try opening a URL using whatever the system has set for opening URLs.
|
||||
*/
|
||||
export const open = async (url: string): Promise<void> => {
|
||||
const args = <string[]>[];
|
||||
const options = <cp.SpawnOptions>{};
|
||||
const platform = await isWsl() ? "wsl" : process.platform;
|
||||
let command = platform === "darwin" ? "open" : "xdg-open";
|
||||
if (platform === "win32" || platform === "wsl") {
|
||||
command = platform === "wsl" ? "cmd.exe" : "cmd";
|
||||
args.push("/c", "start", '""', "/b");
|
||||
url = url.replace(/&/g, "^&");
|
||||
}
|
||||
const proc = cp.spawn(command, [...args, url], options);
|
||||
await new Promise((resolve, reject) => {
|
||||
proc.on("error", reject);
|
||||
proc.on("close", (code) => {
|
||||
return code !== 0
|
||||
? reject(new Error(`Failed to open with code ${code}`))
|
||||
: resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
const args = [] as string[]
|
||||
const options = {} as cp.SpawnOptions
|
||||
const platform = (await isWsl()) ? "wsl" : process.platform
|
||||
let command = platform === "darwin" ? "open" : "xdg-open"
|
||||
if (platform === "win32" || platform === "wsl") {
|
||||
command = platform === "wsl" ? "cmd.exe" : "cmd"
|
||||
args.push("/c", "start", '""', "/b")
|
||||
url = url.replace(/&/g, "^&")
|
||||
}
|
||||
const proc = cp.spawn(command, [...args, url], options)
|
||||
await new Promise((resolve, reject) => {
|
||||
proc.on("error", reject)
|
||||
proc.on("close", (code) => {
|
||||
return code !== 0 ? reject(new Error(`Failed to open with code ${code}`)) : resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract executables to the temporary directory. This is required since we
|
||||
* can't execute binaries stored within our binary.
|
||||
* Extract a file to the temporary directory and make it executable. This is
|
||||
* required since we can't execute binaries stored within our binary.
|
||||
*/
|
||||
export const unpackExecutables = async (): Promise<void> => {
|
||||
const rgPath = (rg as any).binaryRgPath;
|
||||
const destination = path.join(tmpdir, path.basename(rgPath || ""));
|
||||
if (rgPath && !(await util.promisify(fs.exists)(destination))) {
|
||||
await mkdirp(tmpdir);
|
||||
await util.promisify(fs.writeFile)(destination, await util.promisify(fs.readFile)(rgPath));
|
||||
await util.promisify(fs.chmod)(destination, "755");
|
||||
}
|
||||
};
|
||||
export const unpackExecutables = async (filePath: string): Promise<void> => {
|
||||
const destination = path.join(tmpdir, "binaries", path.basename(filePath))
|
||||
if (filePath && !(await util.promisify(fs.exists)(destination))) {
|
||||
await fs.mkdirp(tmpdir)
|
||||
await fs.writeFile(destination, await fs.readFile(filePath))
|
||||
await util.promisify(fs.chmod)(destination, "755")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For iterating over an enum's values.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const enumToArray = (t: any): string[] => {
|
||||
const values = <string[]>[];
|
||||
for (const k in t) {
|
||||
values.push(t[k]);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
export const buildAllowedMessage = (t: any): string => {
|
||||
const values = enumToArray(t);
|
||||
return `Allowed value${values.length === 1 ? " is" : "s are"} ${values.map((t) => `'${t}'`).join(", ")}`;
|
||||
};
|
||||
const values = [] as string[]
|
||||
for (const k in t) {
|
||||
values.push(t[k])
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a local module. This is necessary since VS Code's loader only looks
|
||||
* at the root for Node modules.
|
||||
* For displaying all allowed options in an enum.
|
||||
*/
|
||||
export const localRequire = <T>(modulePath: string): T => {
|
||||
return require.__$__nodeRequire(path.resolve(__dirname, "../../node_modules", modulePath));
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const buildAllowedMessage = (t: any): string => {
|
||||
const values = enumToArray(t)
|
||||
return `Allowed value${values.length === 1 ? " is" : "s are"} ${values.map((t) => `'${t}'`).join(", ")}`
|
||||
}
|
||||
|
||||
export const isObject = <T extends object>(obj: T): obj is T => {
|
||||
return !Array.isArray(obj) && typeof obj === "object" && obj !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend a with b and return a new object. Properties with objects will be
|
||||
* recursively merged while all other properties are just overwritten.
|
||||
*/
|
||||
export function extend<A, B>(a: A, b: B): A & B
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function extend(...args: any[]): any {
|
||||
const c = {} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
for (const obj of args) {
|
||||
if (!isObject(obj)) {
|
||||
continue
|
||||
}
|
||||
for (const key in obj) {
|
||||
c[key] = isObject(obj[key]) ? extend(c[key], obj[key]) : obj[key]
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove extra and trailing slashes in a URL.
|
||||
*/
|
||||
export const normalize = (url: string): string => {
|
||||
return url.replace(/\/\/+/g, "/").replace(/\/+$/, "")
|
||||
}
|
||||
|
59
src/node/vscode/README.md
Normal file
59
src/node/vscode/README.md
Normal file
@ -0,0 +1,59 @@
|
||||
Implementation of [VS Code](https://code.visualstudio.com/) remote/web for use
|
||||
in `code-server`.
|
||||
|
||||
## Docker
|
||||
|
||||
To debug Golang in VS Code using the
|
||||
[ms-vscode-go extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.Go),
|
||||
you need to add `--security-opt seccomp=unconfined` to your `docker run`
|
||||
arguments when launching code-server with Docker. See
|
||||
[#725](https://github.com/cdr/code-server/issues/725) for details.
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Creating custom VS Code extensions and debugging them doesn't work.
|
||||
- Extension profiling and tips are currently disabled.
|
||||
|
||||
## Extensions
|
||||
|
||||
`code-server` does not provide access to the official
|
||||
[Visual Studio Marketplace](https://marketplace.visualstudio.com/vscode). Instead,
|
||||
Coder has created a custom extension marketplace that we manage for open-source
|
||||
extensions. If you want to use an extension with code-server that we do not have
|
||||
in our marketplace please look for a release in the extension’s repository,
|
||||
contact us to see if we have one in the works or, if you build an extension
|
||||
locally from open source, you can copy it to the `extensions` folder. If you
|
||||
build one locally from open-source please contribute it to the project and let
|
||||
us know so we can give you props! If you have your own custom marketplace, it is
|
||||
possible to point code-server to it by setting the `SERVICE_URL` and `ITEM_URL`
|
||||
environment variables.
|
||||
|
||||
## Development: upgrading VS Code
|
||||
|
||||
We patch VS Code to provide and fix some functionality. As the web portion of VS
|
||||
Code matures, we'll be able to shrink and maybe even entirely eliminate our
|
||||
patch. In the meantime, however, upgrading the VS Code version requires ensuring
|
||||
that the patch still applies and has the intended effects.
|
||||
|
||||
If functionality doesn't depend on code from VS Code then it should be moved
|
||||
into code-server otherwise it should be in the patch.
|
||||
|
||||
To generate a new patch, **stage all the changes** you want to be included in
|
||||
the patch in the VS Code source, then run `yarn patch:generate` in this
|
||||
directory.
|
||||
|
||||
Our changes include:
|
||||
|
||||
- Allow multiple extension directories (both user and built-in).
|
||||
- Modify the loader, websocket, webview, service worker, and asset requests to
|
||||
use the URL of the page as a base (and TLS if necessary for the websocket).
|
||||
- Send client-side telemetry through the server.
|
||||
- Make changing the display language work.
|
||||
- Make it possible for us to load code on the client.
|
||||
- Make extensions work in the browser.
|
||||
- Fix getting permanently disconnected when you sleep or hibernate for a while.
|
||||
- Make it possible to automatically update the binary.
|
||||
|
||||
## Future
|
||||
|
||||
- Run VS Code unit tests against our builds to ensure features work as expected.
|
204
src/node/vscode/server.ts
Normal file
204
src/node/vscode/server.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import { field, logger } from "@coder/logger"
|
||||
import * as cp from "child_process"
|
||||
import * as crypto from "crypto"
|
||||
import * as http from "http"
|
||||
import * as net from "net"
|
||||
import * as path from "path"
|
||||
import * as querystring from "querystring"
|
||||
import {
|
||||
CodeServerMessage,
|
||||
Settings,
|
||||
VscodeMessage,
|
||||
VscodeOptions,
|
||||
WorkbenchOptions,
|
||||
} from "../../../lib/vscode/src/vs/server/ipc"
|
||||
import { generateUuid } from "../../common/util"
|
||||
import { HttpProvider, HttpProviderOptions, HttpResponse } from "../http"
|
||||
import { SettingsProvider } from "../settings"
|
||||
import { xdgLocalDir } from "../util"
|
||||
|
||||
export class VscodeHttpProvider extends HttpProvider {
|
||||
private readonly serverRootPath: string
|
||||
private readonly vsRootPath: string
|
||||
private readonly settings = new SettingsProvider<Settings>(path.join(xdgLocalDir, "coder.json"))
|
||||
private _vscode?: Promise<cp.ChildProcess>
|
||||
private workbenchOptions?: WorkbenchOptions
|
||||
|
||||
public constructor(private readonly args: string[], options: HttpProviderOptions) {
|
||||
super(options)
|
||||
this.vsRootPath = path.resolve(this.rootPath, "lib/vscode")
|
||||
this.serverRootPath = path.join(this.vsRootPath, "out/vs/server")
|
||||
}
|
||||
|
||||
private async initialize(options: VscodeOptions): Promise<WorkbenchOptions> {
|
||||
const id = generateUuid()
|
||||
const vscode = await this.fork()
|
||||
|
||||
logger.debug("Setting up VS Code...")
|
||||
return new Promise<WorkbenchOptions>((resolve, reject) => {
|
||||
vscode.once("message", (message: VscodeMessage) => {
|
||||
logger.debug("Got message from VS Code", field("message", message))
|
||||
return message.type === "options" && message.id === id
|
||||
? resolve(message.options)
|
||||
: reject(new Error("Unexpected response during initialization"))
|
||||
})
|
||||
vscode.once("error", reject)
|
||||
vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`)))
|
||||
this.send({ type: "init", id, options }, vscode)
|
||||
})
|
||||
}
|
||||
|
||||
private fork(): Promise<cp.ChildProcess> {
|
||||
if (!this._vscode) {
|
||||
logger.debug("Forking VS Code...")
|
||||
const vscode = cp.fork(path.join(this.serverRootPath, "fork"))
|
||||
vscode.on("error", (error) => {
|
||||
logger.error(error.message)
|
||||
this._vscode = undefined
|
||||
})
|
||||
vscode.on("exit", (code) => {
|
||||
logger.error(`VS Code exited unexpectedly with code ${code}`)
|
||||
this._vscode = undefined
|
||||
})
|
||||
|
||||
this._vscode = new Promise((resolve, reject) => {
|
||||
vscode.once("message", (message: VscodeMessage) => {
|
||||
logger.debug("Got message from VS Code", field("message", message))
|
||||
return message.type === "ready"
|
||||
? resolve(vscode)
|
||||
: reject(new Error("Unexpected response waiting for ready response"))
|
||||
})
|
||||
vscode.once("error", reject)
|
||||
vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`)))
|
||||
})
|
||||
}
|
||||
|
||||
return this._vscode
|
||||
}
|
||||
|
||||
public async handleWebSocket(
|
||||
_base: string,
|
||||
_requestPath: string,
|
||||
query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage,
|
||||
socket: net.Socket
|
||||
): Promise<true> {
|
||||
if (!this.authenticated(request)) {
|
||||
throw new Error("not authenticated")
|
||||
}
|
||||
|
||||
// VS Code expects a raw socket. It will handle all the web socket frames.
|
||||
// We just need to handle the initial upgrade.
|
||||
// This magic value is specified by the websocket spec.
|
||||
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
const reply = crypto
|
||||
.createHash("sha1")
|
||||
.update(request.headers["sec-websocket-key"] + magic)
|
||||
.digest("base64")
|
||||
socket.write(
|
||||
[
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${reply}`,
|
||||
].join("\r\n") + "\r\n\r\n"
|
||||
)
|
||||
|
||||
const vscode = await this._vscode
|
||||
this.send({ type: "socket", query }, vscode, socket)
|
||||
return true
|
||||
}
|
||||
|
||||
private send(message: CodeServerMessage, vscode?: cp.ChildProcess, socket?: net.Socket): void {
|
||||
if (!vscode || vscode.killed) {
|
||||
throw new Error("vscode is not running")
|
||||
}
|
||||
vscode.send(message, socket)
|
||||
}
|
||||
|
||||
public async handleRequest(
|
||||
base: string,
|
||||
requestPath: string,
|
||||
query: querystring.ParsedUrlQuery,
|
||||
request: http.IncomingMessage
|
||||
): Promise<HttpResponse | undefined> {
|
||||
this.ensureGet(request)
|
||||
switch (base) {
|
||||
case "/":
|
||||
if (!this.authenticated(request)) {
|
||||
return { redirect: "/login" }
|
||||
}
|
||||
return this.getRoot(request, query)
|
||||
case "/static": {
|
||||
switch (requestPath) {
|
||||
case "/out/vs/workbench/services/extensions/worker/extensionHostWorkerMain.js": {
|
||||
const response = await this.getUtf8Resource(this.vsRootPath, requestPath)
|
||||
response.content = response.content.replace(
|
||||
/{{COMMIT}}/g,
|
||||
this.workbenchOptions ? this.workbenchOptions.commit : ""
|
||||
)
|
||||
response.cache = true
|
||||
return response
|
||||
}
|
||||
}
|
||||
const response = await this.getResource(this.vsRootPath, requestPath)
|
||||
response.cache = true
|
||||
return response
|
||||
}
|
||||
case "/resource":
|
||||
case "/vscode-remote-resource":
|
||||
this.ensureAuthenticated(request)
|
||||
if (typeof query.path === "string") {
|
||||
return this.getResource(query.path)
|
||||
}
|
||||
break
|
||||
case "/tar":
|
||||
this.ensureAuthenticated(request)
|
||||
if (typeof query.path === "string") {
|
||||
return this.getTarredResource(query.path)
|
||||
}
|
||||
break
|
||||
case "/webview":
|
||||
this.ensureAuthenticated(request)
|
||||
if (/^\/vscode-resource/.test(requestPath)) {
|
||||
return this.getResource(requestPath.replace(/^\/vscode-resource(\/file)?/, ""))
|
||||
}
|
||||
return this.getResource(this.vsRootPath, "out/vs/workbench/contrib/webview/browser/pre", requestPath)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async getRoot(request: http.IncomingMessage, query: querystring.ParsedUrlQuery): Promise<HttpResponse> {
|
||||
const settings = await this.settings.read()
|
||||
const [response, options] = await Promise.all([
|
||||
this.getUtf8Resource(this.serverRootPath, "browser/workbench.html"),
|
||||
this.initialize({
|
||||
args: this.args,
|
||||
query,
|
||||
remoteAuthority: request.headers.host as string,
|
||||
settings,
|
||||
}),
|
||||
])
|
||||
|
||||
this.workbenchOptions = options
|
||||
|
||||
if (options.startPath) {
|
||||
this.settings.write({
|
||||
lastVisited: {
|
||||
path: options.startPath.path,
|
||||
workspace: options.startPath.workspace,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
content: response.content
|
||||
.replace(/{{COMMIT}}/g, options.commit)
|
||||
.replace(`"{{REMOTE_USER_DATA_URI}}"`, `'${JSON.stringify(options.remoteUserDataUri)}'`)
|
||||
.replace(`"{{PRODUCT_CONFIGURATION}}"`, `'${JSON.stringify(options.productConfiguration)}'`)
|
||||
.replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`)
|
||||
.replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`),
|
||||
}
|
||||
}
|
||||
}
|
223
src/node/wrapper.ts
Normal file
223
src/node/wrapper.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { logger, field } from "@coder/logger"
|
||||
import * as cp from "child_process"
|
||||
import { Emitter } from "../common/emitter"
|
||||
|
||||
interface HandshakeMessage {
|
||||
type: "handshake"
|
||||
}
|
||||
|
||||
interface RelaunchMessage {
|
||||
type: "relaunch"
|
||||
version: string
|
||||
}
|
||||
|
||||
export type Message = RelaunchMessage | HandshakeMessage
|
||||
|
||||
export class ProcessError extends Error {
|
||||
public constructor(message: string, public readonly code: number | undefined) {
|
||||
super(message)
|
||||
this.name = this.constructor.name
|
||||
Error.captureStackTrace(this, this.constructor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we control when the process exits.
|
||||
*/
|
||||
const exit = process.exit
|
||||
process.exit = function(code?: number) {
|
||||
logger.warn(`process.exit() was prevented: ${code || "unknown code"}.`)
|
||||
} as (code?: number) => never
|
||||
|
||||
/**
|
||||
* Allows the wrapper and inner processes to communicate.
|
||||
*/
|
||||
export class IpcMain {
|
||||
private readonly _onMessage = new Emitter<Message>()
|
||||
public readonly onMessage = this._onMessage.event
|
||||
private readonly _onDispose = new Emitter<NodeJS.Signals | undefined>()
|
||||
public readonly onDispose = this._onDispose.event
|
||||
|
||||
public constructor(public readonly parentPid?: number) {
|
||||
process.on("SIGINT", () => this._onDispose.emit("SIGINT"))
|
||||
process.on("SIGTERM", () => this._onDispose.emit("SIGTERM"))
|
||||
process.on("exit", () => this._onDispose.emit(undefined))
|
||||
|
||||
this.onDispose((signal) => {
|
||||
// Remove listeners to avoid possibly triggering disposal again.
|
||||
process.removeAllListeners()
|
||||
|
||||
// Let any other handlers run first then exit.
|
||||
logger.debug(`${parentPid ? "inner process" : "wrapper"} ${process.pid} disposing`, field("code", signal))
|
||||
setTimeout(() => exit(0), 0)
|
||||
})
|
||||
|
||||
// Kill the inner process if the parent dies. This is for the case where the
|
||||
// parent process is forcefully terminated and cannot clean up.
|
||||
if (parentPid) {
|
||||
setInterval(() => {
|
||||
try {
|
||||
// process.kill throws an exception if the process doesn't exist.
|
||||
process.kill(parentPid, 0)
|
||||
} catch (_) {
|
||||
// Consider this an error since it should have been able to clean up
|
||||
// the child process unless it was forcefully killed.
|
||||
logger.error(`parent process ${parentPid} died`)
|
||||
this._onDispose.emit(undefined)
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
public handshake(child?: cp.ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const target = child || process
|
||||
const onMessage = (message: Message): void => {
|
||||
logger.debug(
|
||||
`${child ? "wrapper" : "inner process"} ${process.pid} received message from ${
|
||||
child ? child.pid : this.parentPid
|
||||
}`,
|
||||
field("message", message)
|
||||
)
|
||||
if (message.type === "handshake") {
|
||||
target.removeListener("message", onMessage)
|
||||
target.on("message", (msg) => this._onMessage.emit(msg))
|
||||
// The wrapper responds once the inner process starts the handshake.
|
||||
if (child) {
|
||||
if (!target.send) {
|
||||
throw new Error("child not spawned with IPC")
|
||||
}
|
||||
target.send({ type: "handshake" })
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
target.on("message", onMessage)
|
||||
if (child) {
|
||||
child.once("error", reject)
|
||||
child.once("exit", (code) => {
|
||||
reject(new ProcessError(`Unexpected exit with code ${code}`, code !== null ? code : undefined))
|
||||
})
|
||||
} else {
|
||||
// The inner process initiates the handshake.
|
||||
this.send({ type: "handshake" })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public relaunch(version: string): void {
|
||||
this.send({ type: "relaunch", version })
|
||||
}
|
||||
|
||||
private send(message: Message): void {
|
||||
if (!process.send) {
|
||||
throw new Error("not spawned with IPC")
|
||||
}
|
||||
process.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
export const ipcMain = new IpcMain(
|
||||
typeof process.env.CODE_SERVER_PARENT_PID !== "undefined" ? parseInt(process.env.CODE_SERVER_PARENT_PID) : undefined
|
||||
)
|
||||
|
||||
export interface WrapperOptions {
|
||||
maxMemory?: number
|
||||
nodeOptions?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a way to wrap a process for the purpose of updating the running
|
||||
* instance.
|
||||
*/
|
||||
export class WrapperProcess {
|
||||
private process?: cp.ChildProcess
|
||||
private started?: Promise<void>
|
||||
|
||||
public constructor(private currentVersion: string, private readonly options?: WrapperOptions) {
|
||||
ipcMain.onDispose(() => {
|
||||
if (this.process) {
|
||||
this.process.removeAllListeners()
|
||||
this.process.kill()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.onMessage(async (message) => {
|
||||
switch (message.type) {
|
||||
case "relaunch":
|
||||
logger.info(`Relaunching: ${this.currentVersion} -> ${message.version}`)
|
||||
this.currentVersion = message.version
|
||||
this.started = undefined
|
||||
if (this.process) {
|
||||
this.process.removeAllListeners()
|
||||
this.process.kill()
|
||||
}
|
||||
try {
|
||||
await this.start()
|
||||
} catch (error) {
|
||||
logger.error(error.message)
|
||||
exit(typeof error.code === "number" ? error.code : 1)
|
||||
}
|
||||
break
|
||||
default:
|
||||
logger.error(`Unrecognized message ${message}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public start(): Promise<void> {
|
||||
if (!this.started) {
|
||||
const child = this.spawn()
|
||||
logger.debug(`spawned inner process ${child.pid}`)
|
||||
this.started = ipcMain.handshake(child).then(() => {
|
||||
child.once("exit", (code) => {
|
||||
logger.debug(`inner process ${child.pid} exited unexpectedly`)
|
||||
exit(code || 0)
|
||||
})
|
||||
})
|
||||
this.process = child
|
||||
}
|
||||
return this.started
|
||||
}
|
||||
|
||||
private spawn(): cp.ChildProcess {
|
||||
// Flags to pass along to the Node binary.
|
||||
let nodeOptions = `${process.env.NODE_OPTIONS || ""} ${(this.options && this.options.nodeOptions) || ""}`
|
||||
if (!/max_old_space_size=(\d+)/g.exec(nodeOptions)) {
|
||||
nodeOptions += ` --max_old_space_size=${(this.options && this.options.maxMemory) || 2048}`
|
||||
}
|
||||
|
||||
return cp.fork(process.argv[1], process.argv.slice(2), {
|
||||
env: {
|
||||
...process.env,
|
||||
CODE_SERVER_PARENT_PID: process.pid.toString(),
|
||||
NODE_OPTIONS: nodeOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// // It's possible that the pipe has closed (for example if you run code-server
|
||||
// // --version | head -1). Assume that means we're done.
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.on("error", () => exit())
|
||||
}
|
||||
|
||||
export const wrap = (fn: () => Promise<void>): void => {
|
||||
if (ipcMain.parentPid) {
|
||||
ipcMain
|
||||
.handshake()
|
||||
.then(() => fn())
|
||||
.catch((error: ProcessError): void => {
|
||||
logger.error(error.message)
|
||||
exit(typeof error.code === "number" ? error.code : 1)
|
||||
})
|
||||
} else {
|
||||
const wrapper = new WrapperProcess(require("../../package.json").version)
|
||||
wrapper.start().catch((error) => {
|
||||
logger.error(error.message)
|
||||
exit(typeof error.code === "number" ? error.code : 1)
|
||||
})
|
||||
}
|
||||
}
|
124
test/socket.test.ts
Normal file
124
test/socket.test.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { field, logger } from "@coder/logger"
|
||||
import * as assert from "assert"
|
||||
import * as fs from "fs-extra"
|
||||
import "leaked-handles"
|
||||
import * as net from "net"
|
||||
import * as path from "path"
|
||||
import * as tls from "tls"
|
||||
import { Emitter } from "../src/common/emitter"
|
||||
import { generateCertificate, tmpdir } from "../src/node/util"
|
||||
import { SocketProxyProvider } from "../src/node/socket"
|
||||
|
||||
describe("SocketProxyProvider", () => {
|
||||
const provider = new SocketProxyProvider()
|
||||
|
||||
const onServerError = new Emitter<{ event: string; error: Error }>()
|
||||
const onClientError = new Emitter<{ event: string; error: Error }>()
|
||||
const onProxyError = new Emitter<{ event: string; error: Error }>()
|
||||
const fromServerToClient = new Emitter<string>()
|
||||
const fromClientToServer = new Emitter<string>()
|
||||
const fromClientToProxy = new Emitter<Buffer>()
|
||||
|
||||
let errors = 0
|
||||
let close = false
|
||||
const onError = ({ event, error }: { event: string; error: Error }): void => {
|
||||
if (!close || event === "error") {
|
||||
logger.error(event, field("error", error.message))
|
||||
++errors
|
||||
}
|
||||
}
|
||||
onServerError.event(onError)
|
||||
onClientError.event(onError)
|
||||
onProxyError.event(onError)
|
||||
|
||||
let server: tls.TLSSocket
|
||||
let proxy: net.Socket
|
||||
let client: tls.TLSSocket
|
||||
|
||||
const getData = <T>(emitter: Emitter<T>): Promise<T> => {
|
||||
return new Promise((resolve) => {
|
||||
const d = emitter.event((t) => {
|
||||
d.dispose()
|
||||
resolve(t)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const cert = await generateCertificate()
|
||||
const options = {
|
||||
cert: fs.readFileSync(cert.cert),
|
||||
key: fs.readFileSync(cert.certKey),
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
|
||||
await fs.mkdirp(path.join(tmpdir, "tests"))
|
||||
const socketPath = await provider.findFreeSocketPath(path.join(tmpdir, "tests/tls-socket-proxy"))
|
||||
await fs.remove(socketPath)
|
||||
|
||||
return new Promise((_resolve) => {
|
||||
const resolved: { [key: string]: boolean } = { client: false, server: false }
|
||||
const resolve = (type: "client" | "server"): void => {
|
||||
resolved[type] = true
|
||||
if (resolved.client && resolved.server) {
|
||||
// We don't need any more connections.
|
||||
main.close() // eslint-disable-line @typescript-eslint/no-use-before-define
|
||||
_resolve()
|
||||
}
|
||||
}
|
||||
const main = tls
|
||||
.createServer(options, (s) => {
|
||||
server = s
|
||||
server
|
||||
.on("data", (d) => fromClientToServer.emit(d))
|
||||
.on("error", (error) => onServerError.emit({ event: "error", error }))
|
||||
.on("end", () => onServerError.emit({ event: "end", error: new Error("unexpected end") }))
|
||||
.on("close", () => onServerError.emit({ event: "close", error: new Error("unexpected close") }))
|
||||
resolve("server")
|
||||
})
|
||||
.on("error", (error) => onServerError.emit({ event: "error", error }))
|
||||
.on("end", () => onServerError.emit({ event: "end", error: new Error("unexpected end") }))
|
||||
.on("close", () => onServerError.emit({ event: "close", error: new Error("unexpected close") }))
|
||||
.listen(socketPath, () => {
|
||||
client = tls
|
||||
.connect({ ...options, path: socketPath })
|
||||
.on("data", (d) => fromServerToClient.emit(d))
|
||||
.on("error", (error) => onClientError.emit({ event: "error", error }))
|
||||
.on("end", () => onClientError.emit({ event: "end", error: new Error("unexpected end") }))
|
||||
.on("close", () => onClientError.emit({ event: "close", error: new Error("unexpected close") }))
|
||||
.once("connect", () => resolve("client"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("should work without a proxy", async () => {
|
||||
server.write("server->client")
|
||||
assert.equal(await getData(fromServerToClient), "server->client")
|
||||
client.write("client->server")
|
||||
assert.equal(await getData(fromClientToServer), "client->server")
|
||||
assert.equal(errors, 0)
|
||||
})
|
||||
|
||||
it("should work with a proxy", async () => {
|
||||
assert.equal(server instanceof tls.TLSSocket, true)
|
||||
proxy = (await provider.createProxy(server))
|
||||
.on("data", (d) => fromClientToProxy.emit(d))
|
||||
.on("error", (error) => onProxyError.emit({ event: "error", error }))
|
||||
.on("end", () => onProxyError.emit({ event: "end", error: new Error("unexpected end") }))
|
||||
.on("close", () => onProxyError.emit({ event: "close", error: new Error("unexpected close") }))
|
||||
|
||||
provider.stop() // We don't need more proxies.
|
||||
|
||||
proxy.write("server proxy->client")
|
||||
assert.equal(await getData(fromServerToClient), "server proxy->client")
|
||||
client.write("client->server proxy")
|
||||
assert.equal(await getData(fromClientToProxy), "client->server proxy")
|
||||
assert.equal(errors, 0)
|
||||
})
|
||||
|
||||
it("should close", async () => {
|
||||
close = true
|
||||
client.end()
|
||||
proxy.end()
|
||||
})
|
||||
})
|
49
test/util.test.ts
Normal file
49
test/util.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import * as assert from "assert"
|
||||
import { extend, normalize } from "../src/node/util"
|
||||
|
||||
describe("util", () => {
|
||||
describe("extend", () => {
|
||||
it("should extend", () => {
|
||||
const a = { foo: { bar: 0, baz: 2 }, garply: 4, waldo: 6 }
|
||||
const b = { foo: { bar: 1, qux: 3 }, garply: "5", fred: 7 }
|
||||
const extended = extend(a, b)
|
||||
assert.deepEqual(extended, {
|
||||
foo: { bar: 1, baz: 2, qux: 3 },
|
||||
garply: "5",
|
||||
waldo: 6,
|
||||
fred: 7,
|
||||
})
|
||||
})
|
||||
|
||||
it("should make deep copies of the original objects", () => {
|
||||
const a = { foo: 0, bar: { frobnozzle: 2 }, mumble: { qux: { thud: 4 } } }
|
||||
const b = { foo: 1, bar: { chad: 3 } }
|
||||
const extended = extend(a, b)
|
||||
assert.notEqual(a.bar, extended.bar)
|
||||
assert.notEqual(b.bar, extended.bar)
|
||||
assert.notEqual(a.mumble, extended.mumble)
|
||||
assert.notEqual(a.mumble.qux, extended.mumble.qux)
|
||||
})
|
||||
|
||||
it("should handle mismatch in type", () => {
|
||||
const a = { foo: { bar: 0, baz: 2, qux: { mumble: 11 } }, garply: 4, waldo: { thud: 10 } }
|
||||
const b = { foo: { bar: [1], baz: { plugh: 8 }, qux: 12 }, garply: { nox: 9 }, waldo: 7 }
|
||||
const extended = extend(a, b)
|
||||
assert.deepEqual(extended, {
|
||||
foo: { bar: [1], baz: { plugh: 8 }, qux: 12 },
|
||||
garply: { nox: 9 },
|
||||
waldo: 7,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalize", () => {
|
||||
it("should remove multiple slashes", () => {
|
||||
assert.equal(normalize("//foo//bar//baz///mumble"), "/foo/bar/baz/mumble")
|
||||
})
|
||||
|
||||
it("should remove trailing slashes", () => {
|
||||
assert.equal(normalize("qux///"), "qux")
|
||||
})
|
||||
})
|
||||
})
|
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./out",
|
||||
"allowJs": false,
|
||||
"jsx": "react",
|
||||
"declaration": true,
|
||||
"experimentalDecorators": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"sourceMap": true,
|
||||
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||
"incremental": true,
|
||||
"rootDir": "./src",
|
||||
"typeRoots": ["./node_modules/@types", "./typings"]
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.tsx"
|
||||
]
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"../../../tslint.json"
|
||||
],
|
||||
"rules": {
|
||||
"no-unexternalized-strings": false
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
httpolyglot.d.ts
|
52
typings/api.d.ts
vendored
52
typings/api.d.ts
vendored
@ -1,52 +0,0 @@
|
||||
import * as vscode from "vscode";
|
||||
|
||||
// Only export the subset of VS Code we have implemented.
|
||||
export interface VSCodeApi {
|
||||
EventEmitter: typeof vscode.EventEmitter;
|
||||
FileSystemError: typeof vscode.FileSystemError;
|
||||
FileType: typeof vscode.FileType;
|
||||
StatusBarAlignment: typeof vscode.StatusBarAlignment;
|
||||
ThemeColor: typeof vscode.ThemeColor;
|
||||
TreeItemCollapsibleState: typeof vscode.TreeItemCollapsibleState;
|
||||
Uri: typeof vscode.Uri;
|
||||
commands: {
|
||||
executeCommand: typeof vscode.commands.executeCommand;
|
||||
registerCommand: typeof vscode.commands.registerCommand;
|
||||
};
|
||||
window: {
|
||||
createStatusBarItem: typeof vscode.window.createStatusBarItem;
|
||||
registerTreeDataProvider: typeof vscode.window.registerTreeDataProvider;
|
||||
showErrorMessage: typeof vscode.window.showErrorMessage;
|
||||
};
|
||||
workspace: {
|
||||
registerFileSystemProvider: typeof vscode.workspace.registerFileSystemProvider;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CoderApi {
|
||||
registerView: (viewId: string, viewName: string, containerId: string, containerName: string, icon: string) => void;
|
||||
}
|
||||
|
||||
export interface IdeReadyEvent extends CustomEvent<void> {
|
||||
readonly vscode: VSCodeApi;
|
||||
readonly ide: CoderApi;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/**
|
||||
* Full VS Code extension API.
|
||||
*/
|
||||
vscode?: VSCodeApi;
|
||||
|
||||
/**
|
||||
* Coder API.
|
||||
*/
|
||||
ide?: CoderApi;
|
||||
|
||||
/**
|
||||
* Listen for when the IDE API has been set and is ready to use.
|
||||
*/
|
||||
addEventListener(event: "ide-ready", callback: (event: IdeReadyEvent) => void): void;
|
||||
}
|
||||
}
|
7
typings/httpolyglot.d.ts
vendored
7
typings/httpolyglot.d.ts
vendored
@ -1,7 +0,0 @@
|
||||
declare module "httpolyglot" {
|
||||
import * as http from "http";
|
||||
import * as https from "https";
|
||||
|
||||
function createServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server;
|
||||
function createServer(options: https.ServerOptions, requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): https.Server;
|
||||
}
|
10
typings/httpolyglot/index.d.ts
vendored
Normal file
10
typings/httpolyglot/index.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
declare module "httpolyglot" {
|
||||
import * as http from "http"
|
||||
import * as https from "https"
|
||||
|
||||
function createServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server
|
||||
function createServer(
|
||||
options: https.ServerOptions,
|
||||
requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void
|
||||
): https.Server
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "@coder/ide-api",
|
||||
"version": "2.0.3",
|
||||
"typings": "api.d.ts",
|
||||
"license": "MIT",
|
||||
"author": "Coder",
|
||||
"description": "API for interfacing with the API created for content-scripts.",
|
||||
"dependencies": {
|
||||
"@types/vscode": "^1.37.0"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user