diff --git a/src/browser/pages/app.html b/src/browser/pages/app.html index bfd58737a..c148cc060 100644 --- a/src/browser/pages/app.html +++ b/src/browser/pages/app.html @@ -6,8 +6,11 @@ name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" /> - - code-server — {{APP_NAME}} + + code-server - + diff --git a/src/browser/pages/app.ts b/src/browser/pages/app.ts index 4922dc2de..f82efb108 100644 --- a/src/browser/pages/app.ts +++ b/src/browser/pages/app.ts @@ -1,4 +1,5 @@ -import { getOptions } from "../../common/util" +import { getOptions, normalize } from "../../common/util" +import { ApiEndpoint } from "../../common/http" import "./app.css" import "./error.css" @@ -9,4 +10,29 @@ import "./update.css" const options = getOptions() -console.log(options) +const isInput = (el: Element): el is HTMLInputElement => { + return !!(el as HTMLInputElement).name +} + +document.querySelectorAll("form").forEach((form) => { + if (!form.classList.contains("-x11")) { + return + } + form.addEventListener("submit", (event) => { + event.preventDefault() + const values: { [key: string]: string } = {} + Array.from(form.elements).forEach((element) => { + if (isInput(element)) { + values[element.name] = element.value + } + }) + fetch(normalize(`${options.base}/api/${ApiEndpoint.process}`), { + method: "POST", + body: JSON.stringify(values), + }) + }) +}) + +// TEMP: Until we can get the real ready event. +const event = new CustomEvent("ide-ready") +window.dispatchEvent(event) diff --git a/src/browser/pages/global.css b/src/browser/pages/global.css index 9a22645bd..fcc3ace34 100644 --- a/src/browser/pages/global.css +++ b/src/browser/pages/global.css @@ -71,6 +71,15 @@ button { padding: 40px; } +.card-box > .content > .none { + margin: 2px 0; +} + .card-box + .card-box { margin-top: 26px; } + +canvas { + top: 0; + left: 0; +} diff --git a/src/browser/pages/home.css b/src/browser/pages/home.css index 8f3cb1195..d77d26409 100644 --- a/src/browser/pages/home.css +++ b/src/browser/pages/home.css @@ -7,8 +7,11 @@ margin: 2px 0; } -.block-row > .item.-row { - display: flex; +.block-row > button.item { + background: none; + border: none; + cursor: pointer; + text-align: left; } .block-row > .item > .sub { @@ -34,6 +37,7 @@ .block-row > .item > .icon.-missing { background-color: rgba(87, 114, 245, 0.2); + display: inline-block; text-align: center; } diff --git a/src/browser/pages/home.html b/src/browser/pages/home.html index ef2eff148..ff6560da7 100644 --- a/src/browser/pages/home.html +++ b/src/browser/pages/home.html @@ -6,7 +6,7 @@ name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" /> - + code-server
-
-
-

Running

-
Currently running applications.
-
-
- {{APP_LIST:RUNNING}} -
-
- -
-
-

Recent

-
Choose a recent directory or workspace to launch below.
-
-
- {{APP_LIST:RECENT_PROJECTS}} -
-
-

Editors

@@ -50,15 +30,15 @@
- - - - - - - - - +
+
+

Other

