diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/client.zig | 176 | ||||
| -rw-r--r-- | app/main.zig | 204 | ||||
| -rw-r--r-- | app/web/app.mjs | 183 | ||||
| -rw-r--r-- | app/web/client.mjs | 138 | ||||
| -rw-r--r-- | app/web/index.html | 53 | ||||
| -rw-r--r-- | app/web/style.css | 87 |
6 files changed, 0 insertions, 841 deletions
diff --git a/app/client.zig b/app/client.zig deleted file mode 100644 index 2ec9163..0000000 --- a/app/client.zig +++ /dev/null @@ -1,176 +0,0 @@ -//! Browser ABI. Protocol state, encoding, reply validation, and directory decoding -//! all use the same cloud9 sources as native clients. No allocator or host imports. -const std = @import("std"); -const c9 = @import("cloud9"); -const capacity = 65536; -var input: [capacity]u8 = undefined; -var output: [capacity]u8 = undefined; -var staging: [capacity]u8 = undefined; -var json: [capacity * 2]u8 = undefined; -var client: c9.Client = undefined; -var data: []const u8 = ""; -var number: u32 = 0; -var directory: bool = false; - -export fn init() void { - client = .init(.{ .in = &input, .out = &output }); - data = ""; - number = 0; - directory = false; -} -export fn input_ptr() [*]u8 { - return &staging; -} -export fn input_capacity() u32 { - return capacity; -} -export fn output_ptr() [*]const u8 { - return client.output().ptr; -} -export fn output_len() u32 { - return @intCast(client.output().len); -} -export fn sent() void { - client.wrote(client.output().len); -} -export fn data_ptr() [*]const u8 { - return data.ptr; -} -export fn data_len() u32 { - return @intCast(data.len); -} -export fn result_number() u32 { - return number; -} -export fn result_directory() bool { - return directory; -} -export fn is_dead() bool { - return client.dead; -} -export fn max_read() u32 { - return client.maxRead(); -} -export fn max_write() u32 { - return client.maxWrite(); -} - -fn fail(message: []const u8) i32 { - data = message; - return -1; -} -fn submit(request: c9.Client.Request) i32 { - _ = client.submit(request) catch |err| return fail(@errorName(err)); - return 0; -} -export fn version() i32 { - return submit(.{ .version = .{} }); -} -export fn attach(user_len: u32, tree_len: u32) i32 { - if (user_len > capacity or tree_len > capacity - user_len) return fail("InputTooLarge"); - return submit(.{ .attach = .{ .fid = 0, .uname = staging[0..user_len], .aname = staging[user_len..][0..tree_len] } }); -} -export fn walk(fid: u32, newfid: u32, len: u32) i32 { - if (len > capacity) return fail("InputTooLarge"); - var names: [c9.max_welem][]const u8 = undefined; - var count: usize = 0; - var it = std.mem.splitScalar(u8, staging[0..len], '/'); - while (it.next()) |name| { - if (name.len == 0) continue; - if (count == names.len) return fail("TooManyNames"); - names[count] = name; - count += 1; - } - return submit(.{ .walk = .{ .fid = fid, .newfid = newfid, .names = names[0..count] } }); -} -export fn open(fid: u32, mode: u8) i32 { - return submit(.{ .open = .{ .fid = fid, .mode = mode } }); -} -export fn read(fid: u32, offset: u64, count: u32) i32 { - return submit(.{ .read = .{ .fid = fid, .offset = offset, .count = count } }); -} -export fn write(fid: u32, offset: u64, len: u32) i32 { - if (len > capacity) return fail("InputTooLarge"); - return submit(.{ .write = .{ .fid = fid, .offset = offset, .data = staging[0..len] } }); -} -export fn clunk(fid: u32) i32 { - return submit(.{ .clunk = .{ .fid = fid } }); -} -export fn stat(fid: u32) i32 { - return submit(.{ .stat = .{ .fid = fid } }); -} - -fn writeStat(s: c9.Stat, writer: *std.Io.Writer) !void { - try std.json.Stringify.value(.{ .name = s.name, .directory = s.qid.type & c9.qtdir != 0, .length = s.length, .mode = s.mode }, .{}, writer); -} - -/// Result codes: 0 incomplete, -1 error, 1 version, 2 attach, 3 walk, -/// 4 open, 5 read, 6 write, 7 clunk, 8 stat. Data is borrowed until next call. -export fn receive(len: u32) i32 { - if (len > capacity) return fail("InputTooLarge"); - if (client.push(staging[0..len]) != len) return fail("InputFull"); - const done = client.take() orelse return if (client.dead) fail("ProtocolError") else 0; - data = ""; - number = 0; - directory = false; - return switch (done.result) { - .fail => |message| fail(message), - .version => |v| blk: { - data = v.version; - number = v.msize; - break :blk 1; - }, - .attach => |qid| blk: { - directory = qid.type & c9.qtdir != 0; - break :blk 2; - }, - .walk => |w| blk: { - number = w.nwqid; - if (w.nwqid != 0) directory = w.wqid[w.nwqid - 1].type & c9.qtdir != 0; - break :blk 3; - }, - .open => |o| blk: { - number = o.iounit; - directory = o.qid.type & c9.qtdir != 0; - break :blk 4; - }, - .read => |bytes| blk: { - data = bytes; - break :blk 5; - }, - .write => |count| blk: { - number = count; - break :blk 6; - }, - .clunk => 7, - .stat => |s| blk: { - var writer: std.Io.Writer = .fixed(&json); - writeStat(s, &writer) catch return fail("OutputTooLarge"); - data = writer.buffered(); - break :blk 8; - }, - else => fail("UnexpectedReply"), - }; -} - -/// Parse complete directory stat records from one Rread, using cloud9.Stat. -export fn decode_directory(len: u32) i32 { - if (len > capacity) return fail("InputTooLarge"); - var remaining: []const u8 = staging[0..len]; - var writer: std.Io.Writer = .fixed(&json); - writer.writeByte('[') catch unreachable; - var first = true; - while (remaining.len != 0) { - if (remaining.len < 2) return fail("TruncatedDirectory"); - const size: usize = @as(usize, std.mem.readInt(u16, remaining[0..2], .little)) + 2; - if (size > remaining.len) return fail("TruncatedDirectory"); - const s = c9.Stat.decode(remaining[0..size]) catch return fail("InvalidDirectory"); - if (!first) writer.writeByte(',') catch return fail("OutputTooLarge"); - writeStat(s, &writer) catch return fail("OutputTooLarge"); - first = false; - remaining = remaining[size..]; - } - writer.writeByte(']') catch return fail("OutputTooLarge"); - data = writer.buffered(); - return 0; -} diff --git a/app/main.zig b/app/main.zig deleted file mode 100644 index 720503f..0000000 --- a/app/main.zig +++ /dev/null @@ -1,204 +0,0 @@ -//! HTTP application; no web or namespace policy is added to the cloud9 library. -const std = @import("std"); -const c9 = @import("cloud9"); -const Io = std.Io; -const max_frame = 65536; -const max_connections = 32; -var serial_busy: std.atomic.Value(bool) = .init(false); -const Upstream = union(enum) { network: c9.transport.Address, file: []const u8 }; -var connections: std.atomic.Value(u32) = .init(0); - -const Config = struct { - upstream: Upstream, - host: []const u8, - origin: []const u8, - public_host: []const u8, - timeout_ms: u32, - browser_config: []const u8, -}; - -pub fn main(init: std.process.Init) !void { - const allocator = init.arena.allocator(); - const args = try init.minimal.args.toSlice(allocator); - var listen_text: []const u8 = "127.0.0.1:8080"; - var upstream_text: []const u8 = "tcp:127.0.0.1:564"; - var timeout_ms: u32 = 300000; - var user: []const u8 = "user"; - var tree: []const u8 = ""; - var public_origin: ?[]const u8 = null; - var i: usize = 1; - while (i < args.len) : (i += 1) { - if (std.mem.eql(u8, args[i], "--help")) { - std.debug.print("Usage: cloud9-http [--listen IP:PORT] [--upstream tcp:IP:PORT|unix:PATH|file:PATH] [--origin https://HOST:PORT] [--timeout-ms 300000] [--user NAME] [--tree NAME]\nDefault: http://127.0.0.1:8080 -> tcp:127.0.0.1:564\n", .{}); - return; - } - if (i + 1 == args.len) return error.MissingArgument; - if (std.mem.eql(u8, args[i], "--listen")) { - i += 1; - listen_text = args[i]; - } else if (std.mem.eql(u8, args[i], "--upstream")) { - i += 1; - upstream_text = args[i]; - } else if (std.mem.eql(u8, args[i], "--origin")) { - i += 1; - public_origin = args[i]; - } else if (std.mem.eql(u8, args[i], "--timeout-ms")) { - i += 1; - timeout_ms = try std.fmt.parseInt(u32, args[i], 10); - } else if (std.mem.eql(u8, args[i], "--user")) { - i += 1; - user = args[i]; - } else if (std.mem.eql(u8, args[i], "--tree")) { - i += 1; - tree = args[i]; - } else return error.UnknownArgument; - } - const address = try Io.net.IpAddress.parseLiteral(listen_text); - const upstream: Upstream = if (std.mem.startsWith(u8, upstream_text, "tcp:")) - .{ .network = .{ .tcp = try Io.net.IpAddress.parseLiteral(upstream_text[4..]) } } - else if (std.mem.startsWith(u8, upstream_text, "unix:")) - .{ .network = .{ .unix = try allocator.dupeZ(u8, upstream_text[5..]) } } - else if (std.mem.startsWith(u8, upstream_text, "file:")) - .{ .file = upstream_text[5..] } - else - return error.InvalidUpstream; - const io = init.io; - var listener = c9.transport.listen(io, .{ .tcp = address }, max_connections) catch |err| { - if (err == error.AddressInUse) { - std.debug.print("cloud9-http: cannot listen on {s}: address already in use.\nUse --listen 127.0.0.1:0 to select a free port; the URL is printed at startup.\n", .{listen_text}); - } else { - std.debug.print("cloud9-http: cannot listen on {s}: {s}\n", .{ listen_text, @errorName(err) }); - } - return err; - }; - defer listener.deinit(io); - const host = try std.fmt.allocPrint(allocator, "{f}", .{listener.socket.address}); - const origin_text = public_origin orelse try std.fmt.allocPrint(allocator, "http://{s}", .{host}); - const origin_uri = try std.Uri.parse(origin_text); - if ((!std.mem.eql(u8, origin_uri.scheme, "https") and !std.mem.eql(u8, origin_uri.scheme, "http")) or - origin_uri.host == null or origin_uri.user != null or origin_uri.password != null or - origin_uri.path.percent_encoded.len != 0 or origin_uri.query != null or origin_uri.fragment != null) return error.InvalidOrigin; - if (user.len > 256 or tree.len > 256) return error.AttachNameTooLong; - const browser_config = try std.json.Stringify.valueAlloc(allocator, .{ .user = user, .tree = tree }, .{}); - const config: Config = .{ .upstream = upstream, .host = host, .origin = origin_text, .public_host = origin_text[origin_uri.scheme.len + 3 ..], .timeout_ms = timeout_ms, .browser_config = browser_config }; - std.debug.print("cloud9-http {s} -> {s}\n", .{ config.origin, upstream_text }); - var group: Io.Group = .init; - defer group.cancel(io); - while (true) { - const stream = try listener.accept(io); - if (connections.fetchAdd(1, .monotonic) >= max_connections) { - _ = connections.fetchSub(1, .monotonic); - stream.close(io); - continue; - } - group.concurrent(io, handle, .{ io, stream, config }) catch |err| { - _ = connections.fetchSub(1, .monotonic); - stream.close(io); - return err; - }; - } -} - -fn handle(io: Io, stream: Io.net.Stream, config: Config) void { - defer _ = connections.fetchSub(1, .monotonic); - defer stream.close(io); - var work: Connection = .{ .io = io, .stream = stream, .config = config }; - var group: Io.Group = .init; - defer group.cancel(io); - group.concurrent(io, Connection.run, .{&work}) catch return; - const timeout: Io.Timeout = if (config.timeout_ms == 0) .none else .{ .duration = .{ .raw = .fromMilliseconds(config.timeout_ms), .clock = .awake } }; - work.done.waitTimeout(io, timeout) catch {}; -} - -const Connection = struct { - io: Io, - stream: Io.net.Stream, - config: Config, - done: Io.Event = .unset, - fn run(connection: *Connection) void { - defer connection.done.set(connection.io); - serve(connection.io, connection.stream, connection.config) catch {}; - } -}; - -fn respond(request: *std.http.Server.Request, content: []const u8, mime: []const u8, status: std.http.Status) !void { - try request.respond(content, .{ .status = status, .keep_alive = false, .extra_headers = &.{ - .{ .name = "content-type", .value = mime }, - .{ .name = "cache-control", .value = "no-store" }, - .{ .name = "x-content-type-options", .value = "nosniff" }, - .{ .name = "content-security-policy", .value = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'" }, - } }); -} - -fn serve(io: Io, stream: Io.net.Stream, config: Config) !void { - var input: [max_frame + 14]u8 = undefined; - var output: [8192]u8 = undefined; - var reader = stream.reader(io, &input); - var writer = stream.writer(io, &output); - var http: std.http.Server = .init(&reader.interface, &writer.interface); - http.reader.max_head_len = 8192; - var request = try http.receiveHead(); - var host: ?[]const u8 = null; - var origin: ?[]const u8 = null; - var version: ?[]const u8 = null; - var headers = request.iterateHeaders(); - while (headers.next()) |header| { - if (std.ascii.eqlIgnoreCase(header.name, "host")) host = header.value; - if (std.ascii.eqlIgnoreCase(header.name, "origin")) origin = header.value; - if (std.ascii.eqlIgnoreCase(header.name, "sec-websocket-version")) version = header.value; - } - if (!std.mem.eql(u8, host orelse "", config.host) and !std.mem.eql(u8, host orelse "", config.public_host)) return respond(&request, "Unexpected Host\n", "text/plain", .forbidden); - if (request.head.method != .GET) return respond(&request, "Use GET\n", "text/plain", .method_not_allowed); - const path = std.mem.sliceTo(request.head.target, '?'); - if (std.mem.eql(u8, path, "/_cloud9/config.json")) return respond(&request, config.browser_config, "application/json", .ok); - if (std.mem.eql(u8, path, "/_cloud9/app.mjs")) return respond(&request, @embedFile("web/app.mjs"), "text/javascript; charset=utf-8", .ok); - if (std.mem.eql(u8, path, "/_cloud9/client.mjs")) return respond(&request, @embedFile("web/client.mjs"), "text/javascript; charset=utf-8", .ok); - if (std.mem.eql(u8, path, "/_cloud9/style.css")) return respond(&request, @embedFile("web/style.css"), "text/css; charset=utf-8", .ok); - if (std.mem.eql(u8, path, "/_cloud9/cloud9.wasm")) return respond(&request, @embedFile("client.wasm"), "application/wasm", .ok); - if (!std.mem.eql(u8, path, "/_cloud9/9p")) { - if (std.mem.eql(u8, path, "/_cloud9") or std.mem.startsWith(u8, path, "/_cloud9/")) - return respond(&request, "Not found\n", "text/plain", .not_found); - // File URLs boot the same browser client. The client resolves the path - // in the upstream 9P namespace, never in this machine's filesystem. - return respond(&request, @embedFile("web/index.html"), "text/html; charset=utf-8", .ok); - } - if (!std.mem.eql(u8, origin orelse "", config.origin)) return respond(&request, "Unexpected Origin\n", "text/plain", .forbidden); - if (!std.mem.eql(u8, version orelse "", "13")) return respond(&request, "WebSocket version 13 required\n", "text/plain", .bad_request); - switch (config.upstream) { - .network => |address| { - const upstream = c9.transport.connect(io, address) catch return respond(&request, "9P upstream unavailable\n", "text/plain", .bad_gateway); - defer upstream.close(io); - var in_buffer: [8192]u8 = undefined; - var out_buffer: [8192]u8 = undefined; - var upstream_reader = upstream.reader(io, &in_buffer); - var upstream_writer = upstream.writer(io, &out_buffer); - try bridge(io, &request, &upstream_reader.interface, &upstream_writer.interface); - }, - .file => |path_name| { - if (serial_busy.swap(true, .acquire)) return respond(&request, "Device already in use\n", "text/plain", .service_unavailable); - defer serial_busy.store(false, .release); - const file = Io.Dir.cwd().openFile(io, path_name, .{ .mode = .read_write }) catch return respond(&request, "9P device unavailable\n", "text/plain", .bad_gateway); - defer file.close(io); - var in_buffer: [8192]u8 = undefined; - var out_buffer: [8192]u8 = undefined; - var upstream_reader = file.readerStreaming(io, &in_buffer); - var upstream_writer = file.writerStreaming(io, &out_buffer); - try bridge(io, &request, &upstream_reader.interface, &upstream_writer.interface); - }, - } -} - -fn bridge(io: Io, request: *std.http.Server.Request, reader: *Io.Reader, writer: *Io.Writer) !void { - var ws = try c9.http.accept(request); - var requests: [max_frame]u8 = undefined; - var replies: [max_frame]u8 = undefined; - var relay: c9.http.Bridge = .{ - .socket = &ws, - .upstream_reader = reader, - .upstream_writer = writer, - .request_buffer = &requests, - .reply_buffer = &replies, - .frame_limit = max_frame, - }; - try relay.run(io, .none); -} diff --git a/app/web/app.mjs b/app/web/app.mjs deleted file mode 100644 index a6985f0..0000000 --- a/app/web/app.mjs +++ /dev/null @@ -1,183 +0,0 @@ -import { Client } from './client.mjs'; -const $ = id => document.getElementById(id); -const text = new TextDecoder('utf-8', { fatal: true }); -let client = null, current = null, currentPath = '/', busy = false, dirty = false; -let configuration = null; - -function status(message, error = false) { - $('status').textContent = message; - $('status').classList.toggle('error', error); -} -function controls() { - for (const id of ['go', 'reload']) $(id).disabled = busy; - $('save').disabled = busy || !current || current.stat.directory || $('editor').hidden || !dirty; - $('download').disabled = busy || !current; - $('editor').readOnly = busy; - $('edit-status').textContent = dirty ? 'Unsaved changes.' : 'No unsaved changes.'; - document.querySelector('main').setAttribute('aria-busy', String(busy)); -} -async function action(operation) { - if (busy) return; - busy = true; - controls(); - try { await operation(); } - catch (error) { status(error.message, true); } - finally { busy = false; controls(); } -} -async function connectedClient() { - if (client && !client.closed) return client; - if (!configuration) { - const response = await fetch('/_cloud9/config.json'); - if (!response.ok) throw new Error('Unable to load server settings. Reload to try again.'); - configuration = await response.json(); - } - try { client = await Client.connect('/_cloud9/9p', configuration.user, configuration.tree); } - catch { throw new Error('Unable to reach the file server. Reload to try again.'); } - return client; -} -async function readPath(path) { - const active = await connectedClient(); - try { return await active.readPath(path); } - catch (error) { - // A timed-out/closed transport has lost its fids. Retry reads once on a - // fresh session. Writes are never replayed after an uncertain result. - if (!active.closed) throw error; - return (await connectedClient()).readPath(path); - } -} -function normalizePath(path) { - const parts = []; - for (const name of path.split('/')) { - if (!name || name === '.') continue; - if (name === '..') parts.pop(); else parts.push(name); - } - return '/' + parts.join('/'); -} -function pathURL(path) { - const parts = path.split('/').map(encodeURIComponent); - // Preserve access to an upstream directory named _cloud9 without colliding - // with the literal gateway prefix. Decode each component exactly once. - if (parts[1] === '_cloud9') parts[1] = '%5Fcloud9'; - return parts.join('/'); -} -function locationPath() { - const parts = location.pathname.split('/').map(part => { - let name; - try { name = decodeURIComponent(part); } - catch { throw new Error('The URL contains invalid path encoding.'); } - if (name.includes('/') || name.includes('\0')) throw new Error('The URL contains an invalid path component.'); - return name; - }); - return normalizePath(parts.join('/')); -} -function child(path, name) { return `${path.replace(/\/$/, '')}/${name}`; } -function mode(stat) { - let result = stat.directory ? 'd' : '-'; - for (let bit = 8; bit >= 0; bit--) result += stat.mode & (1 << bit) ? 'rwx'[(8 - bit) % 3] : '-'; - return result; -} -function pathLink(label, path, className = '') { - const link = document.createElement('a'); - link.textContent = label; - link.href = pathURL(path); - link.dataset.path = path; - link.className = className; - return link; -} -function breadcrumbs(path) { - const node = $('breadcrumbs'); - node.replaceChildren(pathLink('root', '/')); - let walked = ''; - for (const name of path.split('/').filter(Boolean)) { - walked += '/' + name; - const separator = document.createElement('span'); - separator.textContent = '/'; separator.className = 'separator'; - node.append(separator, pathLink(name, walked)); - } - node.lastElementChild.setAttribute('aria-current', 'page'); - const parent = path.replace(/\/[^/]+$/, '') || '/'; - $('up').hidden = path === '/'; - $('up').href = pathURL(parent); - $('up').dataset.path = parent; -} -async function navigate(path, historyMode = 'push') { - path = normalizePath(path); - status('Loading…'); - const result = await readPath(path); - current = result; currentPath = path; dirty = false; - $('path').value = path; - const url = pathURL(path); - if (historyMode === 'push' && location.pathname + location.search + location.hash !== url) history.pushState(null, '', url); - document.title = `${path} — cloud9`; - breadcrumbs(path); - $('directory').hidden = !result.stat.directory; - $('file').hidden = result.stat.directory; - if (result.stat.directory) { - const rows = document.createDocumentFragment(); - const sorted = result.entries.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)); - for (const entry of sorted) { - const row = document.createElement('tr'); - const permissions = document.createElement('td'), name = document.createElement('td'), size = document.createElement('td'); - permissions.className = 'mode'; permissions.textContent = mode(entry); - name.append(pathLink(entry.name + (entry.directory ? '/' : ''), child(path, entry.name), 'entry' + (entry.directory ? ' directory' : ''))); - size.className = 'size'; size.textContent = entry.directory ? '—' : entry.length.toLocaleString(); - row.append(permissions, name, size); rows.append(row); - } - $('entries').replaceChildren(rows); - $('empty').hidden = sorted.length !== 0; - status(`${sorted.length} ${sorted.length === 1 ? 'entry' : 'entries'}`); - } else { - $('filename').textContent = result.stat.name; - $('file-mode').textContent = mode(result.stat); - $('file-size').textContent = `${result.bytes.length.toLocaleString()} bytes`; - let editable = true; - try { - if (result.bytes.includes(0)) throw new Error('Binary'); - $('editor').value = text.decode(result.bytes); - } catch { editable = false; $('editor').value = ''; } - $('editor').hidden = !editable; - $('binary').hidden = editable; - $('edit-actions').hidden = !editable; - status('File loaded.'); - } -} -function mayLeave() { return !dirty || window.confirm('Discard unsaved changes?'); } -document.addEventListener('click', event => { - const link = event.target.closest('a[data-path]'); - if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; - event.preventDefault(); - if (!busy && mayLeave()) action(() => navigate(link.dataset.path)); -}); -$('path-form').onsubmit = event => { - event.preventDefault(); - if (mayLeave()) action(() => navigate($('path').value)); -}; -$('reload').onclick = () => { if (mayLeave()) action(() => navigate(current ? currentPath : locationPath(), 'none')); }; -$('editor').oninput = () => { dirty = true; controls(); }; -$('save').onclick = () => action(async () => { - const bytes = new TextEncoder().encode($('editor').value); - const active = await connectedClient(); - let count; - try { count = await active.writePath(currentPath, bytes); } - catch (error) { - if (active.closed) throw new Error('Save interrupted. The file may be partially written; your edits are still here.'); - throw error; - } - // Keep the editor intact if refreshing after a successful write fails. - dirty = false; - await navigate(currentPath, 'none'); - status(`Saved ${count.toLocaleString()} bytes.`); -}); -$('download').onclick = () => { - const url = URL.createObjectURL(new Blob([current.bytes])); - const link = document.createElement('a'); link.href = url; link.download = current.stat.name; link.click(); - setTimeout(() => URL.revokeObjectURL(url), 1000); -}; -window.addEventListener('popstate', () => { - if (busy || !mayLeave()) { history.pushState(null, '', pathURL(currentPath)); return; } - action(() => navigate(locationPath(), 'none')); -}); -window.addEventListener('beforeunload', event => { - if (dirty) { event.preventDefault(); event.returnValue = ''; } -}); -action(() => navigate(locationPath(), 'none')); diff --git a/app/web/client.mjs b/app/web/client.mjs deleted file mode 100644 index 69f44d2..0000000 --- a/app/web/client.mjs +++ /dev/null @@ -1,138 +0,0 @@ -// Browser transport and file operations. All 9P serialization lives in WASM. -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); -export class Client { - static async connect(url = '/_cloud9/9p', user = 'user', tree = '') { - const { instance } = await WebAssembly.instantiateStreaming(fetch(new URL('./cloud9.wasm', import.meta.url)), {}); - const wsURL = new URL(url, location.href); - wsURL.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const socket = new WebSocket(wsURL); - socket.binaryType = 'arraybuffer'; - const client = new Client(instance.exports, socket); - try { - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Connection timed out')), 10000); - socket.onopen = () => { clearTimeout(timer); resolve(); }; - socket.onerror = () => { clearTimeout(timer); reject(new Error('Cannot connect to 9P bridge')); }; - }); - await client.ask('version'); - const u = encoder.encode(user), a = encoder.encode(tree); - client.stage(new Uint8Array([...u, ...a])); - await client.ask('attach', u.length, a.length); - return client; - } catch (error) { client.close(); throw error; } - } - constructor(wasm, socket) { - this.wasm = wasm; - this.socket = socket; - this.pending = null; - this.queue = Promise.resolve(); - this.closed = false; - wasm.init(); - socket.onmessage = ({ data }) => { - if (!this.pending) { this.close(); return; } - try { - this.stage(new Uint8Array(data)); - const kind = wasm.receive(data.byteLength); - if (!kind) return; - const result = { kind, data: this.data(), number: wasm.result_number(), directory: Boolean(wasm.result_directory()) }; - const pending = this.pending; - this.pending = null; - clearTimeout(pending.timer); - if (kind < 0) { - const error = new Error(decoder.decode(result.data)); - pending.reject(error); - if (wasm.is_dead()) this.abort(error); - } - else pending.resolve(result); - } catch (error) { this.abort(error); } - }; - socket.onclose = () => this.abort(new Error('Connection closed')); - } - data() { return new Uint8Array(this.wasm.memory.buffer, this.wasm.data_ptr(), this.wasm.data_len()).slice(); } - stage(bytes) { - if (bytes.length > this.wasm.input_capacity()) throw new Error('Input exceeds 64 KiB'); - new Uint8Array(this.wasm.memory.buffer, this.wasm.input_ptr(), bytes.length).set(bytes); - return bytes.length; - } - ask(operation, ...args) { - if (this.closed) return Promise.reject(new Error('Connection closed')); - if (this.pending) return Promise.reject(new Error('Request already in flight')); - if (this.wasm[operation](...args) < 0) return Promise.reject(new Error(decoder.decode(this.data()))); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => this.abort(new Error('9P request timed out')), 15000); - this.pending = { resolve, reject, timer }; - try { - this.socket.send(new Uint8Array(this.wasm.memory.buffer, this.wasm.output_ptr(), this.wasm.output_len())); - this.wasm.sent(); - } catch (error) { this.abort(error); } - }); - } - abort(error) { - this.closed = true; - if (this.pending) { clearTimeout(this.pending.timer); this.pending.reject(error); this.pending = null; } - this.socket.close(); - } - close() { this.abort(new Error('Disconnected')); } - serial(operation) { - const result = this.queue.then(operation); - this.queue = result.catch(() => {}); - return result; - } - async withFile(path, operation) { - const names = path.split('/').filter(Boolean); - let live = false; - try { - // Walk in spec-sized batches, retaining an unopened root fid. - for (let index = 0; index < Math.max(1, names.length); index += 16) { - const batch = names.slice(index, index + 16); - const len = this.stage(encoder.encode(batch.join('/'))); - const result = await this.ask('walk', index === 0 ? 0 : 1, 1, len); - live = live || result.number > 0 || batch.length === 0; - if (result.number !== batch.length) throw new Error('Path does not exist'); - } - return await operation(); - } finally { - if (live && !this.closed) await this.ask('clunk', 1); - } - } - readPath(path) { - return this.serial(() => this.withFile(path, async () => { - const stat = JSON.parse(decoder.decode((await this.ask('stat', 1)).data)); - const opened = await this.ask('open', 1, 0); - const count = Math.min(this.wasm.max_read(), opened.number || Infinity); - let offset = 0; - const chunks = [], entries = []; - while (true) { - const { data } = await this.ask('read', 1, BigInt(offset), count); - if (!data.length) break; - offset += data.length; - if (offset > 8 * 1024 * 1024) throw new Error('Browser view is limited to 8 MiB'); - if (stat.directory) { - this.stage(data); - if (this.wasm.decode_directory(data.length) < 0) throw new Error(decoder.decode(this.data())); - entries.push(...JSON.parse(decoder.decode(this.data()))); - } else chunks.push(data); - } - const bytes = new Uint8Array(stat.directory ? 0 : offset); - let position = 0; - for (const chunk of chunks) { bytes.set(chunk, position); position += chunk.length; } - return { stat, entries, bytes }; - })); - } - writePath(path, bytes) { - if (!(bytes instanceof Uint8Array) || bytes.length > 8 * 1024 * 1024) return Promise.reject(new Error('Write is limited to 8 MiB')); - return this.serial(() => this.withFile(path, async () => { - const opened = await this.ask('open', 1, 1 | 16); // OWRITE | OTRUNC - const count = Math.min(this.wasm.max_write(), opened.number || Infinity); - let offset = 0; - while (offset < bytes.length) { - const len = this.stage(bytes.subarray(offset, offset + count)); - const result = await this.ask('write', 1, BigInt(offset), len); - if (!result.number) throw new Error('Server made no write progress'); - offset += result.number; - } - return offset; - })); - } -} diff --git a/app/web/index.html b/app/web/index.html deleted file mode 100644 index aeca5d3..0000000 --- a/app/web/index.html +++ /dev/null @@ -1,53 +0,0 @@ -<!doctype html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width,initial-scale=1"> - <title>cloud9 — files</title> - <link rel="stylesheet" href="/_cloud9/style.css"> - <script type="module" src="/_cloud9/app.mjs"></script> -</head> -<body> - <header> - <a class="brand" href="/" data-path="/">cloud9</a> - <div class="description">A web interface to your file server.</div> - </header> - <nav class="toolbar" aria-label="File actions"> - <a class="selected" href="/" data-path="/">files</a> - <button id="reload" type="button">reload</button> - <form id="path-form"> - <label for="path">path</label> - <input id="path" name="path" value="/" aria-label="Path" autocomplete="off" spellcheck="false"> - <button id="go">go</button> - </form> - </nav> - <main> - <nav id="breadcrumbs" aria-label="Breadcrumb"><a href="/" data-path="/">root</a></nav> - <div class="summary"> - <p id="status" role="status" aria-live="polite">Loading files…</p> - <a id="up" href="/" data-path="/" hidden>parent directory</a> - </div> - <section id="directory" aria-label="Directory"> - <table> - <thead><tr><th scope="col">Mode</th><th scope="col">Name</th><th scope="col" class="size">Size</th></tr></thead> - <tbody id="entries"></tbody> - </table> - <p id="empty" hidden>This directory is empty.</p> - </section> - <section id="file" hidden> - <div class="file-heading"> - <h1 id="filename"></h1> - <span id="file-mode"></span><span id="file-size"></span> - <button id="download" type="button">download</button> - </div> - <textarea id="editor" aria-label="File contents" spellcheck="false"></textarea> - <p id="binary" hidden>Binary file. Download to view its contents.</p> - <div id="edit-actions" class="actions"> - <button id="save" type="button">save changes</button> - <span id="edit-status">No unsaved changes.</span> - </div> - </section> - </main> - <footer>served by cloud9</footer> -</body> -</html> diff --git a/app/web/style.css b/app/web/style.css deleted file mode 100644 index a8605f5..0000000 --- a/app/web/style.css +++ /dev/null @@ -1,87 +0,0 @@ -:root { - font: 14px/1.45 sans-serif; - color: #333; - background: white; - font-synthesis: none; -} -* { box-sizing: border-box; } -body { margin: 0; padding: 22px 3%; } -a { color: #07539b; text-decoration: none; } -a:hover { text-decoration: underline; } -header { padding: 0 8px 17px; } -.brand { font-size: 30px; font-weight: bold; color: #222; letter-spacing: -1px; } -.description { color: #777; margin-top: 1px; } -.toolbar { - display: flex; - align-items: center; - gap: 2px; - border-bottom: 3px solid #ccc; - padding: 0 7px; -} -.toolbar > a, .toolbar > button { - padding: 5px 14px; - color: #555; - background: none; - border: 0; - font-size: 15px; -} -.toolbar .selected { background: #ccc; color: #111; } -button, input, textarea { font: inherit; color: inherit; } -button { - cursor: pointer; - border: 1px solid #aaa; - padding: 2px 9px; - background: #f5f5f5; - border-radius: 0; -} -button:hover:enabled { background: #e8e8e8; } -button:disabled { color: #999; cursor: default; } -#path-form { margin-left: auto; display: flex; align-items: center; gap: 6px; padding-bottom: 5px; } -#path-form label { color: #777; font-size: 12px; } -#path { width: 250px; border: 1px solid #bbb; padding: 2px 5px; font: 13px/1.5 monospace; } -main { padding: 0 8px; } -#breadcrumbs { display: flex; flex-wrap: wrap; gap: 7px; padding: 13px 0 5px; font-family: monospace; overflow-wrap: anywhere; } -#breadcrumbs .separator { color: #999; } -#breadcrumbs [aria-current] { color: #333; font-weight: bold; } -.summary { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 2px 0 13px; font-size: 12px; } -#status { color: #777; margin: 0; min-height: 18px; } -#status.error { color: #a22; } -#up { white-space: nowrap; } -table { width: 100%; border-collapse: collapse; text-align: left; } -th { background: #eee; padding: 4px 8px; font-weight: bold; border-bottom: 1px solid #ccc; } -td { padding: 3px 8px; vertical-align: top; } -tbody tr:nth-child(even) { background: #f7f7f7; } -tbody tr:hover { background: #edf3f8; } -.mode { width: 120px; color: #777; font: 12px/1.7 monospace; white-space: nowrap; } -.entry { font-family: monospace; overflow-wrap: anywhere; } -.entry.directory { font-weight: bold; } -.size { width: 110px; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } -td.size { color: #666; font-size: 12px; } -#empty { color: #777; padding: 4px 8px; } -.file-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 16px; padding: 6px 8px; background: #eee; border-bottom: 1px solid #ccc; } -h1 { font: bold 14px monospace; margin: 0; overflow-wrap: anywhere; } -#file-mode, #file-size { color: #666; font: 12px monospace; } -#download { margin-left: auto; font-size: 12px; } -textarea { display: block; width: 100%; min-height: 420px; resize: vertical; border: 1px solid #ddd; border-top: 0; padding: 10px; font: 13px/1.6 monospace; tab-size: 4; white-space: pre; overflow: auto; } -#binary { color: #777; padding: 16px 8px; } -.actions { display: flex; align-items: center; gap: 12px; margin-top: 10px; font-size: 12px; } -#edit-status { color: #777; } -footer { margin: 30px 8px 0; padding-top: 7px; border-top: 1px solid #ddd; text-align: right; color: #999; font-size: 11px; } -a:focus-visible, button:focus-visible { outline: 2px solid #07539b; outline-offset: 2px; } -[hidden] { display: none !important; } -@media (max-width: 600px) { - body { padding: 14px 10px; } - header { padding-left: 3px; } - .brand { font-size: 26px; } - .toolbar { flex-wrap: wrap; padding: 0; } - .toolbar > a, .toolbar > button { padding: 5px 10px; } - #path-form { order: -1; width: 100%; margin: 0 0 6px; } - #path { width: auto; min-width: 0; flex: 1; } - main { padding: 0; } - th, td { padding-left: 5px; padding-right: 5px; } - .mode { width: 86px; font-size: 10px; } - .size { width: 65px; } - .summary { align-items: start; } - .file-heading { gap: 7px 12px; } - textarea { min-height: 350px; } -} |