+
Choose an application to launch below.
+
+
+ {{APP_LIST:OTHER}} +
+
@@ -71,5 +51,6 @@
+ diff --git a/src/common/api.ts b/src/common/api.ts index a338bb3c9..2a2b14ea6 100644 --- a/src/common/api.ts +++ b/src/common/api.ts @@ -7,8 +7,14 @@ export interface Application { readonly icon?: string readonly installed?: boolean readonly name: string + /** + * Path if this is a browser app (like VS Code). + */ readonly path?: string - readonly sessionId?: string + /** + * PID if this is a process. + */ + readonly pid?: number readonly version?: string } @@ -17,19 +23,18 @@ export interface ApplicationsResponse { } export enum SessionError { - NotFound = 4000, - FailedToStart, - Starting, - InvalidState, - Unknown, + FailedToStart = 4000, + Starting = 4001, + InvalidState = 4002, + Unknown = 4003, } export interface SessionResponse { /** - * Whether the session was created or an existing one was returned. + * Whether the process was spawned or an existing one was returned. */ created: boolean - sessionId: string + pid: number } export interface RecentResponse { @@ -37,10 +42,6 @@ export interface RecentResponse { readonly workspaces: string[] } -export interface RunningResponse { - readonly applications: ReadonlyArray -} - export interface HealthRequest { readonly event: "health" } diff --git a/src/common/http.ts b/src/common/http.ts index 7d286aa0d..a90cee373 100644 --- a/src/common/http.ts +++ b/src/common/http.ts @@ -17,9 +17,8 @@ export class HttpError extends Error { export enum ApiEndpoint { applications = "/applications", + process = "/process", recent = "/recent", run = "/run", - running = "/running", - session = "/session", status = "/status", } diff --git a/src/common/util.ts b/src/common/util.ts index 9993d4140..d9195b623 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -1,10 +1,10 @@ -import { logger } from "@coder/logger" +import { logger, field } from "@coder/logger" export interface Options { base: string commit: string logLevel: number - sessionId?: string + pid?: number } /** @@ -34,14 +34,12 @@ export const normalize = (url: string, keepTrailing = false): string => { } /** - * Get options embedded in the HTML from the server. + * Get options embedded in the HTML or query params. */ export const getOptions = (): T => { - if (typeof document === "undefined") { - return {} as T - } - const el = document.getElementById("coder-options") + let options: T try { + const el = document.getElementById("coder-options") if (!el) { throw new Error("no options element") } @@ -49,19 +47,31 @@ export const getOptions = (): T => { if (!value) { throw new Error("no options value") } - const options = JSON.parse(value) - if (typeof options.logLevel !== "undefined") { - logger.level = options.logLevel - } - const parts = window.location.pathname.replace(/^\//g, "").split("/") - parts[parts.length - 1] = options.base - const url = new URL(window.location.origin + "/" + parts.join("/")) - return { - ...options, - base: normalize(url.pathname, true), - } + options = JSON.parse(value) } catch (error) { - logger.warn(error.message) - return {} as T + options = {} as T } + + const params = new URLSearchParams(location.search) + const queryOpts = params.get("options") + if (queryOpts) { + options = { + ...options, + ...JSON.parse(queryOpts), + } + } + + if (typeof options.logLevel !== "undefined") { + logger.level = options.logLevel + } + if (options.base) { + const parts = location.pathname.replace(/^\//g, "").split("/") + parts[parts.length - 1] = options.base + const url = new URL(location.origin + "/" + parts.join("/")) + options.base = normalize(url.pathname, true) + } + + logger.debug("got options", field("options", options)) + + return options } diff --git a/src/node/app/api.ts b/src/node/app/api.ts index 98424c025..78375fb65 100644 --- a/src/node/app/api.ts +++ b/src/node/app/api.ts @@ -11,22 +11,15 @@ import { ApplicationsResponse, ClientMessage, RecentResponse, - RunningResponse, ServerMessage, SessionError, SessionResponse, } from "../../common/api" import { ApiEndpoint, HttpCode, HttpError } from "../../common/http" -import { normalize } from "../../common/util" import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http" import { findApplications, findWhitelistedApplications, Vscode } from "./bin" import { VscodeHttpProvider } from "./vscode" -interface ServerSession { - process?: cp.ChildProcess - readonly app: Application -} - interface VsRecents { [key: string]: (string | { configURIPath: string })[] } @@ -38,7 +31,6 @@ type VsSettings = [string, string][] */ export class ApiHttpProvider extends HttpProvider { private readonly ws = new WebSocket.Server({ noServer: true }) - private readonly sessions = new Map() public constructor( options: HttpProviderOptions, @@ -49,14 +41,6 @@ export class ApiHttpProvider extends HttpProvider { super(options) } - public dispose(): void { - this.sessions.forEach((s) => { - if (s.process) { - s.process.kill() - } - }) - } - public async handleRequest(route: Route, request: http.IncomingMessage): Promise { this.ensureAuthenticated(request) if (route.requestPath !== "/index.html") { @@ -67,22 +51,19 @@ export class ApiHttpProvider extends HttpProvider { case ApiEndpoint.applications: this.ensureMethod(request) return { + mime: "application/json", content: { applications: await this.applications(), }, } as HttpResponse - case ApiEndpoint.session: - return this.session(request) + case ApiEndpoint.process: + return this.process(request) case ApiEndpoint.recent: this.ensureMethod(request) return { + mime: "application/json", content: await this.recent(), } as HttpResponse - case ApiEndpoint.running: - this.ensureMethod(request) - return { - content: await this.running(), - } as HttpResponse } throw new HttpError("Not found", HttpCode.NotFound) @@ -137,36 +118,31 @@ export class ApiHttpProvider extends HttpProvider { } /** - * A socket that connects to a session. + * A socket that connects to the process. */ private async handleRunSocket( - route: Route, + _route: Route, request: http.IncomingMessage, socket: net.Socket, head: Buffer, ): Promise { - const sessionId = route.requestPath.replace(/^\//, "") - logger.debug("connecting session", field("sessionId", sessionId)) + logger.debug("connecting to process") const ws = await new Promise((resolve, reject) => { this.ws.handleUpgrade(request, socket, head, (socket) => { socket.binaryType = "arraybuffer" - const session = this.sessions.get(sessionId) - if (!session) { - socket.close(SessionError.NotFound) - return reject(new Error("session not found")) - } - - resolve(socket as WebSocket) - socket.on("error", (error) => { socket.close(SessionError.FailedToStart) logger.error("got error while connecting socket", field("error", error)) reject(error) }) + + resolve(socket as WebSocket) }) }) + logger.debug("connected to process") + // Send ready message. ws.send( Buffer.from( @@ -192,61 +168,40 @@ export class ApiHttpProvider extends HttpProvider { } /** - * Get a running application. + * Handle /process endpoint. */ - public getRunningApplication(sessionIdOrPath?: string): Application | undefined { - if (!sessionIdOrPath) { - return undefined - } - - const sessionId = sessionIdOrPath.replace(/\//g, "") - let session = this.sessions.get(sessionId) - if (session) { - logger.debug("found application by session id", field("id", sessionId)) - return session.app - } - - const base = normalize("/" + sessionIdOrPath) - session = Array.from(this.sessions.values()).find((s) => s.app.path === base) - if (session) { - logger.debug("found application by path", field("path", base)) - return session.app - } - - logger.debug("no application found matching route", field("route", sessionIdOrPath)) - return undefined - } - - /** - * Handle /session endpoint. - */ - private async session(request: http.IncomingMessage): Promise { + private async process(request: http.IncomingMessage): Promise { this.ensureMethod(request, ["DELETE", "POST"]) const data = await this.getData(request) if (!data) { - throw new HttpError("Not found", HttpCode.NotFound) + throw new HttpError("No data was provided", HttpCode.BadRequest) } + const parsed: Application = JSON.parse(data) + switch (request.method) { case "DELETE": - return this.deleteSession(JSON.parse(data).sessionId) - case "POST": { - // Prevent spawning the same app multiple times. - const parsed: Application = JSON.parse(data) - const app = this.getRunningApplication(parsed.sessionId || parsed.path) - if (app) { - return { - content: { - created: false, - sessionId: app.sessionId, - }, - } as HttpResponse + if (parsed.pid) { + await this.killProcess(parsed.pid) + } else if (parsed.path) { + await this.killProcess(parsed.path) + } else { + throw new Error("No pid or path was provided") } return { + mime: "application/json", + code: HttpCode.Ok, + } + case "POST": { + if (!parsed.exec) { + throw new Error("No exec was provided") + } + return { + mime: "application/json", content: { created: true, - sessionId: await this.createSession(parsed), + pid: await this.spawnProcess(parsed.exec), }, } as HttpResponse } @@ -256,55 +211,39 @@ export class ApiHttpProvider extends HttpProvider { } /** - * Kill a session identified by `app.sessionId`. + * Kill a process identified by pid or path if a web app. */ - public async deleteSession(sessionId: string): Promise { - logger.debug("deleting session", field("sessionId", sessionId)) - switch (sessionId) { - case "vscode": - await this.vscode.dispose() - return { code: HttpCode.Ok } - default: { - const session = this.sessions.get(sessionId) - if (!session) { - throw new Error("session does not exist") - } - if (session.process) { - session.process.kill() - } - this.sessions.delete(sessionId) - return { code: HttpCode.Ok } + public async killProcess(pid: number | string): Promise { + if (typeof pid === "string") { + switch (pid) { + case Vscode.path: + await this.vscode.dispose() + break + default: + throw new Error(`Process "${pid}" does not exist`) } + } else { + process.kill(pid) } } /** - * Create a new session and return the session ID. + * Spawn a process and return the pid. */ - public async createSession(app: Application): Promise { - const sessionId = Math.floor(Math.random() * 10000).toString() - if (this.sessions.has(sessionId)) { - throw new Error("conflicting session id") - } - - if (!app.exec) { - throw new Error("cannot execute application with no `exec`") - } - - const appSession: ServerSession = { - app: { - ...app, - sessionId, + public async spawnProcess(exec: string): Promise { + const proc = cp.spawn(exec, { + shell: process.env.SHELL || true, + env: { + ...process.env, }, - } - this.sessions.set(sessionId, appSession) + }) - try { - throw new Error("TODO") - } catch (error) { - this.sessions.delete(sessionId) - throw error - } + proc.on("error", (error) => logger.error("process errored", field("pid", proc.pid), field("error", error))) + proc.on("exit", () => logger.debug("process exited", field("pid", proc.pid))) + + logger.debug("started process", field("pid", proc.pid)) + + return proc.pid } /** @@ -319,7 +258,7 @@ export class ApiHttpProvider extends HttpProvider { const state: VsSettings = JSON.parse(await fs.readFile(path.join(this.dataDir, "User/state/global.json"), "utf8")) const setting = Array.isArray(state) && state.find((item) => item[0] === "recently.opened") if (!setting) { - throw new Error("settings appear malformed") + return { paths: [], workspaces: [] } } const pathPromises: { [key: string]: Promise } = {} @@ -360,34 +299,13 @@ export class ApiHttpProvider extends HttpProvider { return { paths: [], workspaces: [] } } - /** - * Return running sessions. - */ - public async running(): Promise { - return { - applications: (this.vscode.running - ? [ - { - ...Vscode, - sessionId: "vscode", - }, - ] - : [] - ).concat( - Array.from(this.sessions).map(([sessionId, session]) => ({ - ...session.app, - sessionId, - })), - ), - } - } - /** * For these, just return the error message since they'll be requested as * JSON. */ public async getErrorRoot(_route: Route, _title: string, _header: string, error: string): Promise { return { + mime: "application/json", content: JSON.stringify({ error }), } } diff --git a/src/node/app/app.ts b/src/node/app/app.ts deleted file mode 100644 index 9de8ade2a..000000000 --- a/src/node/app/app.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as http from "http" -import { HttpCode, HttpError } from "../../common/http" -import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" -import { ApiHttpProvider } from "./api" - -/** - * App/fallback HTTP provider. - */ -export class AppHttpProvider extends HttpProvider { - public constructor(options: HttpProviderOptions, private readonly api: ApiHttpProvider) { - super(options) - } - - public async handleRequest(route: Route, request: http.IncomingMessage): Promise { - if (!this.authenticated(request)) { - return { redirect: "/login", query: { to: route.fullPath } } - } - - this.ensureMethod(request) - if (route.requestPath !== "/index.html") { - throw new HttpError("Not found", HttpCode.NotFound) - } - - // Run an existing app, but if it doesn't exist go ahead and start it. - let app = this.api.getRunningApplication(route.base) - let sessionId = app && app.sessionId - if (!app) { - app = (await this.api.installedApplications()).find((a) => a.path === route.base) - if (app && app.exec) { - sessionId = await this.api.createSession(app) - } - } - - if (sessionId) { - return this.getAppRoot(route, (app && app.name) || "", sessionId) - } - - throw new HttpError("Application not found", HttpCode.NotFound) - } - - public async getAppRoot(route: Route, name: string, sessionId: string): Promise { - const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html") - response.content = response.content.replace(/{{APP_NAME}}/, name) - return this.replaceTemplates(route, response, sessionId) - } -} diff --git a/src/node/app/dashboard.ts b/src/node/app/dashboard.ts index f8b4df408..217214954 100644 --- a/src/node/app/dashboard.ts +++ b/src/node/app/dashboard.ts @@ -1,10 +1,10 @@ import * as http from "http" import * as querystring from "querystring" -import { Application, RecentResponse } from "../../common/api" +import { Application } from "../../common/api" import { HttpCode, HttpError } from "../../common/http" +import { normalize } from "../../common/util" import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" import { ApiHttpProvider } from "./api" -import { Vscode } from "./bin" import { UpdateHttpProvider } from "./update" /** @@ -25,21 +25,27 @@ export class DashboardHttpProvider extends HttpProvider { } switch (route.base) { - case "/delete": { + case "/spawn": { this.ensureAuthenticated(request) this.ensureMethod(request, "POST") const data = await this.getData(request) - const p = data ? querystring.parse(data) : {} - this.api.deleteSession(p.sessionId as string) + const app = data ? querystring.parse(data) : {} + if (app.path) { + return { redirect: Array.isArray(app.path) ? app.path[0] : app.path } + } + if (!app.exec) { + throw new Error("No exec was provided") + } + this.api.spawnProcess(Array.isArray(app.exec) ? app.exec[0] : app.exec) return { redirect: this.options.base } } - + case "/app": case "/": { this.ensureMethod(request) if (!this.authenticated(request)) { return { redirect: "/login", query: { to: this.options.base } } } - return this.getRoot(route) + return route.base === "/" ? this.getRoot(route) : this.getAppRoot(route) } } @@ -52,8 +58,6 @@ export class DashboardHttpProvider extends HttpProvider { const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/home.html") response.content = response.content .replace(/{{UPDATE:NAME}}/, await this.getUpdate(base)) - .replace(/{{APP_LIST:RUNNING}}/, this.getAppRows(base, (await this.api.running()).applications)) - .replace(/{{APP_LIST:RECENT_PROJECTS}}/, this.getRecentProjectRows(base, await this.api.recent())) .replace( /{{APP_LIST:EDITORS}}/, this.getAppRows( @@ -71,46 +75,32 @@ export class DashboardHttpProvider extends HttpProvider { return this.replaceTemplates(route, response) } - private getRecentProjectRows(base: string, recents: RecentResponse): string { - return recents.paths.length > 0 || recents.workspaces.length > 0 - ? recents.paths.map((recent) => this.getRecentProjectRow(base, recent)).join("\n") + - recents.workspaces.map((recent) => this.getRecentProjectRow(base, recent, true)).join("\n") - : `
No recent directories or workspaces.
` - } - - private getRecentProjectRow(base: string, recent: string, workspace?: boolean): string { - return `` + public async getAppRoot(route: Route): Promise { + const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html") + return this.replaceTemplates(route, response) } private getAppRows(base: string, apps: ReadonlyArray): string { return apps.length > 0 ? apps.map((app) => this.getAppRow(base, app)).join("\n") - : `
No applications are currently running.
` + : `
No applications found.
` } private getAppRow(base: string, app: Application): string { - return `
- + return `
+ -
` - : "" - } -
` + ${app.name} + + ` } private async getUpdate(base: string): Promise { diff --git a/src/node/app/update.ts b/src/node/app/update.ts index 629497c19..3f722d515 100644 --- a/src/node/app/update.ts +++ b/src/node/app/update.ts @@ -140,7 +140,7 @@ export class UpdateHttpProvider extends HttpProvider { update = { checked: now, version: data.name } await this.settings.write({ update }) } - logger.debug("Got latest version", field("latest", update.version)) + logger.debug("got latest version", field("latest", update.version)) return update } catch (error) { logger.error("Failed to get latest version", field("error", error.message)) @@ -160,7 +160,7 @@ export class UpdateHttpProvider extends HttpProvider { */ public isLatestVersion(latest: Update): boolean { const version = this.currentVersion - logger.debug("Comparing versions", field("current", version), field("latest", latest.version)) + logger.debug("comparing versions", field("current", version), field("latest", latest.version)) try { return latest.version === version || semver.lt(latest.version, version) } catch (error) { diff --git a/src/node/entry.ts b/src/node/entry.ts index 3d9f6c494..987f369c0 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -3,7 +3,6 @@ import * as cp from "child_process" import * as path from "path" import { CliMessage } from "../../lib/vscode/src/vs/server/ipc" import { ApiHttpProvider } from "./app/api" -import { AppHttpProvider } from "./app/app" import { DashboardHttpProvider } from "./app/dashboard" import { LoginHttpProvider } from "./app/login" import { StaticHttpProvider } from "./app/static" @@ -49,7 +48,6 @@ const main = async (args: Args): Promise => { const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args) const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"]) const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, !args["disable-updates"]) - httpServer.registerHttpProvider("/app", AppHttpProvider, api) httpServer.registerHttpProvider("/login", LoginHttpProvider) httpServer.registerHttpProvider("/static", StaticHttpProvider) httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update) diff --git a/src/node/http.ts b/src/node/http.ts index 077f01494..54fabe9aa 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -514,8 +514,7 @@ export class HttpServer { private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise => { this.heart.beat() const route = this.parseUrl(request) - try { - const payload = this.maybeRedirect(request, route) || (await route.provider.handleRequest(route, request)) + const write = (payload: HttpResponse): void => { response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, { "Content-Type": payload.mime || getMediaMime(payload.filePath), ...(payload.redirect ? { Location: this.constructRedirect(request, route, payload as RedirectResponse) } : {}), @@ -547,6 +546,13 @@ export class HttpServer { } else { response.end() } + } + try { + const payload = this.maybeRedirect(request, route) || (await route.provider.handleRequest(route, request)) + if (!payload) { + throw new HttpError("Not found", HttpCode.NotFound) + } + write(payload) } catch (error) { let e = error if (error.code === "ENOENT" || error.code === "EISDIR") { @@ -555,9 +561,11 @@ export class HttpServer { logger.debug("Request error", field("url", request.url)) logger.debug(error.stack) const code = typeof e.code === "number" ? e.code : HttpCode.ServerError - const content = (await route.provider.getErrorRoot(route, code, code, e.message)).content - response.writeHead(code) - response.end(content) + const payload = await route.provider.getErrorRoot(route, code, code, e.message) + write({ + code, + ...payload, + }) } } @@ -595,7 +603,7 @@ export class HttpServer { (this.options.cert && !secure ? `${this.protocol}://${request.headers.host}/` : "") + normalize(`${route.provider.base(route)}/${payload.redirect}`, true) + (Object.keys(query).length > 0 ? `?${querystring.stringify(query)}` : "") - logger.debug("Redirecting", field("secure", !!secure), field("from", request.url), field("to", redirect)) + logger.debug("redirecting", field("secure", !!secure), field("from", request.url), field("to", redirect)) return redirect }