[
  {
    "path": ".editorconfig",
    "content": "[*]\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.rs]\ntab_width = 4\n\n[*.{js,jsx,ts,tsx,html,css,svelte,proto}]\ntab_width = 2\n"
  },
  {
    "path": ".eslintrc.cjs",
    "content": "module.exports = {\n  root: true,\n  parser: \"@typescript-eslint/parser\",\n  extends: [\n    \"eslint:recommended\",\n    \"plugin:@typescript-eslint/recommended\",\n    \"prettier\",\n  ],\n  plugins: [\"svelte3\", \"@typescript-eslint\"],\n  ignorePatterns: [\"*.cjs\"],\n  overrides: [{ files: [\"*.svelte\"], processor: \"svelte3/svelte3\" }],\n  settings: {\n    \"svelte3/typescript\": () => require(\"typescript\"),\n  },\n  rules: {\n    \"@typescript-eslint/ban-ts-comment\": \"off\",\n    \"@typescript-eslint/ban-types\": \"off\",\n    \"@typescript-eslint/no-empty-function\": \"off\",\n    \"@typescript-eslint/no-explicit-any\": \"off\",\n    \"@typescript-eslint/no-inferrable-types\": \"off\",\n    \"@typescript-eslint/no-non-null-assertion\": \"off\",\n    \"no-constant-condition\": \"off\",\n    \"no-control-regex\": \"off\",\n    \"no-empty\": \"off\",\n    \"no-undef\": \"off\",\n  },\n  parserOptions: {\n    sourceType: \"module\",\n    ecmaVersion: 2020,\n  },\n  env: {\n    browser: true,\n    es2017: true,\n    node: true,\n  },\n};\n"
  },
  {
    "path": ".github/workflows/ci.yaml",
    "content": "name: CI\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n\njobs:\n  rustfmt:\n    name: Rust format\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - run: rustup toolchain install nightly --profile minimal -c rustfmt\n\n      - run: cargo +nightly fmt -- --check\n\n  rust:\n    name: Rust lint and test\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: arduino/setup-protoc@v2\n\n      - run: rustup toolchain install stable\n\n      - uses: Swatinem/rust-cache@v2\n\n      - run: cargo test\n\n      - run: cargo clippy --all-targets -- -D warnings\n\n  windows_test:\n    name: Client test (Windows)\n    runs-on: windows-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: arduino/setup-protoc@v2\n\n      - run: rustup toolchain install stable\n\n      - uses: Swatinem/rust-cache@v2\n\n      - run: cargo test -p sshx\n\n  web:\n    name: Web lint, check, and build\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: \"18\"\n\n      - run: npm ci\n\n      - run: npm run lint\n\n      - run: npm run check\n\n      - run: npm run build\n\n  deploy:\n    name: Deploy\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' && github.ref == 'refs/heads/main'\n    needs: [rustfmt, rust, web]\n    concurrency:\n      group: deploy\n      cancel-in-progress: true\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: superfly/flyctl-actions/setup-flyctl@v1\n\n      - run: flyctl deploy\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "/.vscode\n\n/target\n\n/node_modules\n/.svelte-kit\n/build\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"proseWrap\": \"always\",\n  \"trailingComma\": \"all\"\n}\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace]\nmembers = [\"crates/*\"]\nresolver = \"2\"\n\n[workspace.package]\nversion = \"0.4.1\"\nauthors = [\"Eric Zhang <ekzhang1@gmail.com>\"]\nlicense = \"MIT\"\ndescription = \"A secure web-based, collaborative terminal.\"\nrepository = \"https://github.com/ekzhang/sshx\"\ndocumentation = \"https://sshx.io\"\nkeywords = [\"ssh\", \"share\", \"terminal\", \"collaborative\"]\n\n[workspace.dependencies]\nanyhow = \"1.0.62\"\nclap = { version = \"4.5.17\", features = [\"derive\", \"env\"] }\nprost = \"0.13.4\"\nrand = \"0.8.5\"\nserde = { version = \"1.0.188\", features = [\"derive\", \"rc\"] }\nsshx-core = { version = \"0.4.1\", path = \"crates/sshx-core\" }\ntokio = { version = \"1.40.0\", features = [\"full\"] }\ntokio-stream = { version = \"0.1.14\", features = [\"sync\"] }\ntonic = { version = \"0.12.3\", features = [\"tls\", \"tls-webpki-roots\"] }\ntonic-build = \"0.12.3\"\ntonic-reflection = \"0.12.3\"\ntracing = \"0.1.37\"\ntracing-subscriber = { version = \"0.3.17\", features = [\"env-filter\"] }\n\n[profile.release]\nstrip = true\n"
  },
  {
    "path": "Cross.toml",
    "content": "[target.x86_64-unknown-freebsd]\npre-build = [\n    \"apt-get update\",\n\n    # Protobuf version is too outdated on the cargo-cross image, which is ubuntu:20.04.\n    # \"apt install -y protobuf-compiler\",\n\n    \"apt install -y wget libarchive-tools\",\n    \"mkdir /protoc\",\n    \"wget -qO- https://github.com/protocolbuffers/protobuf/releases/download/v29.2/protoc-29.2-linux-x86_64.zip | bsdtar -xvf- -C /protoc\",\n    \"mv -v /protoc/bin/protoc /usr/local/bin && chmod +x /usr/local/bin/protoc\",\n    \"mkdir -p /usr/local/include/google/protobuf/\",\n    \"mv -v /protoc/include/google/protobuf/* /usr/local/include/google/protobuf/\",\n    \"rm -rf /protoc\",\n]\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM rust:alpine AS backend\nWORKDIR /home/rust/src\nRUN apk --no-cache add musl-dev openssl-dev protoc\nRUN rustup component add rustfmt\nCOPY . .\nRUN --mount=type=cache,target=/usr/local/cargo/registry \\\n    --mount=type=cache,target=/home/rust/src/target \\\n    cargo build --release --bin sshx-server && \\\n    cp target/release/sshx-server /usr/local/bin\n\nFROM node:lts-alpine AS frontend\nRUN apk --no-cache add git\nWORKDIR /usr/src/app\nCOPY . .\nRUN npm ci\nRUN npm run build\n\nFROM alpine:latest\nWORKDIR /root\nCOPY --from=frontend /usr/src/app/build build\nCOPY --from=backend /usr/local/bin/sshx-server .\nCMD [\"./sshx-server\", \"--listen\", \"::\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023 Eric Zhang\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# sshx\n\nA secure web-based, collaborative terminal.\n\n![](https://i.imgur.com/Q3qKAHW.png)\n\n**Features:**\n\n- Run a single command to share your terminal with anyone.\n- Resize, move windows, and freely zoom and pan on an infinite canvas.\n- See other people's cursors moving in real time.\n- Connect to the nearest server in a globally distributed mesh.\n- End-to-end encryption with Argon2 and AES.\n- Automatic reconnection and real-time latency estimates.\n- Predictive echo for faster local editing (à la Mosh).\n\nVisit [sshx.io](https://sshx.io) to learn more.\n\n## Installation\n\nJust run this command to get the `sshx` binary for your platform.\n\n```shell\ncurl -sSf https://sshx.io/get | sh\n```\n\nSupports Linux and MacOS on x86_64 and ARM64 architectures, as well as embedded\nARMv6 and ARMv7-A systems. The Linux binaries are statically linked.\n\nFor Windows, there are binaries for x86_64, x86, and ARM64, linked to MSVC for\nmaximum compatibility.\n\nIf you just want to try it out without installing, use:\n\n```shell\ncurl -sSf https://sshx.io/get | sh -s run\n```\n\nInspect the script for additional options.\n\nYou can also install it with [Homebrew](https://brew.sh/) on macOS.\n\n```shell\nbrew install sshx\n```\n\n### CI/CD\n\nYou can run sshx in continuous integration workflows to help debug tricky\nissues, like in GitHub Actions.\n\n```yaml\nname: CI\non: push\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n\n      # ... other steps ...\n\n      - run: curl -sSf https://sshx.io/get | sh -s run\n      #      ^\n      #      └ This will open a remote terminal session and print the URL. It\n      #        should take under a second.\n```\n\nWe don't have a prepackaged action because it's just a single command. It works\nanywhere: GitLab CI, CircleCI, Buildkite, CI on your Raspberry Pi, etc.\n\nBe careful adding this to a public GitHub repository, as any user can view the\nlogs of a CI job while it is running.\n\n## Development\n\nHere's how to work on the project, if you want to contribute.\n\n### Building from source\n\nTo build the latest version of the client from source, clone this repository and\nrun, with [Rust](https://rust-lang.com/) installed:\n\n```shell\ncargo install --path crates/sshx\n```\n\nThis will compile the `sshx` binary and place it in your `~/.cargo/bin` folder.\n\n### Workflow\n\nFirst, start service containers for development.\n\n```shell\ndocker compose up -d\n```\n\nInstall [Rust 1.70+](https://www.rust-lang.org/),\n[Node v18](https://nodejs.org/), [NPM v9](https://www.npmjs.com/), and\n[mprocs](https://github.com/pvolok/mprocs). Then, run\n\n```shell\nnpm install\nmprocs\n```\n\nThis will compile and start the server, an instance of the client, and the web\nfrontend in parallel on your machine.\n\n## Deployment\n\nI host the application servers on [Fly.io](https://fly.io/) and with\n[Redis Cloud](https://redis.com/).\n\nSelf-hosted deployments are not supported at the moment. If you want to deploy\nsshx, you'll need to properly implement HTTP/TCP reverse proxies, gRPC\nforwarding, TLS termination, private mesh networking, and graceful shutdown.\n\nPlease do not run the development commands in a public setting, as this is\ninsecure.\n"
  },
  {
    "path": "compose.yaml",
    "content": "# Services used by sshx for development. These listen on ports 126XX, to reduce the chance that they\n# conflict with other processes.\n#\n# You can start them with `docker compose up -d`.\n\nservices:\n  redis:\n    image: bitnami/redis:7.2\n    environment:\n      - ALLOW_EMPTY_PASSWORD=yes\n    ports:\n      - 127.0.0.1:12601:6379\n"
  },
  {
    "path": "crates/sshx/Cargo.toml",
    "content": "[package]\nname = \"sshx\"\nversion.workspace = true\nauthors.workspace = true\nlicense.workspace = true\ndescription.workspace = true\nrepository.workspace = true\ndocumentation.workspace = true\nkeywords.workspace = true\nedition = \"2021\"\n\n[dependencies]\naes = \"0.8.3\"\nansi_term = \"0.12.1\"\nanyhow.workspace = true\nargon2 = { version = \"0.5.2\", default-features = false, features = [\"alloc\"] }\ncfg-if = \"1.0.0\"\nclap.workspace = true\nctr = \"0.9.2\"\nencoding_rs = \"0.8.31\"\npin-project = \"1.1.3\"\nsshx-core.workspace = true\ntokio.workspace = true\ntokio-stream.workspace = true\ntonic.workspace = true\ntracing.workspace = true\ntracing-subscriber.workspace = true\nwhoami = { version = \"1.5.1\", default-features = false }\n\n[target.'cfg(unix)'.dependencies]\nclose_fds = \"0.3.2\"\nnix = { version = \"0.27.1\", features = [\"ioctl\", \"process\", \"signal\", \"term\"] }\n\n[target.'cfg(windows)'.dependencies]\nconpty = \"0.7.0\"\n"
  },
  {
    "path": "crates/sshx/examples/stdin_client.rs",
    "content": "use std::io::Read;\nuse std::sync::Arc;\nuse std::thread;\n\nuse anyhow::Result;\nuse sshx::terminal::{get_default_shell, Terminal};\nuse tokio::io::{self, AsyncReadExt, AsyncWriteExt};\nuse tokio::signal;\nuse tokio::sync::mpsc;\nuse tracing::{error, info, trace};\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n    tracing_subscriber::fmt::init();\n\n    let shell = get_default_shell().await;\n    info!(%shell, \"using default shell\");\n\n    let mut terminal = Terminal::new(&shell).await?;\n\n    // Separate thread for reading from standard input.\n    let (tx, mut rx) = mpsc::channel::<Arc<[u8]>>(16);\n    thread::spawn(move || loop {\n        let mut buf = [0u8; 256];\n        let n = std::io::stdin().read(&mut buf).unwrap();\n        if tx.blocking_send(buf[0..n].into()).is_err() {\n            break;\n        }\n    });\n\n    let exit_signal = signal::ctrl_c();\n    tokio::pin!(exit_signal);\n\n    loop {\n        let mut buf = [0u8; 256];\n\n        tokio::select! {\n            Some(bytes) = rx.recv() => {\n                terminal.write_all(&bytes).await?;\n            }\n            result = terminal.read(&mut buf) => {\n                let n = result?;\n                io::stdout().write_all(&buf[..n]).await?;\n            }\n            result = &mut exit_signal => {\n                if let Err(err) = result {\n                    error!(?err, \"failed to listen for exit signal\");\n                }\n                trace!(\"gracefully exiting main\");\n                break;\n            }\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/sshx/src/controller.rs",
    "content": "//! Network gRPC client allowing server control of terminals.\n\nuse std::collections::HashMap;\nuse std::pin::pin;\n\nuse anyhow::{Context, Result};\nuse sshx_core::proto::{\n    client_update::ClientMessage, server_update::ServerMessage,\n    sshx_service_client::SshxServiceClient, ClientUpdate, CloseRequest, NewShell, OpenRequest,\n};\nuse sshx_core::{rand_alphanumeric, Sid};\nuse tokio::sync::mpsc;\nuse tokio::task;\nuse tokio::time::{self, Duration, Instant, MissedTickBehavior};\nuse tokio_stream::{wrappers::ReceiverStream, StreamExt};\nuse tonic::transport::Channel;\nuse tracing::{debug, error, warn};\n\nuse crate::encrypt::Encrypt;\nuse crate::runner::{Runner, ShellData};\n\n/// Interval for sending empty heartbeat messages to the server.\nconst HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);\n\n/// Interval to automatically reestablish connections.\nconst RECONNECT_INTERVAL: Duration = Duration::from_secs(60);\n\n/// Handles a single session's communication with the remote server.\npub struct Controller {\n    origin: String,\n    runner: Runner,\n    encrypt: Encrypt,\n    encryption_key: String,\n\n    name: String,\n    token: String,\n    url: String,\n    write_url: Option<String>,\n\n    /// Channels with backpressure routing messages to each shell task.\n    shells_tx: HashMap<Sid, mpsc::Sender<ShellData>>,\n    /// Channel shared with tasks to allow them to output client messages.\n    output_tx: mpsc::Sender<ClientMessage>,\n    /// Owned receiving end of the `output_tx` channel.\n    output_rx: mpsc::Receiver<ClientMessage>,\n}\n\nimpl Controller {\n    /// Construct a new controller, connecting to the remote server.\n    pub async fn new(\n        origin: &str,\n        name: &str,\n        runner: Runner,\n        enable_readers: bool,\n    ) -> Result<Self> {\n        debug!(%origin, \"connecting to server\");\n        let encryption_key = rand_alphanumeric(14); // 83.3 bits of entropy\n\n        let kdf_task = {\n            let encryption_key = encryption_key.clone();\n            task::spawn_blocking(move || Encrypt::new(&encryption_key))\n        };\n\n        let (write_password, kdf_write_password_task) = if enable_readers {\n            let write_password = rand_alphanumeric(14); // 83.3 bits of entropy\n            let task = {\n                let write_password = write_password.clone();\n                task::spawn_blocking(move || Encrypt::new(&write_password))\n            };\n            (Some(write_password), Some(task))\n        } else {\n            (None, None)\n        };\n\n        let mut client = Self::connect(origin).await?;\n        let encrypt = kdf_task.await?;\n        let write_password_hash = if let Some(task) = kdf_write_password_task {\n            Some(task.await?.zeros().into())\n        } else {\n            None\n        };\n\n        let req = OpenRequest {\n            origin: origin.into(),\n            encrypted_zeros: encrypt.zeros().into(),\n            name: name.into(),\n            write_password_hash,\n        };\n        let mut resp = client.open(req).await?.into_inner();\n        resp.url = resp.url + \"#\" + &encryption_key;\n\n        let write_url = if let Some(write_password) = write_password {\n            Some(resp.url.clone() + \",\" + &write_password)\n        } else {\n            None\n        };\n\n        let (output_tx, output_rx) = mpsc::channel(64);\n        Ok(Self {\n            origin: origin.into(),\n            runner,\n            encrypt,\n            encryption_key,\n            name: resp.name,\n            token: resp.token,\n            url: resp.url,\n            write_url,\n            shells_tx: HashMap::new(),\n            output_tx,\n            output_rx,\n        })\n    }\n\n    /// Create a new gRPC client to the HTTP(S) origin.\n    ///\n    /// This is used on reconnection to the server, since some replicas may be\n    /// gracefully shutting down, which means connected clients need to start a\n    /// new TCP handshake.\n    async fn connect(origin: &str) -> Result<SshxServiceClient<Channel>, tonic::transport::Error> {\n        SshxServiceClient::connect(String::from(origin)).await\n    }\n\n    /// Returns the name of the session.\n    pub fn name(&self) -> &str {\n        &self.name\n    }\n\n    /// Returns the URL of the session.\n    pub fn url(&self) -> &str {\n        &self.url\n    }\n\n    /// Returns the write URL of the session, if it exists.\n    pub fn write_url(&self) -> Option<&str> {\n        self.write_url.as_deref()\n    }\n\n    /// Returns the encryption key for this session, hidden from the server.\n    pub fn encryption_key(&self) -> &str {\n        &self.encryption_key\n    }\n\n    /// Run the controller forever, listening for requests from the server.\n    pub async fn run(&mut self) -> ! {\n        let mut last_retry = Instant::now();\n        let mut retries = 0;\n        loop {\n            if let Err(err) = self.try_channel().await {\n                if last_retry.elapsed() >= Duration::from_secs(10) {\n                    retries = 0;\n                }\n                let secs = 2_u64.pow(retries.min(4));\n                error!(%err, \"disconnected, retrying in {secs}s...\");\n                time::sleep(Duration::from_secs(secs)).await;\n                retries += 1;\n            }\n            last_retry = Instant::now();\n        }\n    }\n\n    /// Helper function used by `run()` that can return errors.\n    async fn try_channel(&mut self) -> Result<()> {\n        let (tx, rx) = mpsc::channel(16);\n\n        let hello = ClientMessage::Hello(format!(\"{},{}\", self.name, self.token));\n        send_msg(&tx, hello).await?;\n\n        let mut client = Self::connect(&self.origin).await?;\n        let resp = client.channel(ReceiverStream::new(rx)).await?;\n        let mut messages = resp.into_inner(); // A stream of server messages.\n\n        let mut interval = time::interval(HEARTBEAT_INTERVAL);\n        interval.set_missed_tick_behavior(MissedTickBehavior::Delay);\n        let mut reconnect = pin!(time::sleep(RECONNECT_INTERVAL));\n        loop {\n            let message = tokio::select! {\n                _ = interval.tick() => {\n                    tx.send(ClientUpdate::default()).await?;\n                    continue;\n                }\n                msg = self.output_rx.recv() => {\n                    let msg = msg.context(\"unreachable: output_tx was closed?\")?;\n                    send_msg(&tx, msg).await?;\n                    continue;\n                }\n                item = messages.next() => {\n                    item.context(\"server closed connection\")??\n                        .server_message\n                        .context(\"server message is missing\")?\n                }\n                _ = &mut reconnect => {\n                    return Ok(()); // Reconnect to the server.\n                }\n            };\n\n            match message {\n                ServerMessage::Input(input) => {\n                    let data = self.encrypt.segment(0x200000000, input.offset, &input.data);\n                    if let Some(sender) = self.shells_tx.get(&Sid(input.id)) {\n                        // This line applies backpressure if the shell task is overloaded.\n                        sender.send(ShellData::Data(data)).await.ok();\n                    } else {\n                        warn!(%input.id, \"received data for non-existing shell\");\n                    }\n                }\n                ServerMessage::CreateShell(new_shell) => {\n                    let id = Sid(new_shell.id);\n                    let center = (new_shell.x, new_shell.y);\n                    if !self.shells_tx.contains_key(&id) {\n                        self.spawn_shell_task(id, center);\n                    } else {\n                        warn!(%id, \"server asked to create duplicate shell\");\n                    }\n                }\n                ServerMessage::CloseShell(id) => {\n                    // Closes the channel when it is dropped, notifying the task to shut down.\n                    self.shells_tx.remove(&Sid(id));\n                    send_msg(&tx, ClientMessage::ClosedShell(id)).await?;\n                }\n                ServerMessage::Sync(seqnums) => {\n                    for (id, seq) in seqnums.map {\n                        if let Some(sender) = self.shells_tx.get(&Sid(id)) {\n                            sender.send(ShellData::Sync(seq)).await.ok();\n                        } else {\n                            warn!(%id, \"received sequence number for non-existing shell\");\n                            send_msg(&tx, ClientMessage::ClosedShell(id)).await?;\n                        }\n                    }\n                }\n                ServerMessage::Resize(msg) => {\n                    if let Some(sender) = self.shells_tx.get(&Sid(msg.id)) {\n                        sender.send(ShellData::Size(msg.rows, msg.cols)).await.ok();\n                    } else {\n                        warn!(%msg.id, \"received resize for non-existing shell\");\n                    }\n                }\n                ServerMessage::Ping(ts) => {\n                    // Echo back the timestamp, for stateless latency measurement.\n                    send_msg(&tx, ClientMessage::Pong(ts)).await?;\n                }\n                ServerMessage::Error(err) => {\n                    error!(?err, \"error received from server\");\n                }\n            }\n        }\n    }\n\n    /// Entry point to start a new terminal task on the client.\n    fn spawn_shell_task(&mut self, id: Sid, center: (i32, i32)) {\n        let (shell_tx, shell_rx) = mpsc::channel(16);\n        let opt = self.shells_tx.insert(id, shell_tx);\n        debug_assert!(opt.is_none(), \"shell ID cannot be in existing tasks\");\n\n        let runner = self.runner.clone();\n        let encrypt = self.encrypt.clone();\n        let output_tx = self.output_tx.clone();\n        tokio::spawn(async move {\n            debug!(%id, \"spawning new shell\");\n            let new_shell = NewShell {\n                id: id.0,\n                x: center.0,\n                y: center.1,\n            };\n            if let Err(err) = output_tx.send(ClientMessage::CreatedShell(new_shell)).await {\n                error!(%id, ?err, \"failed to send shell creation message\");\n                return;\n            }\n            if let Err(err) = runner.run(id, encrypt, shell_rx, output_tx.clone()).await {\n                let err = ClientMessage::Error(err.to_string());\n                output_tx.send(err).await.ok();\n            }\n            output_tx.send(ClientMessage::ClosedShell(id.0)).await.ok();\n        });\n    }\n\n    /// Terminate this session gracefully.\n    pub async fn close(&self) -> Result<()> {\n        debug!(\"closing session\");\n        let req = CloseRequest {\n            name: self.name.clone(),\n            token: self.token.clone(),\n        };\n        let mut client = Self::connect(&self.origin).await?;\n        client.close(req).await?;\n        Ok(())\n    }\n}\n\n/// Attempt to send a client message over an update channel.\nasync fn send_msg(tx: &mpsc::Sender<ClientUpdate>, message: ClientMessage) -> Result<()> {\n    let update = ClientUpdate {\n        client_message: Some(message),\n    };\n    tx.send(update)\n        .await\n        .context(\"failed to send message to server\")\n}\n"
  },
  {
    "path": "crates/sshx/src/encrypt.rs",
    "content": "//! Encryption of byte streams based on a random key.\n\nuse aes::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek};\n\ntype Aes128Ctr64BE = ctr::Ctr64BE<aes::Aes128>;\n\n// Note: The KDF salt is public, as it needs to be used from the web client. It\n// only exists to make rainbow table attacks less likely.\nconst SALT: &str =\n    \"This is a non-random salt for sshx.io, since we want to stretch the security of 83-bit keys!\";\n\n/// Encrypts byte streams using the Argon2 hash of a random key.\n#[derive(Clone)]\npub struct Encrypt {\n    aes_key: [u8; 16], // 16-bit\n}\n\nimpl Encrypt {\n    /// Construct a new encryptor.\n    pub fn new(key: &str) -> Self {\n        use argon2::{Algorithm, Argon2, Params, Version};\n        // These parameters must match the browser implementation.\n        let hasher = Argon2::new(\n            Algorithm::Argon2id,\n            Version::V0x13,\n            Params::new(19 * 1024, 2, 1, Some(16)).unwrap(),\n        );\n        let mut aes_key = [0; 16];\n        hasher\n            .hash_password_into(key.as_bytes(), SALT.as_bytes(), &mut aes_key)\n            .expect(\"failed to hash key with argon2\");\n        Self { aes_key }\n    }\n\n    /// Get the encrypted zero block.\n    pub fn zeros(&self) -> Vec<u8> {\n        let mut zeros = [0; 16];\n        let mut cipher = Aes128Ctr64BE::new(&self.aes_key.into(), &zeros.into());\n        cipher.apply_keystream(&mut zeros);\n        zeros.to_vec()\n    }\n\n    /// Encrypt a segment of data from a stream.\n    ///\n    /// Note that in CTR mode, the encryption operation is the same as the\n    /// decryption operation.\n    pub fn segment(&self, stream_num: u64, offset: u64, data: &[u8]) -> Vec<u8> {\n        assert_ne!(stream_num, 0, \"stream number must be nonzero\"); // security check\n\n        let mut iv = [0; 16];\n        iv[0..8].copy_from_slice(&stream_num.to_be_bytes());\n\n        let mut cipher = Aes128Ctr64BE::new(&self.aes_key.into(), &iv.into());\n        let mut buf = data.to_vec();\n        cipher.seek(offset);\n        cipher.apply_keystream(&mut buf);\n        buf\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::Encrypt;\n\n    #[test]\n    fn make_encrypt() {\n        let encrypt = Encrypt::new(\"test\");\n        assert_eq!(\n            encrypt.zeros(),\n            [198, 3, 249, 238, 65, 10, 224, 98, 253, 73, 148, 1, 138, 3, 108, 143],\n        );\n    }\n\n    #[test]\n    fn roundtrip_ctr() {\n        let encrypt = Encrypt::new(\"this is a test key\");\n        let data = b\"hello world\";\n        let encrypted = encrypt.segment(1, 0, data);\n        assert_eq!(encrypted.len(), data.len());\n        let decrypted = encrypt.segment(1, 0, &encrypted);\n        assert_eq!(decrypted, data);\n    }\n\n    #[test]\n    fn matches_offset() {\n        let encrypt = Encrypt::new(\"this is a test key\");\n        let data = b\"1st block.(16B)|2nd block......|3rd block\";\n        let encrypted = encrypt.segment(1, 0, data);\n        assert_eq!(encrypted.len(), data.len());\n        for i in 1..data.len() {\n            let encrypted_suffix = encrypt.segment(1, i as u64, &data[i..]);\n            assert_eq!(encrypted_suffix, &encrypted[i..]);\n        }\n    }\n\n    #[test]\n    #[should_panic]\n    fn zero_stream_num() {\n        let encrypt = Encrypt::new(\"this is a test key\");\n        encrypt.segment(0, 0, b\"hello world\");\n    }\n}\n"
  },
  {
    "path": "crates/sshx/src/lib.rs",
    "content": "//! Library code for the sshx command-line client application.\n//!\n//! This crate does not forbid use of unsafe code because it needs to interact\n//! with operating-system APIs to access pseudoterminal (PTY) devices.\n\n#![deny(unsafe_code)]\n#![warn(missing_docs)]\n\npub mod controller;\npub mod encrypt;\npub mod runner;\npub mod terminal;\n"
  },
  {
    "path": "crates/sshx/src/main.rs",
    "content": "use std::process::ExitCode;\n\nuse ansi_term::Color::{Cyan, Fixed, Green};\nuse anyhow::Result;\nuse clap::Parser;\nuse sshx::{controller::Controller, runner::Runner, terminal::get_default_shell};\nuse tokio::signal;\nuse tracing::error;\n\n/// A secure web-based, collaborative terminal.\n#[derive(Parser, Debug)]\n#[clap(author, version, about, long_about = None)]\nstruct Args {\n    /// Address of the remote sshx server.\n    #[clap(long, default_value = \"https://sshx.io\", env = \"SSHX_SERVER\")]\n    server: String,\n\n    /// Local shell command to run in the terminal.\n    #[clap(long)]\n    shell: Option<String>,\n\n    /// Quiet mode, only prints the URL to stdout.\n    #[clap(short, long)]\n    quiet: bool,\n\n    /// Session name displayed in the title (defaults to user@hostname).\n    #[clap(long)]\n    name: Option<String>,\n\n    /// Enable read-only access mode - generates separate URLs for viewers and\n    /// editors.\n    #[clap(long)]\n    enable_readers: bool,\n}\n\nfn print_greeting(shell: &str, controller: &Controller) {\n    let version_str = match option_env!(\"CARGO_PKG_VERSION\") {\n        Some(version) => format!(\"v{version}\"),\n        None => String::from(\"[dev]\"),\n    };\n    if let Some(write_url) = controller.write_url() {\n        println!(\n            r#\"\n  {sshx} {version}\n\n  {arr}  Read-only link: {link_v}\n  {arr}  Writable link:  {link_e}\n  {arr}  Shell:          {shell_v}\n\"#,\n            sshx = Green.bold().paint(\"sshx\"),\n            version = Green.paint(&version_str),\n            arr = Green.paint(\"➜\"),\n            link_v = Cyan.underline().paint(controller.url()),\n            link_e = Cyan.underline().paint(write_url),\n            shell_v = Fixed(8).paint(shell),\n        );\n    } else {\n        println!(\n            r#\"\n  {sshx} {version}\n\n  {arr}  Link:  {link_v}\n  {arr}  Shell: {shell_v}\n\"#,\n            sshx = Green.bold().paint(\"sshx\"),\n            version = Green.paint(&version_str),\n            arr = Green.paint(\"➜\"),\n            link_v = Cyan.underline().paint(controller.url()),\n            shell_v = Fixed(8).paint(shell),\n        );\n    }\n}\n\n#[tokio::main]\nasync fn start(args: Args) -> Result<()> {\n    let shell = match args.shell {\n        Some(shell) => shell,\n        None => get_default_shell().await,\n    };\n\n    let name = args.name.unwrap_or_else(|| {\n        let mut name = whoami::username();\n        if let Ok(host) = whoami::fallible::hostname() {\n            // Trim domain information like .lan or .local\n            let host = host.split('.').next().unwrap_or(&host);\n            name += \"@\";\n            name += host;\n        }\n        name\n    });\n\n    let runner = Runner::Shell(shell.clone());\n    let mut controller = Controller::new(&args.server, &name, runner, args.enable_readers).await?;\n    if args.quiet {\n        if let Some(write_url) = controller.write_url() {\n            println!(\"{}\", write_url);\n        } else {\n            println!(\"{}\", controller.url());\n        }\n    } else {\n        print_greeting(&shell, &controller);\n    }\n\n    let exit_signal = signal::ctrl_c();\n    tokio::pin!(exit_signal);\n    tokio::select! {\n        _ = controller.run() => unreachable!(),\n        Ok(()) = &mut exit_signal => (),\n    };\n    controller.close().await?;\n\n    Ok(())\n}\n\nfn main() -> ExitCode {\n    let args = Args::parse();\n\n    let default_level = if args.quiet { \"error\" } else { \"info\" };\n\n    tracing_subscriber::fmt()\n        .with_env_filter(std::env::var(\"RUST_LOG\").unwrap_or(default_level.into()))\n        .with_writer(std::io::stderr)\n        .init();\n\n    match start(args) {\n        Ok(()) => ExitCode::SUCCESS,\n        Err(err) => {\n            error!(\"{err:?}\");\n            ExitCode::FAILURE\n        }\n    }\n}\n"
  },
  {
    "path": "crates/sshx/src/runner.rs",
    "content": "//! Defines tasks that control the behavior of a single shell in the client.\n\nuse anyhow::Result;\nuse encoding_rs::{CoderResult, UTF_8};\nuse sshx_core::proto::{client_update::ClientMessage, TerminalData};\nuse sshx_core::Sid;\nuse tokio::{\n    io::{AsyncReadExt, AsyncWriteExt},\n    sync::mpsc,\n};\n\nuse crate::encrypt::Encrypt;\nuse crate::terminal::Terminal;\n\nconst CONTENT_CHUNK_SIZE: usize = 1 << 16; // Send at most this many bytes at a time.\nconst CONTENT_ROLLING_BYTES: usize = 8 << 20; // Store at least this much content.\nconst CONTENT_PRUNE_BYTES: usize = 12 << 20; // Prune when we exceed this length.\n\n/// Variants of terminal behavior that are used by the controller.\n#[derive(Debug, Clone)]\npub enum Runner {\n    /// Spawns the specified shell as a subprocess, forwarding PTYs.\n    Shell(String),\n\n    /// Mock runner that only echos its input, useful for testing.\n    Echo,\n}\n\n/// Internal message routed to shell runners.\npub enum ShellData {\n    /// Sequence of input bytes from the server.\n    Data(Vec<u8>),\n    /// Information about the server's current sequence number.\n    Sync(u64),\n    /// Resize the shell to a different number of rows and columns.\n    Size(u32, u32),\n}\n\nimpl Runner {\n    /// Asynchronous task to run a single shell with process I/O.\n    pub async fn run(\n        &self,\n        id: Sid,\n        encrypt: Encrypt,\n        shell_rx: mpsc::Receiver<ShellData>,\n        output_tx: mpsc::Sender<ClientMessage>,\n    ) -> Result<()> {\n        match self {\n            Self::Shell(shell) => shell_task(id, encrypt, shell, shell_rx, output_tx).await,\n            Self::Echo => echo_task(id, encrypt, shell_rx, output_tx).await,\n        }\n    }\n}\n\n/// Asynchronous task handling a single shell within the session.\nasync fn shell_task(\n    id: Sid,\n    encrypt: Encrypt,\n    shell: &str,\n    mut shell_rx: mpsc::Receiver<ShellData>,\n    output_tx: mpsc::Sender<ClientMessage>,\n) -> Result<()> {\n    let mut term = Terminal::new(shell).await?;\n    term.set_winsize(24, 80)?;\n\n    let mut content = String::new(); // content from the terminal\n    let mut content_offset = 0; // bytes before the first character of `content`\n    let mut decoder = UTF_8.new_decoder(); // UTF-8 streaming decoder\n    let mut seq = 0; // our log of the server's sequence number\n    let mut seq_outdated = 0; // number of times seq has been outdated\n    let mut buf = [0u8; 4096]; // buffer for reading\n    let mut finished = false; // set when this is done\n\n    while !finished {\n        tokio::select! {\n            result = term.read(&mut buf) => {\n                let n = result?;\n                if n == 0 {\n                    finished = true;\n                } else {\n                    content.reserve(decoder.max_utf8_buffer_length(n).unwrap());\n                    let (result, _, _) = decoder.decode_to_string(&buf[..n], &mut content, false);\n                    debug_assert!(result == CoderResult::InputEmpty);\n                }\n            }\n            item = shell_rx.recv() => {\n                match item {\n                    Some(ShellData::Data(data)) => {\n                        term.write_all(&data).await?;\n                    }\n                    Some(ShellData::Sync(seq2)) => {\n                        if seq2 < seq as u64 {\n                            seq_outdated += 1;\n                            if seq_outdated >= 3 {\n                                seq = seq2 as usize;\n                            }\n                        }\n                    }\n                    Some(ShellData::Size(rows, cols)) => {\n                        term.set_winsize(rows as u16, cols as u16)?;\n                    }\n                    None => finished = true, // Server closed this shell.\n                }\n            }\n        }\n\n        if finished {\n            content.reserve(decoder.max_utf8_buffer_length(0).unwrap());\n            let (result, _, _) = decoder.decode_to_string(&[], &mut content, true);\n            debug_assert!(result == CoderResult::InputEmpty);\n        }\n\n        // Send data if the server has fallen behind.\n        if content_offset + content.len() > seq {\n            let start = prev_char_boundary(&content, seq - content_offset);\n            let end = prev_char_boundary(&content, (start + CONTENT_CHUNK_SIZE).min(content.len()));\n            let data = encrypt.segment(\n                0x100000000 | id.0 as u64, // stream number\n                (content_offset + start) as u64,\n                &content.as_bytes()[start..end],\n            );\n            let data = TerminalData {\n                id: id.0,\n                data: data.into(),\n                seq: (content_offset + start) as u64,\n            };\n            output_tx.send(ClientMessage::Data(data)).await?;\n            seq = content_offset + end;\n            seq_outdated = 0;\n        }\n\n        if content.len() > CONTENT_PRUNE_BYTES && seq - CONTENT_ROLLING_BYTES > content_offset {\n            let pruned = (seq - CONTENT_ROLLING_BYTES) - content_offset;\n            let pruned = prev_char_boundary(&content, pruned);\n            content_offset += pruned;\n            content.drain(..pruned);\n        }\n    }\n    Ok(())\n}\n\n/// Find the last char boundary before an index in O(1) time.\nfn prev_char_boundary(s: &str, i: usize) -> usize {\n    (0..=i)\n        .rev()\n        .find(|&j| s.is_char_boundary(j))\n        .expect(\"no previous char boundary\")\n}\n\nasync fn echo_task(\n    id: Sid,\n    encrypt: Encrypt,\n    mut shell_rx: mpsc::Receiver<ShellData>,\n    output_tx: mpsc::Sender<ClientMessage>,\n) -> Result<()> {\n    let mut seq = 0;\n    while let Some(item) = shell_rx.recv().await {\n        match item {\n            ShellData::Data(data) => {\n                let msg = String::from_utf8_lossy(&data);\n                let term_data = TerminalData {\n                    id: id.0,\n                    data: encrypt\n                        .segment(0x100000000 | id.0 as u64, seq, msg.as_bytes())\n                        .into(),\n                    seq,\n                };\n                output_tx.send(ClientMessage::Data(term_data)).await?;\n                seq += msg.len() as u64;\n            }\n            ShellData::Sync(_) => (),\n            ShellData::Size(_, _) => (),\n        }\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "crates/sshx/src/terminal/unix.rs",
    "content": "use std::convert::Infallible;\nuse std::env;\nuse std::ffi::{CStr, CString};\nuse std::os::fd::{AsRawFd, RawFd};\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\n\nuse anyhow::Result;\nuse close_fds::CloseFdsBuilder;\nuse nix::errno::Errno;\nuse nix::libc::{login_tty, TIOCGWINSZ, TIOCSWINSZ};\nuse nix::pty::{self, Winsize};\nuse nix::sys::signal::{kill, Signal::SIGKILL};\nuse nix::sys::wait::waitpid;\nuse nix::unistd::{execvp, fork, ForkResult, Pid};\nuse pin_project::{pin_project, pinned_drop};\nuse tokio::fs::{self, File};\nuse tokio::io::{self, AsyncRead, AsyncWrite};\nuse tracing::{instrument, trace};\n\n/// Returns the default shell on this system.\npub async fn get_default_shell() -> String {\n    if let Ok(shell) = env::var(\"SHELL\") {\n        if !shell.is_empty() {\n            return shell;\n        }\n    }\n    for shell in [\n        \"/bin/bash\",\n        \"/bin/sh\",\n        \"/usr/local/bin/bash\",\n        \"/usr/local/bin/sh\",\n    ] {\n        if fs::metadata(shell).await.is_ok() {\n            return shell.to_string();\n        }\n    }\n    String::from(\"sh\")\n}\n\n/// An object that stores the state for a terminal session.\n#[pin_project(PinnedDrop)]\npub struct Terminal {\n    child: Pid,\n    #[pin]\n    master_read: File,\n    #[pin]\n    master_write: File,\n}\n\nimpl Terminal {\n    /// Create a new terminal, with attached PTY.\n    #[instrument]\n    pub async fn new(shell: &str) -> Result<Terminal> {\n        let result = pty::openpty(None, None)?;\n\n        // The slave file descriptor was created by openpty() and is forked here.\n        let child = Self::fork_child(shell, result.slave.as_raw_fd())?;\n\n        // We need to clone the file object to prevent livelocks in Tokio, when multiple\n        // reads and writes happen concurrently on the same file descriptor. This is a\n        // current limitation of how the `tokio::fs::File` struct is implemented, due to\n        // its blocking I/O on a separate thread.\n        let master_read = File::from(std::fs::File::from(result.master));\n        let master_write = master_read.try_clone().await?;\n\n        trace!(%child, \"creating new terminal\");\n\n        Ok(Self {\n            child,\n            master_read,\n            master_write,\n        })\n    }\n\n    /// Entry point for the child process, which spawns a shell.\n    fn fork_child(shell: &str, slave_port: RawFd) -> Result<Pid> {\n        let shell = CString::new(shell.to_owned())?;\n\n        // Safety: This does not use any async-signal-unsafe operations in the child\n        // branch, such as memory allocation.\n        match unsafe { fork() }? {\n            ForkResult::Parent { child } => Ok(child),\n            ForkResult::Child => match Self::execv_child(&shell, slave_port) {\n                Ok(infallible) => match infallible {},\n                Err(_) => std::process::exit(1),\n            },\n        }\n    }\n\n    fn execv_child(shell: &CStr, slave_port: RawFd) -> Result<Infallible, Errno> {\n        // Safety: The slave file descriptor was created by openpty().\n        Errno::result(unsafe { login_tty(slave_port) })?;\n        // Safety: This is called immediately before an execv(), and there are no other\n        // threads in this process to interact with its file descriptor table.\n        unsafe { CloseFdsBuilder::new().closefrom(3) };\n\n        // Set terminal environment variables appropriately.\n        env::set_var(\"TERM\", \"xterm-256color\");\n        env::set_var(\"COLORTERM\", \"truecolor\");\n        env::set_var(\"TERM_PROGRAM\", \"sshx\");\n        env::remove_var(\"TERM_PROGRAM_VERSION\");\n\n        // Start the process.\n        execvp(shell, &[shell])\n    }\n\n    /// Get the window size of the TTY.\n    pub fn get_winsize(&self) -> Result<(u16, u16)> {\n        nix::ioctl_read_bad!(ioctl_get_winsize, TIOCGWINSZ, Winsize);\n        let mut winsize = make_winsize(0, 0);\n        // Safety: The master file descriptor was created by openpty().\n        unsafe { ioctl_get_winsize(self.master_read.as_raw_fd(), &mut winsize) }?;\n        Ok((winsize.ws_row, winsize.ws_col))\n    }\n\n    /// Set the window size of the TTY.\n    pub fn set_winsize(&mut self, rows: u16, cols: u16) -> Result<()> {\n        nix::ioctl_write_ptr_bad!(ioctl_set_winsize, TIOCSWINSZ, Winsize);\n        let winsize = make_winsize(rows, cols);\n        // Safety: The master file descriptor was created by openpty().\n        unsafe { ioctl_set_winsize(self.master_read.as_raw_fd(), &winsize) }?;\n        Ok(())\n    }\n}\n\n// Redirect terminal reads to the read file object.\nimpl AsyncRead for Terminal {\n    fn poll_read(\n        self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n        buf: &mut io::ReadBuf<'_>,\n    ) -> Poll<io::Result<()>> {\n        self.project().master_read.poll_read(cx, buf)\n    }\n}\n\n// Redirect terminal writes to the write file object.\nimpl AsyncWrite for Terminal {\n    fn poll_write(\n        self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n        buf: &[u8],\n    ) -> Poll<io::Result<usize>> {\n        self.project().master_write.poll_write(cx, buf)\n    }\n\n    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {\n        self.project().master_write.poll_flush(cx)\n    }\n\n    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {\n        self.project().master_write.poll_shutdown(cx)\n    }\n}\n\n#[pinned_drop]\nimpl PinnedDrop for Terminal {\n    fn drop(self: Pin<&mut Self>) {\n        let this = self.project();\n        let child = *this.child;\n        trace!(%child, \"dropping terminal\");\n\n        // Kill the child process on closure so that it doesn't keep running.\n        kill(child, SIGKILL).ok();\n\n        // Reap the zombie process in a background thread.\n        std::thread::spawn(move || {\n            waitpid(child, None).ok();\n        });\n    }\n}\n\nfn make_winsize(rows: u16, cols: u16) -> Winsize {\n    Winsize {\n        ws_row: rows,\n        ws_col: cols,\n        ws_xpixel: 0, // ignored\n        ws_ypixel: 0, // ignored\n    }\n}\n"
  },
  {
    "path": "crates/sshx/src/terminal/windows.rs",
    "content": "use std::pin::Pin;\nuse std::process::Command;\nuse std::task::Context;\nuse std::task::Poll;\n\nuse anyhow::Result;\nuse pin_project::{pin_project, pinned_drop};\nuse tokio::fs::{self, File};\nuse tokio::io::{self, AsyncRead, AsyncWrite};\nuse tracing::instrument;\n\n/// Returns the default shell on this system.\n///\n/// For Windows, this is implemented currently to just look for shells at a\n/// couple locations. If it fails, it returns `cmd.exe`.\n///\n/// Note: I can't get `powershell.exe` to work with ConPTY, since it returns\n/// error 8009001d. There's some magic environment variables that need to be set\n/// for Powershell to launch. This is why I don't typically use Windows!\npub async fn get_default_shell() -> String {\n    for shell in [\n        \"C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe\",\n        \"C:\\\\Windows\\\\System32\\\\cmd.exe\",\n    ] {\n        if fs::metadata(shell).await.is_ok() {\n            return shell.to_string();\n        }\n    }\n    String::from(\"cmd.exe\")\n}\n\n/// An object that stores the state for a terminal session.\n#[pin_project(PinnedDrop)]\npub struct Terminal {\n    child: conpty::Process,\n    #[pin]\n    reader: File,\n    #[pin]\n    writer: File,\n    winsize: (u16, u16),\n}\n\nimpl Terminal {\n    /// Create a new terminal, with attached PTY.\n    #[instrument]\n    pub async fn new(shell: &str) -> Result<Terminal> {\n        let mut command = Command::new(shell);\n\n        // Set terminal environment variables appropriately.\n        command.env(\"TERM\", \"xterm-256color\");\n        command.env(\"COLORTERM\", \"truecolor\");\n        command.env(\"TERM_PROGRAM\", \"sshx\");\n        command.env_remove(\"TERM_PROGRAM_VERSION\");\n\n        let mut child =\n            tokio::task::spawn_blocking(move || conpty::Process::spawn(command)).await??;\n        let reader = File::from_std(child.output()?.into());\n        let writer = File::from_std(child.input()?.into());\n\n        Ok(Self {\n            child,\n            reader,\n            writer,\n            winsize: (0, 0),\n        })\n    }\n\n    /// Get the window size of the TTY.\n    pub fn get_winsize(&self) -> Result<(u16, u16)> {\n        Ok(self.winsize)\n    }\n\n    /// Set the window size of the TTY.\n    pub fn set_winsize(&mut self, rows: u16, cols: u16) -> Result<()> {\n        let rows_i16 = rows.min(i16::MAX as u16) as i16;\n        let cols_i16 = cols.min(i16::MAX as u16) as i16;\n        self.child.resize(cols_i16, rows_i16)?; // Note argument order\n        self.winsize = (rows, cols);\n        Ok(())\n    }\n}\n\n// Redirect terminal reads to the read file object.\nimpl AsyncRead for Terminal {\n    fn poll_read(\n        self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n        buf: &mut io::ReadBuf<'_>,\n    ) -> Poll<io::Result<()>> {\n        self.project().reader.poll_read(cx, buf)\n    }\n}\n\n// Redirect terminal writes to the write file object.\nimpl AsyncWrite for Terminal {\n    fn poll_write(\n        self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n        buf: &[u8],\n    ) -> Poll<io::Result<usize>> {\n        self.project().writer.poll_write(cx, buf)\n    }\n\n    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {\n        self.project().writer.poll_flush(cx)\n    }\n\n    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {\n        self.project().writer.poll_shutdown(cx)\n    }\n}\n\n#[pinned_drop]\nimpl PinnedDrop for Terminal {\n    fn drop(self: Pin<&mut Self>) {\n        let this = self.project();\n        this.child.exit(0).ok();\n    }\n}\n"
  },
  {
    "path": "crates/sshx/src/terminal.rs",
    "content": "//! Terminal driver, which communicates with a shell subprocess through PTY.\n\n#![allow(unsafe_code)]\n\ncfg_if::cfg_if! {\n    if #[cfg(unix)] {\n        mod unix;\n        pub use unix::{get_default_shell, Terminal};\n    } else if #[cfg(windows)] {\n        mod windows;\n        pub use windows::{get_default_shell, Terminal};\n    } else {\n        compile_error!(\"unsupported platform for terminal driver\");\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use anyhow::Result;\n\n    use super::Terminal;\n\n    #[tokio::test]\n    async fn winsize() -> Result<()> {\n        let shell = if cfg!(unix) { \"/bin/sh\" } else { \"cmd.exe\" };\n        let mut terminal = Terminal::new(shell).await?;\n        assert_eq!(terminal.get_winsize()?, (0, 0));\n        terminal.set_winsize(120, 72)?;\n        assert_eq!(terminal.get_winsize()?, (120, 72));\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/sshx-core/Cargo.toml",
    "content": "[package]\nname = \"sshx-core\"\nversion.workspace = true\nauthors.workspace = true\nlicense.workspace = true\ndescription.workspace = true\nrepository.workspace = true\ndocumentation.workspace = true\nkeywords.workspace = true\nedition = \"2021\"\n\n[dependencies]\nprost.workspace = true\nrand.workspace = true\nserde.workspace = true\ntonic.workspace = true\n\n[build-dependencies]\ntonic-build.workspace = true\n"
  },
  {
    "path": "crates/sshx-core/build.rs",
    "content": "use std::{env, path::PathBuf};\n\nfn main() -> Result<(), Box<dyn std::error::Error>> {\n    let descriptor_path = PathBuf::from(env::var(\"OUT_DIR\").unwrap()).join(\"sshx.bin\");\n    tonic_build::configure()\n        .file_descriptor_set_path(descriptor_path)\n        .bytes([\".\"])\n        .compile_protos(&[\"proto/sshx.proto\"], &[\"proto/\"])?;\n    Ok(())\n}\n"
  },
  {
    "path": "crates/sshx-core/proto/sshx.proto",
    "content": "// This file contains the service definition for sshx, used by the client to\n// communicate their terminal state over gRPC.\n\nsyntax = \"proto3\";\npackage sshx;\n\nservice SshxService {\n  // Create a new SSH session for a given computer.\n  rpc Open(OpenRequest) returns (OpenResponse);\n\n  // Stream real-time commands and terminal outputs to the session.\n  rpc Channel(stream ClientUpdate) returns (stream ServerUpdate);\n\n  // Gracefully shut down an existing SSH session.\n  rpc Close(CloseRequest) returns (CloseResponse);\n}\n\n// Details of bytes exchanged with the terminal.\nmessage TerminalData {\n  uint32 id = 1;  // ID of the shell.\n  bytes data = 2; // Encrypted, UTF-8 terminal data.\n  uint64 seq = 3; // Sequence number of the first byte.\n}\n\n// Details of bytes input to the terminal (not necessarily valid UTF-8).\nmessage TerminalInput {\n  uint32 id = 1;     // ID of the shell.\n  bytes data = 2;    // Encrypted binary sequence of terminal data.\n  uint64 offset = 3; // Offset of the first byte for encryption.\n}\n\n// Pair of a terminal ID and its associated size.\nmessage TerminalSize {\n  uint32 id = 1;   // ID of the shell.\n  uint32 rows = 2; // Number of rows for the terminal.\n  uint32 cols = 3; // Number of columns for the terminal.\n}\n\n// Request to open an sshx session.\nmessage OpenRequest {\n  string origin = 1;                      // Web origin of the server.\n  bytes encrypted_zeros = 2;              // Encrypted zero block, for client verification.\n  string name = 3;                        // Name of the session (user@hostname).\n  optional bytes write_password_hash = 4; // Hashed write password, if read-only mode is enabled.\n}\n\n// Details of a newly-created sshx session.\nmessage OpenResponse {\n  string name = 1;  // Name of the session.\n  string token = 2; // Signed verification token for the client.\n  string url = 3;   // Public web URL to view the session.\n}\n\n// Sequence numbers for all active shells, used for synchronization.\nmessage SequenceNumbers {\n  map<uint32, uint64> map = 1; // Active shells and their sequence numbers.\n}\n\n// Data for a new shell.\nmessage NewShell {\n  uint32 id = 1; // ID of the shell.\n  int32 x = 2;   // X position of the shell.\n  int32 y = 3;   // Y position of the shell.\n}\n\n// Bidirectional streaming update from the client.\nmessage ClientUpdate {\n  oneof client_message {\n    string hello = 1;           // First stream message: \"name,token\".\n    TerminalData data = 2;      // Stream data from the terminal.\n    NewShell created_shell = 3; // Acknowledge that a new shell was created.\n    uint32 closed_shell = 4;    // Acknowledge that a shell was closed.\n    fixed64 pong = 14;          // Response for latency measurement.\n    string error = 15;\n  }\n}\n\n// Bidirectional streaming update from the server.\nmessage ServerUpdate {\n  oneof server_message {\n    TerminalInput input = 1;   // Remote input bytes, received from the user.\n    NewShell create_shell = 2; // ID of a new shell.\n    uint32 close_shell = 3;    // ID of a shell to close.\n    SequenceNumbers sync = 4;  // Periodic sequence number sync.\n    TerminalSize resize = 5;   // Resize a terminal window.\n    fixed64 ping = 14;         // Request a pong, with the timestamp.\n    string error = 15;\n  }\n}\n\n// Request to stop a sshx session gracefully.\nmessage CloseRequest {\n  string name = 1;  // Name of the session to terminate.\n  string token = 2; // Session verification token.\n}\n\n// Server response to closing a session.\nmessage CloseResponse {}\n\n// Snapshot of a session, used to restore state for persistence across servers.\nmessage SerializedSession {\n  bytes encrypted_zeros = 1;\n  map<uint32, SerializedShell> shells = 2;\n  uint32 next_sid = 3;\n  uint32 next_uid = 4;\n  string name = 5;\n  optional bytes write_password_hash = 6;\n}\n\nmessage SerializedShell {\n  uint64 seqnum = 1;\n  repeated bytes data = 2;\n  uint64 chunk_offset = 3;\n  uint64 byte_offset = 4;\n  bool closed = 5;\n  int32 winsize_x = 6;\n  int32 winsize_y = 7;\n  uint32 winsize_rows = 8;\n  uint32 winsize_cols = 9;\n}\n"
  },
  {
    "path": "crates/sshx-core/src/lib.rs",
    "content": "//! The core crate for shared code used in the sshx application.\n\n#![forbid(unsafe_code)]\n#![warn(missing_docs)]\n\nuse std::fmt::Display;\nuse std::sync::atomic::{AtomicU32, Ordering};\n\nuse serde::{Deserialize, Serialize};\n\n/// Protocol buffer and gRPC definitions, automatically generated by Tonic.\n#[allow(missing_docs, non_snake_case)]\n#[allow(clippy::derive_partial_eq_without_eq)]\npub mod proto {\n    tonic::include_proto!(\"sshx\");\n\n    /// File descriptor set used for gRPC reflection.\n    pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!(\"sshx\");\n}\n\n/// Generate a cryptographically-secure, random alphanumeric value.\npub fn rand_alphanumeric(len: usize) -> String {\n    use rand::{distributions::Alphanumeric, thread_rng, Rng};\n    thread_rng()\n        .sample_iter(Alphanumeric)\n        .take(len)\n        .map(char::from)\n        .collect()\n}\n\n/// Unique identifier for a shell within the session.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct Sid(pub u32);\n\nimpl Display for Sid {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.0)\n    }\n}\n\n/// Unique identifier for a user within the session.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct Uid(pub u32);\n\nimpl Display for Uid {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.0)\n    }\n}\n\n/// A counter for generating unique identifiers.\n#[derive(Debug)]\npub struct IdCounter {\n    next_sid: AtomicU32,\n    next_uid: AtomicU32,\n}\n\nimpl Default for IdCounter {\n    fn default() -> Self {\n        Self {\n            next_sid: AtomicU32::new(1),\n            next_uid: AtomicU32::new(1),\n        }\n    }\n}\n\nimpl IdCounter {\n    /// Returns the next unique shell ID.\n    pub fn next_sid(&self) -> Sid {\n        Sid(self.next_sid.fetch_add(1, Ordering::Relaxed))\n    }\n\n    /// Returns the next unique user ID.\n    pub fn next_uid(&self) -> Uid {\n        Uid(self.next_uid.fetch_add(1, Ordering::Relaxed))\n    }\n\n    /// Return the current internal values of the counter.\n    pub fn get_current_values(&self) -> (Sid, Uid) {\n        (\n            Sid(self.next_sid.load(Ordering::Relaxed)),\n            Uid(self.next_uid.load(Ordering::Relaxed)),\n        )\n    }\n\n    /// Set the internal values of the counter.\n    pub fn set_current_values(&self, sid: Sid, uid: Uid) {\n        self.next_sid.store(sid.0, Ordering::Relaxed);\n        self.next_uid.store(uid.0, Ordering::Relaxed);\n    }\n}\n"
  },
  {
    "path": "crates/sshx-server/Cargo.toml",
    "content": "[package]\nname = \"sshx-server\"\nversion.workspace = true\nauthors.workspace = true\nlicense.workspace = true\ndescription.workspace = true\nrepository.workspace = true\ndocumentation.workspace = true\nkeywords.workspace = true\nedition = \"2021\"\n\n[dependencies]\nanyhow.workspace = true\nasync-channel = \"1.9.0\"\nasync-stream = \"0.3.5\"\naxum = { version = \"0.8.1\", features = [\"http2\", \"ws\"] }\nbase64 = \"0.21.4\"\nbytes = { version = \"1.5.0\", features = [\"serde\"] }\nciborium = \"0.2.1\"\nclap.workspace = true\ndashmap = \"5.5.3\"\ndeadpool = \"0.12.2\"\ndeadpool-redis = \"0.18.0\"\nfutures-util = { version = \"0.3.28\", features = [\"sink\"] }\nhmac = \"0.12.1\"\nhttp = \"1.2.0\"\nparking_lot = \"0.12.1\"\nprost.workspace = true\nrand.workspace = true\nredis = { version = \"0.27.6\", features = [\"tokio-rustls-comp\", \"tls-rustls-webpki-roots\"] }\nserde.workspace = true\nsha2 = \"0.10.7\"\nsshx-core.workspace = true\nsubtle = \"2.5.0\"\ntokio.workspace = true\ntokio-stream.workspace = true\ntokio-tungstenite = \"0.26.1\"\ntonic.workspace = true\ntonic-reflection.workspace = true\ntower = { version = \"0.4.13\", features = [\"steer\"] }\ntower-http = { version = \"0.6.2\", features = [\"fs\", \"redirect\", \"trace\"] }\ntracing.workspace = true\ntracing-subscriber.workspace = true\nzstd = \"0.12.4\"\n\n[dev-dependencies]\nreqwest = { version = \"0.12.12\", default-features = false, features = [\"rustls-tls\"] }\nsshx = { path = \"../sshx\" }\n"
  },
  {
    "path": "crates/sshx-server/src/grpc.rs",
    "content": "//! Defines gRPC routes and application request logic.\n\nuse std::sync::Arc;\nuse std::time::{Duration, SystemTime};\n\nuse base64::prelude::{Engine as _, BASE64_STANDARD};\nuse hmac::Mac;\nuse sshx_core::proto::{\n    client_update::ClientMessage, server_update::ServerMessage, sshx_service_server::SshxService,\n    ClientUpdate, CloseRequest, CloseResponse, OpenRequest, OpenResponse, ServerUpdate,\n};\nuse sshx_core::{rand_alphanumeric, Sid};\nuse tokio::sync::mpsc;\nuse tokio::time::{self, MissedTickBehavior};\nuse tokio_stream::{wrappers::ReceiverStream, StreamExt};\nuse tonic::{Request, Response, Status, Streaming};\nuse tracing::{error, info, warn};\n\nuse crate::session::{Metadata, Session};\nuse crate::ServerState;\n\n/// Interval for synchronizing sequence numbers with the client.\npub const SYNC_INTERVAL: Duration = Duration::from_secs(5);\n\n/// Interval for measuring client latency.\npub const PING_INTERVAL: Duration = Duration::from_secs(2);\n\n/// Server that handles gRPC requests from the sshx command-line client.\n#[derive(Clone)]\npub struct GrpcServer(Arc<ServerState>);\n\nimpl GrpcServer {\n    /// Construct a new [`GrpcServer`] instance with associated state.\n    pub fn new(state: Arc<ServerState>) -> Self {\n        Self(state)\n    }\n}\n\ntype RR<T> = Result<Response<T>, Status>;\n\n#[tonic::async_trait]\nimpl SshxService for GrpcServer {\n    type ChannelStream = ReceiverStream<Result<ServerUpdate, Status>>;\n\n    async fn open(&self, request: Request<OpenRequest>) -> RR<OpenResponse> {\n        let request = request.into_inner();\n        let origin = self.0.override_origin().unwrap_or(request.origin);\n        if origin.is_empty() {\n            return Err(Status::invalid_argument(\"origin is empty\"));\n        }\n        let name = rand_alphanumeric(10);\n        info!(%name, \"creating new session\");\n\n        match self.0.lookup(&name) {\n            Some(_) => return Err(Status::already_exists(\"generated duplicate ID\")),\n            None => {\n                let metadata = Metadata {\n                    encrypted_zeros: request.encrypted_zeros,\n                    name: request.name,\n                    write_password_hash: request.write_password_hash,\n                };\n                self.0.insert(&name, Arc::new(Session::new(metadata)));\n            }\n        };\n        let token = self.0.mac().chain_update(&name).finalize();\n        let url = format!(\"{origin}/s/{name}\");\n        Ok(Response::new(OpenResponse {\n            name,\n            token: BASE64_STANDARD.encode(token.into_bytes()),\n            url,\n        }))\n    }\n\n    async fn channel(&self, request: Request<Streaming<ClientUpdate>>) -> RR<Self::ChannelStream> {\n        let mut stream = request.into_inner();\n        let first_update = match stream.next().await {\n            Some(result) => result?,\n            None => return Err(Status::invalid_argument(\"missing first message\")),\n        };\n        let session_name = match first_update.client_message {\n            Some(ClientMessage::Hello(hello)) => {\n                let (name, token) = hello\n                    .split_once(',')\n                    .ok_or_else(|| Status::invalid_argument(\"missing name and token\"))?;\n                validate_token(self.0.mac(), name, token)?;\n                name.to_string()\n            }\n            _ => return Err(Status::invalid_argument(\"invalid first message\")),\n        };\n        let session = match self.0.backend_connect(&session_name).await {\n            Ok(Some(session)) => session,\n            Ok(None) => return Err(Status::not_found(\"session not found\")),\n            Err(err) => {\n                error!(?err, \"failed to connect to backend session\");\n                return Err(Status::internal(err.to_string()));\n            }\n        };\n\n        // We now spawn an asynchronous task that sends updates to the client. Note that\n        // when this task finishes, the sender end is dropped, so the receiver is\n        // automatically closed.\n        let (tx, rx) = mpsc::channel(16);\n        tokio::spawn(async move {\n            if let Err(err) = handle_streaming(&tx, &session, stream).await {\n                warn!(?err, \"connection exiting early due to an error\");\n            }\n        });\n\n        Ok(Response::new(ReceiverStream::new(rx)))\n    }\n\n    async fn close(&self, request: Request<CloseRequest>) -> RR<CloseResponse> {\n        let request = request.into_inner();\n        validate_token(self.0.mac(), &request.name, &request.token)?;\n        info!(\"closing session {}\", request.name);\n        if let Err(err) = self.0.close_session(&request.name).await {\n            error!(?err, \"failed to close session {}\", request.name);\n            return Err(Status::internal(err.to_string()));\n        }\n        Ok(Response::new(CloseResponse {}))\n    }\n}\n\n/// Validate the client token for a session.\n#[allow(clippy::result_large_err)]\nfn validate_token(mac: impl Mac, name: &str, token: &str) -> tonic::Result<()> {\n    if let Ok(token) = BASE64_STANDARD.decode(token) {\n        if mac.chain_update(name).verify_slice(&token).is_ok() {\n            return Ok(());\n        }\n    }\n    Err(Status::unauthenticated(\"invalid token\"))\n}\n\ntype ServerTx = mpsc::Sender<Result<ServerUpdate, Status>>;\n\n/// Handle bidirectional streaming messages RPC messages.\nasync fn handle_streaming(\n    tx: &ServerTx,\n    session: &Session,\n    mut stream: Streaming<ClientUpdate>,\n) -> Result<(), &'static str> {\n    let mut sync_interval = time::interval(SYNC_INTERVAL);\n    sync_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);\n\n    let mut ping_interval = time::interval(PING_INTERVAL);\n    ping_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);\n\n    loop {\n        tokio::select! {\n            // Send periodic sync messages to the client.\n            _ = sync_interval.tick() => {\n                let msg = ServerMessage::Sync(session.sequence_numbers());\n                if !send_msg(tx, msg).await {\n                    return Err(\"failed to send sync message\");\n                }\n            }\n            // Send periodic pings to the client.\n            _ = ping_interval.tick() => {\n                send_msg(tx, ServerMessage::Ping(get_time_ms())).await;\n            }\n            // Send buffered server updates to the client.\n            Ok(msg) = session.update_rx().recv() => {\n                if !send_msg(tx, msg).await {\n                    return Err(\"failed to send update message\");\n                }\n            }\n            // Handle incoming client messages.\n            maybe_update = stream.next() => {\n                if let Some(Ok(update)) = maybe_update {\n                    if !handle_update(tx, session, update).await {\n                        return Err(\"error responding to client update\");\n                    }\n                } else {\n                    // The client has hung up on their end.\n                    return Ok(());\n                }\n            }\n            // Exit on a session shutdown signal.\n            _ = session.terminated() => {\n                let msg = String::from(\"disconnecting because session is closed\");\n                send_msg(tx, ServerMessage::Error(msg)).await;\n                return Ok(());\n            }\n        };\n    }\n}\n\n/// Handles a singe update from the client. Returns `true` on success.\nasync fn handle_update(tx: &ServerTx, session: &Session, update: ClientUpdate) -> bool {\n    session.access();\n    match update.client_message {\n        Some(ClientMessage::Hello(_)) => {\n            return send_err(tx, \"unexpected hello\".into()).await;\n        }\n        Some(ClientMessage::Data(data)) => {\n            if let Err(err) = session.add_data(Sid(data.id), data.data, data.seq) {\n                return send_err(tx, format!(\"add data: {:?}\", err)).await;\n            }\n        }\n        Some(ClientMessage::CreatedShell(new_shell)) => {\n            let id = Sid(new_shell.id);\n            let center = (new_shell.x, new_shell.y);\n            if let Err(err) = session.add_shell(id, center) {\n                return send_err(tx, format!(\"add shell: {:?}\", err)).await;\n            }\n        }\n        Some(ClientMessage::ClosedShell(id)) => {\n            if let Err(err) = session.close_shell(Sid(id)) {\n                return send_err(tx, format!(\"close shell: {:?}\", err)).await;\n            }\n        }\n        Some(ClientMessage::Pong(ts)) => {\n            let latency = get_time_ms().saturating_sub(ts);\n            session.send_latency_measurement(latency);\n        }\n        Some(ClientMessage::Error(err)) => {\n            // TODO: Propagate these errors to listeners on the web interface?\n            error!(?err, \"error received from client\");\n        }\n        None => (), // Heartbeat message, ignored.\n    }\n    true\n}\n\n/// Attempt to send a server message to the client.\nasync fn send_msg(tx: &ServerTx, message: ServerMessage) -> bool {\n    let update = Ok(ServerUpdate {\n        server_message: Some(message),\n    });\n    tx.send(update).await.is_ok()\n}\n\n/// Attempt to send an error string to the client.\nasync fn send_err(tx: &ServerTx, err: String) -> bool {\n    send_msg(tx, ServerMessage::Error(err)).await\n}\n\nfn get_time_ms() -> u64 {\n    SystemTime::now()\n        .duration_since(SystemTime::UNIX_EPOCH)\n        .expect(\"system time is before the UNIX epoch\")\n        .as_millis() as u64\n}\n"
  },
  {
    "path": "crates/sshx-server/src/lib.rs",
    "content": "//! The sshx server, which coordinates terminal sharing.\n//!\n//! Requests are communicated to the server via gRPC (for command-line sharing\n//! clients) and WebSocket connections (for web listeners). The server is built\n//! using a hybrid Hyper service, split between a Tonic gRPC handler and an Axum\n//! web listener.\n//!\n//! Most web requests are routed directly to static files located in the\n//! `build/` folder relative to where this binary is running, allowing the\n//! frontend to be separately developed from the server.\n\n#![forbid(unsafe_code)]\n#![warn(missing_docs)]\n\nuse std::{fmt::Debug, net::SocketAddr, sync::Arc};\n\nuse anyhow::Result;\nuse axum::serve::{Listener, ListenerExt};\nuse tokio::net::TcpListener;\nuse tracing::debug;\nuse utils::Shutdown;\n\nuse crate::state::ServerState;\n\npub mod grpc;\nmod listen;\npub mod session;\npub mod state;\npub mod utils;\npub mod web;\n\n/// Options when constructing the application server.\n#[derive(Clone, Debug, Default)]\n#[non_exhaustive]\npub struct ServerOptions {\n    /// Secret used for signing tokens. Set randomly if not provided.\n    pub secret: Option<String>,\n\n    /// Override the origin returned for the Open() RPC.\n    pub override_origin: Option<String>,\n\n    /// URL of the Redis server that stores session data.\n    pub redis_url: Option<String>,\n\n    /// Hostname of this server, if running multiple servers.\n    pub host: Option<String>,\n}\n\n/// Stateful object that manages the sshx server, with graceful termination.\npub struct Server {\n    state: Arc<ServerState>,\n    shutdown: Shutdown,\n}\n\nimpl Server {\n    /// Create a new application server, but do not listen for connections yet.\n    pub fn new(options: ServerOptions) -> Result<Self> {\n        Ok(Self {\n            state: Arc::new(ServerState::new(options)?),\n            shutdown: Shutdown::new(),\n        })\n    }\n\n    /// Returns the server's state object.\n    pub fn state(&self) -> Arc<ServerState> {\n        Arc::clone(&self.state)\n    }\n\n    /// Run the application server, listening on a stream of connections.\n    pub async fn listen<L>(&self, listener: L) -> Result<()>\n    where\n        L: Listener,\n        L::Addr: Debug,\n    {\n        let state = self.state.clone();\n        let terminated = self.shutdown.wait();\n        tokio::spawn(async move {\n            let background_tasks = futures_util::future::join(\n                state.listen_for_transfers(),\n                state.close_old_sessions(),\n            );\n            tokio::select! {\n                _ = terminated => {}\n                _ = background_tasks => {}\n            }\n        });\n\n        listen::start_server(self.state(), listener, self.shutdown.wait()).await\n    }\n\n    /// Convenience function to call [`Server::listen`] bound to a TCP address.\n    ///\n    /// This also sets `TCP_NODELAY` on the incoming connections for performance\n    /// reasons, as a reasonable default.\n    pub async fn bind(&self, addr: &SocketAddr) -> Result<()> {\n        let listener = TcpListener::bind(addr).await?.tap_io(|tcp_stream| {\n            if let Err(err) = tcp_stream.set_nodelay(true) {\n                debug!(\"failed to set TCP_NODELAY on incoming connection: {err:#}\");\n            }\n        });\n        self.listen(listener).await\n    }\n\n    /// Send a graceful shutdown signal to the server.\n    pub fn shutdown(&self) {\n        // Stop receiving new network connections.\n        self.shutdown.shutdown();\n        // Terminate each of the existing sessions.\n        self.state.shutdown();\n    }\n}\n"
  },
  {
    "path": "crates/sshx-server/src/listen.rs",
    "content": "use std::{fmt::Debug, future::Future, sync::Arc};\n\nuse anyhow::Result;\nuse axum::body::Body;\nuse axum::serve::Listener;\nuse http::{header::CONTENT_TYPE, Request};\nuse sshx_core::proto::{sshx_service_server::SshxServiceServer, FILE_DESCRIPTOR_SET};\nuse tonic::service::Routes as TonicRoutes;\nuse tower::{make::Shared, steer::Steer, ServiceExt};\nuse tower_http::trace::TraceLayer;\n\nuse crate::{grpc::GrpcServer, web, ServerState};\n\n/// Bind and listen from the application, with a state and termination signal.\n///\n/// This internal method is responsible for multiplexing the HTTP and gRPC\n/// servers onto a single, consolidated `hyper` service.\npub(crate) async fn start_server<L>(\n    state: Arc<ServerState>,\n    listener: L,\n    signal: impl Future<Output = ()> + Send + 'static,\n) -> Result<()>\nwhere\n    L: Listener,\n    L::Addr: Debug,\n{\n    let http_service = web::app()\n        .with_state(state.clone())\n        .layer(TraceLayer::new_for_http())\n        .into_service()\n        .boxed_clone();\n\n    let grpc_service = TonicRoutes::default()\n        .add_service(SshxServiceServer::new(GrpcServer::new(state)))\n        .add_service(\n            tonic_reflection::server::Builder::configure()\n                .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)\n                .build_v1()?,\n        )\n        .into_axum_router()\n        .layer(TraceLayer::new_for_grpc())\n        .into_service()\n        // This type conversion is necessary because Tonic 0.12 uses Axum 0.7, so its `axum::Router`\n        // and `axum::Body` are based on an older `axum_core` version.\n        .map_response(|r| r.map(Body::new))\n        .boxed_clone();\n\n    let svc = Steer::new(\n        [http_service, grpc_service],\n        |req: &Request<Body>, _services: &[_]| {\n            let headers = req.headers();\n            match headers.get(CONTENT_TYPE) {\n                Some(content) if content == \"application/grpc\" => 1,\n                _ => 0,\n            }\n        },\n    );\n    let make_svc = Shared::new(svc);\n\n    axum::serve(listener, make_svc)\n        .with_graceful_shutdown(signal)\n        .await?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/sshx-server/src/main.rs",
    "content": "use std::{\n    net::{IpAddr, SocketAddr},\n    process::ExitCode,\n};\n\nuse anyhow::Result;\nuse clap::Parser;\nuse sshx_server::{Server, ServerOptions};\nuse tokio::signal::unix::{signal, SignalKind};\nuse tracing::{error, info};\n\n/// The sshx server CLI interface.\n#[derive(Parser, Debug)]\n#[clap(author, version, about, long_about = None)]\nstruct Args {\n    /// Specify port to listen on.\n    #[clap(long, default_value_t = 8051)]\n    port: u16,\n\n    /// Which IP address or network interface to listen on.\n    #[clap(long, value_parser, default_value = \"::1\")]\n    listen: IpAddr,\n\n    /// Secret used for signing session tokens.\n    #[clap(long, env = \"SSHX_SECRET\")]\n    secret: Option<String>,\n\n    /// Override the origin URL returned by the Open() RPC.\n    #[clap(long)]\n    override_origin: Option<String>,\n\n    /// URL of the Redis server that stores session data.\n    #[clap(long, env = \"SSHX_REDIS_URL\")]\n    redis_url: Option<String>,\n\n    /// Hostname of this server, if running multiple servers.\n    #[clap(long)]\n    host: Option<String>,\n}\n\n#[tokio::main]\nasync fn start(args: Args) -> Result<()> {\n    let addr = SocketAddr::new(args.listen, args.port);\n\n    let mut sigterm = signal(SignalKind::terminate())?;\n    let mut sigint = signal(SignalKind::interrupt())?;\n\n    let mut options = ServerOptions::default();\n    options.secret = args.secret;\n    options.override_origin = args.override_origin;\n    options.redis_url = args.redis_url;\n    options.host = args.host;\n\n    let server = Server::new(options)?;\n\n    let serve_task = async {\n        info!(\"server listening at {addr}\");\n        server.bind(&addr).await\n    };\n\n    let signals_task = async {\n        tokio::select! {\n            Some(()) = sigterm.recv() => (),\n            Some(()) = sigint.recv() => (),\n            else => return Ok(()),\n        }\n        info!(\"gracefully shutting down...\");\n        server.shutdown();\n        Ok(())\n    };\n\n    tokio::try_join!(serve_task, signals_task)?;\n    Ok(())\n}\n\nfn main() -> ExitCode {\n    let args = Args::parse();\n\n    tracing_subscriber::fmt()\n        .with_env_filter(std::env::var(\"RUST_LOG\").unwrap_or(\"info\".into()))\n        .with_writer(std::io::stderr)\n        .init();\n\n    match start(args) {\n        Ok(()) => ExitCode::SUCCESS,\n        Err(err) => {\n            error!(\"{err:?}\");\n            ExitCode::FAILURE\n        }\n    }\n}\n"
  },
  {
    "path": "crates/sshx-server/src/session/snapshot.rs",
    "content": "//! Snapshot and restore sessions from serialized state.\n\nuse std::collections::BTreeMap;\n\nuse anyhow::{ensure, Context, Result};\nuse prost::Message;\nuse sshx_core::{\n    proto::{SerializedSession, SerializedShell},\n    Sid, Uid,\n};\n\nuse super::{Metadata, Session, State};\nuse crate::web::protocol::WsWinsize;\n\n/// Persist at most this many bytes of output in storage, per shell.\nconst SHELL_SNAPSHOT_BYTES: u64 = 1 << 15; // 32 KiB\n\nconst MAX_SNAPSHOT_SIZE: usize = 1 << 22; // 4 MiB\n\nimpl Session {\n    /// Snapshot the session, returning a compressed representation.\n    pub fn snapshot(&self) -> Result<Vec<u8>> {\n        let ids = self.counter.get_current_values();\n        let winsizes: BTreeMap<Sid, WsWinsize> = self.source.borrow().iter().cloned().collect();\n        let message = SerializedSession {\n            encrypted_zeros: self.metadata().encrypted_zeros.clone(),\n            shells: self\n                .shells\n                .read()\n                .iter()\n                .map(|(sid, shell)| {\n                    // Prune off data until its total length is at most `SHELL_SNAPSHOT_BYTES`.\n                    let mut prefix = 0;\n                    let mut chunk_offset = shell.chunk_offset;\n                    let mut byte_offset = shell.byte_offset;\n\n                    for i in 0..shell.data.len() {\n                        if shell.seqnum - byte_offset > SHELL_SNAPSHOT_BYTES {\n                            prefix += 1;\n                            chunk_offset += 1;\n                            byte_offset += shell.data[i].len() as u64;\n                        } else {\n                            break;\n                        }\n                    }\n\n                    let winsize = winsizes.get(sid).cloned().unwrap_or_default();\n                    let shell = SerializedShell {\n                        seqnum: shell.seqnum,\n                        data: shell.data[prefix..].to_vec(),\n                        chunk_offset,\n                        byte_offset,\n                        closed: shell.closed,\n                        winsize_x: winsize.x,\n                        winsize_y: winsize.y,\n                        winsize_rows: winsize.rows.into(),\n                        winsize_cols: winsize.cols.into(),\n                    };\n                    (sid.0, shell)\n                })\n                .collect(),\n            next_sid: ids.0 .0,\n            next_uid: ids.1 .0,\n            name: self.metadata().name.clone(),\n            write_password_hash: self.metadata().write_password_hash.clone(),\n        };\n        let data = message.encode_to_vec();\n        ensure!(data.len() < MAX_SNAPSHOT_SIZE, \"snapshot too large\");\n        Ok(zstd::bulk::compress(&data, 3)?)\n    }\n\n    /// Restore the session from a previous compressed snapshot.\n    pub fn restore(data: &[u8]) -> Result<Self> {\n        let data = zstd::bulk::decompress(data, MAX_SNAPSHOT_SIZE)?;\n        let message = SerializedSession::decode(&*data)?;\n\n        let metadata = Metadata {\n            encrypted_zeros: message.encrypted_zeros,\n            name: message.name,\n            write_password_hash: message.write_password_hash,\n        };\n\n        let session = Self::new(metadata);\n        let mut shells = session.shells.write();\n        let mut winsizes = Vec::new();\n        for (sid, shell) in message.shells {\n            winsizes.push((\n                Sid(sid),\n                WsWinsize {\n                    x: shell.winsize_x,\n                    y: shell.winsize_y,\n                    rows: shell.winsize_rows.try_into().context(\"rows overflow\")?,\n                    cols: shell.winsize_cols.try_into().context(\"cols overflow\")?,\n                },\n            ));\n            let shell = State {\n                seqnum: shell.seqnum,\n                data: shell.data,\n                chunk_offset: shell.chunk_offset,\n                byte_offset: shell.byte_offset,\n                closed: shell.closed,\n                notify: Default::default(),\n            };\n            shells.insert(Sid(sid), shell);\n        }\n        drop(shells);\n        session.source.send_replace(winsizes);\n        session\n            .counter\n            .set_current_values(Sid(message.next_sid), Uid(message.next_uid));\n\n        Ok(session)\n    }\n}\n"
  },
  {
    "path": "crates/sshx-server/src/session.rs",
    "content": "//! Core logic for sshx sessions, independent of message transport.\n\nuse std::collections::HashMap;\nuse std::ops::DerefMut;\nuse std::sync::Arc;\n\nuse anyhow::{bail, Context, Result};\nuse bytes::Bytes;\nuse parking_lot::{Mutex, RwLock, RwLockWriteGuard};\nuse sshx_core::{\n    proto::{server_update::ServerMessage, SequenceNumbers},\n    IdCounter, Sid, Uid,\n};\nuse tokio::sync::{broadcast, watch, Notify};\nuse tokio::time::Instant;\nuse tokio_stream::wrappers::{errors::BroadcastStreamRecvError, BroadcastStream, WatchStream};\nuse tokio_stream::Stream;\nuse tracing::{debug, warn};\n\nuse crate::utils::Shutdown;\nuse crate::web::protocol::{WsServer, WsUser, WsWinsize};\n\nmod snapshot;\n\n/// Store a rolling buffer with at most this quantity of output, per shell.\nconst SHELL_STORED_BYTES: u64 = 1 << 21; // 2 MiB\n\n/// Static metadata for this session.\n#[derive(Debug, Clone)]\npub struct Metadata {\n    /// Used to validate that clients have the correct encryption key.\n    pub encrypted_zeros: Bytes,\n\n    /// Name of the session (human-readable).\n    pub name: String,\n\n    /// Password for write access to the session.\n    pub write_password_hash: Option<Bytes>,\n}\n\n/// In-memory state for a single sshx session.\n#[derive(Debug)]\npub struct Session {\n    /// Static metadata for this session.\n    metadata: Metadata,\n\n    /// In-memory state for the session.\n    shells: RwLock<HashMap<Sid, State>>,\n\n    /// Metadata for currently connected users.\n    users: RwLock<HashMap<Uid, WsUser>>,\n\n    /// Atomic counter to get new, unique IDs.\n    counter: IdCounter,\n\n    /// Timestamp of the last backend client message from an active connection.\n    last_accessed: Mutex<Instant>,\n\n    /// Watch channel source for the ordered list of open shells and sizes.\n    source: watch::Sender<Vec<(Sid, WsWinsize)>>,\n\n    /// Broadcasts updates to all WebSocket clients.\n    ///\n    /// Every update inside this channel must be of idempotent form, since\n    /// messages may arrive before or after any snapshot of the current session\n    /// state. Duplicated events should remain consistent.\n    broadcast: broadcast::Sender<WsServer>,\n\n    /// Sender end of a channel that buffers messages for the client.\n    update_tx: async_channel::Sender<ServerMessage>,\n\n    /// Receiver end of a channel that buffers messages for the client.\n    update_rx: async_channel::Receiver<ServerMessage>,\n\n    /// Triggered from metadata events when an immediate snapshot is needed.\n    sync_notify: Notify,\n\n    /// Set when this session has been closed and removed.\n    shutdown: Shutdown,\n}\n\n/// Internal state for each shell.\n#[derive(Default, Debug)]\nstruct State {\n    /// Sequence number, indicating how many bytes have been received.\n    seqnum: u64,\n\n    /// Terminal data chunks.\n    data: Vec<Bytes>,\n\n    /// Number of pruned data chunks before `data[0]`.\n    chunk_offset: u64,\n\n    /// Number of bytes in pruned data chunks.\n    byte_offset: u64,\n\n    /// Set when this shell is terminated.\n    closed: bool,\n\n    /// Updated when any of the above fields change.\n    notify: Arc<Notify>,\n}\n\nimpl Session {\n    /// Construct a new session.\n    pub fn new(metadata: Metadata) -> Self {\n        let now = Instant::now();\n        let (update_tx, update_rx) = async_channel::bounded(256);\n        Session {\n            metadata,\n            shells: RwLock::new(HashMap::new()),\n            users: RwLock::new(HashMap::new()),\n            counter: IdCounter::default(),\n            last_accessed: Mutex::new(now),\n            source: watch::channel(Vec::new()).0,\n            broadcast: broadcast::channel(64).0,\n            update_tx,\n            update_rx,\n            sync_notify: Notify::new(),\n            shutdown: Shutdown::new(),\n        }\n    }\n\n    /// Returns the metadata for this session.\n    pub fn metadata(&self) -> &Metadata {\n        &self.metadata\n    }\n\n    /// Gives access to the ID counter for obtaining new IDs.\n    pub fn counter(&self) -> &IdCounter {\n        &self.counter\n    }\n\n    /// Return the sequence numbers for current shells.\n    pub fn sequence_numbers(&self) -> SequenceNumbers {\n        let shells = self.shells.read();\n        let mut map = HashMap::with_capacity(shells.len());\n        for (key, value) in &*shells {\n            if !value.closed {\n                map.insert(key.0, value.seqnum);\n            }\n        }\n        SequenceNumbers { map }\n    }\n\n    /// Receive a notification on broadcasted message events.\n    pub fn subscribe_broadcast(\n        &self,\n    ) -> impl Stream<Item = Result<WsServer, BroadcastStreamRecvError>> + Unpin {\n        BroadcastStream::new(self.broadcast.subscribe())\n    }\n\n    /// Receive a notification every time the set of shells is changed.\n    pub fn subscribe_shells(&self) -> impl Stream<Item = Vec<(Sid, WsWinsize)>> + Unpin {\n        WatchStream::new(self.source.subscribe())\n    }\n\n    /// Subscribe for chunks from a shell, until it is closed.\n    pub fn subscribe_chunks(\n        &self,\n        id: Sid,\n        mut chunknum: u64,\n    ) -> impl Stream<Item = (u64, Vec<Bytes>)> + '_ {\n        async_stream::stream! {\n            while !self.shutdown.is_terminated() {\n                // We absolutely cannot hold `shells` across an await point,\n                // since that would cause deadlocks.\n                let (seqnum, chunks, notified) = {\n                    let shells = self.shells.read();\n                    let shell = match shells.get(&id) {\n                        Some(shell) if !shell.closed => shell,\n                        _ => return,\n                    };\n                    let notify = Arc::clone(&shell.notify);\n                    let notified = async move { notify.notified().await };\n                    let mut seqnum = shell.byte_offset;\n                    let mut chunks = Vec::new();\n                    let current_chunks = shell.chunk_offset + shell.data.len() as u64;\n                    if chunknum < current_chunks {\n                        let start = chunknum.saturating_sub(shell.chunk_offset) as usize;\n                        seqnum += shell.data[..start].iter().map(|x| x.len() as u64).sum::<u64>();\n                        chunks = shell.data[start..].to_vec();\n                        chunknum = current_chunks;\n                    }\n                    (seqnum, chunks, notified)\n                };\n\n                if !chunks.is_empty() {\n                    yield (seqnum, chunks);\n                }\n                tokio::select! {\n                    _ = notified => (),\n                    _ = self.terminated() => return,\n                }\n            }\n        }\n    }\n\n    /// Add a new shell to the session.\n    pub fn add_shell(&self, id: Sid, center: (i32, i32)) -> Result<()> {\n        use std::collections::hash_map::Entry::*;\n        let _guard = match self.shells.write().entry(id) {\n            Occupied(_) => bail!(\"shell already exists with id={id}\"),\n            Vacant(v) => v.insert(State::default()),\n        };\n        self.source.send_modify(|source| {\n            let winsize = WsWinsize {\n                x: center.0,\n                y: center.1,\n                ..Default::default()\n            };\n            source.push((id, winsize));\n        });\n        self.sync_now();\n        Ok(())\n    }\n\n    /// Terminates an existing shell.\n    pub fn close_shell(&self, id: Sid) -> Result<()> {\n        match self.shells.write().get_mut(&id) {\n            Some(shell) if !shell.closed => {\n                shell.closed = true;\n                shell.notify.notify_waiters();\n            }\n            Some(_) => return Ok(()),\n            None => bail!(\"cannot close shell with id={id}, does not exist\"),\n        }\n        self.source.send_modify(|source| {\n            source.retain(|&(x, _)| x != id);\n        });\n        self.sync_now();\n        Ok(())\n    }\n\n    fn get_shell_mut(&self, id: Sid) -> Result<impl DerefMut<Target = State> + '_> {\n        let shells = self.shells.write();\n        match shells.get(&id) {\n            Some(shell) if !shell.closed => {\n                Ok(RwLockWriteGuard::map(shells, |s| s.get_mut(&id).unwrap()))\n            }\n            Some(_) => bail!(\"cannot update shell with id={id}, already closed\"),\n            None => bail!(\"cannot update shell with id={id}, does not exist\"),\n        }\n    }\n\n    /// Change the size of a terminal, notifying clients if necessary.\n    pub fn move_shell(&self, id: Sid, winsize: Option<WsWinsize>) -> Result<()> {\n        let _guard = self.get_shell_mut(id)?; // Ensures mutual exclusion.\n        self.source.send_modify(|source| {\n            if let Some(idx) = source.iter().position(|&(sid, _)| sid == id) {\n                let (_, oldsize) = source.remove(idx);\n                source.push((id, winsize.unwrap_or(oldsize)));\n            }\n        });\n        Ok(())\n    }\n\n    /// Receive new data into the session.\n    pub fn add_data(&self, id: Sid, data: Bytes, seq: u64) -> Result<()> {\n        let mut shell = self.get_shell_mut(id)?;\n\n        if seq <= shell.seqnum && seq + data.len() as u64 > shell.seqnum {\n            let start = shell.seqnum - seq;\n            let segment = data.slice(start as usize..);\n            debug!(%id, bytes = segment.len(), \"adding data to shell\");\n            shell.seqnum += segment.len() as u64;\n            shell.data.push(segment);\n\n            // Prune old chunks if we've exceeded the maximum stored bytes.\n            let mut stored_bytes = shell.seqnum - shell.byte_offset;\n            if stored_bytes > SHELL_STORED_BYTES {\n                let mut offset = 0;\n                while offset < shell.data.len() && stored_bytes > SHELL_STORED_BYTES {\n                    let bytes = shell.data[offset].len() as u64;\n                    stored_bytes -= bytes;\n                    shell.chunk_offset += 1;\n                    shell.byte_offset += bytes;\n                    offset += 1;\n                }\n                shell.data.drain(..offset);\n            }\n\n            shell.notify.notify_waiters();\n        }\n\n        Ok(())\n    }\n\n    /// List all the users in the session.\n    pub fn list_users(&self) -> Vec<(Uid, WsUser)> {\n        self.users\n            .read()\n            .iter()\n            .map(|(k, v)| (*k, v.clone()))\n            .collect()\n    }\n\n    /// Update a user in place by ID, applying a callback to the object.\n    pub fn update_user(&self, id: Uid, f: impl FnOnce(&mut WsUser)) -> Result<()> {\n        let updated_user = {\n            let mut users = self.users.write();\n            let user = users.get_mut(&id).context(\"user not found\")?;\n            f(user);\n            user.clone()\n        };\n        self.broadcast\n            .send(WsServer::UserDiff(id, Some(updated_user)))\n            .ok();\n        Ok(())\n    }\n\n    /// Add a new user, and return a guard that removes the user when dropped.\n    pub fn user_scope(&self, id: Uid, can_write: bool) -> Result<impl Drop + '_> {\n        use std::collections::hash_map::Entry::*;\n\n        #[must_use]\n        struct UserGuard<'a>(&'a Session, Uid);\n        impl Drop for UserGuard<'_> {\n            fn drop(&mut self) {\n                self.0.remove_user(self.1);\n            }\n        }\n\n        match self.users.write().entry(id) {\n            Occupied(_) => bail!(\"user already exists with id={id}\"),\n            Vacant(v) => {\n                let user = WsUser {\n                    name: format!(\"User {id}\"),\n                    cursor: None,\n                    focus: None,\n                    can_write,\n                };\n                v.insert(user.clone());\n                self.broadcast.send(WsServer::UserDiff(id, Some(user))).ok();\n                Ok(UserGuard(self, id))\n            }\n        }\n    }\n\n    /// Remove an existing user.\n    fn remove_user(&self, id: Uid) {\n        if self.users.write().remove(&id).is_none() {\n            warn!(%id, \"invariant violation: removed user that does not exist\");\n        }\n        self.broadcast.send(WsServer::UserDiff(id, None)).ok();\n    }\n\n    /// Check if a user has write permission in the session.\n    pub fn check_write_permission(&self, user_id: Uid) -> Result<()> {\n        let users = self.users.read();\n        let user = users.get(&user_id).context(\"user not found\")?;\n        if !user.can_write {\n            bail!(\"No write permission\");\n        }\n        Ok(())\n    }\n\n    /// Send a chat message into the room.\n    pub fn send_chat(&self, id: Uid, msg: &str) -> Result<()> {\n        // Populate the message with the current name in case it's not known later.\n        let name = {\n            let users = self.users.read();\n            users.get(&id).context(\"user not found\")?.name.clone()\n        };\n        self.broadcast\n            .send(WsServer::Hear(id, name, msg.into()))\n            .ok();\n        Ok(())\n    }\n\n    /// Send a measurement of the shell latency.\n    pub fn send_latency_measurement(&self, latency: u64) {\n        self.broadcast.send(WsServer::ShellLatency(latency)).ok();\n    }\n\n    /// Register a backend client heartbeat, refreshing the timestamp.\n    pub fn access(&self) {\n        *self.last_accessed.lock() = Instant::now();\n    }\n\n    /// Returns the timestamp of the last backend client activity.\n    pub fn last_accessed(&self) -> Instant {\n        *self.last_accessed.lock()\n    }\n\n    /// Access the sender of the client message channel for this session.\n    pub fn update_tx(&self) -> &async_channel::Sender<ServerMessage> {\n        &self.update_tx\n    }\n\n    /// Access the receiver of the client message channel for this session.\n    pub fn update_rx(&self) -> &async_channel::Receiver<ServerMessage> {\n        &self.update_rx\n    }\n\n    /// Mark the session as requiring an immediate storage sync.\n    ///\n    /// This is needed for consistency when creating new shells, removing old\n    /// shells, or updating the ID counter. If these operations are lost in a\n    /// server restart, then the snapshot that contains them would be invalid\n    /// compared to the current backend client state.\n    ///\n    /// Note that it is not necessary to do this all the time though, since that\n    /// would put too much pressure on the database. Lost terminal data is\n    /// already re-synchronized periodically.\n    pub fn sync_now(&self) {\n        self.sync_notify.notify_one();\n    }\n\n    /// Resolves when the session has been marked for an immediate sync.\n    pub async fn sync_now_wait(&self) {\n        self.sync_notify.notified().await\n    }\n\n    /// Send a termination signal to exit this session.\n    pub fn shutdown(&self) {\n        self.shutdown.shutdown()\n    }\n\n    /// Resolves when the session has received a shutdown signal.\n    pub async fn terminated(&self) {\n        self.shutdown.wait().await\n    }\n}\n"
  },
  {
    "path": "crates/sshx-server/src/state/mesh.rs",
    "content": "//! Storage and distributed communication.\n\nuse std::{pin::pin, sync::Arc, time::Duration};\n\nuse anyhow::Result;\nuse redis::AsyncCommands;\nuse tokio::time;\nuse tokio_stream::{Stream, StreamExt};\nuse tracing::error;\n\nuse crate::session::Session;\n\n/// Interval for syncing the latest session state into persistent storage.\nconst STORAGE_SYNC_INTERVAL: Duration = Duration::from_secs(20);\n\n/// Length of time a key lasts in Redis before it is expired.\nconst STORAGE_EXPIRY: Duration = Duration::from_secs(300);\n\nfn set_opts() -> redis::SetOptions {\n    redis::SetOptions::default()\n        .with_expiration(redis::SetExpiry::PX(STORAGE_EXPIRY.as_millis() as u64))\n}\n\n/// Communication with a distributed mesh of sshx server nodes.\n///\n/// This uses a Redis instance to persist data across restarts, as well as a\n/// pub/sub channel to keep be notified of when another node becomes the owner\n/// of an active session.\n///\n/// All servers must be accessible to each other through TCP mesh networking,\n/// since requests are forwarded to the controller of a given session.\n#[derive(Clone)]\npub struct StorageMesh {\n    redis: deadpool_redis::Pool,\n    redis_pubsub: redis::Client,\n    host: Option<String>,\n}\n\nimpl StorageMesh {\n    /// Construct a new storage object from Redis URL.\n    pub fn new(redis_url: &str, host: Option<&str>) -> Result<Self> {\n        let redis = deadpool_redis::Config::from_url(redis_url)\n            .builder()?\n            .max_size(10)\n            .wait_timeout(Some(Duration::from_secs(5)))\n            .runtime(deadpool_redis::Runtime::Tokio1)\n            .build()?;\n\n        // Separate `redis::Client` just for pub/sub connections.\n        //\n        // At time of writing, deadpool-redis has not been updated to support the new\n        // pub/sub client APIs in Rust. This is a temporary workaround that creates a\n        // new Redis client on the side, bypassing the connection pool.\n        //\n        // Reference: https://github.com/deadpool-rs/deadpool/issues/226\n        let redis_pubsub = redis::Client::open(redis_url)?;\n\n        Ok(Self {\n            redis,\n            redis_pubsub,\n            host: host.map(|s| s.to_string()),\n        })\n    }\n\n    /// Returns the hostname of this server, if running in mesh node.\n    pub fn host(&self) -> Option<&str> {\n        self.host.as_deref()\n    }\n\n    /// Retrieve the hostname of the owner of a session.\n    pub async fn get_owner(&self, name: &str) -> Result<Option<String>> {\n        let mut conn = self.redis.get().await?;\n        let (owner, closed) = redis::pipe()\n            .get(format!(\"session:{{{name}}}:owner\"))\n            .get(format!(\"session:{{{name}}}:closed\"))\n            .query_async(&mut conn)\n            .await?;\n        if closed {\n            Ok(None)\n        } else {\n            Ok(owner)\n        }\n    }\n\n    /// Retrieve the owner and snapshot of a session.\n    pub async fn get_owner_snapshot(\n        &self,\n        name: &str,\n    ) -> Result<(Option<String>, Option<Vec<u8>>)> {\n        let mut conn = self.redis.get().await?;\n        let (owner, snapshot, closed) = redis::pipe()\n            .get(format!(\"session:{{{name}}}:owner\"))\n            .get(format!(\"session:{{{name}}}:snapshot\"))\n            .get(format!(\"session:{{{name}}}:closed\"))\n            .query_async(&mut conn)\n            .await?;\n        if closed {\n            Ok((None, None))\n        } else {\n            Ok((owner, snapshot))\n        }\n    }\n\n    /// Periodically set the owner and snapshot of a session.\n    pub async fn background_sync(&self, name: &str, session: Arc<Session>) {\n        let mut interval = time::interval(STORAGE_SYNC_INTERVAL);\n        interval.set_missed_tick_behavior(time::MissedTickBehavior::Delay);\n        loop {\n            tokio::select! {\n                _ = interval.tick() => {}\n                _ = session.sync_now_wait() => {}\n                _ = session.terminated() => break,\n            }\n            let mut conn = match self.redis.get().await {\n                Ok(conn) => conn,\n                Err(err) => {\n                    error!(?err, \"failed to connect to redis for sync\");\n                    continue;\n                }\n            };\n            let snapshot = match session.snapshot() {\n                Ok(snapshot) => snapshot,\n                Err(err) => {\n                    error!(?err, \"failed to snapshot session {name}\");\n                    continue;\n                }\n            };\n            let mut pipe = redis::pipe();\n            if let Some(host) = &self.host {\n                pipe.set_options(format!(\"session:{{{name}}}:owner\"), host, set_opts());\n            }\n            pipe.set_options(format!(\"session:{{{name}}}:snapshot\"), snapshot, set_opts());\n            match pipe.query_async(&mut conn).await {\n                Ok(()) => {}\n                Err(err) => error!(?err, \"failed to sync session {name}\"),\n            }\n        }\n    }\n\n    /// Mark a session as closed, so it will expire and never be accessed again.\n    pub async fn mark_closed(&self, name: &str) -> Result<()> {\n        let mut conn = self.redis.get().await?;\n        let (owner,): (Option<String>,) = redis::pipe()\n            .get_del(format!(\"session:{{{name}}}:owner\"))\n            .del(format!(\"session:{{{name}}}:snapshot\"))\n            .ignore()\n            .set_options(format!(\"session:{{{name}}}:closed\"), true, set_opts())\n            .ignore()\n            .query_async(&mut conn)\n            .await?;\n        if let Some(owner) = owner {\n            self.notify_transfer(name, &owner).await?;\n        }\n        Ok(())\n    }\n\n    /// Notify a host that a session has been transferred.\n    pub async fn notify_transfer(&self, name: &str, host: &str) -> Result<()> {\n        let mut conn = self.redis.get().await?;\n        () = conn.publish(format!(\"transfers:{host}\"), name).await?;\n        Ok(())\n    }\n\n    /// Listen for sessions that are transferred away from this host.\n    pub fn listen_for_transfers(&self) -> impl Stream<Item = String> + Send + '_ {\n        async_stream::stream! {\n            let Some(host) = &self.host else {\n                // If not in a mesh, there are no transfers.\n                return;\n            };\n\n            loop {\n                // Requires an owned, non-pool connection for ownership reasons.\n                let mut pubsub = match self.redis_pubsub.get_async_pubsub().await {\n                    Ok(pubsub) => pubsub,\n                    Err(err) => {\n                        error!(?err, \"failed to connect to redis for pub/sub\");\n                        time::sleep(Duration::from_secs(5)).await;\n                        continue;\n                    }\n                };\n                if let Err(err) = pubsub.subscribe(format!(\"transfers:{host}\")).await {\n                    error!(?err, \"failed to subscribe to transfers\");\n                    time::sleep(Duration::from_secs(1)).await;\n                    continue;\n                }\n\n                let mut msg_stream = pin!(pubsub.into_on_message());\n                while let Some(msg) = msg_stream.next().await {\n                    match msg.get_payload::<String>() {\n                        Ok(payload) => yield payload,\n                        Err(err) => {\n                            error!(?err, \"failed to parse transfers message\");\n                            continue;\n                        }\n                    };\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/sshx-server/src/state.rs",
    "content": "//! Stateful components of the server, managing multiple sessions.\n\nuse std::pin::pin;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse anyhow::Result;\nuse dashmap::DashMap;\nuse hmac::{Hmac, Mac as _};\nuse sha2::Sha256;\nuse sshx_core::rand_alphanumeric;\nuse tokio::time;\nuse tokio_stream::StreamExt;\nuse tracing::error;\n\nuse self::mesh::StorageMesh;\nuse crate::session::Session;\nuse crate::ServerOptions;\n\npub mod mesh;\n\n/// Timeout for a disconnected session to be evicted and closed.\n///\n/// If a session has no backend clients making connections in this interval,\n/// then its updated timestamp will be out-of-date, so we close it and remove it\n/// from the state to reduce memory usage.\nconst DISCONNECTED_SESSION_EXPIRY: Duration = Duration::from_secs(300);\n\n/// Shared state object for global server logic.\npub struct ServerState {\n    /// Message authentication code for signing tokens.\n    mac: Hmac<Sha256>,\n\n    /// Override the origin returned for the Open() RPC.\n    override_origin: Option<String>,\n\n    /// A concurrent map of session IDs to session objects.\n    store: DashMap<String, Arc<Session>>,\n\n    /// Storage and distributed communication provider, if enabled.\n    mesh: Option<StorageMesh>,\n}\n\nimpl ServerState {\n    /// Create an empty server state using the given secret.\n    pub fn new(options: ServerOptions) -> Result<Self> {\n        let secret = options.secret.unwrap_or_else(|| rand_alphanumeric(22));\n        let mesh = match options.redis_url {\n            Some(url) => Some(StorageMesh::new(&url, options.host.as_deref())?),\n            None => None,\n        };\n        Ok(Self {\n            mac: Hmac::new_from_slice(secret.as_bytes()).unwrap(),\n            override_origin: options.override_origin,\n            store: DashMap::new(),\n            mesh,\n        })\n    }\n\n    /// Returns the message authentication code used for signing tokens.\n    pub fn mac(&self) -> Hmac<Sha256> {\n        self.mac.clone()\n    }\n\n    /// Returns the override origin for the Open() RPC.\n    pub fn override_origin(&self) -> Option<String> {\n        self.override_origin.clone()\n    }\n\n    /// Lookup a local session by name.\n    pub fn lookup(&self, name: &str) -> Option<Arc<Session>> {\n        self.store.get(name).map(|s| s.clone())\n    }\n\n    /// Insert a session into the local store.\n    pub fn insert(&self, name: &str, session: Arc<Session>) {\n        if let Some(mesh) = &self.mesh {\n            let name = name.to_string();\n            let session = session.clone();\n            let mesh = mesh.clone();\n            tokio::spawn(async move {\n                mesh.background_sync(&name, session).await;\n            });\n        }\n        if let Some(prev_session) = self.store.insert(name.to_string(), session) {\n            prev_session.shutdown();\n        }\n    }\n\n    /// Remove a session from the local store.\n    pub fn remove(&self, name: &str) -> bool {\n        if let Some((_, session)) = self.store.remove(name) {\n            session.shutdown();\n            true\n        } else {\n            false\n        }\n    }\n\n    /// Close a session permanently on this and other servers.\n    pub async fn close_session(&self, name: &str) -> Result<()> {\n        self.remove(name);\n        if let Some(mesh) = &self.mesh {\n            mesh.mark_closed(name).await?;\n        }\n        Ok(())\n    }\n\n    /// Connect to a session by name from the `sshx` client, which provides the\n    /// actual terminal backend.\n    pub async fn backend_connect(&self, name: &str) -> Result<Option<Arc<Session>>> {\n        if let Some(session) = self.lookup(name) {\n            return Ok(Some(session));\n        }\n\n        if let Some(mesh) = &self.mesh {\n            let (owner, snapshot) = mesh.get_owner_snapshot(name).await?;\n            if let Some(snapshot) = snapshot {\n                let session = Arc::new(Session::restore(&snapshot)?);\n                self.insert(name, session.clone());\n                if let Some(owner) = owner {\n                    mesh.notify_transfer(name, &owner).await?;\n                }\n                return Ok(Some(session));\n            }\n        }\n\n        Ok(None)\n    }\n\n    /// Connect to a session from a web browser frontend, possibly redirecting.\n    pub async fn frontend_connect(\n        &self,\n        name: &str,\n    ) -> Result<Result<Arc<Session>, Option<String>>> {\n        if let Some(session) = self.lookup(name) {\n            return Ok(Ok(session));\n        }\n\n        if let Some(mesh) = &self.mesh {\n            let mut owner = mesh.get_owner(name).await?;\n            if owner.is_some() && owner.as_deref() == mesh.host() {\n                // Do not redirect back to the same server.\n                owner = None;\n            }\n            return Ok(Err(owner));\n        }\n\n        Ok(Err(None))\n    }\n\n    /// Listen for and remove sessions that are transferred away from this host.\n    pub async fn listen_for_transfers(&self) {\n        if let Some(mesh) = &self.mesh {\n            let mut transfers = pin!(mesh.listen_for_transfers());\n            while let Some(name) = transfers.next().await {\n                self.remove(&name);\n            }\n        }\n    }\n\n    /// Close all sessions that have been disconnected for too long.\n    pub async fn close_old_sessions(&self) {\n        loop {\n            time::sleep(DISCONNECTED_SESSION_EXPIRY / 5).await;\n            let mut to_close = Vec::new();\n            for entry in &self.store {\n                let session = entry.value();\n                if session.last_accessed().elapsed() > DISCONNECTED_SESSION_EXPIRY {\n                    to_close.push(entry.key().clone());\n                }\n            }\n            for name in to_close {\n                if let Err(err) = self.close_session(&name).await {\n                    error!(?err, \"failed to close old session {name}\");\n                }\n            }\n        }\n    }\n\n    /// Send a graceful shutdown signal to every session.\n    pub fn shutdown(&self) {\n        for entry in &self.store {\n            entry.value().shutdown();\n        }\n    }\n}\n"
  },
  {
    "path": "crates/sshx-server/src/utils.rs",
    "content": "//! Utility functions shared among server logic.\n\nuse std::fmt::Debug;\nuse std::future::Future;\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse std::sync::Arc;\n\nuse tokio::sync::Notify;\n\n/// A cloneable structure that handles shutdown signals.\n#[derive(Clone)]\npub struct Shutdown {\n    inner: Arc<(AtomicBool, Notify)>,\n}\n\nimpl Shutdown {\n    /// Construct a new [`Shutdown`] object.\n    pub fn new() -> Self {\n        Self {\n            inner: Arc::new((AtomicBool::new(false), Notify::new())),\n        }\n    }\n\n    /// Send a shutdown signal to all listeners.\n    pub fn shutdown(&self) {\n        self.inner.0.swap(true, Ordering::Relaxed);\n        self.inner.1.notify_waiters();\n    }\n\n    /// Returns whether the shutdown signal has been previously sent.\n    pub fn is_terminated(&self) -> bool {\n        self.inner.0.load(Ordering::Relaxed)\n    }\n\n    /// Wait for the shutdown signal, if it has not already been sent.\n    pub fn wait(&'_ self) -> impl Future<Output = ()> + Send {\n        let inner = self.inner.clone();\n        async move {\n            // Initial fast check\n            if !inner.0.load(Ordering::Relaxed) {\n                let notify = inner.1.notified();\n                // Second check to avoid \"missed wakeup\" race conditions\n                if !inner.0.load(Ordering::Relaxed) {\n                    notify.await;\n                }\n            }\n        }\n    }\n}\n\nimpl Default for Shutdown {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl Debug for Shutdown {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"Shutdown\")\n            .field(\"is_terminated\", &self.inner.0.load(Ordering::Relaxed))\n            .finish()\n    }\n}\n"
  },
  {
    "path": "crates/sshx-server/src/web/protocol.rs",
    "content": "//! Serializable types sent and received by the web server.\n\nuse bytes::Bytes;\nuse serde::{Deserialize, Serialize};\nuse sshx_core::{Sid, Uid};\n\n/// Real-time message conveying the position and size of a terminal.\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]\n#[serde(rename_all = \"camelCase\")]\npub struct WsWinsize {\n    /// The top-left x-coordinate of the window, offset from origin.\n    pub x: i32,\n    /// The top-left y-coordinate of the window, offset from origin.\n    pub y: i32,\n    /// The number of rows in the window.\n    pub rows: u16,\n    /// The number of columns in the terminal.\n    pub cols: u16,\n}\n\nimpl Default for WsWinsize {\n    fn default() -> Self {\n        WsWinsize {\n            x: 0,\n            y: 0,\n            rows: 24,\n            cols: 80,\n        }\n    }\n}\n\n/// Real-time message providing information about a user.\n#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]\n#[serde(rename_all = \"camelCase\")]\npub struct WsUser {\n    /// The user's display name.\n    pub name: String,\n    /// Live coordinates of the mouse cursor, if available.\n    pub cursor: Option<(i32, i32)>,\n    /// Currently focused terminal window ID.\n    pub focus: Option<Sid>,\n    /// Whether the user has write permissions in the session.\n    pub can_write: bool,\n}\n\n/// A real-time message sent from the server over WebSocket.\n#[derive(Serialize, Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub enum WsServer {\n    /// Initial server message, with the user's ID and session metadata.\n    Hello(Uid, String),\n    /// The user's authentication was invalid.\n    InvalidAuth(),\n    /// A snapshot of all current users in the session.\n    Users(Vec<(Uid, WsUser)>),\n    /// Info about a single user in the session: joined, left, or changed.\n    UserDiff(Uid, Option<WsUser>),\n    /// Notification when the set of open shells has changed.\n    Shells(Vec<(Sid, WsWinsize)>),\n    /// Subscription results, in the form of terminal data chunks.\n    Chunks(Sid, u64, Vec<Bytes>),\n    /// Get a chat message tuple `(uid, name, text)` from the room.\n    Hear(Uid, String, String),\n    /// Forward a latency measurement between the server and backend shell.\n    ShellLatency(u64),\n    /// Echo back a timestamp, for the the client's own latency measurement.\n    Pong(u64),\n    /// Alert the client of an application error.\n    Error(String),\n}\n\n/// A real-time message sent from the client over WebSocket.\n#[derive(Serialize, Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub enum WsClient {\n    /// Authenticate the user's encryption key by zeros block and write password\n    /// (if provided).\n    Authenticate(Bytes, Option<Bytes>),\n    /// Set the name of the current user.\n    SetName(String),\n    /// Send real-time information about the user's cursor.\n    SetCursor(Option<(i32, i32)>),\n    /// Set the currently focused shell.\n    SetFocus(Option<Sid>),\n    /// Create a new shell.\n    Create(i32, i32),\n    /// Close a specific shell.\n    Close(Sid),\n    /// Move a shell window to a new position and focus it.\n    Move(Sid, Option<WsWinsize>),\n    /// Add user data to a given shell.\n    Data(Sid, Bytes, u64),\n    /// Subscribe to a shell, starting at a given chunk index.\n    Subscribe(Sid, u64),\n    /// Send a a chat message to the room.\n    Chat(String),\n    /// Send a ping to the server, for latency measurement.\n    Ping(u64),\n}\n"
  },
  {
    "path": "crates/sshx-server/src/web/socket.rs",
    "content": "use std::collections::HashSet;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse axum::extract::{\n    ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade},\n    Path, State,\n};\nuse axum::response::IntoResponse;\nuse bytes::Bytes;\nuse futures_util::SinkExt;\nuse sshx_core::proto::{server_update::ServerMessage, NewShell, TerminalInput, TerminalSize};\nuse sshx_core::Sid;\nuse subtle::ConstantTimeEq;\nuse tokio::sync::mpsc;\nuse tokio_stream::StreamExt;\nuse tracing::{error, info_span, warn, Instrument};\n\nuse crate::session::Session;\nuse crate::web::protocol::{WsClient, WsServer};\nuse crate::ServerState;\n\npub async fn get_session_ws(\n    Path(name): Path<String>,\n    ws: WebSocketUpgrade,\n    State(state): State<Arc<ServerState>>,\n) -> impl IntoResponse {\n    ws.on_upgrade(move |mut socket| {\n        let span = info_span!(\"ws\", %name);\n        async move {\n            match state.frontend_connect(&name).await {\n                Ok(Ok(session)) => {\n                    if let Err(err) = handle_socket(&mut socket, session).await {\n                        warn!(?err, \"websocket exiting early\");\n                    } else {\n                        socket.close().await.ok();\n                    }\n                }\n                Ok(Err(Some(host))) => {\n                    if let Err(err) = proxy_redirect(&mut socket, &host, &name).await {\n                        error!(?err, \"failed to proxy websocket\");\n                        let frame = CloseFrame {\n                            code: 4500,\n                            reason: format!(\"proxy redirect: {err}\").into(),\n                        };\n                        socket.send(Message::Close(Some(frame))).await.ok();\n                    } else {\n                        socket.close().await.ok();\n                    }\n                }\n                Ok(Err(None)) => {\n                    let frame = CloseFrame {\n                        code: 4404,\n                        reason: \"could not find the requested session\".into(),\n                    };\n                    socket.send(Message::Close(Some(frame))).await.ok();\n                }\n                Err(err) => {\n                    error!(?err, \"failed to connect to frontend session\");\n                    let frame = CloseFrame {\n                        code: 4500,\n                        reason: format!(\"session connect: {err}\").into(),\n                    };\n                    socket.send(Message::Close(Some(frame))).await.ok();\n                }\n            }\n        }\n        .instrument(span)\n    })\n}\n\n/// Handle an incoming live WebSocket connection to a given session.\nasync fn handle_socket(socket: &mut WebSocket, session: Arc<Session>) -> Result<()> {\n    /// Send a message to the client over WebSocket.\n    async fn send(socket: &mut WebSocket, msg: WsServer) -> Result<()> {\n        let mut buf = Vec::new();\n        ciborium::ser::into_writer(&msg, &mut buf)?;\n        socket.send(Message::Binary(Bytes::from(buf))).await?;\n        Ok(())\n    }\n\n    /// Receive a message from the client over WebSocket.\n    async fn recv(socket: &mut WebSocket) -> Result<Option<WsClient>> {\n        Ok(loop {\n            match socket.recv().await.transpose()? {\n                Some(Message::Text(_)) => warn!(\"ignoring text message over WebSocket\"),\n                Some(Message::Binary(msg)) => break Some(ciborium::de::from_reader(&*msg)?),\n                Some(_) => (), // ignore other message types, keep looping\n                None => break None,\n            }\n        })\n    }\n\n    let metadata = session.metadata();\n    let user_id = session.counter().next_uid();\n    session.sync_now();\n    send(socket, WsServer::Hello(user_id, metadata.name.clone())).await?;\n\n    let can_write = match recv(socket).await? {\n        Some(WsClient::Authenticate(bytes, write_password_bytes)) => {\n            // Constant-time comparison of bytes, converting Choice to bool\n            if !bool::from(bytes.ct_eq(metadata.encrypted_zeros.as_ref())) {\n                send(socket, WsServer::InvalidAuth()).await?;\n                return Ok(());\n            }\n\n            match (write_password_bytes, &metadata.write_password_hash) {\n                // No password needed, so all users can write (default).\n                (_, None) => true,\n\n                // Password stored but not provided, user is read-only.\n                (None, Some(_)) => false,\n\n                // Password stored and provided, compare them.\n                (Some(provided), Some(stored)) => {\n                    if !bool::from(provided.ct_eq(stored)) {\n                        send(socket, WsServer::InvalidAuth()).await?;\n                        return Ok(());\n                    }\n                    true\n                }\n            }\n        }\n        _ => {\n            send(socket, WsServer::InvalidAuth()).await?;\n            return Ok(());\n        }\n    };\n\n    let _user_guard = session.user_scope(user_id, can_write)?;\n\n    let update_tx = session.update_tx(); // start listening for updates before any state reads\n    let mut broadcast_stream = session.subscribe_broadcast();\n    send(socket, WsServer::Users(session.list_users())).await?;\n\n    let mut subscribed = HashSet::new(); // prevent duplicate subscriptions\n    let (chunks_tx, mut chunks_rx) = mpsc::channel::<(Sid, u64, Vec<Bytes>)>(1);\n\n    let mut shells_stream = session.subscribe_shells();\n    loop {\n        let msg = tokio::select! {\n            _ = session.terminated() => break,\n            Some(result) = broadcast_stream.next() => {\n                let msg = result.context(\"client fell behind on broadcast stream\")?;\n                send(socket, msg).await?;\n                continue;\n            }\n            Some(shells) = shells_stream.next() => {\n                send(socket, WsServer::Shells(shells)).await?;\n                continue;\n            }\n            Some((id, seqnum, chunks)) = chunks_rx.recv() => {\n                send(socket, WsServer::Chunks(id, seqnum, chunks)).await?;\n                continue;\n            }\n            result = recv(socket) => {\n                match result? {\n                    Some(msg) => msg,\n                    None => break,\n                }\n            }\n        };\n\n        match msg {\n            WsClient::Authenticate(_, _) => {}\n            WsClient::SetName(name) => {\n                if !name.is_empty() {\n                    session.update_user(user_id, |user| user.name = name)?;\n                }\n            }\n            WsClient::SetCursor(cursor) => {\n                session.update_user(user_id, |user| user.cursor = cursor)?;\n            }\n            WsClient::SetFocus(id) => {\n                session.update_user(user_id, |user| user.focus = id)?;\n            }\n            WsClient::Create(x, y) => {\n                if let Err(e) = session.check_write_permission(user_id) {\n                    send(socket, WsServer::Error(e.to_string())).await?;\n                    continue;\n                }\n                let id = session.counter().next_sid();\n                session.sync_now();\n                let new_shell = NewShell { id: id.0, x, y };\n                update_tx\n                    .send(ServerMessage::CreateShell(new_shell))\n                    .await?;\n            }\n            WsClient::Close(id) => {\n                if let Err(e) = session.check_write_permission(user_id) {\n                    send(socket, WsServer::Error(e.to_string())).await?;\n                    continue;\n                }\n                update_tx.send(ServerMessage::CloseShell(id.0)).await?;\n            }\n            WsClient::Move(id, winsize) => {\n                if let Err(e) = session.check_write_permission(user_id) {\n                    send(socket, WsServer::Error(e.to_string())).await?;\n                    continue;\n                }\n                if let Err(err) = session.move_shell(id, winsize) {\n                    send(socket, WsServer::Error(err.to_string())).await?;\n                    continue;\n                }\n                if let Some(winsize) = winsize {\n                    let msg = ServerMessage::Resize(TerminalSize {\n                        id: id.0,\n                        rows: winsize.rows as u32,\n                        cols: winsize.cols as u32,\n                    });\n                    session.update_tx().send(msg).await?;\n                }\n            }\n            WsClient::Data(id, data, offset) => {\n                if let Err(e) = session.check_write_permission(user_id) {\n                    send(socket, WsServer::Error(e.to_string())).await?;\n                    continue;\n                }\n                let input = TerminalInput {\n                    id: id.0,\n                    data,\n                    offset,\n                };\n                update_tx.send(ServerMessage::Input(input)).await?;\n            }\n            WsClient::Subscribe(id, chunknum) => {\n                if subscribed.contains(&id) {\n                    continue;\n                }\n                subscribed.insert(id);\n                let session = Arc::clone(&session);\n                let chunks_tx = chunks_tx.clone();\n                tokio::spawn(async move {\n                    let stream = session.subscribe_chunks(id, chunknum);\n                    tokio::pin!(stream);\n                    while let Some((seqnum, chunks)) = stream.next().await {\n                        if chunks_tx.send((id, seqnum, chunks)).await.is_err() {\n                            break;\n                        }\n                    }\n                });\n            }\n            WsClient::Chat(msg) => {\n                session.send_chat(user_id, &msg)?;\n            }\n            WsClient::Ping(ts) => {\n                send(socket, WsServer::Pong(ts)).await?;\n            }\n        }\n    }\n    Ok(())\n}\n\n/// Transparently reverse-proxy a WebSocket connection to a different host.\nasync fn proxy_redirect(socket: &mut WebSocket, host: &str, name: &str) -> Result<()> {\n    use tokio_tungstenite::{\n        connect_async,\n        tungstenite::protocol::{CloseFrame as TCloseFrame, Message as TMessage},\n    };\n\n    let (mut upstream, _) = connect_async(format!(\"ws://{host}/api/s/{name}\")).await?;\n    loop {\n        // Due to axum having its own WebSocket API types, we need to manually translate\n        // between it and tungstenite's message type.\n        tokio::select! {\n            Some(client_msg) = socket.recv() => {\n                let msg = match client_msg {\n                    Ok(Message::Text(s)) => Some(TMessage::Text(s.as_str().into())),\n                    Ok(Message::Binary(b)) => Some(TMessage::Binary(b)),\n                    Ok(Message::Close(frame)) => {\n                        let frame = frame.map(|frame| TCloseFrame {\n                            code: frame.code.into(),\n                            reason: frame.reason.as_str().into(),\n                        });\n                        Some(TMessage::Close(frame))\n                    }\n                    Ok(_) => None,\n                    Err(_) => break,\n                };\n                if let Some(msg) = msg {\n                    if upstream.send(msg).await.is_err() {\n                        break;\n                    }\n                }\n            }\n            Some(server_msg) = upstream.next() => {\n                let msg = match server_msg {\n                    Ok(TMessage::Text(s)) => Some(Message::Text(s.as_str().into())),\n                    Ok(TMessage::Binary(b)) => Some(Message::Binary(b)),\n                    Ok(TMessage::Close(frame)) => {\n                        let frame = frame.map(|frame| CloseFrame {\n                            code: frame.code.into(),\n                            reason: frame.reason.as_str().into(),\n                        });\n                        Some(Message::Close(frame))\n                    }\n                    Ok(_) => None,\n                    Err(_) => break,\n                };\n                if let Some(msg) = msg {\n                    if socket.send(msg).await.is_err() {\n                        break;\n                    }\n                }\n            }\n            else => break,\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/sshx-server/src/web.rs",
    "content": "//! HTTP and WebSocket handlers for the sshx web interface.\n\nuse std::sync::Arc;\n\nuse axum::routing::{any, get_service};\nuse axum::Router;\nuse tower_http::services::{ServeDir, ServeFile};\n\nuse crate::ServerState;\n\npub mod protocol;\nmod socket;\n\n/// Returns the web application server, routed with Axum.\npub fn app() -> Router<Arc<ServerState>> {\n    let root_spa = ServeFile::new(\"build/spa.html\")\n        .precompressed_gzip()\n        .precompressed_br();\n\n    // Serves static SvelteKit build files.\n    let static_files = ServeDir::new(\"build\")\n        .precompressed_gzip()\n        .precompressed_br()\n        .fallback(root_spa);\n\n    Router::new()\n        .nest(\"/api\", backend())\n        .fallback_service(get_service(static_files))\n}\n\n/// Routes for the backend web API server.\nfn backend() -> Router<Arc<ServerState>> {\n    Router::new().route(\"/s/{name}\", any(socket::get_session_ws))\n}\n"
  },
  {
    "path": "crates/sshx-server/tests/common/mod.rs",
    "content": "use std::collections::{BTreeMap, HashMap};\nuse std::net::SocketAddr;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse anyhow::{ensure, Result};\nuse axum::serve::ListenerExt;\nuse futures_util::{SinkExt, StreamExt};\nuse http::StatusCode;\nuse sshx::encrypt::Encrypt;\nuse sshx_core::proto::sshx_service_client::SshxServiceClient;\nuse sshx_core::{Sid, Uid};\nuse sshx_server::{\n    state::ServerState,\n    web::protocol::{WsClient, WsServer, WsUser, WsWinsize},\n    Server,\n};\nuse tokio::net::{TcpListener, TcpStream};\nuse tokio::time;\nuse tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream};\nuse tonic::transport::Channel;\n\n/// An ephemeral, isolated server that is created for each test.\npub struct TestServer {\n    local_addr: SocketAddr,\n    server: Arc<Server>,\n}\n\nimpl TestServer {\n    /// Create a fresh server listening on an unused local port for testing.\n    ///\n    /// Returns an object with the local address, as well as a custom [`Drop`]\n    /// implementation that gracefully shuts down the server.\n    pub async fn new() -> Self {\n        let listener = TcpListener::bind(\"[::1]:0\").await.unwrap();\n        let local_addr = listener.local_addr().unwrap();\n\n        let server = Arc::new(Server::new(Default::default()).unwrap());\n        {\n            let server = Arc::clone(&server);\n            let listener = listener.tap_io(|tcp_stream| {\n                _ = tcp_stream.set_nodelay(true);\n            });\n            tokio::spawn(async move {\n                server.listen(listener).await.unwrap();\n            });\n        }\n\n        TestServer { local_addr, server }\n    }\n\n    /// Returns the local TCP address of this server.\n    pub fn local_addr(&self) -> SocketAddr {\n        self.local_addr\n    }\n\n    /// Returns the HTTP/2 base endpoint URI for this server.\n    pub fn endpoint(&self) -> String {\n        format!(\"http://{}\", self.local_addr)\n    }\n\n    /// Returns the WebSocket endpoint for streaming connections to a session.\n    pub fn ws_endpoint(&self, name: &str) -> String {\n        format!(\"ws://{}/api/s/{}\", self.local_addr, name)\n    }\n\n    /// Creates a gRPC client connected to this server.\n    pub async fn grpc_client(&self) -> SshxServiceClient<Channel> {\n        SshxServiceClient::connect(self.endpoint()).await.unwrap()\n    }\n\n    /// Return the current server state object.\n    pub fn state(&self) -> Arc<ServerState> {\n        self.server.state()\n    }\n}\n\nimpl Drop for TestServer {\n    fn drop(&mut self) {\n        self.server.shutdown();\n    }\n}\n\n/// A WebSocket client that interacts with the server, used for testing.\npub struct ClientSocket {\n    inner: WebSocketStream<MaybeTlsStream<TcpStream>>,\n    encrypt: Encrypt,\n    write_encrypt: Option<Encrypt>,\n\n    pub user_id: Uid,\n    pub users: BTreeMap<Uid, WsUser>,\n    pub shells: BTreeMap<Sid, WsWinsize>,\n    pub data: HashMap<Sid, String>,\n    pub messages: Vec<(Uid, String, String)>,\n    pub errors: Vec<String>,\n}\n\nimpl ClientSocket {\n    /// Connect to a WebSocket endpoint.\n    pub async fn connect(uri: &str, key: &str, write_password: Option<&str>) -> Result<Self> {\n        let (stream, resp) = tokio_tungstenite::connect_async(uri).await?;\n        ensure!(resp.status() == StatusCode::SWITCHING_PROTOCOLS);\n\n        let mut this = Self {\n            inner: stream,\n            encrypt: Encrypt::new(key),\n            write_encrypt: write_password.map(Encrypt::new),\n            user_id: Uid(0),\n            users: BTreeMap::new(),\n            shells: BTreeMap::new(),\n            data: HashMap::new(),\n            messages: Vec::new(),\n            errors: Vec::new(),\n        };\n        this.authenticate().await;\n        Ok(this)\n    }\n\n    async fn authenticate(&mut self) {\n        let encrypted_zeros = self.encrypt.zeros().into();\n        let write_zeros = self.write_encrypt.as_ref().map(|e| e.zeros().into());\n\n        self.send(WsClient::Authenticate(encrypted_zeros, write_zeros))\n            .await;\n    }\n\n    pub async fn send(&mut self, msg: WsClient) {\n        let mut buf = Vec::new();\n        ciborium::ser::into_writer(&msg, &mut buf).unwrap();\n        self.inner.send(Message::Binary(buf.into())).await.unwrap();\n    }\n\n    pub async fn send_input(&mut self, id: Sid, data: &[u8]) {\n        let offset = 42; // arbitrary, don't reuse the offset in real code though\n        let data = self.encrypt.segment(0x200000000, offset, data);\n        self.send(WsClient::Data(id, data.into(), offset)).await;\n    }\n\n    async fn recv(&mut self) -> Option<WsServer> {\n        loop {\n            match self.inner.next().await.transpose().unwrap() {\n                Some(Message::Text(_)) => panic!(\"unexpected text message over WebSocket\"),\n                Some(Message::Binary(msg)) => {\n                    break Some(ciborium::de::from_reader(&*msg).unwrap())\n                }\n                Some(_) => (), // ignore other message types, keep looping\n                None => break None,\n            }\n        }\n    }\n\n    pub async fn expect_close(&mut self, code: u16) {\n        let msg = self.inner.next().await.unwrap().unwrap();\n        match msg {\n            Message::Close(Some(frame)) => assert!(frame.code == code.into()),\n            _ => panic!(\"unexpected non-close message over WebSocket: {:?}\", msg),\n        }\n    }\n\n    pub async fn flush(&mut self) {\n        const FLUSH_DURATION: Duration = Duration::from_millis(50);\n        let flush_task = async {\n            while let Some(msg) = self.recv().await {\n                match msg {\n                    WsServer::Hello(user_id, _) => self.user_id = user_id,\n                    WsServer::InvalidAuth() => panic!(\"invalid authentication\"),\n                    WsServer::Users(users) => self.users = BTreeMap::from_iter(users),\n                    WsServer::UserDiff(id, maybe_user) => {\n                        self.users.remove(&id);\n                        if let Some(user) = maybe_user {\n                            self.users.insert(id, user);\n                        }\n                    }\n                    WsServer::Shells(shells) => self.shells = BTreeMap::from_iter(shells),\n                    WsServer::Chunks(id, seqnum, chunks) => {\n                        let value = self.data.entry(id).or_default();\n                        assert_eq!(seqnum, value.len() as u64);\n                        for buf in chunks {\n                            let plaintext = self.encrypt.segment(\n                                0x100000000 | id.0 as u64,\n                                value.len() as u64,\n                                &buf,\n                            );\n                            value.push_str(std::str::from_utf8(&plaintext).unwrap());\n                        }\n                    }\n                    WsServer::Hear(id, name, msg) => {\n                        self.messages.push((id, name, msg));\n                    }\n                    WsServer::ShellLatency(_) => {}\n                    WsServer::Pong(_) => {}\n                    WsServer::Error(err) => self.errors.push(err),\n                }\n            }\n        };\n        time::timeout(FLUSH_DURATION, flush_task).await.ok();\n    }\n\n    pub fn read(&self, id: Sid) -> &str {\n        self.data.get(&id).map(|s| &**s).unwrap_or(\"\")\n    }\n}\n"
  },
  {
    "path": "crates/sshx-server/tests/simple.rs",
    "content": "use anyhow::Result;\nuse sshx::encrypt::Encrypt;\nuse sshx_core::proto::*;\n\nuse crate::common::*;\n\npub mod common;\n\n#[tokio::test]\nasync fn test_rpc() -> Result<()> {\n    let server = TestServer::new().await;\n    let mut client = server.grpc_client().await;\n\n    let req = OpenRequest {\n        origin: \"sshx.io\".into(),\n        encrypted_zeros: Encrypt::new(\"\").zeros().into(),\n        name: String::new(),\n        write_password_hash: None,\n    };\n    let resp = client.open(req).await?;\n    assert!(!resp.into_inner().name.is_empty());\n\n    Ok(())\n}\n\n#[tokio::test]\nasync fn test_web_get() -> Result<()> {\n    let server = TestServer::new().await;\n\n    let resp = reqwest::get(server.endpoint()).await?;\n    assert!(!resp.status().is_server_error());\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/sshx-server/tests/snapshot.rs",
    "content": "use std::sync::Arc;\n\nuse anyhow::Result;\nuse sshx::{controller::Controller, runner::Runner};\nuse sshx_core::{Sid, Uid};\nuse sshx_server::{\n    session::Session,\n    web::protocol::{WsClient, WsWinsize},\n};\n\nuse crate::common::*;\n\npub mod common;\n\n#[tokio::test]\nasync fn test_basic_restore() -> Result<()> {\n    let server = TestServer::new().await;\n\n    let mut controller = Controller::new(&server.endpoint(), \"\", Runner::Echo, false).await?;\n    let name = controller.name().to_owned();\n    let key = controller.encryption_key().to_owned();\n    tokio::spawn(async move { controller.run().await });\n\n    let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?;\n    s.flush().await;\n    assert_eq!(s.user_id, Uid(1));\n\n    s.send(WsClient::Create(0, 0)).await;\n    s.flush().await;\n\n    let new_size = WsWinsize {\n        x: 42,\n        y: 105,\n        rows: 200,\n        cols: 20,\n    };\n\n    s.send_input(Sid(1), b\"hello there!\").await;\n    s.send_input(Sid(1), b\" - another message\").await;\n    s.send(WsClient::Move(Sid(1), Some(new_size))).await;\n    s.flush().await;\n    assert!(s.shells.contains_key(&Sid(1)));\n\n    // Replace the shell with its snapshot.\n    let data = server.state().lookup(&name).unwrap().snapshot()?;\n    server\n        .state()\n        .insert(&name, Arc::new(Session::restore(&data)?));\n\n    let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?;\n    s.send(WsClient::Subscribe(Sid(1), 0)).await;\n    s.flush().await;\n\n    assert_eq!(s.read(Sid(1)), \"hello there! - another message\");\n    assert_eq!(s.shells.get(&Sid(1)).unwrap(), &new_size);\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/sshx-server/tests/with_client.rs",
    "content": "use anyhow::{Context, Result};\nuse sshx::{controller::Controller, encrypt::Encrypt, runner::Runner};\nuse sshx_core::{\n    proto::{server_update::ServerMessage, NewShell, TerminalInput},\n    Sid, Uid,\n};\nuse sshx_server::web::protocol::{WsClient, WsWinsize};\nuse tokio::time::{self, Duration};\n\nuse crate::common::*;\n\npub mod common;\n\n#[tokio::test]\nasync fn test_handshake() -> Result<()> {\n    let server = TestServer::new().await;\n    let controller = Controller::new(&server.endpoint(), \"\", Runner::Echo, false).await?;\n    controller.close().await?;\n    Ok(())\n}\n\n#[tokio::test]\nasync fn test_command() -> Result<()> {\n    let server = TestServer::new().await;\n    let runner = Runner::Shell(\"/bin/bash\".into());\n    let mut controller = Controller::new(&server.endpoint(), \"\", runner, false).await?;\n\n    let session = server\n        .state()\n        .lookup(controller.name())\n        .context(\"couldn't find session in server state\")?;\n\n    let updates = session.update_tx();\n    let new_shell = NewShell { id: 1, x: 0, y: 0 };\n    updates.send(ServerMessage::CreateShell(new_shell)).await?;\n\n    let key = controller.encryption_key();\n    let encrypt = Encrypt::new(key);\n    let offset = 4242;\n    let data = TerminalInput {\n        id: 1,\n        data: encrypt.segment(0x200000000, offset, b\"ls\\r\\n\").into(),\n        offset,\n    };\n    updates.send(ServerMessage::Input(data)).await?;\n\n    tokio::select! {\n        _ = controller.run() => (),\n        _ = time::sleep(Duration::from_millis(1000)) => (),\n    };\n    controller.close().await?;\n    Ok(())\n}\n\n#[tokio::test]\nasync fn test_ws_missing() -> Result<()> {\n    let server = TestServer::new().await;\n\n    let bad_endpoint = format!(\"ws://{}/not/an/endpoint\", server.local_addr());\n    assert!(ClientSocket::connect(&bad_endpoint, \"\", None)\n        .await\n        .is_err());\n\n    let mut s = ClientSocket::connect(&server.ws_endpoint(\"foobar\"), \"\", None).await?;\n    s.expect_close(4404).await;\n\n    Ok(())\n}\n\n#[tokio::test]\nasync fn test_ws_basic() -> Result<()> {\n    let server = TestServer::new().await;\n\n    let mut controller = Controller::new(&server.endpoint(), \"\", Runner::Echo, false).await?;\n    let name = controller.name().to_owned();\n    let key = controller.encryption_key().to_owned();\n    tokio::spawn(async move { controller.run().await });\n\n    let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?;\n    s.flush().await;\n    assert_eq!(s.user_id, Uid(1));\n\n    s.send(WsClient::Create(0, 0)).await;\n    s.flush().await;\n    assert_eq!(s.shells.len(), 1);\n    assert!(s.shells.contains_key(&Sid(1)));\n\n    s.send(WsClient::Subscribe(Sid(1), 0)).await;\n    assert_eq!(s.read(Sid(1)), \"\");\n\n    s.send_input(Sid(1), b\"hello!\").await;\n    s.flush().await;\n    assert_eq!(s.read(Sid(1)), \"hello!\");\n\n    s.send_input(Sid(1), b\" 123\").await;\n    s.flush().await;\n    assert_eq!(s.read(Sid(1)), \"hello! 123\");\n\n    Ok(())\n}\n\n#[tokio::test]\nasync fn test_ws_resize() -> Result<()> {\n    let server = TestServer::new().await;\n\n    let mut controller = Controller::new(&server.endpoint(), \"\", Runner::Echo, false).await?;\n    let name = controller.name().to_owned();\n    let key = controller.encryption_key().to_owned();\n    tokio::spawn(async move { controller.run().await });\n\n    let mut s = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?;\n\n    s.send(WsClient::Move(Sid(1), None)).await; // error: does not exist yet!\n    s.flush().await;\n    assert_eq!(s.errors.len(), 1);\n\n    s.send(WsClient::Create(0, 0)).await;\n    s.flush().await;\n    assert_eq!(s.shells.len(), 1);\n    assert_eq!(*s.shells.get(&Sid(1)).unwrap(), WsWinsize::default());\n\n    let new_size = WsWinsize {\n        x: 42,\n        y: 105,\n        rows: 200,\n        cols: 20,\n    };\n    s.send(WsClient::Move(Sid(1), Some(new_size))).await;\n    s.send(WsClient::Move(Sid(2), Some(new_size))).await; // error: does not exist\n    s.flush().await;\n    assert_eq!(s.shells.len(), 1);\n    assert_eq!(*s.shells.get(&Sid(1)).unwrap(), new_size);\n    assert_eq!(s.errors.len(), 2);\n\n    s.send(WsClient::Close(Sid(1))).await;\n    s.flush().await;\n    assert_eq!(s.shells.len(), 0);\n\n    s.send(WsClient::Move(Sid(1), None)).await; // error: shell was closed\n    s.flush().await;\n    assert_eq!(s.errors.len(), 3);\n\n    Ok(())\n}\n\n#[tokio::test]\nasync fn test_users_join() -> Result<()> {\n    let server = TestServer::new().await;\n\n    let mut controller = Controller::new(&server.endpoint(), \"\", Runner::Echo, false).await?;\n    let name = controller.name().to_owned();\n    let key = controller.encryption_key().to_owned();\n    tokio::spawn(async move { controller.run().await });\n\n    let endpoint = server.ws_endpoint(&name);\n    let mut s1 = ClientSocket::connect(&endpoint, &key, None).await?;\n    s1.flush().await;\n    assert_eq!(s1.users.len(), 1);\n\n    let mut s2 = ClientSocket::connect(&endpoint, &key, None).await?;\n    s2.flush().await;\n    assert_eq!(s2.users.len(), 2);\n\n    drop(s2);\n    let mut s3 = ClientSocket::connect(&endpoint, &key, None).await?;\n    s3.flush().await;\n    assert_eq!(s3.users.len(), 2);\n\n    s1.flush().await;\n    assert_eq!(s1.users.len(), 2);\n\n    Ok(())\n}\n\n#[tokio::test]\nasync fn test_users_metadata() -> Result<()> {\n    let server = TestServer::new().await;\n\n    let mut controller = Controller::new(&server.endpoint(), \"\", Runner::Echo, false).await?;\n    let name = controller.name().to_owned();\n    let key = controller.encryption_key().to_owned();\n    tokio::spawn(async move { controller.run().await });\n\n    let endpoint = server.ws_endpoint(&name);\n    let mut s = ClientSocket::connect(&endpoint, &key, None).await?;\n    s.flush().await;\n    assert_eq!(s.users.len(), 1);\n    assert_eq!(s.users.get(&s.user_id).unwrap().cursor, None);\n\n    s.send(WsClient::SetName(\"mr. foo\".into())).await;\n    s.send(WsClient::SetCursor(Some((40, 524)))).await;\n    s.flush().await;\n    let user = s.users.get(&s.user_id).unwrap();\n    assert_eq!(user.name, \"mr. foo\");\n    assert_eq!(user.cursor, Some((40, 524)));\n\n    Ok(())\n}\n\n#[tokio::test]\nasync fn test_chat_messages() -> Result<()> {\n    let server = TestServer::new().await;\n\n    let mut controller = Controller::new(&server.endpoint(), \"\", Runner::Echo, false).await?;\n    let name = controller.name().to_owned();\n    let key = controller.encryption_key().to_owned();\n    tokio::spawn(async move { controller.run().await });\n\n    let endpoint = server.ws_endpoint(&name);\n    let mut s1 = ClientSocket::connect(&endpoint, &key, None).await?;\n    let mut s2 = ClientSocket::connect(&endpoint, &key, None).await?;\n\n    s1.send(WsClient::SetName(\"billy\".into())).await;\n    s1.send(WsClient::Chat(\"hello there!\".into())).await;\n    s1.flush().await;\n\n    s2.flush().await;\n    assert_eq!(s2.messages.len(), 1);\n    assert_eq!(\n        s2.messages[0],\n        (s1.user_id, \"billy\".into(), \"hello there!\".into())\n    );\n\n    let mut s3 = ClientSocket::connect(&endpoint, &key, None).await?;\n    s3.flush().await;\n    assert_eq!(s1.messages.len(), 1);\n    assert_eq!(s3.messages.len(), 0);\n\n    Ok(())\n}\n\n#[tokio::test]\nasync fn test_read_write_permissions() -> Result<()> {\n    let server = TestServer::new().await;\n\n    // create controller with read-only mode enabled\n    let mut controller = Controller::new(&server.endpoint(), \"\", Runner::Echo, true).await?;\n    let name = controller.name().to_owned();\n    let key = controller.encryption_key().to_owned();\n    let write_url = controller\n        .write_url()\n        .expect(\"Should have write URL when enable_readers is true\")\n        .to_string();\n\n    tokio::spawn(async move { controller.run().await });\n\n    let write_password = write_url\n        .split(',')\n        .nth(1)\n        .expect(\"Write URL should contain password\");\n\n    // connect with write access\n    let mut writer =\n        ClientSocket::connect(&server.ws_endpoint(&name), &key, Some(write_password)).await?;\n    writer.flush().await;\n\n    // test write permissions\n    writer.send(WsClient::Create(0, 0)).await;\n    writer.flush().await;\n    assert_eq!(\n        writer.shells.len(),\n        1,\n        \"Writer should be able to create a shell\"\n    );\n    assert!(writer.errors.is_empty(), \"Writer should not receive errors\");\n\n    // connect with read-only access\n    let mut reader = ClientSocket::connect(&server.ws_endpoint(&name), &key, None).await?;\n    reader.flush().await;\n\n    // test read-only restrictions\n    reader.send(WsClient::Create(0, 0)).await;\n    reader.flush().await;\n    assert!(\n        !reader.errors.is_empty(),\n        \"Reader should receive an error when attempting to create shell\"\n    );\n    assert_eq!(\n        reader.shells.len(),\n        1,\n        \"Reader should still see the existing shell\"\n    );\n\n    Ok(())\n}\n"
  },
  {
    "path": "fly.toml",
    "content": "app = \"sshx\"\nprimary_region = \"ewr\"\nkill_signal = \"SIGINT\"\nkill_timeout = 90\n\n[experimental]\n  auto_rollback = true\n  cmd = [\"sh\", \"-c\", \"./sshx-server --listen :: --host \\\"$FLY_ALLOC_ID.vm.sshx.internal:8051\\\"\"]\n\n[[services]]\n  protocol = \"tcp\"\n  internal_port = 8051\n  processes = [\"app\"]\n\n  [services.concurrency]\n    type = \"connections\"\n    hard_limit = 65536\n    soft_limit = 1024\n\n  [[services.ports]]\n    port = 80\n    handlers = [\"http\"]\n    force_https = true\n\n  [[services.ports]]\n    port = 443\n    handlers = [\"tls\"]\n    [services.ports.tls_options]\n      alpn = [\"h2\", \"http/1.1\"]\n\n  [[services.tcp_checks]]\n    interval = \"15s\"\n    timeout = \"2s\"\n    grace_period = \"1s\"\n    restart_limit = 0\n"
  },
  {
    "path": "mprocs.yaml",
    "content": "# prettier-ignore\nprocs:\n  server:\n    shell: >-\n      cargo run --bin sshx-server --\n      --override-origin http://localhost:5173\n      --secret dev-secret\n      --redis-url redis://localhost:12601\n  client:\n    shell: >-\n      cargo run --bin sshx --\n      --server http://localhost:8051\n  web:\n    shell: npm run dev\n    stop: SIGKILL  # TODO: Why is this necessary?\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"sshx\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"check\": \"svelte-check --tsconfig ./tsconfig.json\",\n    \"check:watch\": \"svelte-check --tsconfig ./tsconfig.json --watch\",\n    \"lint\": \"prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .\",\n    \"format\": \"prettier --ignore-path .gitignore --write --plugin-search-dir=. .\"\n  },\n  \"dependencies\": {\n    \"@fontsource-variable/inter\": \"^5.0.8\",\n    \"@rgossiaux/svelte-headlessui\": \"^2.0.0\",\n    \"@tldraw/vec\": \"^1.9.2\",\n    \"@use-gesture/vanilla\": \"^10.2.27\",\n    \"argon2-browser\": \"^1.18.0\",\n    \"buffer\": \"^6.0.3\",\n    \"cbor-x\": \"^1.6.0\",\n    \"firacode\": \"^6.2.0\",\n    \"fontfaceobserver\": \"^2.3.0\",\n    \"lodash-es\": \"^4.17.21\",\n    \"perfect-cursors\": \"^1.0.5\",\n    \"sshx-xterm\": \"5.2.1\",\n    \"svelte\": \"^3.59.2\",\n    \"svelte-feather-icons\": \"^4.0.1\",\n    \"svelte-persisted-store\": \"^0.7.0\",\n    \"xterm-addon-image\": \"^0.5.0\",\n    \"xterm-addon-web-links\": \"^0.9.0\",\n    \"xterm-addon-webgl\": \"^0.16.0\"\n  },\n  \"devDependencies\": {\n    \"@sveltejs/adapter-static\": \"^2.0.3\",\n    \"@sveltejs/kit\": \"^1.24.1\",\n    \"@types/lodash-es\": \"^4.17.9\",\n    \"@typescript-eslint/eslint-plugin\": \"^6.7.0\",\n    \"@typescript-eslint/parser\": \"^6.7.0\",\n    \"autoprefixer\": \"^10.4.15\",\n    \"cssnano\": \"^6.0.1\",\n    \"eslint\": \"^8.49.0\",\n    \"eslint-config-prettier\": \"^9.0.0\",\n    \"eslint-plugin-svelte3\": \"^4.0.0\",\n    \"postcss\": \"^8.4.29\",\n    \"postcss-load-config\": \"^4.0.1\",\n    \"prettier\": \"2.8.8\",\n    \"prettier-plugin-svelte\": \"2.10.1\",\n    \"svelte-check\": \"^3.5.1\",\n    \"svelte-preprocess\": \"^5.0.4\",\n    \"tailwindcss\": \"^3.3.3\",\n    \"tslib\": \"^2.6.2\",\n    \"typescript\": \"~5.2.2\",\n    \"vite\": \"^4.4.9\"\n  }\n}\n"
  },
  {
    "path": "postcss.config.cjs",
    "content": "const tailwindcss = require(\"tailwindcss\");\nconst autoprefixer = require(\"autoprefixer\");\nconst cssnano = require(\"cssnano\");\n\nconst mode = process.env.NODE_ENV;\nconst dev = mode === \"development\";\n\nconst config = {\n  plugins: [\n    // Some plugins, like tailwindcss/nesting, need to run before Tailwind,\n    tailwindcss(),\n    // But others, like autoprefixer, need to run after,\n    autoprefixer(),\n    !dev && cssnano({ preset: \"default\" }),\n  ],\n};\n\nmodule.exports = config;\n"
  },
  {
    "path": "rustfmt.toml",
    "content": "unstable_features = true\ngroup_imports = \"StdExternalCrate\"\nwrap_comments = true\nformat_strings = true\nnormalize_comments = true\nreorder_impl_items = true\n"
  },
  {
    "path": "scripts/release.sh",
    "content": "#!/bin/bash\n\n# Manually releases the latest binaries to AWS S3.\n#\n# This runs on my M1 Macbook Pro with cross-compilation toolchains. I think it's\n# probably better to replace this script with a CI configuration later.\n\nset +e\n\n# x86_64: for most Linux servers\nTARGET_CC=x86_64-unknown-linux-musl-cc \\\nCARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-unknown-linux-musl-gcc \\\ncargo build --release --target x86_64-unknown-linux-musl\n\n# aarch64: for newer Linux servers\nTARGET_CC=aarch64-unknown-linux-musl-cc \\\nCARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-unknown-linux-musl-gcc \\\ncargo build --release --target aarch64-unknown-linux-musl\n\n# armv6l: for devices like Raspberry Pi Zero W\nTARGET_CC=arm-unknown-linux-musleabihf-cc \\\nCARGO_TARGET_ARM_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-unknown-linux-musleabihf-gcc \\\ncargo build --release --target arm-unknown-linux-musleabihf\n\n# armv7l: for devices like Oxdroid XU4\nTARGET_CC=armv7-unknown-linux-musleabihf-cc \\\nCARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=armv7-unknown-linux-musleabihf-gcc \\\ncargo build --release --target armv7-unknown-linux-musleabihf\n\n# x86_64-apple-darwin: for macOS on Intel\nSDKROOT=$(xcrun --show-sdk-path) \\\nMACOSX_DEPLOYMENT_TARGET=$(xcrun --show-sdk-platform-version) \\\ncargo build --release --target x86_64-apple-darwin\n\n# aarch64-apple-darwin: for macOS on Apple Silicon\ncargo build --release --target aarch64-apple-darwin\n\n# x86_64-unknown-freebsd: for FreeBSD\ncross build --release --target x86_64-unknown-freebsd\n\n# *-pc-windows-msvc: for Windows, requires cargo-xwin\nXWIN_ARCH=x86,x86_64,aarch64 cargo xwin build -p sshx --release --target x86_64-pc-windows-msvc\nXWIN_ARCH=x86,x86_64,aarch64 cargo xwin build -p sshx --release --target i686-pc-windows-msvc\nXWIN_ARCH=x86,x86_64,aarch64 cargo xwin build -p sshx --release --target aarch64-pc-windows-msvc --cross-compiler clang\n\ntemp=$(mktemp)\ntargets=(\n  x86_64-unknown-linux-musl\n  aarch64-unknown-linux-musl\n  arm-unknown-linux-musleabihf\n  armv7-unknown-linux-musleabihf\n  x86_64-apple-darwin\n  aarch64-apple-darwin\n  x86_64-unknown-freebsd\n  x86_64-pc-windows-msvc\n  i686-pc-windows-msvc\n  aarch64-pc-windows-msvc\n)\nfor target in \"${targets[@]}\"\ndo\n  if [[ ! $target == *\"windows\"* ]]; then\n    echo \"compress: target/$target/release/sshx\"\n    tar --no-xattrs -czf $temp -C target/$target/release sshx\n    aws s3 cp $temp s3://sshx/sshx-$target.tar.gz\n\n    echo \"compress: target/$target/release/sshx-server\"\n    tar --no-xattrs -czf $temp -C target/$target/release sshx-server\n    aws s3 cp $temp s3://sshx/sshx-server-$target.tar.gz\n  else\n    echo \"compress: target/$target/release/sshx.exe\"\n    rm $temp && zip -X -j $temp target/$target/release/sshx.exe\n    aws s3 cp $temp s3://sshx/sshx-$target.zip\n  fi\ndone\n"
  },
  {
    "path": "src/app.css",
    "content": "@font-face {\n  font-family: \"Fira Code VF\";\n  src: url(\"firacode/distr/woff2/FiraCode-VF.woff2\") format(\"woff2-variations\"),\n    url(\"firacode/distr/woff/FiraCode-VF.woff\") format(\"woff-variations\");\n  /* font-weight requires a range: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide#Using_a_variable_font_font-face_changes */\n  font-weight: 300 700;\n  font-style: normal;\n}\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  body {\n    color-scheme: dark;\n  }\n}\n\n@layer components {\n  .panel {\n    @apply border border-zinc-800 bg-zinc-900/90 backdrop-blur-sm rounded-xl pointer-events-auto;\n  }\n}\n"
  },
  {
    "path": "src/app.d.ts",
    "content": "/// <reference types=\"@sveltejs/kit\" />\n\n// Injected by vite.config.ts\ndeclare const __APP_VERSION__: string;\n\n// See https://kit.svelte.dev/docs/types#the-app-namespace\n// for information about these interfaces\ndeclare namespace App {\n  // interface Locals {}\n  // interface Platform {}\n  // interface Session {}\n  // interface Stuff {}\n}\n\n// Type declarations for external libraries.\ndeclare module \"fontfaceobserver\";\n"
  },
  {
    "path": "src/app.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" href=\"/favicon.svg\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no\"\n    />\n\n    <title>sshx</title>\n    <meta property=\"og:title\" content=\"sshx · share collaborative terminals\" />\n    <meta\n      name=\"description\"\n      content=\"Fast, collaborative live terminals in the browser, with real-time chat, cursors, and activity tracking.\"\n    />\n    <meta\n      property=\"og:description\"\n      content=\"Fast, collaborative live terminals in the browser, with real-time chat, cursors, and activity tracking.\"\n    />\n    <meta\n      property=\"og:image\"\n      content=\"https://sshx.io/images/social-image2.png\"\n    />\n    <meta name=\"twitter:card\" content=\"summary_large_image\" />\n\n    %sveltekit.head%\n  </head>\n  <body class=\"dark:bg-[#111111] dark:text-white\">\n    <div>%sveltekit.body%</div>\n  </body>\n</html>\n"
  },
  {
    "path": "src/lib/Session.svelte",
    "content": "<script lang=\"ts\">\n  import {\n    onDestroy,\n    onMount,\n    tick,\n    beforeUpdate,\n    afterUpdate,\n    createEventDispatcher,\n  } from \"svelte\";\n  import { fade } from \"svelte/transition\";\n  import { debounce, throttle } from \"lodash-es\";\n\n  import { Encrypt } from \"./encrypt\";\n  import { createLock } from \"./lock\";\n  import { Srocket } from \"./srocket\";\n  import type { WsClient, WsServer, WsUser, WsWinsize } from \"./protocol\";\n  import { makeToast } from \"./toast\";\n  import Chat, { type ChatMessage } from \"./ui/Chat.svelte\";\n  import ChooseName from \"./ui/ChooseName.svelte\";\n  import NameList from \"./ui/NameList.svelte\";\n  import NetworkInfo from \"./ui/NetworkInfo.svelte\";\n  import Settings from \"./ui/Settings.svelte\";\n  import Toolbar from \"./ui/Toolbar.svelte\";\n  import XTerm from \"./ui/XTerm.svelte\";\n  import Avatars from \"./ui/Avatars.svelte\";\n  import LiveCursor from \"./ui/LiveCursor.svelte\";\n  import { slide } from \"./action/slide\";\n  import { TouchZoom, INITIAL_ZOOM } from \"./action/touchZoom\";\n  import { arrangeNewTerminal } from \"./arrange\";\n  import { settings } from \"./settings\";\n  import { EyeIcon } from \"svelte-feather-icons\";\n\n  export let id: string;\n\n  const dispatch = createEventDispatcher<{ receiveName: string }>();\n\n  // The magic numbers \"left\" and \"top\" are used to approximately center the\n  // terminal at the time that it is first created.\n  const CONSTANT_OFFSET_LEFT = 378;\n  const CONSTANT_OFFSET_TOP = 240;\n\n  const OFFSET_LEFT_CSS = `calc(50vw - ${CONSTANT_OFFSET_LEFT}px)`;\n  const OFFSET_TOP_CSS = `calc(50vh - ${CONSTANT_OFFSET_TOP}px)`;\n  const OFFSET_TRANSFORM_ORIGIN_CSS = `calc(-1 * ${OFFSET_LEFT_CSS}) calc(-1 * ${OFFSET_TOP_CSS})`;\n\n  // Terminal width and height limits.\n  const TERM_MIN_ROWS = 8;\n  const TERM_MIN_COLS = 32;\n\n  function getConstantOffset() {\n    return [\n      0.5 * window.innerWidth - CONSTANT_OFFSET_LEFT,\n      0.5 * window.innerHeight - CONSTANT_OFFSET_TOP,\n    ];\n  }\n\n  let fabricEl: HTMLElement;\n  let touchZoom: TouchZoom;\n  let center = [0, 0];\n  let zoom = INITIAL_ZOOM;\n\n  let showChat = false; // @hmr:keep\n  let settingsOpen = false; // @hmr:keep\n  let showNetworkInfo = false; // @hmr:keep\n\n  onMount(() => {\n    touchZoom = new TouchZoom(fabricEl);\n    touchZoom.onMove(() => {\n      center = touchZoom.center;\n      zoom = touchZoom.zoom;\n\n      // Blur if the user is currently focused on a terminal.\n      //\n      // This makes it so that panning does not stop when the cursor happens to\n      // intersect with the textarea, which absorbs wheel and touch events.\n      if (document.activeElement) {\n        const classList = [...document.activeElement.classList];\n        if (classList.includes(\"xterm-helper-textarea\")) {\n          (document.activeElement as HTMLElement).blur();\n        }\n      }\n\n      showNetworkInfo = false;\n    });\n  });\n\n  /** Returns the mouse position in infinite grid coordinates, offset transformations and zoom. */\n  function normalizePosition(event: MouseEvent): [number, number] {\n    const [ox, oy] = getConstantOffset();\n    return [\n      Math.round(center[0] + event.pageX / zoom - ox),\n      Math.round(center[1] + event.pageY / zoom - oy),\n    ];\n  }\n\n  let encrypt: Encrypt;\n  let srocket: Srocket<WsServer, WsClient> | null = null;\n\n  let connected = false;\n  let exitReason: string | null = null;\n\n  /** Bound \"write\" method for each terminal. */\n  const writers: Record<number, (data: string) => void> = {};\n  const termWrappers: Record<number, HTMLDivElement> = {};\n  const termElements: Record<number, HTMLDivElement> = {};\n  const chunknums: Record<number, number> = {};\n  const locks: Record<number, any> = {};\n  let userId = 0;\n  let users: [number, WsUser][] = [];\n  let shells: [number, WsWinsize][] = [];\n  let subscriptions = new Set<number>();\n\n  // May be undefined before `users` is first populated.\n  $: hasWriteAccess = users.find(([uid]) => uid === userId)?.[1]?.canWrite;\n\n  let moving = -1; // Terminal ID that is being dragged.\n  let movingOrigin = [0, 0]; // Coordinates of mouse at origin when drag started.\n  let movingSize: WsWinsize; // New [x, y] position of the dragged terminal.\n  let movingIsDone = false; // Moving finished but hasn't been acknowledged.\n\n  let resizing = -1; // Terminal ID that is being resized.\n  let resizingOrigin = [0, 0]; // Coordinates of top-left origin when resize started.\n  let resizingCell = [0, 0]; // Pixel dimensions of a single terminal cell.\n  let resizingSize: WsWinsize; // Last resize message sent.\n\n  let chatMessages: ChatMessage[] = [];\n  let newMessages = false;\n\n  let serverLatencies: number[] = [];\n  let shellLatencies: number[] = [];\n\n  onMount(async () => {\n    // The page hash sets the end-to-end encryption key.\n    const key = window.location.hash?.slice(1).split(\",\")[0] ?? \"\";\n    const writePassword = window.location.hash?.slice(1).split(\",\")[1] ?? null;\n\n    encrypt = await Encrypt.new(key);\n    const encryptedZeros = await encrypt.zeros();\n\n    const writeEncryptedZeros = writePassword\n      ? await (await Encrypt.new(writePassword)).zeros()\n      : null;\n\n    srocket = new Srocket<WsServer, WsClient>(`/api/s/${id}`, {\n      onMessage(message) {\n        if (message.hello) {\n          userId = message.hello[0];\n          dispatch(\"receiveName\", message.hello[1]);\n          makeToast({\n            kind: \"success\",\n            message: `Connected to the server.`,\n          });\n          exitReason = null;\n        } else if (message.invalidAuth) {\n          exitReason =\n            \"The URL is not correct, invalid end-to-end encryption key.\";\n          srocket?.dispose();\n        } else if (message.chunks) {\n          let [id, seqnum, chunks] = message.chunks;\n          locks[id](async () => {\n            await tick();\n            chunknums[id] += chunks.length;\n            for (const data of chunks) {\n              const buf = await encrypt.segment(\n                0x100000000n | BigInt(id),\n                BigInt(seqnum),\n                data,\n              );\n              seqnum += data.length;\n              writers[id](new TextDecoder().decode(buf));\n            }\n          });\n        } else if (message.users) {\n          users = message.users;\n        } else if (message.userDiff) {\n          const [id, update] = message.userDiff;\n          users = users.filter(([uid]) => uid !== id);\n          if (update !== null) {\n            users = [...users, [id, update]];\n          }\n        } else if (message.shells) {\n          shells = message.shells;\n          if (movingIsDone) {\n            moving = -1;\n          }\n          for (const [id] of message.shells) {\n            if (!subscriptions.has(id)) {\n              chunknums[id] ??= 0;\n              locks[id] ??= createLock();\n              subscriptions.add(id);\n              srocket?.send({ subscribe: [id, chunknums[id]] });\n            }\n          }\n        } else if (message.hear) {\n          const [uid, name, msg] = message.hear;\n          chatMessages.push({ uid, name, msg, sentAt: new Date() });\n          chatMessages = chatMessages;\n          if (!showChat) newMessages = true;\n        } else if (message.shellLatency !== undefined) {\n          const shellLatency = Number(message.shellLatency);\n          shellLatencies = [...shellLatencies, shellLatency].slice(-10);\n        } else if (message.pong !== undefined) {\n          const serverLatency = Date.now() - Number(message.pong);\n          serverLatencies = [...serverLatencies, serverLatency].slice(-10);\n        } else if (message.error) {\n          console.warn(\"Server error: \" + message.error);\n        }\n      },\n\n      onConnect() {\n        srocket?.send({ authenticate: [encryptedZeros, writeEncryptedZeros] });\n        if ($settings.name) {\n          srocket?.send({ setName: $settings.name });\n        }\n        connected = true;\n      },\n\n      onDisconnect() {\n        connected = false;\n        subscriptions.clear();\n        users = [];\n        serverLatencies = [];\n        shellLatencies = [];\n      },\n\n      onClose(event) {\n        if (event.code === 4404) {\n          exitReason = \"Failed to connect: \" + event.reason;\n        } else if (event.code === 4500) {\n          exitReason = \"Internal server error: \" + event.reason;\n        }\n      },\n    });\n  });\n\n  onDestroy(() => srocket?.dispose());\n\n  // Send periodic ping messages for latency estimation.\n  onMount(() => {\n    const pingIntervalId = window.setInterval(() => {\n      if (srocket?.connected) {\n        srocket.send({ ping: BigInt(Date.now()) });\n      }\n    }, 2000);\n    return () => window.clearInterval(pingIntervalId);\n  });\n\n  function integerMedian(values: number[]) {\n    if (values.length === 0) {\n      return null;\n    }\n    const sorted = values.toSorted();\n    const mid = Math.floor(sorted.length / 2);\n    return sorted.length % 2 !== 0\n      ? sorted[mid]\n      : Math.round((sorted[mid - 1] + sorted[mid]) / 2);\n  }\n\n  $: if ($settings.name) {\n    srocket?.send({ setName: $settings.name });\n  }\n\n  let counter = 0n;\n\n  async function handleCreate() {\n    if (hasWriteAccess === false) {\n      makeToast({\n        kind: \"info\",\n        message: \"You are in read-only mode and cannot create new terminals.\",\n      });\n      return;\n    }\n    if (shells.length >= 14) {\n      makeToast({\n        kind: \"error\",\n        message: \"You can only create up to 14 terminals.\",\n      });\n      return;\n    }\n    const existing = shells.map(([id, winsize]) => ({\n      x: winsize.x,\n      y: winsize.y,\n      width: termWrappers[id].clientWidth,\n      height: termWrappers[id].clientHeight,\n    }));\n    const { x, y } = arrangeNewTerminal(existing);\n    srocket?.send({ create: [x, y] });\n    touchZoom.moveTo([x, y], INITIAL_ZOOM);\n  }\n\n  async function handleInput(id: number, data: Uint8Array) {\n    if (counter === 0n) {\n      // On the first call, initialize the counter to a random 64-bit integer.\n      const array = new Uint8Array(8);\n      crypto.getRandomValues(array);\n      counter = new DataView(array.buffer).getBigUint64(0);\n    }\n    const offset = counter;\n    counter += BigInt(data.length); // Must increment before the `await`.\n    const encrypted = await encrypt.segment(0x200000000n, offset, data);\n    srocket?.send({ data: [id, encrypted, offset] });\n  }\n\n  // Stupid hack to preserve input focus when terminals are reordered.\n  // See: https://github.com/sveltejs/svelte/issues/3973\n  let activeElement: Element | null = null;\n\n  beforeUpdate(() => {\n    activeElement = document.activeElement;\n  });\n\n  afterUpdate(() => {\n    if (activeElement instanceof HTMLElement) activeElement.focus();\n  });\n\n  // Global mouse handler logic follows, attached to the window element for smoothness.\n  onMount(() => {\n    // 50 milliseconds between successive terminal move updates.\n    const sendMove = throttle((message: WsClient) => {\n      srocket?.send(message);\n    }, 50);\n\n    // 80 milliseconds between successive cursor updates.\n    const sendCursor = throttle((message: WsClient) => {\n      srocket?.send(message);\n    }, 80);\n\n    function handleMouse(event: MouseEvent) {\n      if (moving !== -1 && !movingIsDone) {\n        const [x, y] = normalizePosition(event);\n        movingSize = {\n          ...movingSize,\n          x: Math.round(x - movingOrigin[0]),\n          y: Math.round(y - movingOrigin[1]),\n        };\n        sendMove({ move: [moving, movingSize] });\n      }\n\n      if (resizing !== -1) {\n        const cols = Math.max(\n          Math.floor((event.pageX - resizingOrigin[0]) / resizingCell[0]),\n          TERM_MIN_COLS, // Minimum number of columns.\n        );\n        const rows = Math.max(\n          Math.floor((event.pageY - resizingOrigin[1]) / resizingCell[1]),\n          TERM_MIN_ROWS, // Minimum number of rows.\n        );\n        if (rows !== resizingSize.rows || cols !== resizingSize.cols) {\n          resizingSize = { ...resizingSize, rows, cols };\n          srocket?.send({ move: [resizing, resizingSize] });\n        }\n      }\n\n      sendCursor({ setCursor: normalizePosition(event) });\n    }\n\n    function handleMouseEnd(event: MouseEvent) {\n      if (moving !== -1) {\n        movingIsDone = true;\n        sendMove.cancel();\n        srocket?.send({ move: [moving, movingSize] });\n      }\n\n      if (resizing !== -1) {\n        resizing = -1;\n      }\n\n      if (event.type === \"mouseleave\") {\n        sendCursor.cancel();\n        srocket?.send({ setCursor: null });\n      }\n    }\n\n    window.addEventListener(\"mousemove\", handleMouse);\n    window.addEventListener(\"mouseup\", handleMouseEnd);\n    document.body.addEventListener(\"mouseleave\", handleMouseEnd);\n    return () => {\n      window.removeEventListener(\"mousemove\", handleMouse);\n      window.removeEventListener(\"mouseup\", handleMouseEnd);\n      document.body.removeEventListener(\"mouseleave\", handleMouseEnd);\n    };\n  });\n\n  let focused: number[] = [];\n  $: setFocus(focused);\n\n  // Wait a small amount of time, since blur events happen before focus events.\n  const setFocus = debounce((focused: number[]) => {\n    srocket?.send({ setFocus: focused[0] ?? null });\n  }, 20);\n</script>\n\n<!-- Wheel handler stops native macOS Chrome zooming on pinch. -->\n<main\n  class=\"p-8\"\n  class:cursor-nwse-resize={resizing !== -1}\n  on:wheel={(event) => event.preventDefault()}\n>\n  <div\n    class=\"absolute top-8 inset-x-0 flex justify-center pointer-events-none z-10\"\n  >\n    <Toolbar\n      {connected}\n      {newMessages}\n      {hasWriteAccess}\n      on:create={handleCreate}\n      on:chat={() => {\n        showChat = !showChat;\n        newMessages = false;\n      }}\n      on:settings={() => {\n        settingsOpen = true;\n      }}\n      on:networkInfo={() => {\n        showNetworkInfo = !showNetworkInfo;\n      }}\n    />\n\n    {#if showNetworkInfo}\n      <div class=\"absolute top-20 translate-x-[116.5px]\">\n        <NetworkInfo\n          status={connected\n            ? \"connected\"\n            : exitReason\n            ? \"no-shell\"\n            : \"no-server\"}\n          serverLatency={integerMedian(serverLatencies)}\n          shellLatency={integerMedian(shellLatencies)}\n        />\n      </div>\n    {/if}\n  </div>\n\n  {#if showChat}\n    <div\n      class=\"absolute flex flex-col justify-end inset-y-4 right-4 w-80 pointer-events-none z-10\"\n    >\n      <Chat\n        {userId}\n        messages={chatMessages}\n        on:chat={(event) => srocket?.send({ chat: event.detail })}\n        on:close={() => (showChat = false)}\n      />\n    </div>\n  {/if}\n\n  <Settings open={settingsOpen} on:close={() => (settingsOpen = false)} />\n\n  <ChooseName />\n\n  <!--\n    Dotted circle background appears underneath the rest of the elements, but\n    moves and zooms with the fabric of the canvas.\n  -->\n  <div\n    class=\"absolute inset-0 -z-10\"\n    style:background-image=\"radial-gradient(#333 {zoom}px, transparent 0)\"\n    style:background-size=\"{24 * zoom}px {24 * zoom}px\"\n    style:background-position=\"{-zoom * center[0]}px {-zoom * center[1]}px\"\n  />\n\n  <div class=\"py-2\">\n    {#if exitReason !== null}\n      <div class=\"text-red-400\">{exitReason}</div>\n    {:else if connected}\n      <div class=\"flex items-center\">\n        <div class=\"text-green-400\">You are connected!</div>\n        {#if userId && hasWriteAccess === false}\n          <div\n            class=\"bg-yellow-900 text-yellow-200 px-1 py-0.5 rounded ml-3 inline-flex items-center gap-1\"\n          >\n            <EyeIcon size=\"14\" />\n            <span class=\"text-xs\">Read-only</span>\n          </div>\n        {/if}\n      </div>\n    {:else}\n      <div class=\"text-yellow-400\">Connecting…</div>\n    {/if}\n\n    <div class=\"mt-4\">\n      <NameList {users} />\n    </div>\n  </div>\n\n  <div class=\"absolute inset-0 overflow-hidden touch-none\" bind:this={fabricEl}>\n    {#each shells as [id, winsize] (id)}\n      {@const ws = id === moving ? movingSize : winsize}\n      <div\n        class=\"absolute\"\n        style:left={OFFSET_LEFT_CSS}\n        style:top={OFFSET_TOP_CSS}\n        style:transform-origin={OFFSET_TRANSFORM_ORIGIN_CSS}\n        transition:fade|local\n        use:slide={{ x: ws.x, y: ws.y, center, zoom, immediate: id === moving }}\n        bind:this={termWrappers[id]}\n      >\n        <XTerm\n          rows={ws.rows}\n          cols={ws.cols}\n          bind:write={writers[id]}\n          bind:termEl={termElements[id]}\n          on:data={({ detail: data }) =>\n            hasWriteAccess && handleInput(id, data)}\n          on:close={() => srocket?.send({ close: id })}\n          on:shrink={() => {\n            if (!hasWriteAccess) return;\n            const rows = Math.max(ws.rows - 4, TERM_MIN_ROWS);\n            const cols = Math.max(ws.cols - 10, TERM_MIN_COLS);\n            if (rows !== ws.rows || cols !== ws.cols) {\n              srocket?.send({ move: [id, { ...ws, rows, cols }] });\n            }\n          }}\n          on:expand={() => {\n            if (!hasWriteAccess) return;\n            const rows = ws.rows + 4;\n            const cols = ws.cols + 10;\n            srocket?.send({ move: [id, { ...ws, rows, cols }] });\n          }}\n          on:bringToFront={() => {\n            if (!hasWriteAccess) return;\n            showNetworkInfo = false;\n            srocket?.send({ move: [id, null] });\n          }}\n          on:startMove={({ detail: event }) => {\n            if (!hasWriteAccess) return;\n            const [x, y] = normalizePosition(event);\n            moving = id;\n            movingOrigin = [x - ws.x, y - ws.y];\n            movingSize = ws;\n            movingIsDone = false;\n          }}\n          on:focus={() => {\n            if (!hasWriteAccess) return;\n            focused = [...focused, id];\n          }}\n          on:blur={() => {\n            focused = focused.filter((i) => i !== id);\n          }}\n        />\n\n        <!-- User avatars -->\n        <div class=\"absolute bottom-2.5 right-2.5 pointer-events-none\">\n          <Avatars\n            users={users.filter(\n              ([uid, user]) => uid !== userId && user.focus === id,\n            )}\n          />\n        </div>\n\n        <!-- Interactable element for resizing -->\n        <div\n          class=\"absolute w-5 h-5 -bottom-1 -right-1 cursor-nwse-resize\"\n          on:mousedown={(event) => {\n            const canvasEl = termElements[id].querySelector(\".xterm-screen\");\n            if (canvasEl) {\n              resizing = id;\n              const r = canvasEl.getBoundingClientRect();\n              resizingOrigin = [event.pageX - r.width, event.pageY - r.height];\n              resizingCell = [r.width / ws.cols, r.height / ws.rows];\n              resizingSize = ws;\n            }\n          }}\n          on:pointerdown={(event) => event.stopPropagation()}\n        />\n      </div>\n    {/each}\n\n    {#each users.filter(([id, user]) => id !== userId && user.cursor !== null) as [id, user] (id)}\n      <div\n        class=\"absolute\"\n        style:left={OFFSET_LEFT_CSS}\n        style:top={OFFSET_TOP_CSS}\n        style:transform-origin={OFFSET_TRANSFORM_ORIGIN_CSS}\n        transition:fade|local={{ duration: 200 }}\n        use:slide={{\n          x: user.cursor?.[0] ?? 0,\n          y: user.cursor?.[1] ?? 0,\n          center,\n          zoom,\n        }}\n      >\n        <LiveCursor {user} />\n      </div>\n    {/each}\n  </div>\n</main>\n"
  },
  {
    "path": "src/lib/action/slide.ts",
    "content": "import { tweened } from \"svelte/motion\";\nimport { cubicOut } from \"svelte/easing\";\nimport type { Action } from \"svelte/action\";\nimport { PerfectCursor } from \"perfect-cursors\";\n\nexport type SlideParams = {\n  x: number;\n  y: number;\n  center: number[];\n  zoom: number;\n  immediate?: boolean;\n};\n\n/** An action for tweened transitions with global transformations. */\nexport const slide: Action<HTMLElement, SlideParams> = (node, params) => {\n  let center = params?.center ?? [0, 0];\n  let zoom = params?.zoom ?? 1;\n\n  const pos = { x: params?.x ?? 0, y: params?.y ?? 0 };\n  const spos = tweened(pos, { duration: 150, easing: cubicOut });\n\n  const disposeSub = spos.subscribe((pos) => {\n    node.style.transform = `scale(${(zoom * 100).toFixed(3)}%)\n      translate3d(${pos.x - center[0]}px, ${pos.y - center[1]}px, 0)`;\n  });\n\n  return {\n    update(params) {\n      center = params?.center ?? [0, 0];\n      zoom = params?.zoom ?? 1;\n      const pos = { x: params?.x ?? 0, y: params?.y ?? 0 };\n      spos.set(pos, { duration: params.immediate ? 0 : 150 });\n    },\n\n    destroy() {\n      disposeSub();\n      node.style.transform = \"\";\n    },\n  };\n};\n\n/**\n * An action using perfect-cursors to transition an element.\n *\n * The transitions are really smooth geometrically, but they seem to introduce\n * too much noticeable delay. Keeping this function for reference.\n */\nexport const slideCursor: Action<HTMLElement, SlideParams> = (node, params) => {\n  const pos = params ?? { x: 0, y: 0 };\n\n  const pc = new PerfectCursor(([x, y]: number[]) => {\n    node.style.transform = `translate3d(${x}px, ${y}px, 0)`;\n  });\n  pc.addPoint([pos.x, pos.y]);\n\n  return {\n    update(params) {\n      const pos = params ?? { x: 0, y: 0 };\n      pc.addPoint([pos.x, pos.y]);\n    },\n\n    destroy() {\n      pc.dispose();\n      node.style.transform = \"\";\n    },\n  };\n};\n"
  },
  {
    "path": "src/lib/action/touchZoom.ts",
    "content": "/**\n * @file Handles pan and zoom events to create an infinite canvas.\n *\n * This file is modified from Dispict <https://github.com/ekzhang/dispict>,\n * which itself is loosely based on tldraw.\n */\n\nimport {\n  Gesture,\n  type Handler,\n  type WebKitGestureEvent,\n} from \"@use-gesture/vanilla\";\nimport Vec from \"@tldraw/vec\";\n\n// Credits: from excalidraw\n// https://github.com/excalidraw/excalidraw/blob/07ebd7c68ce6ff92ddbc22d1c3d215f2b21328d6/src/utils.ts#L542-L563\nconst getNearestScrollableContainer = (\n  element: HTMLElement,\n): HTMLElement | Document => {\n  let parent = element.parentElement;\n  while (parent) {\n    if (parent === document.body) {\n      return document;\n    }\n    const { overflowY } = window.getComputedStyle(parent);\n    const hasScrollableContent = parent.scrollHeight > parent.clientHeight;\n    if (\n      hasScrollableContent &&\n      (overflowY === \"auto\" ||\n        overflowY === \"scroll\" ||\n        overflowY === \"overlay\")\n    ) {\n      return parent;\n    }\n    parent = parent.parentElement;\n  }\n  return document;\n};\n\nfunction isDarwin(): boolean {\n  return /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);\n}\n\nfunction debounce<T extends (...args: any[]) => void>(fn: T, ms = 0) {\n  let timeoutId: number | any;\n  return function (...args: Parameters<T>) {\n    clearTimeout(timeoutId);\n    timeoutId = setTimeout(() => fn.apply(args), ms);\n  };\n}\n\nconst MIN_ZOOM = 0.35;\nconst MAX_ZOOM = 2;\nexport const INITIAL_ZOOM = 1.0;\n\nexport class TouchZoom {\n  #node: HTMLElement;\n  #scrollingAnchor: HTMLElement | Document;\n  #gesture: Gesture;\n  #resizeObserver: ResizeObserver;\n\n  #bounds = {\n    minX: 0,\n    maxX: 0,\n    minY: 0,\n    maxY: 0,\n    width: 0,\n    height: 0,\n  };\n  #originPoint: number[] | undefined = undefined;\n  #delta: number[] = [0, 0];\n  #lastMovement = 1;\n  #wheelLastTimeStamp = 0;\n\n  #callbacks = new Set<(manual: boolean) => void>();\n\n  isPinching = false;\n  center: number[] = [0, 0];\n  zoom = INITIAL_ZOOM;\n\n  #preventGesture = (event: TouchEvent) => event.preventDefault();\n\n  constructor(node: HTMLElement) {\n    this.#node = node;\n    this.#scrollingAnchor = getNearestScrollableContainer(node);\n    // @ts-ignore\n    document.addEventListener(\"gesturestart\", this.#preventGesture);\n    // @ts-ignore\n    document.addEventListener(\"gesturechange\", this.#preventGesture);\n\n    this.#updateBounds();\n    window.addEventListener(\"resize\", this.#updateBoundsD);\n    this.#scrollingAnchor.addEventListener(\"scroll\", this.#updateBoundsD);\n\n    this.#resizeObserver = new ResizeObserver((entries) => {\n      if (this.isPinching) return;\n      if (entries[0].contentRect) this.#updateBounds();\n    });\n    this.#resizeObserver.observe(node);\n\n    this.#gesture = new Gesture(\n      node,\n      {\n        onWheel: this.#handleWheel,\n        onPinchStart: this.#handlePinchStart,\n        onPinch: this.#handlePinch,\n        onPinchEnd: this.#handlePinchEnd,\n        onDrag: this.#handleDrag,\n      },\n      {\n        target: node,\n        eventOptions: { passive: false },\n        pinch: {\n          from: [this.zoom, 0],\n          scaleBounds: () => {\n            return { from: this.zoom, max: MAX_ZOOM, min: MIN_ZOOM };\n          },\n        },\n        drag: {\n          filterTaps: true,\n          pointer: { keys: false },\n        },\n      },\n    );\n  }\n\n  #getPoint(e: PointerEvent | Touch | WheelEvent): number[] {\n    return [\n      +e.clientX.toFixed(2) - this.#bounds.minX,\n      +e.clientY.toFixed(2) - this.#bounds.minY,\n    ];\n  }\n\n  #updateBounds = () => {\n    const rect = this.#node.getBoundingClientRect();\n    this.#bounds = {\n      minX: rect.left,\n      maxX: rect.left + rect.width,\n      minY: rect.top,\n      maxY: rect.top + rect.height,\n      width: rect.width,\n      height: rect.height,\n    };\n  };\n\n  #updateBoundsD = debounce(this.#updateBounds, 100);\n\n  onMove(callback: (manual: boolean) => void): () => void {\n    this.#callbacks.add(callback);\n    return () => this.#callbacks.delete(callback);\n  }\n\n  async moveTo(pos: number[], zoom: number) {\n    // Cubic bezier easing\n    const smoothstep = (z: number) => {\n      const x = Math.max(0, Math.min(1, z));\n      return x * x * (3 - 2 * x);\n    };\n\n    const beginTime = Date.now();\n    const totalTime = 350; // milliseconds\n\n    const start = this.center;\n    const startZ = 1 / this.zoom;\n    const finishZ = 1 / zoom;\n    while (true) {\n      const t = Date.now() - beginTime;\n      if (t > totalTime) break;\n      const k = smoothstep(t / totalTime);\n\n      this.center = Vec.lrp(start, pos, k);\n      this.zoom = 1 / (startZ * (1 - k) + finishZ * k);\n      this.#moved(false);\n      await new Promise((resolve) => requestAnimationFrame(resolve));\n    }\n    this.center = pos;\n    this.zoom = zoom;\n    this.#moved(false);\n  }\n\n  #moved(manual = true) {\n    for (const callback of this.#callbacks) {\n      callback(manual);\n    }\n  }\n\n  #handleWheel: Handler<\"wheel\", WheelEvent> = ({ event: e }) => {\n    e.preventDefault();\n    if (this.isPinching || e.timeStamp <= this.#wheelLastTimeStamp) return;\n\n    this.#wheelLastTimeStamp = e.timeStamp;\n\n    const [x, y, z] = normalizeWheel(e);\n\n    // alt+scroll or ctrl+scroll = zoom (when not clicking)\n    if ((e.altKey || e.ctrlKey || e.metaKey) && e.buttons === 0) {\n      const point =\n        e.clientX && e.clientY\n          ? this.#getPoint(e)\n          : [this.#bounds.width / 2, this.#bounds.height / 2];\n      const delta = z * 0.618;\n\n      let newZoom = (1 - delta / 320) * this.zoom;\n      newZoom = Vec.clamp(newZoom, MIN_ZOOM, MAX_ZOOM);\n\n      const offset = Vec.sub(point, [0, 0]);\n      const movement = Vec.mul(offset, 1 / this.zoom - 1 / newZoom);\n      this.center = Vec.add(this.center, movement);\n      this.zoom = newZoom;\n\n      this.#moved();\n      return;\n    }\n\n    // otherwise pan\n    const delta = Vec.mul(\n      e.shiftKey && !isDarwin()\n        ? // shift+scroll = pan horizontally\n          [y, 0]\n        : // scroll = pan vertically (or in any direction on a trackpad)\n          [x, y],\n      0.5,\n    );\n\n    if (Vec.isEqual(delta, [0, 0])) return;\n\n    this.center = Vec.add(this.center, Vec.div(delta, this.zoom));\n    this.#moved();\n  };\n\n  #handlePinchStart: Handler<\n    \"pinch\",\n    WheelEvent | PointerEvent | TouchEvent | WebKitGestureEvent\n  > = ({ origin, event }) => {\n    if (event instanceof WheelEvent) return;\n\n    this.isPinching = true;\n    this.#originPoint = origin;\n    this.#delta = [0, 0];\n    this.#lastMovement = 1;\n    this.#moved();\n  };\n\n  #handlePinch: Handler<\n    \"pinch\",\n    WheelEvent | PointerEvent | TouchEvent | WebKitGestureEvent\n  > = ({ origin, movement, event }) => {\n    if (event instanceof WheelEvent) return;\n\n    if (!this.#originPoint) return;\n    const delta = Vec.sub(this.#originPoint, origin);\n    const trueDelta = Vec.sub(delta, this.#delta);\n    this.#delta = delta;\n\n    const zoomLevel = movement[0] / this.#lastMovement;\n    this.#lastMovement = movement[0];\n\n    this.center = Vec.add(this.center, Vec.div(trueDelta, this.zoom * 2));\n    this.zoom = Vec.clamp(this.zoom * zoomLevel, MIN_ZOOM, MAX_ZOOM);\n    this.#moved();\n  };\n\n  #handlePinchEnd: Handler<\n    \"pinch\",\n    WheelEvent | PointerEvent | TouchEvent | WebKitGestureEvent\n  > = () => {\n    this.isPinching = false;\n    this.#originPoint = undefined;\n    this.#delta = [0, 0];\n    this.#lastMovement = 1;\n    this.#moved();\n  };\n\n  #handleDrag: Handler<\n    \"drag\",\n    MouseEvent | PointerEvent | TouchEvent | KeyboardEvent\n  > = ({ delta, elapsedTime }) => {\n    if (delta[0] === 0 && delta[1] === 0 && elapsedTime < 200) return;\n    this.center = Vec.sub(this.center, Vec.div(delta, this.zoom));\n    this.#moved();\n  };\n\n  destroy() {\n    if (this.#node) {\n      // @ts-ignore\n      document.addEventListener(\"gesturestart\", this.#preventGesture);\n      // @ts-ignore\n      document.addEventListener(\"gesturechange\", this.#preventGesture);\n\n      window.removeEventListener(\"resize\", this.#updateBoundsD);\n      this.#scrollingAnchor.removeEventListener(\"scroll\", this.#updateBoundsD);\n\n      this.#resizeObserver.disconnect();\n\n      this.#gesture.destroy();\n      this.#node = null as any;\n    }\n  }\n}\n\n// Reasonable defaults\nconst MAX_ZOOM_STEP = 10;\n\n// Adapted from https://stackoverflow.com/a/13650579\nfunction normalizeWheel(event: WheelEvent) {\n  const { deltaY, deltaX } = event;\n\n  let deltaZ = 0;\n\n  if (event.ctrlKey || event.metaKey) {\n    const signY = Math.sign(event.deltaY);\n    const absDeltaY = Math.abs(event.deltaY);\n\n    let dy = deltaY;\n\n    if (absDeltaY > MAX_ZOOM_STEP) {\n      dy = MAX_ZOOM_STEP * signY;\n    }\n\n    deltaZ = dy;\n  }\n\n  return [deltaX, deltaY, deltaZ];\n}\n"
  },
  {
    "path": "src/lib/arrange.ts",
    "content": "const ISECT_W = 752;\nconst ISECT_H = 515;\nconst ISECT_PAD = 16;\n\ntype ExistingTerminal = {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n};\n\n/** Choose a position for a new terminal that does not intersect existing ones. */\nexport function arrangeNewTerminal(existing: ExistingTerminal[]) {\n  if (existing.length === 0) {\n    return { x: 0, y: 0 };\n  }\n\n  const startX = 100 * (Math.random() - 0.5);\n  const startY = 60 * (Math.random() - 0.5);\n\n  for (let i = 0; ; i++) {\n    const t = 1.94161103872 * i;\n    const x = Math.round(startX + 8 * i * Math.cos(t));\n    const y = Math.round(startY + 8 * i * Math.sin(t));\n    let ok = true;\n    for (const box of existing) {\n      if (\n        isect(x, x + ISECT_W, box.x, box.x + box.width) &&\n        isect(y, y + ISECT_H, box.y, box.y + box.height)\n      ) {\n        ok = false;\n        break;\n      }\n    }\n    if (ok) {\n      return { x, y };\n    }\n  }\n}\n\nfunction isect(s1: number, e1: number, s2: number, e2: number): boolean {\n  return s1 - ISECT_PAD < e2 && e1 + ISECT_PAD > s2;\n}\n"
  },
  {
    "path": "src/lib/encrypt.ts",
    "content": "/**\n * @file Encryption of byte streams based on a random key.\n *\n * This is used for end-to-end encryption between the terminal source and its\n * client. Keep this file consistent with the Rust implementation.\n */\n\nconst SALT: string =\n  \"This is a non-random salt for sshx.io, since we want to stretch the security of 83-bit keys!\";\n\nexport class Encrypt {\n  private constructor(private aesKey: CryptoKey) {}\n\n  static async new(key: string): Promise<Encrypt> {\n    const argon2 = await import(\n      \"argon2-browser/dist/argon2-bundled.min.js\" as any\n    );\n    const result = await argon2.hash({\n      pass: key,\n      salt: SALT,\n      type: argon2.ArgonType.Argon2id,\n      mem: 19 * 1024, // Memory cost in KiB\n      time: 2, // Number of iterations\n      parallelism: 1,\n      hashLen: 16, // Hash length in bytes\n    });\n    const aesKey = await crypto.subtle.importKey(\n      \"raw\",\n      Uint8Array.from(\n        result.hashHex\n          .match(/.{1,2}/g)\n          .map((byte: string) => parseInt(byte, 16)),\n      ),\n      { name: \"AES-CTR\" },\n      false,\n      [\"encrypt\"],\n    );\n    return new Encrypt(aesKey);\n  }\n\n  async zeros(): Promise<Uint8Array> {\n    const zeros = new Uint8Array(16);\n    const cipher = await crypto.subtle.encrypt(\n      { name: \"AES-CTR\", counter: zeros, length: 64 },\n      this.aesKey,\n      zeros,\n    );\n    return new Uint8Array(cipher);\n  }\n\n  async segment(\n    streamNum: bigint,\n    offset: bigint,\n    data: Uint8Array,\n  ): Promise<Uint8Array> {\n    if (streamNum === 0n) throw new Error(\"stream number must be nonzero\"); // security check)\n\n    const blockNum = offset >> 4n;\n    const iv = new Uint8Array(16);\n    new DataView(iv.buffer).setBigUint64(0, streamNum);\n    new DataView(iv.buffer).setBigUint64(8, blockNum);\n\n    const padBytes = Number(offset % 16n);\n    const paddedData = new Uint8Array(padBytes + data.length);\n    paddedData.set(data, padBytes);\n\n    const encryptedData = await crypto.subtle.encrypt(\n      {\n        name: \"AES-CTR\",\n        counter: iv,\n        length: 64,\n      },\n      this.aesKey,\n      paddedData,\n    );\n    return new Uint8Array(encryptedData, padBytes, data.length);\n  }\n}\n"
  },
  {
    "path": "src/lib/lock.ts",
    "content": "// Simple async lock for use in streaming encryption.\n// See <https://stackoverflow.com/a/74538176>.\nexport function createLock() {\n  const queue: (() => Promise<void>)[] = [];\n  let active = false;\n  return (fn: () => Promise<void>) => {\n    let deferredResolve: any;\n    let deferredReject: any;\n    const deferred = new Promise((resolve, reject) => {\n      deferredResolve = resolve;\n      deferredReject = reject;\n    });\n    const exec = async () => {\n      await fn().then(deferredResolve, deferredReject);\n      if (queue.length > 0) {\n        queue.shift()!();\n      } else {\n        active = false;\n      }\n    };\n    if (active) {\n      queue.push(exec);\n    } else {\n      active = true;\n      exec();\n    }\n    return deferred;\n  };\n}\n"
  },
  {
    "path": "src/lib/protocol.ts",
    "content": "type Sid = number; // u32\ntype Uid = number; // u32\n\n/** Position and size of a window, see the Rust version. */\nexport type WsWinsize = {\n  x: number;\n  y: number;\n  rows: number;\n  cols: number;\n};\n\n/** Information about a user, see the Rust version */\nexport type WsUser = {\n  name: string;\n  cursor: [number, number] | null;\n  focus: number | null;\n  canWrite: boolean;\n};\n\n/** Server message type, see the Rust version. */\nexport type WsServer = {\n  hello?: [Uid, string];\n  invalidAuth?: [];\n  users?: [Uid, WsUser][];\n  userDiff?: [Uid, WsUser | null];\n  shells?: [Sid, WsWinsize][];\n  chunks?: [Sid, number, Uint8Array[]];\n  hear?: [Uid, string, string];\n  shellLatency?: number | bigint;\n  pong?: number | bigint;\n  error?: string;\n};\n\n/** Client message type, see the Rust version. */\nexport type WsClient = {\n  authenticate?: [Uint8Array, Uint8Array | null];\n  setName?: string;\n  setCursor?: [number, number] | null;\n  setFocus?: number | null;\n  create?: [number, number];\n  close?: Sid;\n  move?: [Sid, WsWinsize | null];\n  data?: [Sid, Uint8Array, bigint];\n  subscribe?: [Sid, number];\n  chat?: string;\n  ping?: bigint;\n};\n"
  },
  {
    "path": "src/lib/settings.ts",
    "content": "import { persisted } from \"svelte-persisted-store\";\nimport themes, { type ThemeName, defaultTheme } from \"./ui/themes\";\nimport { derived, type Readable } from \"svelte/store\";\n\nexport type Settings = {\n  name: string;\n  theme: ThemeName;\n  scrollback: number;\n};\n\nconst storedSettings = persisted<Partial<Settings>>(\"sshx-settings-store\", {});\n\n/** A persisted store for settings of the current user. */\nexport const settings: Readable<Settings> = derived(\n  storedSettings,\n  ($storedSettings) => {\n    // Do some validation on all of the stored settings.\n    const name = $storedSettings.name ?? \"\";\n\n    let theme = $storedSettings.theme;\n    if (!theme || !Object.hasOwn(themes, theme)) {\n      theme = defaultTheme;\n    }\n\n    let scrollback = $storedSettings.scrollback;\n    if (typeof scrollback !== \"number\" || scrollback < 0) {\n      scrollback = 5000;\n    }\n\n    return {\n      name,\n      theme,\n      scrollback,\n    };\n  },\n);\n\nexport function updateSettings(values: Partial<Settings>) {\n  storedSettings.update((settings) => ({ ...settings, ...values }));\n}\n"
  },
  {
    "path": "src/lib/srocket.ts",
    "content": "/**\n * @file Internal library for sshx, providing real-time communication.\n *\n * The contents of this file are technically general, not sshx-specific, but it\n * is not open-sourced as its own library because it's not ready for that.\n */\n\nimport { encode, decode } from \"cbor-x\";\n\n/** How long to wait between reconnections (in milliseconds). */\nconst RECONNECT_DELAY = 500;\n\n/** Number of messages to queue while disconnected. */\nconst BUFFER_SIZE = 64;\n\nexport type SrocketOptions<T> = {\n  /** Handle a message received from the server. */\n  onMessage(message: T): void;\n\n  /** Called when the socket connects to the server. */\n  onConnect?(): void;\n\n  /** Called when a connected socket is closed. */\n  onDisconnect?(): void;\n\n  /** Called when an incoming or existing connection is closed. */\n  onClose?(event: CloseEvent): void;\n};\n\n/** A reconnecting WebSocket client for real-time communication. */\nexport class Srocket<T, U> {\n  #url: string;\n  #options: SrocketOptions<T>;\n\n  #ws: WebSocket | null;\n  #connected: boolean;\n  #buffer: Uint8Array[];\n  #disposed: boolean;\n\n  constructor(url: string, options: SrocketOptions<T>) {\n    this.#url = url;\n    if (this.#url.startsWith(\"/\")) {\n      // Get WebSocket URL relative to the current origin.\n      this.#url =\n        (window.location.protocol === \"https:\" ? \"wss://\" : \"ws://\") +\n        window.location.host +\n        this.#url;\n    }\n    this.#options = options;\n\n    this.#ws = null;\n    this.#connected = false;\n    this.#buffer = [];\n    this.#disposed = false;\n    this.#reconnect();\n  }\n\n  get connected() {\n    return this.#connected;\n  }\n\n  /** Queue a message to send to the server, with \"at-most-once\" semantics. */\n  send(message: U) {\n    // Types in cbor-x are incorrect here, so cast to fix the error.\n    // See: https://github.com/kriszyp/cbor-x/issues/120\n    const data = <Uint8Array>(encode(message) as unknown);\n\n    if (this.#connected && this.#ws) {\n      this.#ws.send(data);\n    } else {\n      if (this.#buffer.length < BUFFER_SIZE) {\n        this.#buffer.push(data);\n      }\n    }\n  }\n\n  /** Dispose of this WebSocket permanently. */\n  dispose() {\n    this.#stateChange(false);\n    this.#disposed = true;\n    this.#ws?.close();\n  }\n\n  #reconnect() {\n    if (this.#disposed) return;\n    if (this.#ws !== null) {\n      throw new Error(\"invariant violation: reconnecting while connected\");\n    }\n    this.#ws = new WebSocket(this.#url);\n    this.#ws.binaryType = \"arraybuffer\";\n    this.#ws.onopen = () => {\n      this.#stateChange(true);\n    };\n    this.#ws.onclose = (event) => {\n      this.#options.onClose?.(event);\n      this.#ws = null;\n      this.#stateChange(false);\n      setTimeout(() => this.#reconnect(), RECONNECT_DELAY);\n    };\n    this.#ws.onmessage = (event) => {\n      if (event.data instanceof ArrayBuffer) {\n        const message: T = decode(new Uint8Array(event.data));\n        this.#options.onMessage(message);\n      } else {\n        console.warn(\"unexpected non-buffer message, ignoring\");\n      }\n    };\n  }\n\n  #stateChange(connected: boolean) {\n    if (!this.#disposed && connected !== this.#connected) {\n      this.#connected = connected;\n      if (connected) {\n        this.#options.onConnect?.();\n\n        if (!this.#ws) {\n          throw new Error(\"invariant violation: connected but ws is null\");\n        }\n        // Send any queued messages.\n        for (const message of this.#buffer) {\n          this.#ws.send(message);\n        }\n        this.#buffer = [];\n      } else {\n        this.#options.onDisconnect?.();\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/toast.ts",
    "content": "/** @file Provides a simple, native toast library. */\n\nimport { writable } from \"svelte/store\";\n\nexport const toastStore = writable<(Toast & { expires: number })[]>([]);\n\nexport type Toast = {\n  kind: \"info\" | \"success\" | \"error\";\n  message: string;\n  action?: string;\n  onAction?: () => void;\n};\n\nexport function makeToast(toast: Toast, duration = 3000) {\n  const obj = Object.assign({ expires: Date.now() + duration }, toast);\n  toastStore.update(($toasts) => [...$toasts, obj]);\n}\n"
  },
  {
    "path": "src/lib/typeahead.ts",
    "content": "// A terminal \"local echo\" or typeahead addon for xterm.js.\n//\n// This is forked from VSCode's typeahead implementation at\n// https://github.com/microsoft/vscode/blob/1.80.1/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts\n//\n// I made this mostly standalone by porting over the \"vs\" common libraries.\n\nimport type {\n  IBuffer,\n  IBufferCell,\n  IDisposable,\n  ITerminalAddon,\n  Terminal,\n} from \"sshx-xterm\";\n\n///// BEGIN PORTS FROM PACKAGES vs/base/* /////\n\n/** Simplified port of vscode's Disposable class, from `vs/base/common/lifecycle.ts`. */\nabstract class Disposable implements IDisposable {\n  protected isDisposed = false;\n  protected readonly store: IDisposable[] = [];\n\n  dispose(): void {\n    if (!this.isDisposed) {\n      this.isDisposed = true;\n      for (const d of this.store) {\n        d.dispose();\n      }\n    }\n  }\n\n  protected _register<T extends IDisposable>(o: T): T {\n    if ((o as unknown as Disposable) == this) {\n      throw new Error(\"cannot _register a Disposable on itself\");\n    }\n    if (this.isDisposed) {\n      console.warn(\n        new Error(\"trying to _register on a Disposable that is disposed\"),\n      );\n    } else {\n      this.store.push(o);\n    }\n    return o;\n  }\n}\n\n/** Port of vscode's toDisposable function, from `vs/base/common/lifecycle.ts`. */\nfunction toDisposable(fn: () => void): IDisposable {\n  let isDisposed = false;\n  return {\n    dispose() {\n      if (!isDisposed) {\n        isDisposed = true;\n        fn();\n      }\n    },\n  };\n}\n\n/** Port from `vs/base/common/async.ts`. */\nfunction disposableTimeout(handler: () => void, timeout = 0): IDisposable {\n  const timer = setTimeout(handler, timeout);\n  return toDisposable(() => clearTimeout(timer));\n}\n\n/**\n * An event with zero or one parameters that ca n be subscribed to. The event is a function itself.\n *\n * Simplified port from `vs/base/common/event.ts`.\n */\ninterface Event<T> {\n  (listener: (e: T) => any): IDisposable;\n}\n\n/** Very simplified port (rewrite) from `vs/base/common/event.ts`. */\nclass Emitter<T> {\n  private _disposed = false;\n  private _event?: Event<T>;\n  private _listeners: ((e: T) => any)[] = [];\n\n  dispose() {\n    if (!this._disposed) {\n      this._disposed = true;\n    }\n  }\n\n  get event(): Event<T> {\n    this._event ??= (callback: (e: T) => any) => {\n      if (this._disposed) {\n        return toDisposable(() => {});\n      }\n      this._listeners.push(callback);\n      return toDisposable(() => {\n        this._listeners = this._listeners.filter((l) => l !== callback);\n      });\n    };\n    return this._event;\n  }\n\n  fire(event: T): void {\n    if (this._disposed) return;\n    for (const listener of this._listeners) {\n      listener(event);\n    }\n  }\n}\n\n/**\n * Escapes regular expression characters in a given string, from `vs/base/common/strings.ts`.\n */\nfunction escapeRegExpCharacters(value: string): string {\n  // eslint-disable-next-line no-useless-escape\n  return value.replace(/[\\\\\\{\\}\\*\\+\\?\\|\\^\\$\\.\\[\\]\\(\\)]/g, \"\\\\$&\");\n}\n\n/** Port from `vs/base/common/decorators.ts` */\nfunction createDecorator(\n  mapFn: (fn: Function, key: string) => Function,\n): Function {\n  return (target: any, key: string, descriptor: any) => {\n    let fnKey: string | null = null;\n    let fn: Function | null = null;\n\n    if (typeof descriptor.value === \"function\") {\n      fnKey = \"value\";\n      fn = descriptor.value;\n    } else if (typeof descriptor.get === \"function\") {\n      fnKey = \"get\";\n      fn = descriptor.get;\n    }\n\n    if (!fn) {\n      throw new Error(\"not supported\");\n    }\n\n    descriptor[fnKey!] = mapFn(fn, key);\n  };\n}\n\n/** Port from `vs/base/common/decorators.ts` */\ninterface IDebounceReducer<T> {\n  (previousValue: T, ...args: any[]): T;\n}\n\n/** Port from `vs/base/common/decorators.ts` */\nfunction debounce<T>(\n  delay: number,\n  reducer?: IDebounceReducer<T>,\n  initialValueProvider?: () => T,\n): Function {\n  return createDecorator((fn, key) => {\n    const timerKey = `$debounce$${key}`;\n    const resultKey = `$debounce$result$${key}`;\n\n    return function (this: any, ...args: any[]) {\n      if (!this[resultKey]) {\n        this[resultKey] = initialValueProvider\n          ? initialValueProvider()\n          : undefined;\n      }\n\n      clearTimeout(this[timerKey]);\n\n      if (reducer) {\n        this[resultKey] = reducer(this[resultKey], ...args);\n        args = [this[resultKey]];\n      }\n\n      this[timerKey] = setTimeout(() => {\n        fn.apply(this, args);\n        this[resultKey] = initialValueProvider\n          ? initialValueProvider()\n          : undefined;\n      }, delay);\n    };\n  });\n}\n\n///// END PORTS FROM PACKAGES vs/base/* /////\n\nconst enum VT {\n  Esc = \"\\x1b\",\n  Csi = `\\x1b[`,\n  ShowCursor = `\\x1b[?25h`,\n  HideCursor = `\\x1b[?25l`,\n  DeleteChar = `\\x1b[X`,\n  DeleteRestOfLine = `\\x1b[K`,\n}\n\nconst CSI_STYLE_RE = /^\\x1b\\[[0-9;]*m/;\nconst CSI_MOVE_RE = /^\\x1b\\[?([0-9]*)(;[35])?O?([DC])/;\nconst NOT_WORD_RE = /[^a-z0-9]/i;\n\nconst enum StatsConstants {\n  StatsBufferSize = 24,\n  StatsSendTelemetryEvery = 1000 * 60 * 5, // how often to collect stats\n  StatsMinSamplesToTurnOn = 5,\n  StatsMinAccuracyToTurnOn = 0.3,\n  StatsToggleOffThreshold = 0.5, // if latency is less than `threshold * this`, turn off\n}\n\n/**\n * Codes that should be omitted from sending to the prediction engine and instead omitted directly:\n * - Hide cursor (DECTCEM): We wrap the local echo sequence in hide and show\n *   CSI ? 2 5 l\n * - Show cursor (DECTCEM): We wrap the local echo sequence in hide and show\n *   CSI ? 2 5 h\n * - Device Status Report (DSR): These sequence fire report events from xterm which could cause\n *   double reporting and potentially a stack overflow (#119472)\n *   CSI Ps n\n *   CSI ? Ps n\n */\nconst PREDICTION_OMIT_RE = /^(\\x1b\\[(\\??25[hl]|\\??[0-9;]+n))+/;\n\nconst core = (terminal: Terminal): any => (terminal as any)._core; // => IXtermCore\nconst flushOutput = (terminal: Terminal) => {\n  // TODO: Flushing output is not possible anymore without async\n  void terminal;\n};\n\nconst enum CursorMoveDirection {\n  Back = \"D\",\n  Forwards = \"C\",\n}\n\ninterface ICoordinate {\n  x: number;\n  y: number;\n  baseY: number;\n}\n\nclass Cursor implements ICoordinate {\n  private _x = 0;\n  private _y = 1;\n  private _baseY = 1;\n\n  get x() {\n    return this._x;\n  }\n\n  get y() {\n    return this._y;\n  }\n\n  get baseY() {\n    return this._baseY;\n  }\n\n  get coordinate(): ICoordinate {\n    return { x: this._x, y: this._y, baseY: this._baseY };\n  }\n\n  constructor(\n    readonly rows: number,\n    readonly cols: number,\n    private readonly _buffer: IBuffer,\n  ) {\n    this._x = _buffer.cursorX;\n    this._y = _buffer.cursorY;\n    this._baseY = _buffer.baseY;\n  }\n\n  getLine() {\n    return this._buffer.getLine(this._y + this._baseY);\n  }\n\n  getCell(loadInto?: IBufferCell) {\n    return this.getLine()?.getCell(this._x, loadInto);\n  }\n\n  moveTo(coordinate: ICoordinate) {\n    this._x = coordinate.x;\n    this._y = coordinate.y + coordinate.baseY - this._baseY;\n    return this.moveInstruction();\n  }\n\n  clone() {\n    const c = new Cursor(this.rows, this.cols, this._buffer);\n    c.moveTo(this);\n    return c;\n  }\n\n  move(x: number, y: number) {\n    this._x = x;\n    this._y = y;\n    return this.moveInstruction();\n  }\n\n  shift(x: number = 0, y: number = 0) {\n    this._x += x;\n    this._y += y;\n    return this.moveInstruction();\n  }\n\n  moveInstruction() {\n    if (this._y >= this.rows) {\n      this._baseY += this._y - (this.rows - 1);\n      this._y = this.rows - 1;\n    } else if (this._y < 0) {\n      this._baseY -= this._y;\n      this._y = 0;\n    }\n\n    return `${VT.Csi}${this._y + 1};${this._x + 1}H`;\n  }\n}\n\nconst moveToWordBoundary = (b: IBuffer, cursor: Cursor, direction: -1 | 1) => {\n  let ateLeadingWhitespace = false;\n  if (direction < 0) {\n    cursor.shift(-1);\n  }\n\n  let cell: IBufferCell | undefined;\n  while (cursor.x >= 0) {\n    cell = cursor.getCell(cell);\n    if (!cell?.getCode()) {\n      return;\n    }\n\n    const chars = cell.getChars();\n    if (NOT_WORD_RE.test(chars)) {\n      if (ateLeadingWhitespace) {\n        break;\n      }\n    } else {\n      ateLeadingWhitespace = true;\n    }\n\n    cursor.shift(direction);\n  }\n\n  if (direction < 0) {\n    cursor.shift(1); // we want to place the cursor after the whitespace starting the word\n  }\n};\n\nconst enum MatchResult {\n  /** matched successfully */\n  Success,\n  /** failed to match */\n  Failure,\n  /** buffer data, it might match in the future one more data comes in */\n  Buffer,\n}\n\nexport interface IPrediction {\n  /**\n   * Whether applying this prediction can modify the style attributes of the\n   * terminal. If so it means we need to reset the cursor style if it's\n   * rolled back.\n   */\n  readonly affectsStyle?: boolean;\n\n  /**\n   * If set to false, the prediction will not be cleared if no input is\n   * received from the server.\n   */\n  readonly clearAfterTimeout?: boolean;\n\n  /**\n   * Returns a sequence to apply the prediction.\n   * @param buffer to write to\n   * @param cursor position to write the data. Should advance the cursor.\n   * @returns a string to be written to the user terminal, or optionally a\n   * string for the user terminal and real pty.\n   */\n  apply(buffer: IBuffer, cursor: Cursor): string;\n\n  /**\n   * Returns a sequence to roll back a previous `apply()` call. If\n   * `rollForwards` is not given, then this is also called if a prediction\n   * is correct before show the user's data.\n   */\n  rollback(cursor: Cursor): string;\n\n  /**\n   * If available, this will be called when the prediction is correct.\n   */\n  rollForwards(cursor: Cursor, withInput: string): string;\n\n  /**\n   * Returns whether the given input is one expected by this prediction.\n   * @param input reader for the input the PTY is giving\n   * @param lookBehind the last successfully-made prediction, if any\n   */\n  matches(input: StringReader, lookBehind?: IPrediction): MatchResult;\n}\n\nclass StringReader {\n  index = 0;\n\n  get remaining() {\n    return this._input.length - this.index;\n  }\n\n  get eof() {\n    return this.index === this._input.length;\n  }\n\n  get rest() {\n    return this._input.slice(this.index);\n  }\n\n  constructor(private readonly _input: string) {}\n\n  /**\n   * Advances the reader and returns the character if it matches.\n   */\n  eatChar(char: string) {\n    if (this._input[this.index] !== char) {\n      return;\n    }\n\n    this.index++;\n    return char;\n  }\n\n  /**\n   * Advances the reader and returns the string if it matches.\n   */\n  eatStr(substr: string) {\n    if (this._input.slice(this.index, substr.length) !== substr) {\n      return;\n    }\n\n    this.index += substr.length;\n    return substr;\n  }\n\n  /**\n   * Matches and eats the substring character-by-character. If EOF is reached\n   * before the substring is consumed, it will buffer. Index is not moved\n   * if it's not a match.\n   */\n  eatGradually(substr: string): MatchResult {\n    const prevIndex = this.index;\n    for (let i = 0; i < substr.length; i++) {\n      if (i > 0 && this.eof) {\n        return MatchResult.Buffer;\n      }\n\n      if (!this.eatChar(substr[i])) {\n        this.index = prevIndex;\n        return MatchResult.Failure;\n      }\n    }\n\n    return MatchResult.Success;\n  }\n\n  /**\n   * Advances the reader and returns the regex if it matches.\n   */\n  eatRe(re: RegExp) {\n    const match = re.exec(this._input.slice(this.index));\n    if (!match) {\n      return;\n    }\n\n    this.index += match[0].length;\n    return match;\n  }\n\n  /**\n   * Advances the reader and returns the character if the code matches.\n   */\n  eatCharCode(min = 0, max = min + 1) {\n    const code = this._input.charCodeAt(this.index);\n    if (code < min || code >= max) {\n      return undefined;\n    }\n\n    this.index++;\n    return code;\n  }\n}\n\n/**\n * Preidction which never tests true. Will always discard predictions made\n * after it.\n */\nclass HardBoundary implements IPrediction {\n  readonly clearAfterTimeout = false;\n\n  apply() {\n    return \"\";\n  }\n\n  rollback() {\n    return \"\";\n  }\n\n  rollForwards() {\n    return \"\";\n  }\n\n  matches() {\n    return MatchResult.Failure;\n  }\n}\n\n/**\n * Wraps another prediction. Does not apply the prediction, but will pass\n * through its `matches` request.\n */\nclass TentativeBoundary implements IPrediction {\n  private _appliedCursor?: Cursor;\n\n  constructor(readonly inner: IPrediction) {}\n\n  apply(buffer: IBuffer, cursor: Cursor) {\n    this._appliedCursor = cursor.clone();\n    this.inner.apply(buffer, this._appliedCursor);\n    return \"\";\n  }\n\n  rollback(cursor: Cursor) {\n    this.inner.rollback(cursor.clone());\n    return \"\";\n  }\n\n  rollForwards(cursor: Cursor, withInput: string) {\n    if (this._appliedCursor) {\n      cursor.moveTo(this._appliedCursor);\n    }\n\n    return withInput;\n  }\n\n  matches(input: StringReader) {\n    return this.inner.matches(input);\n  }\n}\n\nconst isTenativeCharacterPrediction = (\n  p: unknown,\n): p is TentativeBoundary & { inner: CharacterPrediction } =>\n  p instanceof TentativeBoundary && p.inner instanceof CharacterPrediction;\n\n/**\n * Prediction for a single alphanumeric character.\n */\nclass CharacterPrediction implements IPrediction {\n  readonly affectsStyle = true;\n\n  appliedAt?: {\n    pos: ICoordinate;\n    oldAttributes: string;\n    oldChar: string;\n  };\n\n  constructor(\n    private readonly _style: TypeAheadStyle,\n    private readonly _char: string,\n  ) {}\n\n  apply(_: IBuffer, cursor: Cursor) {\n    const cell = cursor.getCell();\n    this.appliedAt = cell\n      ? {\n          pos: cursor.coordinate,\n          oldAttributes: attributesToSeq(cell),\n          oldChar: cell.getChars(),\n        }\n      : { pos: cursor.coordinate, oldAttributes: \"\", oldChar: \"\" };\n\n    cursor.shift(1);\n\n    return this._style.apply + this._char + this._style.undo;\n  }\n\n  rollback(cursor: Cursor) {\n    if (!this.appliedAt) {\n      return \"\"; // not applied\n    }\n\n    const { oldAttributes, oldChar, pos } = this.appliedAt;\n    const r =\n      cursor.moveTo(pos) +\n      (oldChar\n        ? `${oldAttributes}${oldChar}${cursor.moveTo(pos)}`\n        : VT.DeleteChar);\n    return r;\n  }\n\n  rollForwards(cursor: Cursor, input: string) {\n    if (!this.appliedAt) {\n      return \"\"; // not applied\n    }\n\n    return cursor.clone().moveTo(this.appliedAt.pos) + input;\n  }\n\n  matches(input: StringReader, lookBehind?: IPrediction) {\n    const startIndex = input.index;\n\n    // remove any styling CSI before checking the char\n    while (input.eatRe(CSI_STYLE_RE)) {}\n\n    if (input.eof) {\n      return MatchResult.Buffer;\n    }\n\n    if (input.eatChar(this._char)) {\n      return MatchResult.Success;\n    }\n\n    if (lookBehind instanceof CharacterPrediction) {\n      // see #112842\n      const sillyZshOutcome = input.eatGradually(\n        `\\b${lookBehind._char}${this._char}`,\n      );\n      if (sillyZshOutcome !== MatchResult.Failure) {\n        return sillyZshOutcome;\n      }\n    }\n\n    input.index = startIndex;\n    return MatchResult.Failure;\n  }\n}\n\nclass BackspacePrediction implements IPrediction {\n  protected _appliedAt?: {\n    pos: ICoordinate;\n    oldAttributes: string;\n    oldChar: string;\n    isLastChar: boolean;\n  };\n\n  constructor(private readonly _terminal: Terminal) {}\n\n  apply(_: IBuffer, cursor: Cursor) {\n    // at eol if everything to the right is whitespace (zsh will emit a \"clear line\" code in this case)\n    // todo: can be optimized if `getTrimmedLength` is exposed from xterm\n    const isLastChar = !cursor\n      .getLine()\n      ?.translateToString(undefined, cursor.x)\n      .trim();\n    const pos = cursor.coordinate;\n    const move = cursor.shift(-1);\n    const cell = cursor.getCell();\n    this._appliedAt = cell\n      ? {\n          isLastChar,\n          pos,\n          oldAttributes: attributesToSeq(cell),\n          oldChar: cell.getChars(),\n        }\n      : { isLastChar, pos, oldAttributes: \"\", oldChar: \"\" };\n\n    return move + VT.DeleteChar;\n  }\n\n  rollback(cursor: Cursor) {\n    if (!this._appliedAt) {\n      return \"\"; // not applied\n    }\n\n    const { oldAttributes, oldChar, pos } = this._appliedAt;\n    if (!oldChar) {\n      return cursor.moveTo(pos) + VT.DeleteChar;\n    }\n\n    return (\n      oldAttributes +\n      oldChar +\n      cursor.moveTo(pos) +\n      attributesToSeq(core(this._terminal)._inputHandler._curAttrData)\n    );\n  }\n\n  rollForwards() {\n    return \"\";\n  }\n\n  matches(input: StringReader) {\n    if (this._appliedAt?.isLastChar) {\n      const r1 = input.eatGradually(`\\b${VT.Csi}K`);\n      if (r1 !== MatchResult.Failure) {\n        return r1;\n      }\n\n      const r2 = input.eatGradually(`\\b \\b`);\n      if (r2 !== MatchResult.Failure) {\n        return r2;\n      }\n    }\n\n    return MatchResult.Failure;\n  }\n}\n\nclass NewlinePrediction implements IPrediction {\n  protected _prevPosition?: ICoordinate;\n\n  apply(_: IBuffer, cursor: Cursor) {\n    this._prevPosition = cursor.coordinate;\n    cursor.move(0, cursor.y + 1);\n    return \"\\r\\n\";\n  }\n\n  rollback(cursor: Cursor) {\n    return this._prevPosition ? cursor.moveTo(this._prevPosition) : \"\";\n  }\n\n  rollForwards() {\n    return \"\"; // does not need to rewrite\n  }\n\n  matches(input: StringReader) {\n    return input.eatGradually(\"\\r\\n\");\n  }\n}\n\n/**\n * Prediction when the cursor reaches the end of the line. Similar to newline\n * prediction, but shells handle it slightly differently.\n */\nclass LinewrapPrediction extends NewlinePrediction implements IPrediction {\n  override apply(_: IBuffer, cursor: Cursor) {\n    this._prevPosition = cursor.coordinate;\n    cursor.move(0, cursor.y + 1);\n    return \" \\r\";\n  }\n\n  override matches(input: StringReader) {\n    // bash and zshell add a space which wraps in the terminal, then a CR\n    const r = input.eatGradually(\" \\r\");\n    if (r !== MatchResult.Failure) {\n      // zshell additionally adds a clear line after wrapping to be safe -- eat it\n      const r2 = input.eatGradually(VT.DeleteRestOfLine);\n      return r2 === MatchResult.Buffer ? MatchResult.Buffer : r;\n    }\n\n    return input.eatGradually(\"\\r\\n\");\n  }\n}\n\nclass CursorMovePrediction implements IPrediction {\n  private _applied?: {\n    rollForward: string;\n    prevPosition: number;\n    prevAttrs: string;\n    amount: number;\n  };\n\n  constructor(\n    private readonly _direction: CursorMoveDirection,\n    private readonly _moveByWords: boolean,\n    private readonly _amount: number,\n  ) {}\n\n  apply(buffer: IBuffer, cursor: Cursor) {\n    const prevPosition = cursor.x;\n    const currentCell = cursor.getCell();\n    const prevAttrs = currentCell ? attributesToSeq(currentCell) : \"\";\n\n    const {\n      _amount: amount,\n      _direction: direction,\n      _moveByWords: moveByWords,\n    } = this;\n    const delta = direction === CursorMoveDirection.Back ? -1 : 1;\n\n    const target = cursor.clone();\n    if (moveByWords) {\n      for (let i = 0; i < amount; i++) {\n        moveToWordBoundary(buffer, target, delta);\n      }\n    } else {\n      target.shift(delta * amount);\n    }\n\n    this._applied = {\n      amount: Math.abs(cursor.x - target.x),\n      prevPosition,\n      prevAttrs,\n      rollForward: cursor.moveTo(target),\n    };\n\n    return this._applied.rollForward;\n  }\n\n  rollback(cursor: Cursor) {\n    if (!this._applied) {\n      return \"\";\n    }\n\n    return (\n      cursor.move(this._applied.prevPosition, cursor.y) +\n      this._applied.prevAttrs\n    );\n  }\n\n  rollForwards() {\n    return \"\"; // does not need to rewrite\n  }\n\n  matches(input: StringReader) {\n    if (!this._applied) {\n      return MatchResult.Failure;\n    }\n\n    const direction = this._direction;\n    const { amount, rollForward } = this._applied;\n\n    // arg can be omitted to move one character. We don't eatGradually() here\n    // or below moves that don't go as far as the cursor would be buffered\n    // indefinitely\n    if (input.eatStr(`${VT.Csi}${direction}`.repeat(amount))) {\n      return MatchResult.Success;\n    }\n\n    // \\b is the equivalent to moving one character back\n    if (direction === CursorMoveDirection.Back) {\n      if (input.eatStr(`\\b`.repeat(amount))) {\n        return MatchResult.Success;\n      }\n    }\n\n    // check if the cursor position is set absolutely\n    if (rollForward) {\n      const r = input.eatGradually(rollForward);\n      if (r !== MatchResult.Failure) {\n        return r;\n      }\n    }\n\n    // check for a relative move in the direction\n    return input.eatGradually(`${VT.Csi}${amount}${direction}`);\n  }\n}\n\nexport class PredictionStats extends Disposable {\n  private readonly _stats: [latency: number, correct: boolean][] = [];\n  private _index = 0;\n  private readonly _addedAtTime = new WeakMap<IPrediction, number>();\n  private readonly _changeEmitter = new Emitter<void>();\n  readonly onChange = this._changeEmitter.event;\n\n  /**\n   * Gets the percent (0-1) of predictions that were accurate.\n   */\n  get accuracy() {\n    let correctCount = 0;\n    for (const [, correct] of this._stats) {\n      if (correct) {\n        correctCount++;\n      }\n    }\n\n    return correctCount / (this._stats.length || 1);\n  }\n\n  /**\n   * Gets the number of recorded stats.\n   */\n  get sampleSize() {\n    return this._stats.length;\n  }\n\n  /**\n   * Gets latency stats of successful predictions.\n   */\n  get latency() {\n    const latencies = this._stats\n      .filter(([, correct]) => correct)\n      .map(([s]) => s)\n      .sort();\n\n    return {\n      count: latencies.length,\n      min: latencies[0],\n      median: latencies[Math.floor(latencies.length / 2)],\n      max: latencies[latencies.length - 1],\n    };\n  }\n\n  /**\n   * Gets the maximum observed latency.\n   */\n  get maxLatency() {\n    let max = -Infinity;\n    for (const [latency, correct] of this._stats) {\n      if (correct) {\n        max = Math.max(latency, max);\n      }\n    }\n\n    return max;\n  }\n\n  constructor(timeline: PredictionTimeline) {\n    super();\n    this._register(\n      timeline.onPredictionAdded((p) => this._addedAtTime.set(p, Date.now())),\n    );\n    this._register(\n      timeline.onPredictionSucceeded(this._pushStat.bind(this, true)),\n    );\n    this._register(\n      timeline.onPredictionFailed(this._pushStat.bind(this, false)),\n    );\n  }\n\n  private _pushStat(correct: boolean, prediction: IPrediction) {\n    const started = this._addedAtTime.get(prediction)!;\n    this._stats[this._index] = [Date.now() - started, correct];\n    this._index = (this._index + 1) % StatsConstants.StatsBufferSize;\n    this._changeEmitter.fire();\n  }\n}\n\nexport class PredictionTimeline {\n  /**\n   * Expected queue of events. Only predictions for the lowest are\n   * written into the terminal.\n   */\n  private _expected: { gen: number; p: IPrediction }[] = [];\n\n  /**\n   * Current prediction generation.\n   */\n  private _currentGen = 0;\n\n  /**\n   * Current cursor position -- kept outside the buffer since it can be ahead\n   * if typing swiftly. The position of the cursor that the user is currently\n   * looking at on their screen (or will be looking at after all pending writes\n   * are flushed.)\n   */\n  private _physicalCursor: Cursor | undefined;\n\n  /**\n   * Cursor position taking into account all (possibly not-yet-applied)\n   * predictions. A new prediction inserted, if applied, will be applied at\n   * the position of the tentative cursor.\n   */\n  private _tenativeCursor: Cursor | undefined;\n\n  /**\n   * Previously sent data that was buffered and should be prepended to the\n   * next input.\n   */\n  private _inputBuffer?: string;\n\n  /**\n   * Whether predictions are echoed to the terminal. If false, predictions\n   * will still be computed internally for latency metrics, but input will\n   * never be adjusted.\n   */\n  private _showPredictions = false;\n\n  /**\n   * The last successfully-made prediction.\n   */\n  private _lookBehind?: IPrediction;\n\n  private readonly _addedEmitter = new Emitter<IPrediction>();\n  readonly onPredictionAdded = this._addedEmitter.event;\n  private readonly _failedEmitter = new Emitter<IPrediction>();\n  readonly onPredictionFailed = this._failedEmitter.event;\n  private readonly _succeededEmitter = new Emitter<IPrediction>();\n  readonly onPredictionSucceeded = this._succeededEmitter.event;\n\n  private get _currentGenerationPredictions() {\n    return this._expected\n      .filter(({ gen }) => gen === this._expected[0].gen)\n      .map(({ p }) => p);\n  }\n\n  get isShowingPredictions() {\n    return this._showPredictions;\n  }\n\n  get length() {\n    return this._expected.length;\n  }\n\n  constructor(\n    readonly terminal: Terminal,\n    private readonly _style: TypeAheadStyle,\n  ) {}\n\n  setShowPredictions(show: boolean) {\n    if (show === this._showPredictions) {\n      return;\n    }\n\n    // console.log('set predictions:', show);\n    this._showPredictions = show;\n\n    const buffer = this._getActiveBuffer();\n    if (!buffer) {\n      return;\n    }\n\n    const toApply = this._currentGenerationPredictions;\n    if (show) {\n      this.clearCursor();\n      this._style.expectIncomingStyle(\n        toApply.reduce((count, p) => (p.affectsStyle ? count + 1 : count), 0),\n      );\n      this.terminal.write(\n        toApply\n          .map((p) => p.apply(buffer, this.physicalCursor(buffer)))\n          .join(\"\"),\n      );\n    } else {\n      this.terminal.write(\n        toApply\n          .reverse()\n          .map((p) => p.rollback(this.physicalCursor(buffer)))\n          .join(\"\"),\n      );\n    }\n  }\n\n  /**\n   * Undoes any predictions written and resets expectations.\n   */\n  undoAllPredictions() {\n    const buffer = this._getActiveBuffer();\n    if (this._showPredictions && buffer) {\n      this.terminal.write(\n        this._currentGenerationPredictions\n          .reverse()\n          .map((p) => p.rollback(this.physicalCursor(buffer)))\n          .join(\"\"),\n      );\n    }\n\n    this._expected = [];\n  }\n\n  /**\n   * Should be called when input is incoming to the temrinal.\n   */\n  beforeServerInput(input: string): string {\n    const originalInput = input;\n    if (this._inputBuffer) {\n      input = this._inputBuffer + input;\n      this._inputBuffer = undefined;\n    }\n\n    if (!this._expected.length) {\n      this._clearPredictionState();\n      return input;\n    }\n\n    const buffer = this._getActiveBuffer();\n    if (!buffer) {\n      this._clearPredictionState();\n      return input;\n    }\n\n    let output = \"\";\n\n    const reader = new StringReader(input);\n    const startingGen = this._expected[0].gen;\n    const emitPredictionOmitted = () => {\n      const omit = reader.eatRe(PREDICTION_OMIT_RE);\n      if (omit) {\n        output += omit[0];\n      }\n    };\n\n    ReadLoop: while (this._expected.length && reader.remaining > 0) {\n      emitPredictionOmitted();\n\n      const { p: prediction, gen } = this._expected[0];\n      const cursor = this.physicalCursor(buffer);\n      const beforeTestReaderIndex = reader.index;\n      switch (prediction.matches(reader, this._lookBehind)) {\n        case MatchResult.Success: {\n          // if the input character matches what the next prediction expected, undo\n          // the prediction and write the real character out.\n          const eaten = input.slice(beforeTestReaderIndex, reader.index);\n          if (gen === startingGen) {\n            output += prediction.rollForwards?.(cursor, eaten);\n          } else {\n            prediction.apply(buffer, this.physicalCursor(buffer)); // move cursor for additional apply\n            output += eaten;\n          }\n\n          this._succeededEmitter.fire(prediction);\n          this._lookBehind = prediction;\n          this._expected.shift();\n          break;\n        }\n        case MatchResult.Buffer:\n          // on a buffer, store the remaining data and completely read data\n          // to be output as normal.\n          this._inputBuffer = input.slice(beforeTestReaderIndex);\n          reader.index = input.length;\n          break ReadLoop;\n        case MatchResult.Failure: {\n          // on a failure, roll back all remaining items in this generation\n          // and clear predictions, since they are no longer valid\n          const rollback = this._expected\n            .filter((p) => p.gen === startingGen)\n            .reverse();\n          output += rollback\n            .map(({ p }) => p.rollback(this.physicalCursor(buffer)))\n            .join(\"\");\n          if (rollback.some((r) => r.p.affectsStyle)) {\n            // reading the current style should generally be safe, since predictions\n            // always restore the style if they modify it.\n            output += attributesToSeq(\n              core(this.terminal)._inputHandler._curAttrData,\n            );\n          }\n          this._clearPredictionState();\n          this._failedEmitter.fire(prediction);\n          break ReadLoop;\n        }\n      }\n    }\n\n    emitPredictionOmitted();\n\n    // Extra data (like the result of running a command) should cause us to\n    // reset the cursor\n    if (!reader.eof) {\n      output += reader.rest;\n      this._clearPredictionState();\n    }\n\n    // If we passed a generation boundary, apply the current generation's predictions\n    if (this._expected.length && startingGen !== this._expected[0].gen) {\n      for (const { p, gen } of this._expected) {\n        if (gen !== this._expected[0].gen) {\n          break;\n        }\n        if (p.affectsStyle) {\n          this._style.expectIncomingStyle();\n        }\n\n        output += p.apply(buffer, this.physicalCursor(buffer));\n      }\n    }\n\n    if (!this._showPredictions) {\n      return originalInput;\n    }\n\n    if (output.length === 0 || output === input) {\n      return output;\n    }\n\n    if (this._physicalCursor) {\n      output += this._physicalCursor.moveInstruction();\n    }\n\n    // prevent cursor flickering while typing\n    output = VT.HideCursor + output + VT.ShowCursor;\n\n    return output;\n  }\n\n  /**\n   * Clears any expected predictions and stored state. Should be called when\n   * the pty gives us something we don't recognize.\n   */\n  private _clearPredictionState() {\n    this._expected = [];\n    this.clearCursor();\n    this._lookBehind = undefined;\n  }\n\n  /**\n   * Appends a typeahead prediction.\n   */\n  addPrediction(buffer: IBuffer, prediction: IPrediction) {\n    this._expected.push({ gen: this._currentGen, p: prediction });\n    this._addedEmitter.fire(prediction);\n\n    if (this._currentGen !== this._expected[0].gen) {\n      prediction.apply(buffer, this.tentativeCursor(buffer));\n      return false;\n    }\n\n    const text = prediction.apply(buffer, this.physicalCursor(buffer));\n    this._tenativeCursor = undefined; // next read will get or clone the physical cursor\n\n    if (this._showPredictions && text) {\n      if (prediction.affectsStyle) {\n        this._style.expectIncomingStyle();\n      }\n      // console.log('predict:', JSON.stringify(text));\n      this.terminal.write(text);\n    }\n\n    return true;\n  }\n\n  /**\n   * Appends a prediction followed by a boundary. The predictions applied\n   * after this one will only be displayed after the give prediction matches\n   * pty output/\n   */\n  addBoundary(): void;\n  addBoundary(buffer: IBuffer, prediction: IPrediction): boolean;\n  addBoundary(buffer?: IBuffer, prediction?: IPrediction) {\n    let applied = false;\n    if (buffer && prediction) {\n      // We apply the prediction so that it's matched against, but wrapped\n      // in a tentativeboundary so that it doesn't affect the physical cursor.\n      // Then we apply it specifically to the tentative cursor.\n      applied = this.addPrediction(buffer, new TentativeBoundary(prediction));\n      prediction.apply(buffer, this.tentativeCursor(buffer));\n    }\n    this._currentGen++;\n    return applied;\n  }\n\n  /**\n   * Peeks the last prediction written.\n   */\n  peekEnd(): IPrediction | undefined {\n    return this._expected[this._expected.length - 1]?.p;\n  }\n\n  /**\n   * Peeks the first pending prediction.\n   */\n  peekStart(): IPrediction | undefined {\n    return this._expected[0]?.p;\n  }\n\n  /**\n   * Current position of the cursor in the terminal.\n   */\n  physicalCursor(buffer: IBuffer) {\n    if (!this._physicalCursor) {\n      if (this._showPredictions) {\n        flushOutput(this.terminal);\n      }\n      this._physicalCursor = new Cursor(\n        this.terminal.rows,\n        this.terminal.cols,\n        buffer,\n      );\n    }\n\n    return this._physicalCursor;\n  }\n\n  /**\n   * Cursor position if all predictions and boundaries that have been inserted\n   * so far turn out to be successfully predicted.\n   */\n  tentativeCursor(buffer: IBuffer) {\n    if (!this._tenativeCursor) {\n      this._tenativeCursor = this.physicalCursor(buffer).clone();\n    }\n\n    return this._tenativeCursor;\n  }\n\n  clearCursor() {\n    this._physicalCursor = undefined;\n    this._tenativeCursor = undefined;\n  }\n\n  private _getActiveBuffer() {\n    const buffer = this.terminal.buffer.active;\n    return buffer.type === \"normal\" ? buffer : undefined;\n  }\n}\n\n/**\n * Gets the escape sequence args to restore state/appearance in the cell.\n */\nconst attributesToArgs = (cell: any) => {\n  // cell: XtermAttributes\n  if (cell.isAttributeDefault()) {\n    return [0];\n  }\n\n  const args = [];\n  if (cell.isBold()) {\n    args.push(1);\n  }\n  if (cell.isDim()) {\n    args.push(2);\n  }\n  if (cell.isItalic()) {\n    args.push(3);\n  }\n  if (cell.isUnderline()) {\n    args.push(4);\n  }\n  if (cell.isBlink()) {\n    args.push(5);\n  }\n  if (cell.isInverse()) {\n    args.push(7);\n  }\n  if (cell.isInvisible()) {\n    args.push(8);\n  }\n\n  if (cell.isFgRGB()) {\n    args.push(\n      38,\n      2,\n      cell.getFgColor() >>> 24,\n      (cell.getFgColor() >>> 16) & 0xff,\n      cell.getFgColor() & 0xff,\n    );\n  }\n  if (cell.isFgPalette()) {\n    args.push(38, 5, cell.getFgColor());\n  }\n  if (cell.isFgDefault()) {\n    args.push(39);\n  }\n\n  if (cell.isBgRGB()) {\n    args.push(\n      48,\n      2,\n      cell.getBgColor() >>> 24,\n      (cell.getBgColor() >>> 16) & 0xff,\n      cell.getBgColor() & 0xff,\n    );\n  }\n  if (cell.isBgPalette()) {\n    args.push(48, 5, cell.getBgColor());\n  }\n  if (cell.isBgDefault()) {\n    args.push(49);\n  }\n\n  return args;\n};\n\n/**\n * Gets the escape sequence to restore state/appearance in the cell.\n */\nconst attributesToSeq = (cell: any) =>\n  `${VT.Csi}${attributesToArgs(cell).join(\";\")}m`; // cell: XtermAttributes\n\nconst arrayHasPrefixAt = <T>(\n  a: ReadonlyArray<T>,\n  ai: number,\n  b: ReadonlyArray<T>,\n) => {\n  if (a.length - ai > b.length) {\n    return false;\n  }\n\n  for (let bi = 0; bi < b.length; bi++, ai++) {\n    if (b[ai] !== a[ai]) {\n      return false;\n    }\n  }\n\n  return true;\n};\n\n/**\n * @see https://github.com/xtermjs/xterm.js/blob/065eb13a9d3145bea687239680ec9696d9112b8e/src/common/InputHandler.ts#L2127\n */\nconst getColorWidth = (params: (number | number[])[], pos: number) => {\n  const accu = [0, 0, -1, 0, 0, 0];\n  let cSpace = 0;\n  let advance = 0;\n\n  do {\n    const v = params[pos + advance];\n    accu[advance + cSpace] = typeof v === \"number\" ? v : v[0];\n    if (typeof v !== \"number\") {\n      let i = 0;\n      do {\n        if (accu[1] === 5) {\n          cSpace = 1;\n        }\n        accu[advance + i + 1 + cSpace] = v[i];\n      } while (++i < v.length && i + advance + 1 + cSpace < accu.length);\n      break;\n    }\n    // exit early if can decide color mode with semicolons\n    if (\n      (accu[1] === 5 && advance + cSpace >= 2) ||\n      (accu[1] === 2 && advance + cSpace >= 5)\n    ) {\n      break;\n    }\n    // offset colorSpace slot for semicolon mode\n    if (accu[1]) {\n      cSpace = 1;\n    }\n  } while (++advance + pos < params.length && advance + cSpace < accu.length);\n\n  return advance;\n};\n\nclass TypeAheadStyle implements IDisposable {\n  private static _compileArgs(args: ReadonlyArray<number>) {\n    return `${VT.Csi}${args.join(\";\")}m`;\n  }\n\n  /**\n   * Number of typeahead style arguments we expect to read. If this is 0 and\n   * we see a style coming in, we know that the PTY actually wanted to update.\n   */\n  private _expectedIncomingStyles = 0;\n  private _applyArgs!: ReadonlyArray<number>;\n  private _originalUndoArgs!: ReadonlyArray<number>;\n  private _undoArgs!: ReadonlyArray<number>;\n\n  apply!: string;\n  undo!: string;\n  private _csiHandler?: IDisposable;\n\n  constructor(value: string, private readonly _terminal: Terminal) {\n    this.onUpdate(value);\n  }\n\n  /**\n   * Signals that a style was written to the terminal and we should watch\n   * for it coming in.\n   */\n  expectIncomingStyle(n = 1) {\n    this._expectedIncomingStyles += n * 2;\n  }\n\n  /**\n   * Starts tracking for CSI changes in the terminal.\n   */\n  startTracking() {\n    this._expectedIncomingStyles = 0;\n    this._onDidWriteSGR(\n      attributesToArgs(core(this._terminal)._inputHandler._curAttrData),\n    );\n    this._csiHandler = this._terminal.parser.registerCsiHandler(\n      { final: \"m\" },\n      (args) => {\n        this._onDidWriteSGR(args);\n        return false;\n      },\n    );\n  }\n\n  /**\n   * Stops tracking terminal CSI changes.\n   */\n  @debounce(2000)\n  debounceStopTracking() {\n    this._stopTracking();\n  }\n\n  /**\n   * @inheritdoc\n   */\n  dispose() {\n    this._stopTracking();\n  }\n\n  private _stopTracking() {\n    this._csiHandler?.dispose();\n    this._csiHandler = undefined;\n  }\n\n  private _onDidWriteSGR(args: (number | number[])[]) {\n    const originalUndo = this._undoArgs;\n    for (let i = 0; i < args.length; ) {\n      const px = args[i];\n      const p = typeof px === \"number\" ? px : px[0];\n\n      if (this._expectedIncomingStyles) {\n        if (arrayHasPrefixAt(args, i, this._undoArgs)) {\n          this._expectedIncomingStyles--;\n          i += this._undoArgs.length;\n          continue;\n        }\n        if (arrayHasPrefixAt(args, i, this._applyArgs)) {\n          this._expectedIncomingStyles--;\n          i += this._applyArgs.length;\n          continue;\n        }\n      }\n\n      const width =\n        p === 38 || p === 48 || p === 58 ? getColorWidth(args, i) : 1;\n      switch (this._applyArgs[0]) {\n        case 1:\n          if (p === 2) {\n            this._undoArgs = [22, 2];\n          } else if (p === 22 || p === 0) {\n            this._undoArgs = [22];\n          }\n          break;\n        case 2:\n          if (p === 1) {\n            this._undoArgs = [22, 1];\n          } else if (p === 22 || p === 0) {\n            this._undoArgs = [22];\n          }\n          break;\n        case 38:\n          if (p === 0 || p === 39 || p === 100) {\n            this._undoArgs = [39];\n          } else if ((p >= 30 && p <= 38) || (p >= 90 && p <= 97)) {\n            this._undoArgs = args.slice(i, i + width) as number[];\n          }\n          break;\n        default:\n          if (p === this._applyArgs[0]) {\n            this._undoArgs = this._applyArgs;\n          } else if (p === 0) {\n            this._undoArgs = this._originalUndoArgs;\n          }\n        // no-op\n      }\n\n      i += width;\n    }\n\n    if (originalUndo !== this._undoArgs) {\n      this.undo = TypeAheadStyle._compileArgs(this._undoArgs);\n    }\n  }\n\n  /**\n   * Updates the current typeahead style.\n   */\n  onUpdate(style: string) {\n    const { applyArgs, undoArgs } = this._getArgs(style);\n    this._applyArgs = applyArgs;\n    this._undoArgs = this._originalUndoArgs = undoArgs;\n    this.apply = TypeAheadStyle._compileArgs(this._applyArgs);\n    this.undo = TypeAheadStyle._compileArgs(this._undoArgs);\n  }\n\n  private _getArgs(style: string) {\n    switch (style) {\n      case \"bold\":\n        return { applyArgs: [1], undoArgs: [22] };\n      case \"dim\":\n        return { applyArgs: [2], undoArgs: [22] };\n      case \"italic\":\n        return { applyArgs: [3], undoArgs: [23] };\n      case \"underlined\":\n        return { applyArgs: [4], undoArgs: [24] };\n      case \"inverted\":\n        return { applyArgs: [7], undoArgs: [27] };\n      default: {\n        // NOTE(ekzhang): This originally used `vs/base/common/color.ts`, and I reimplemented it.\n        let r: number, g: number, b: number;\n        try {\n          const parseHexColor = (style: string): number[] => {\n            const matches = style.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);\n            if (!matches) {\n              throw new Error(\"Invalid color\");\n            }\n            const hex = matches[1];\n            if (hex.length === 3) {\n              return [\n                parseInt(hex.charAt(0), 16) * 17,\n                parseInt(hex.charAt(1), 16) * 17,\n                parseInt(hex.charAt(2), 16) * 17,\n              ];\n            }\n            return [\n              parseInt(hex.substring(0, 2), 16),\n              parseInt(hex.substring(2, 4), 16),\n              parseInt(hex.substring(4, 6), 16),\n            ];\n          };\n          [r, g, b] = parseHexColor(style);\n        } catch {\n          [r, g, b] = [255, 0, 0];\n        }\n\n        return { applyArgs: [38, 2, r, g, b], undoArgs: [39] };\n      }\n    }\n  }\n}\n\nconst compileExcludeRegexp = (programs: ReadonlyArray<string>) =>\n  new RegExp(`\\\\b(${programs.map(escapeRegExpCharacters).join(\"|\")})\\\\b`, \"i\");\n\nexport const enum CharPredictState {\n  /** No characters typed on this line yet */\n  Unknown,\n  /** Has a pending character prediction */\n  HasPendingChar,\n  /** Character validated on this line */\n  Validated,\n}\n\nexport class TypeAheadAddon extends Disposable implements ITerminalAddon {\n  private _typeaheadStyle?: TypeAheadStyle;\n  private _typeaheadThreshold = 50; // ITerminalConfiguration.localEchoLatencyThreshold\n  private _excludeProgramRe = compileExcludeRegexp([\n    \"vim\",\n    \"vi\",\n    \"nano\",\n    \"tmux\",\n    \"nvim\",\n    \"mprocs\",\n  ]); // ITerminalConfiguration.localEchoExcludePrograms,\n  protected _lastRow?: {\n    y: number;\n    startingX: number;\n    endingX: number;\n    charState: CharPredictState;\n  };\n  protected _timeline?: PredictionTimeline;\n  private _terminalTitle = \"\";\n  stats?: PredictionStats;\n\n  /**\n   * Debounce that clears predictions after a timeout if the PTY doesn't apply them.\n   */\n  private _clearPredictionDebounce?: IDisposable;\n\n  constructor() {\n    // private _processManager: ITerminalProcessManager,\n    super();\n    this._register(\n      toDisposable(() => this._clearPredictionDebounce?.dispose()),\n    );\n  }\n\n  activate(terminal: Terminal): void {\n    const style = (this._typeaheadStyle = this._register(\n      new TypeAheadStyle(\n        \"dim\", // ITerminalConfiguration.localEchoStyle\n        terminal,\n      ),\n    ));\n    const timeline = (this._timeline = new PredictionTimeline(\n      terminal,\n      this._typeaheadStyle!,\n    ));\n    const stats = (this.stats = this._register(\n      new PredictionStats(this._timeline),\n    ));\n\n    timeline.setShowPredictions(this._typeaheadThreshold === 0);\n    this._register(terminal.onData((e) => this._onUserData(e)));\n    this._register(\n      terminal.onTitleChange((title) => {\n        this._terminalTitle = title;\n        this._reevaluatePredictorState(stats, timeline);\n      }),\n    );\n    this._register(\n      terminal.onResize(() => {\n        timeline.setShowPredictions(false);\n        timeline.clearCursor();\n        this._reevaluatePredictorState(stats, timeline);\n      }),\n    );\n    this._register(\n      this._timeline.onPredictionSucceeded((p) => {\n        if (\n          this._lastRow?.charState === CharPredictState.HasPendingChar &&\n          isTenativeCharacterPrediction(p) &&\n          p.inner.appliedAt\n        ) {\n          if (\n            p.inner.appliedAt.pos.y + p.inner.appliedAt.pos.baseY ===\n            this._lastRow.y\n          ) {\n            this._lastRow.charState = CharPredictState.Validated;\n          }\n        }\n      }),\n    );\n    // this._register(\n    //   this._processManager.onBeforeProcessData((e) =>\n    //     this._onBeforeProcessData(e),\n    //   ),\n    // );\n\n    let nextStatsSend: any;\n    this._register(\n      stats.onChange(() => {\n        if (!nextStatsSend) {\n          nextStatsSend = setTimeout(() => {\n            this._sendLatencyStats(stats);\n            nextStatsSend = undefined;\n          }, StatsConstants.StatsSendTelemetryEvery);\n        }\n\n        if (timeline.length === 0) {\n          style.debounceStopTracking();\n        }\n\n        this._reevaluatePredictorState(stats, timeline);\n      }),\n    );\n  }\n\n  reset() {\n    this._lastRow = undefined;\n  }\n\n  private _deferClearingPredictions() {\n    if (!this.stats || !this._timeline) {\n      return;\n    }\n\n    this._clearPredictionDebounce?.dispose();\n    if (\n      this._timeline.length === 0 ||\n      this._timeline.peekStart()?.clearAfterTimeout === false\n    ) {\n      this._clearPredictionDebounce = undefined;\n      return;\n    }\n\n    this._clearPredictionDebounce = disposableTimeout(() => {\n      this._timeline?.undoAllPredictions();\n      if (this._lastRow?.charState === CharPredictState.HasPendingChar) {\n        this._lastRow.charState = CharPredictState.Unknown;\n      }\n    }, Math.max(500, (this.stats.maxLatency * 3) / 2));\n  }\n\n  /**\n   * Note on debounce:\n   *\n   * We want to toggle the state only when the user has a pause in their\n   * typing. Otherwise, we could turn this on when the PTY sent data but the\n   * terminal cursor is not updated, causes issues.\n   */\n  @debounce(100)\n  protected _reevaluatePredictorState(\n    stats: PredictionStats,\n    timeline: PredictionTimeline,\n  ) {\n    this._reevaluatePredictorStateNow(stats, timeline);\n  }\n\n  protected _reevaluatePredictorStateNow(\n    stats: PredictionStats,\n    timeline: PredictionTimeline,\n  ) {\n    if (this._excludeProgramRe.test(this._terminalTitle)) {\n      timeline.setShowPredictions(false);\n    } else if (this._typeaheadThreshold < 0) {\n      timeline.setShowPredictions(false);\n    } else if (this._typeaheadThreshold === 0) {\n      timeline.setShowPredictions(true);\n    } else if (\n      stats.sampleSize > StatsConstants.StatsMinSamplesToTurnOn &&\n      stats.accuracy > StatsConstants.StatsMinAccuracyToTurnOn\n    ) {\n      const latency = stats.latency.median;\n      if (latency >= this._typeaheadThreshold) {\n        timeline.setShowPredictions(true);\n      } else if (\n        latency <\n        this._typeaheadThreshold / StatsConstants.StatsToggleOffThreshold\n      ) {\n        timeline.setShowPredictions(false);\n      }\n    }\n  }\n\n  private _sendLatencyStats(stats: PredictionStats) {\n    /* __GDPR__\n\t\t\t\"terminalLatencyStats\" : {\n\t\t\t\t\"owner\": \"Tyriar\",\n\t\t\t\t\"min\" : { \"classification\": \"SystemMetaData\", \"purpose\": \"PerformanceAndHealth\", \"isMeasurement\": true },\n\t\t\t\t\"max\" : { \"classification\": \"SystemMetaData\", \"purpose\": \"PerformanceAndHealth\", \"isMeasurement\": true },\n\t\t\t\t\"median\" : { \"classification\": \"SystemMetaData\", \"purpose\": \"PerformanceAndHealth\", \"isMeasurement\": true },\n\t\t\t\t\"count\" : { \"classification\": \"SystemMetaData\", \"purpose\": \"PerformanceAndHealth\", \"isMeasurement\": true },\n\t\t\t\t\"predictionAccuracy\" : { \"classification\": \"SystemMetaData\", \"purpose\": \"PerformanceAndHealth\", \"isMeasurement\": true }\n\t\t\t}\n\t\t */\n    // this._telemetryService.publicLog(\"terminalLatencyStats\", {\n    //   ...stats.latency,\n    //   predictionAccuracy: stats.accuracy,\n    // });\n    void stats;\n  }\n\n  private _onUserData(data: string): void {\n    if (this._timeline?.terminal.buffer.active.type !== \"normal\") {\n      return;\n    }\n\n    // console.log('user data:', JSON.stringify(data));\n\n    const terminal = this._timeline.terminal;\n    const buffer = terminal.buffer.active;\n\n    // Detect programs like git log/less that use the normal buffer but don't\n    // take input by deafult (fixes #109541)\n    if (buffer.cursorX === 1 && buffer.cursorY === terminal.rows - 1) {\n      if (\n        buffer\n          .getLine(buffer.cursorY + buffer.baseY)\n          ?.getCell(0)\n          ?.getChars() === \":\"\n      ) {\n        return;\n      }\n    }\n\n    // the following code guards the terminal prompt to avoid being able to\n    // arrow or backspace-into the prompt. Record the lowest X value at which\n    // the user gave input, and mark all additions before that as tentative.\n    const actualY = buffer.baseY + buffer.cursorY;\n    if (actualY !== this._lastRow?.y) {\n      this._lastRow = {\n        y: actualY,\n        startingX: buffer.cursorX,\n        endingX: buffer.cursorX,\n        charState: CharPredictState.Unknown,\n      };\n    } else {\n      this._lastRow.startingX = Math.min(\n        this._lastRow.startingX,\n        buffer.cursorX,\n      );\n      this._lastRow.endingX = Math.max(\n        this._lastRow.endingX,\n        this._timeline.physicalCursor(buffer).x,\n      );\n    }\n\n    const addLeftNavigating = (p: IPrediction) =>\n      this._timeline!.tentativeCursor(buffer).x <= this._lastRow!.startingX\n        ? this._timeline!.addBoundary(buffer, p)\n        : this._timeline!.addPrediction(buffer, p);\n\n    const addRightNavigating = (p: IPrediction) =>\n      this._timeline!.tentativeCursor(buffer).x >= this._lastRow!.endingX - 1\n        ? this._timeline!.addBoundary(buffer, p)\n        : this._timeline!.addPrediction(buffer, p);\n\n    /** @see https://github.com/xtermjs/xterm.js/blob/1913e9512c048e3cf56bb5f5df51bfff6899c184/src/common/input/Keyboard.ts */\n    const reader = new StringReader(data);\n    while (reader.remaining > 0) {\n      if (reader.eatCharCode(127)) {\n        // backspace\n        const previous = this._timeline.peekEnd();\n        if (previous && previous instanceof CharacterPrediction) {\n          this._timeline.addBoundary();\n        }\n\n        // backspace must be able to read the previously-written character in\n        // the event that it needs to undo it\n        if (this._timeline.isShowingPredictions) {\n          flushOutput(this._timeline.terminal);\n        }\n\n        if (\n          this._timeline.tentativeCursor(buffer).x <= this._lastRow!.startingX\n        ) {\n          this._timeline.addBoundary(\n            buffer,\n            new BackspacePrediction(this._timeline.terminal),\n          );\n        } else {\n          // Backspace decrements our ability to go right.\n          this._lastRow.endingX--;\n          this._timeline!.addPrediction(\n            buffer,\n            new BackspacePrediction(this._timeline.terminal),\n          );\n        }\n\n        continue;\n      }\n\n      if (reader.eatCharCode(32, 126)) {\n        // alphanum\n        const char = data[reader.index - 1];\n        const prediction = new CharacterPrediction(this._typeaheadStyle!, char);\n        if (this._lastRow.charState === CharPredictState.Unknown) {\n          this._timeline.addBoundary(buffer, prediction);\n          this._lastRow.charState = CharPredictState.HasPendingChar;\n        } else {\n          this._timeline.addPrediction(buffer, prediction);\n        }\n\n        if (this._timeline.tentativeCursor(buffer).x >= terminal.cols) {\n          this._timeline.addBoundary(buffer, new LinewrapPrediction());\n        }\n        continue;\n      }\n\n      const cursorMv = reader.eatRe(CSI_MOVE_RE);\n      if (cursorMv) {\n        const direction = cursorMv[3] as CursorMoveDirection;\n        const p = new CursorMovePrediction(\n          direction,\n          !!cursorMv[2],\n          Number(cursorMv[1]) || 1,\n        );\n        if (direction === CursorMoveDirection.Back) {\n          addLeftNavigating(p);\n        } else {\n          addRightNavigating(p);\n        }\n        continue;\n      }\n\n      if (reader.eatStr(`${VT.Esc}f`)) {\n        addRightNavigating(\n          new CursorMovePrediction(CursorMoveDirection.Forwards, true, 1),\n        );\n        continue;\n      }\n\n      if (reader.eatStr(`${VT.Esc}b`)) {\n        addLeftNavigating(\n          new CursorMovePrediction(CursorMoveDirection.Back, true, 1),\n        );\n        continue;\n      }\n\n      if (reader.eatChar(\"\\r\") && buffer.cursorY < terminal.rows - 1) {\n        this._timeline.addPrediction(buffer, new NewlinePrediction());\n        continue;\n      }\n\n      // something else\n      this._timeline.addBoundary(buffer, new HardBoundary());\n      break;\n    }\n\n    if (this._timeline.length === 1) {\n      this._deferClearingPredictions();\n      this._typeaheadStyle!.startTracking();\n    }\n  }\n\n  // private _onBeforeProcessData(event: IBeforeProcessDataEvent): void {\n  //   if (!this._timeline) {\n  //     return;\n  //   }\n\n  //   // console.log('incoming data:', JSON.stringify(event.data));\n  //   event.data = this._timeline.beforeServerInput(event.data);\n  //   // console.log('emitted data:', JSON.stringify(event.data));\n\n  //   this._deferClearingPredictions();\n  // }\n\n  onBeforeProcessData(data: string): string {\n    if (!this._timeline) return data;\n    // console.log(\"incoming data:\", JSON.stringify(data));\n    data = this._timeline.beforeServerInput(data);\n    // console.log(\"emitted data:\", JSON.stringify(data));\n    this._deferClearingPredictions();\n    return data;\n  }\n}\n"
  },
  {
    "path": "src/lib/ui/Avatars.svelte",
    "content": "<script lang=\"ts\">\n  import { fade } from \"svelte/transition\";\n\n  import type { WsUser } from \"$lib/protocol\";\n  import { nameToHue } from \"./LiveCursor.svelte\";\n\n  export let users: [number, WsUser][];\n\n  function nameToInitials(name: string): string {\n    const parts = name.split(/\\s/).filter((s) => s);\n    if (parts.length === 0) {\n      return \"-\";\n    } else if (parts.length === 1) {\n      return parts[0][0].toLocaleUpperCase();\n    } else {\n      return (parts[0][0] + parts[1][0]).toLocaleUpperCase();\n    }\n  }\n</script>\n\n<div class=\"flex flex-row-reverse\">\n  {#each users as [id, user] (id)}\n    <div\n      class=\"avatar\"\n      style:background=\"hsla({nameToHue(user.name)}, 80%, 30%, 90%)\"\n      transition:fade|local={{ duration: 200 }}\n    >\n      {nameToInitials(user.name)}\n    </div>\n  {/each}\n</div>\n\n<style lang=\"postcss\">\n  .avatar {\n    @apply w-7 h-7 rounded-full text-xs font-medium flex justify-center items-center;\n    @apply mr-1 first:mr-0;\n  }\n</style>\n"
  },
  {
    "path": "src/lib/ui/Chat.svelte",
    "content": "<script lang=\"ts\" context=\"module\">\n  export type ChatMessage = {\n    uid: number;\n    name: string;\n    msg: string;\n    sentAt: Date;\n  };\n</script>\n\n<script lang=\"ts\">\n  import { createEventDispatcher, tick } from \"svelte\";\n  import { fade, fly } from \"svelte/transition\";\n  import { SendIcon } from \"svelte-feather-icons\";\n\n  import CircleButton from \"./CircleButton.svelte\";\n  import CircleButtons from \"./CircleButtons.svelte\";\n\n  const dispatch = createEventDispatcher<{ chat: string; close: void }>();\n\n  export let userId: number;\n  export let messages: ChatMessage[];\n\n  let groupedMessages: ChatMessage[][];\n  $: {\n    groupedMessages = [];\n    let lastSender = -1;\n    for (const chat of messages) {\n      if (chat.uid === lastSender) {\n        groupedMessages[groupedMessages.length - 1].push(chat);\n      } else {\n        groupedMessages.push([chat]);\n        lastSender = chat.uid;\n      }\n    }\n  }\n\n  let scroller: HTMLElement;\n  $: if (scroller && groupedMessages.length) {\n    tick().then(() => {\n      scroller.scroll({ top: scroller.scrollHeight });\n    });\n  }\n\n  let text: string;\n\n  function handleSubmit() {\n    if (text) {\n      dispatch(\"chat\", text);\n      text = \"\";\n    }\n  }\n</script>\n\n<div\n  class=\"panel flex flex-col h-full max-h-[480px]\"\n  in:fade|local={{ duration: 100 }}\n  out:fade|local={{ duration: 75 }}\n>\n  <div class=\"flex items-center p-3\">\n    <CircleButtons>\n      <CircleButton kind=\"red\" on:click={() => dispatch(\"close\")} />\n    </CircleButtons>\n    <div class=\"ml-3 text-zinc-300 text-sm font-medium\">Chat Messages</div>\n  </div>\n\n  <div class=\"px-3 py-2 flex-1 overflow-y-auto\" bind:this={scroller}>\n    <div class=\"space-y-3\">\n      {#each groupedMessages as chatGroup}\n        <div class=\"message-group\" class:from-me={userId === chatGroup[0].uid}>\n          <aside class=\"pl-2.5 text-zinc-400 text-xs\">\n            {chatGroup[0].name}\n          </aside>\n          {#each chatGroup as chat (chat)}\n            <div\n              class=\"chat\"\n              title=\"sent at {chat.sentAt.toLocaleTimeString()}\"\n            >\n              {chat.msg}\n            </div>\n          {/each}\n        </div>\n      {/each}\n    </div>\n  </div>\n\n  <form class=\"relative p-3\" on:submit|preventDefault={handleSubmit}>\n    <input\n      class=\"w-full rounded-2xl bg-zinc-800 pl-3.5 pr-9 py-1.5 outline-none text-zinc-300 focus:ring-2 focus:ring-indigo-500/50\"\n      placeholder=\"Aa\"\n      bind:value={text}\n    />\n    {#if text}\n      <button\n        class=\"absolute w-4 h-4 top-[22px] right-[23px]\"\n        transition:fly|local={{ x: 8 }}\n      >\n        <SendIcon\n          class=\"w-full h-full text-indigo-300 hover:text-white transition-colors\"\n        />\n      </button>\n    {/if}\n  </form>\n</div>\n\n<style lang=\"postcss\">\n  .message-group {\n    @apply flex flex-col items-start space-y-0.5 max-w-[75%];\n  }\n\n  .message-group.from-me {\n    @apply ml-auto items-end;\n  }\n\n  .message-group.from-me > aside {\n    @apply hidden;\n  }\n\n  .chat {\n    @apply px-2.5 py-1.5 text-sm rounded-2xl max-w-full break-words bg-zinc-800;\n    @apply hover:bg-zinc-700 transition-colors;\n  }\n\n  .message-group.from-me .chat {\n    @apply bg-indigo-700;\n    @apply hover:bg-indigo-600;\n  }\n</style>\n"
  },
  {
    "path": "src/lib/ui/ChooseName.svelte",
    "content": "<script lang=\"ts\">\n  import { browser } from \"$app/environment\";\n\n  import OverlayMenu from \"./OverlayMenu.svelte\";\n  import { settings, updateSettings } from \"$lib/settings\";\n\n  let value = \"\";\n\n  function handleSubmit() {\n    updateSettings({ name: value });\n  }\n</script>\n\n<OverlayMenu\n  title=\"Welcome!\"\n  description=\"Before you join — what should we call you?\"\n  maxWidth={640}\n  open={browser && !$settings.name}\n>\n  <form class=\"flex gap-2\" on:submit|preventDefault={handleSubmit}>\n    <input\n      class=\"flex-1 w-full px-3 py-2 rounded outline-none text-zinc-300 bg-zinc-800\"\n      placeholder=\"Your name\"\n      required\n      minlength=\"2\"\n      maxlength=\"50\"\n      bind:value\n    />\n    <button\n      class=\"flex-shrink-0 px-3 py-2 bg-pink-700 hover:bg-pink-600 active:ring-4 active:ring-pink-500/50 rounded font-medium\"\n      >Join</button\n    >\n  </form>\n</OverlayMenu>\n"
  },
  {
    "path": "src/lib/ui/CircleButton.svelte",
    "content": "<script lang=\"ts\">\n  import { MinusIcon, PlusIcon, XIcon } from \"svelte-feather-icons\";\n\n  export let kind: keyof typeof details;\n\n  const details = {\n    red: {\n      cls: \"bg-red-500 active:bg-red-700\",\n      icon: XIcon,\n    },\n    yellow: {\n      cls: \"bg-yellow-500 active:bg-yellow-700\",\n      icon: MinusIcon,\n    },\n    green: {\n      cls: \"bg-green-500 active:bg-green-700\",\n      icon: PlusIcon,\n    },\n  };\n</script>\n\n<button\n  class=\"w-3 h-3 p-[1px] rounded-full {details[kind].cls}\"\n  on:mousedown|stopPropagation\n  on:click\n>\n  <svelte:component\n    this={details[kind].icon}\n    class=\"w-full h-full\"\n    strokeWidth={3}\n  />\n</button>\n"
  },
  {
    "path": "src/lib/ui/CircleButtons.svelte",
    "content": "<div class=\"flex space-x-2 text-transparent hover:text-black/75\">\n  <slot />\n</div>\n"
  },
  {
    "path": "src/lib/ui/CopyableCode.svelte",
    "content": "<script lang=\"ts\">\n  import { CheckIcon, CopyIcon } from \"svelte-feather-icons\";\n\n  export let value: string;\n\n  let copied = false;\n\n  async function handleClick() {\n    await navigator.clipboard.writeText(value);\n    copied = true;\n    setTimeout(() => {\n      copied = false;\n    }, 1000);\n  }\n</script>\n\n<div class=\"flex items-center gap-4\">\n  <code class=\"text-zinc-100\">{value}</code>\n  <button\n    class={\"rounded p-1.5 transition-colors \" +\n      (!copied ? \"hover:bg-white/10\" : \"hover:bg-green-500/10\")}\n    on:click={handleClick}\n  >\n    {#if copied}\n      <CheckIcon size=\"16\" class=\"text-green-400\" />\n    {:else}\n      <CopyIcon size=\"16\" />\n    {/if}\n  </button>\n</div>\n"
  },
  {
    "path": "src/lib/ui/DownloadLink.svelte",
    "content": "<script lang=\"ts\">\n  import { ExternalLinkIcon } from \"svelte-feather-icons\";\n\n  export let href: string;\n</script>\n\n<a\n  {href}\n  class=\"flex items-baseline gap-1.5 py-0.5 px-1.5 rounded bg-white/[7%] hover:bg-white/[15%] border border-transparent active:border-white/50 text-zinc-300 transition-colors\"\n>\n  <span class=\"text-sm\">\n    <slot />\n  </span>\n  <ExternalLinkIcon size=\"12\" class=\"text-zinc-400\" />\n</a>\n"
  },
  {
    "path": "src/lib/ui/LiveCursor.svelte",
    "content": "<script lang=\"ts\" context=\"module\">\n  import type { WsUser } from \"$lib/protocol\";\n\n  /** Convert a string into a unique, hashed hue from 0 to 360. */\n  export function nameToHue(name: string): number {\n    // Hashes the string with FNV.\n    let hash = 2166136261;\n    for (let i = 0; i < name.length; i++) {\n      hash = (hash ^ name.charCodeAt(i)) * 16777619;\n    }\n    hash = (hash * 16777619) ^ -1;\n    return 360 * (hash / (1 << 31));\n  }\n</script>\n\n<script lang=\"ts\">\n  import { fade } from \"svelte/transition\";\n\n  export let user: WsUser;\n  export let showName = false;\n\n  let hovering = false;\n  let lastMove = Date.now();\n\n  let lastCursor: [number, number] | null = null;\n  let time = Date.now();\n  $: if (\n    !lastCursor ||\n    (user.cursor &&\n      (lastCursor[0] !== user.cursor[0] || lastCursor[1] != user.cursor[1]))\n  ) {\n    lastCursor = user.cursor;\n    lastMove = Date.now();\n    setTimeout(() => {\n      time = Date.now();\n    }, 1600);\n  }\n</script>\n\n<div\n  class=\"flex items-start\"\n  on:mouseenter={() => (hovering = true)}\n  on:mouseleave={() => (hovering = false)}\n>\n  <svg width=\"23\" height=\"23\" viewBox=\"0 0 23 23\">\n    <path\n      d=\"M11 22L2 2L22 11L14 14Z\"\n      fill=\"hsl({nameToHue(user.name)}, 100%, 50%)\"\n      stroke=\"white\"\n    />\n  </svg>\n  {#if showName || hovering || time - lastMove < 1500}\n    <p\n      class=\"mt-4 bg-zinc-700 text-xs px-1.5 py-[1px] rounded font-medium\"\n      transition:fade|local={{ duration: 150 }}\n    >\n      {user.name}\n    </p>\n  {/if}\n</div>\n"
  },
  {
    "path": "src/lib/ui/NameList.svelte",
    "content": "<script lang=\"ts\">\n  import { flip } from \"svelte/animate\";\n\n  import type { WsUser } from \"$lib/protocol\";\n  import { nameToHue } from \"./LiveCursor.svelte\";\n\n  export let users: [number, WsUser][];\n  $: sortedUsers = [...users].sort(\n    (a, b) => Number(b[1].canWrite) - Number(a[1].canWrite),\n  );\n</script>\n\n<ul class=\"flex flex-col\">\n  {#each sortedUsers as [id, user] (id)}\n    <li\n      class={`flex p-1 gap-3 items-center ${user.canWrite ? \"\" : \"opacity-75\"}`}\n      animate:flip={{ duration: 250 }}\n    >\n      <div\n        style:background=\"hsl({nameToHue(user.name)}, 75%, 60%)\"\n        class=\"w-3.5 h-3.5 rounded-full\"\n      />\n      <div\n        class=\"text-sm font-medium bg-zinc-800 px-1.5 py-0.5 rounded text-zinc-300\"\n      >\n        {user.name}\n      </div>\n    </li>\n  {/each}\n</ul>\n"
  },
  {
    "path": "src/lib/ui/NetworkInfo.svelte",
    "content": "<script lang=\"ts\">\n  import { fade } from \"svelte/transition\";\n\n  export let status: \"connected\" | \"no-server\" | \"no-shell\";\n\n  export let serverLatency: number | null;\n  export let shellLatency: number | null;\n\n  function displayLatency(latency: number) {\n    if (latency < 1) {\n      return \"1 ms\";\n    } else if (latency <= 950) {\n      return `${Math.round(latency)} ms`;\n    } else {\n      return `${(latency / 1000).toFixed(1)} s`;\n    }\n  }\n\n  function colorLatency(latency: number | null) {\n    if (latency === null) {\n      return \"\";\n    } else if (latency < 80) {\n      return \"text-green-300\";\n    } else if (latency < 300) {\n      return \"text-yellow-300\";\n    } else {\n      return \"text-red-300\";\n    }\n  }\n</script>\n\n<div\n  class=\"relative panel p-4\"\n  in:fade|local={{ duration: 100 }}\n  out:fade|local={{ duration: 75 }}\n>\n  <div class=\"absolute left-[calc(50%-8px)] top-[-16px] w-4 h-4\">\n    <svg viewBox=\"0 0 16 16\">\n      <path d=\"M 0 12 L 8 0 L 16 12 Z\" fill=\"#222\" stroke=\"#333\" />\n    </svg>\n  </div>\n\n  <h2 class=\"font-medium mb-1 text-center\">Network</h2>\n  <p class=\"text-zinc-400 text-sm text-center\">\n    {#if status === \"connected\"}\n      {#if serverLatency === null || shellLatency === null}\n        Connected, estimating latency…\n      {:else}\n        Total latency: {displayLatency(serverLatency + shellLatency)}\n      {/if}\n    {:else}\n      You are currently disconnected.\n    {/if}\n  </p>\n\n  <div class=\"flex justify-between items-center mt-6\">\n    <div class=\"ball filled\" />\n    <div class=\"border-t-2 border-dashed border-zinc-600 w-32\" />\n    <div class=\"ball\" class:filled={status !== \"no-server\"} />\n    <div class=\"border-t-2 border-dashed border-zinc-600 w-32\" />\n    <div class=\"ball\" class:filled={status === \"connected\"} />\n  </div>\n\n  <div class=\"flex justify-between items-center mt-2.5\">\n    <p class=\"text-xs text-zinc-300 w-8\">You</p>\n\n    {#if status === \"connected\"}\n      <p class=\"text-xs w-14 text-left {colorLatency(serverLatency)}\">\n        {#if serverLatency !== null}\n          ~{displayLatency(serverLatency)}\n        {/if}\n      </p>\n    {/if}\n\n    <p class=\"text-xs text-zinc-300\">Server</p>\n\n    {#if status === \"connected\"}\n      <p class=\"text-xs w-14 text-right {colorLatency(shellLatency)}\">\n        {#if shellLatency !== null}\n          ~{displayLatency(shellLatency)}\n        {/if}\n      </p>\n    {/if}\n\n    <p class=\"text-xs text-zinc-300 w-8 text-right\">Shell</p>\n  </div>\n</div>\n\n<style lang=\"postcss\">\n  .ball {\n    @apply rounded-full w-4 h-4;\n  }\n\n  .ball.filled {\n    @apply border border-zinc-300 bg-zinc-600;\n  }\n\n  .ball:not(.filled) {\n    @apply border-2 border-zinc-600;\n  }\n</style>\n"
  },
  {
    "path": "src/lib/ui/OverlayMenu.svelte",
    "content": "<script lang=\"ts\">\n  import {\n    Dialog,\n    DialogDescription,\n    DialogOverlay,\n    DialogTitle,\n    Transition,\n    TransitionChild,\n  } from \"@rgossiaux/svelte-headlessui\";\n  import { XIcon } from \"svelte-feather-icons\";\n  import { createEventDispatcher } from \"svelte\";\n\n  const dispatch = createEventDispatcher<{ close: void }>();\n\n  export let title: string;\n  export let description: string;\n  export let showCloseButton = false;\n  export let maxWidth: number = 768; // screen-md\n  export let open: boolean;\n</script>\n\n<Transition show={open}>\n  <Dialog on:close class=\"fixed inset-0 z-50 grid place-items-center\">\n    <DialogOverlay class=\"fixed -z-10 inset-0 bg-black/20 backdrop-blur-sm\" />\n\n    <TransitionChild\n      enter=\"duration-300 ease-out\"\n      enterFrom=\"scale-95 opacity-0\"\n      enterTo=\"scale-100 opacity-100\"\n      leave=\"duration-75 ease-out\"\n      leaveFrom=\"scale-200 opacity-100\"\n      leaveTo=\"scale-95 opacity-0\"\n      class=\"w-full sm:w-[calc(100%-32px)]\"\n      style=\"max-width: {maxWidth}px\"\n    >\n      <div\n        class=\"relative bg-[#111] sm:border border-zinc-800 px-6 py-10 sm:py-6\n         h-screen sm:h-auto max-h-screen sm:rounded-lg overflow-y-auto\"\n      >\n        {#if showCloseButton}\n          <button\n            class=\"absolute top-4 right-4 p-1 rounded hover:bg-zinc-700 active:bg-indigo-700 transition-colors\"\n            aria-label=\"Close {title}\"\n            on:click={() => dispatch(\"close\")}\n          >\n            <XIcon class=\"h-5 w-5\" />\n          </button>\n        {/if}\n\n        <div class=\"mb-8 text-center\">\n          <DialogTitle class=\"text-xl font-medium mb-2\">\n            {title}\n          </DialogTitle>\n          <DialogDescription class=\"text-zinc-400\">\n            {description}\n          </DialogDescription>\n        </div>\n\n        <slot />\n      </div>\n    </TransitionChild>\n  </Dialog>\n</Transition>\n"
  },
  {
    "path": "src/lib/ui/Settings.svelte",
    "content": "<script lang=\"ts\">\n  import { ChevronDownIcon } from \"svelte-feather-icons\";\n\n  import { settings, updateSettings } from \"$lib/settings\";\n  import OverlayMenu from \"./OverlayMenu.svelte\";\n  import themes, { type ThemeName } from \"./themes\";\n\n  export let open: boolean;\n\n  let inputName: string;\n  let inputTheme: ThemeName;\n  let inputScrollback: number;\n\n  let initialized = false;\n  $: open, (initialized = false);\n  $: if (!initialized) {\n    initialized = true;\n    inputName = $settings.name;\n    inputTheme = $settings.theme;\n    inputScrollback = $settings.scrollback;\n  }\n</script>\n\n<OverlayMenu\n  title=\"Terminal Settings\"\n  description=\"Customize your collaborative terminal.\"\n  showCloseButton\n  {open}\n  on:close\n>\n  <div class=\"flex flex-col gap-4\">\n    <div class=\"item\">\n      <div>\n        <p class=\"item-title\">Name</p>\n        <p class=\"item-subtitle\">Choose how you appear to other users.</p>\n      </div>\n      <div>\n        <input\n          class=\"input-common\"\n          placeholder=\"Your name\"\n          bind:value={inputName}\n          maxlength=\"50\"\n          on:input={() => {\n            if (inputName.length >= 2) {\n              updateSettings({ name: inputName });\n            }\n          }}\n        />\n      </div>\n    </div>\n    <div class=\"item\">\n      <div>\n        <p class=\"item-title\">Color palette</p>\n        <p class=\"item-subtitle\">Color theme for text in terminals.</p>\n      </div>\n      <div class=\"relative\">\n        <ChevronDownIcon\n          class=\"absolute top-[11px] right-2.5 w-4 h-4 text-zinc-400\"\n        />\n        <select\n          class=\"input-common !pr-5\"\n          bind:value={inputTheme}\n          on:change={() => updateSettings({ theme: inputTheme })}\n        >\n          {#each Object.keys(themes) as themeName (themeName)}\n            <option value={themeName}>{themeName}</option>\n          {/each}\n        </select>\n      </div>\n    </div>\n    <div class=\"item\">\n      <div>\n        <p class=\"item-title\">Scrollback</p>\n        <p class=\"item-subtitle\">\n          Lines of previous text displayed in the terminal window.\n        </p>\n      </div>\n      <div>\n        <input\n          type=\"number\"\n          class=\"input-common\"\n          bind:value={inputScrollback}\n          on:input={() => {\n            if (inputScrollback >= 0) {\n              updateSettings({ scrollback: inputScrollback });\n            }\n          }}\n          step=\"100\"\n        />\n      </div>\n    </div>\n    <!-- <div class=\"item\">\n      <div>\n        <p class=\"item-title\">Cursor style</p>\n        <p class=\"item-subtitle\">Style of live cursors.</p>\n      </div>\n      <div class=\"text-red-500\">Coming soon</div>\n    </div> -->\n  </div>\n\n  <!-- svelte-ignore missing-declaration -->\n  <p class=\"mt-6 text-sm text-right text-zinc-400\">\n    <a target=\"_blank\" rel=\"noreferrer\" href=\"https://github.com/ekzhang/sshx\"\n      >sshx-server v{__APP_VERSION__}</a\n    >\n  </p>\n</OverlayMenu>\n\n<style lang=\"postcss\">\n  .item {\n    @apply bg-zinc-800/25 rounded-lg p-4 flex gap-4 flex-col sm:flex-row items-start;\n  }\n\n  .item > div:first-child {\n    @apply flex-1;\n  }\n\n  .item-title {\n    @apply font-medium text-zinc-200 mb-1;\n  }\n\n  .item-subtitle {\n    @apply text-sm text-zinc-400;\n  }\n\n  .input-common {\n    @apply w-52 px-3 py-2 text-sm rounded-md bg-transparent hover:bg-white/5;\n    @apply border border-zinc-700 outline-none focus:ring-2 focus:ring-indigo-500/50;\n    @apply appearance-none transition-colors;\n  }\n</style>\n"
  },
  {
    "path": "src/lib/ui/TeaserVideo.svelte",
    "content": "<script lang=\"ts\">\n  import logo from \"$lib/assets/logo.svg\";\n  import {\n    ArrowLeftIcon,\n    ArrowRightIcon,\n    InfoIcon,\n    MoreVerticalIcon,\n    UserIcon,\n    XIcon,\n  } from \"svelte-feather-icons\";\n</script>\n\n<div class=\"rounded-lg border border-white/10 overflow-hidden\">\n  <div class=\"flex bg-zinc-900 items-end\">\n    <div class=\"px-4 py-3 flex gap-1.5\">\n      <div class=\"w-2.5 h-2.5 rounded-full bg-red-500\" />\n      <div class=\"w-2.5 h-2.5 rounded-full bg-yellow-500\" />\n      <div class=\"w-2.5 h-2.5 rounded-full bg-green-500\" />\n    </div>\n    <div class=\"flex ml-2\">\n      <div class=\"rounded-t-lg bg-zinc-800 h-7 w-44 px-2 flex items-center\">\n        <img src={logo} alt=\"sshx logo\" class=\"h-5 w-5\" />\n        <p class=\"ml-1.5 text-xs\">sshx</p>\n        <XIcon class=\"w-3.5 h-3.5 ml-auto text-zinc-400\" />\n      </div>\n    </div>\n  </div>\n  <div class=\"flex py-1 bg-zinc-800 gap-2\">\n    <div class=\"flex px-2 py-1 gap-2\">\n      <ArrowLeftIcon class=\"w-5 h-5 text-zinc-500\" />\n      <ArrowRightIcon class=\"w-5 h-5 text-zinc-500\" />\n    </div>\n    <div class=\"rounded-full flex-1 bg-zinc-900/60 flex items-center px-2\">\n      <InfoIcon class=\"w-4 h-4 text-zinc-400\" />\n      <p class=\"text-sm ml-2 select-none text-zinc-300\">\n        sshx.io/s/gzN0WHsm6r#tiOAVOLsNXEZxJ\n      </p>\n    </div>\n    <div class=\"flex items-center gap-3 px-2\">\n      <UserIcon class=\"w-4 h-4 text-zinc-300\" />\n      <MoreVerticalIcon class=\"w-4 h-4 text-zinc-300\" />\n    </div>\n  </div>\n  <video playsinline muted autoplay loop controls width={2476} height={1534}>\n    <!-- HEVC (Safari) -->\n    <source\n      src=\"https://sshx.s3.amazonaws.com/media/teaser-video.mp4\"\n      type={`video/mp4; codecs=\"hvc1\"`}\n    />\n    <!-- VP9 (Chrome and other browsers) -->\n    <source\n      src=\"https://sshx.s3.amazonaws.com/media/teaser-video.webm\"\n      type=\"video/webm\"\n    />\n    <track kind=\"captions\" />\n  </video>\n</div>\n"
  },
  {
    "path": "src/lib/ui/Toast.svelte",
    "content": "<script lang=\"ts\">\n  import { createEventDispatcher } from \"svelte\";\n  import {\n    CheckCircleIcon,\n    HelpCircleIcon,\n    InfoIcon,\n    XCircleIcon,\n  } from \"svelte-feather-icons\";\n\n  const dispatch = createEventDispatcher<{ action: void }>();\n\n  /** The kind of toast to display. */\n  export let kind: \"info\" | \"success\" | \"error\" = \"info\";\n\n  /** The message to display inside the toast. */\n  export let message: string;\n\n  /** An optional action to provide as a button on the toast. */\n  export let action = \"\";\n</script>\n\n<div class=\"toast-box\">\n  {#if kind === \"info\"}\n    <InfoIcon class=\"w-5 h-5 text-accent-lime flex-shrink-0\" />\n  {:else if kind === \"success\"}\n    <CheckCircleIcon class=\"w-5 h-5 text-green-300 flex-shrink-0\" />\n  {:else if kind === \"error\"}\n    <XCircleIcon class=\"w-5 h-5 text-red-300 flex-shrink-0\" />\n  {:else}\n    <HelpCircleIcon class=\"w-5 h-5 text-accent-lime flex-shrink-0\" />\n  {/if}\n\n  <p class=\"ml-3\">\n    {message}\n  </p>\n\n  {#if action}\n    <div class=\"ml-auto\">\n      <button\n        class=\"h-5 ml-3 px-2 flex items-center text-xs border rounded-md border-zinc-400 hover:border-zinc-200 hover:text-white transition-colors\"\n        on:click={() => dispatch(\"action\")}\n      >\n        {action}\n      </button>\n    </div>\n  {/if}\n</div>\n\n<style lang=\"postcss\">\n  .toast-box {\n    @apply border border-zinc-700 bg-zinc-900/80 backdrop-blur-sm;\n    @apply p-4 rounded-md flex items-start pointer-events-auto;\n    @apply text-sm;\n  }\n</style>\n"
  },
  {
    "path": "src/lib/ui/ToastContainer.svelte",
    "content": "<script lang=\"ts\">\n  import { onMount } from \"svelte\";\n  import { flip } from \"svelte/animate\";\n  import { fly } from \"svelte/transition\";\n  import { Portal } from \"@rgossiaux/svelte-headlessui\";\n\n  import Toast from \"./Toast.svelte\";\n  import { toastStore } from \"$lib/toast\";\n\n  onMount(() => {\n    // Remove old toasts periodically.\n    const id = setInterval(() => {\n      const now = Date.now();\n      toastStore.update(($toasts) => $toasts.filter((t) => t.expires > now));\n    }, 250);\n    return () => clearInterval(id);\n  });\n</script>\n\n<Portal>\n  <div class=\"fixed inset-0 z-40 pointer-events-none flex justify-end p-4\">\n    <div class=\"w-full max-w-md\">\n      {#each $toastStore.slice().reverse() as toast (toast)}\n        <div\n          class=\"mb-2\"\n          on:click={() =>\n            ($toastStore = $toastStore.filter((t) => t !== toast))}\n          on:keypress={() => null}\n          animate:flip={{ duration: 500 }}\n          transition:fly={{ x: 360, duration: 500 }}\n        >\n          <Toast\n            kind={toast.kind}\n            message={toast.message}\n            action={toast.action}\n            on:action={toast.onAction ?? (() => null)}\n          />\n        </div>\n      {/each}\n    </div>\n  </div>\n</Portal>\n"
  },
  {
    "path": "src/lib/ui/Toolbar.svelte",
    "content": "<script lang=\"ts\">\n  import { createEventDispatcher } from \"svelte\";\n  import {\n    MessageSquareIcon,\n    PlusCircleIcon,\n    SettingsIcon,\n    WifiIcon,\n  } from \"svelte-feather-icons\";\n\n  import logo from \"$lib/assets/logo.svg\";\n\n  export let connected: boolean;\n  export let hasWriteAccess: boolean | undefined;\n  export let newMessages: boolean;\n\n  const dispatch = createEventDispatcher<{\n    create: void;\n    chat: void;\n    settings: void;\n    networkInfo: void;\n  }>();\n</script>\n\n<div class=\"panel inline-block px-3 py-2\">\n  <div class=\"flex items-center select-none\">\n    <a href=\"/\" class=\"flex-shrink-0\"\n      ><img src={logo} alt=\"sshx logo\" class=\"h-10\" /></a\n    >\n    <p class=\"ml-1.5 mr-2 font-medium\">sshx</p>\n\n    <div class=\"v-divider\" />\n\n    <div class=\"flex space-x-1\">\n      <button\n        class=\"icon-button\"\n        on:click={() => dispatch(\"create\")}\n        disabled={!connected || !hasWriteAccess}\n        title={!connected\n          ? \"Not connected\"\n          : hasWriteAccess === false // Only show the \"No write access\" title after confirming read-only mode.\n          ? \"No write access\"\n          : \"Create new terminal\"}\n      >\n        <PlusCircleIcon strokeWidth={1.5} class=\"p-0.5\" />\n      </button>\n      <button class=\"icon-button\" on:click={() => dispatch(\"chat\")}>\n        <MessageSquareIcon strokeWidth={1.5} class=\"p-0.5\" />\n        {#if newMessages}\n          <div class=\"activity\" />\n        {/if}\n      </button>\n      <button class=\"icon-button\" on:click={() => dispatch(\"settings\")}>\n        <SettingsIcon strokeWidth={1.5} class=\"p-0.5\" />\n      </button>\n    </div>\n\n    <div class=\"v-divider\" />\n\n    <div class=\"flex space-x-1\">\n      <button class=\"icon-button\" on:click={() => dispatch(\"networkInfo\")}>\n        <WifiIcon strokeWidth={1.5} class=\"p-0.5\" />\n      </button>\n    </div>\n  </div>\n</div>\n\n<style lang=\"postcss\">\n  .v-divider {\n    @apply h-5 mx-2 border-l-4 border-zinc-800;\n  }\n\n  .icon-button {\n    @apply relative rounded-md p-1 hover:bg-zinc-700 active:bg-indigo-700 transition-colors;\n    @apply disabled:opacity-50 disabled:bg-transparent;\n  }\n\n  .activity {\n    @apply absolute top-1 right-0.5 text-xs p-[4.5px] bg-red-500 rounded-full;\n  }\n</style>\n"
  },
  {
    "path": "src/lib/ui/XTerm.svelte",
    "content": "<!-- @component Interactive terminal rendered with xterm.js -->\n<script lang=\"ts\" context=\"module\">\n  import { makeToast } from \"$lib/toast\";\n\n  // Deduplicated terminal font loading.\n  const waitForFonts = (() => {\n    let state: \"initial\" | \"loading\" | \"loaded\" = \"initial\";\n    const waitlist: (() => void)[] = [];\n\n    return async function waitForFonts() {\n      if (state === \"loaded\") return;\n      else if (state === \"initial\") {\n        const FontFaceObserver = (await import(\"fontfaceobserver\")).default;\n        state = \"loading\";\n        try {\n          await new FontFaceObserver(\"Fira Code VF\").load();\n        } catch (error) {\n          makeToast({\n            kind: \"error\",\n            message: \"Could not load terminal font.\",\n          });\n        }\n        state = \"loaded\";\n        for (const fn of waitlist) fn();\n      } else {\n        await new Promise<void>((resolve) => {\n          if (state === \"loaded\") resolve();\n          else waitlist.push(resolve);\n        });\n      }\n    };\n  })();\n</script>\n\n<script lang=\"ts\">\n  import { browser } from \"$app/environment\";\n\n  import { createEventDispatcher, onDestroy, onMount } from \"svelte\";\n  import type { Terminal } from \"sshx-xterm\";\n  import { Buffer } from \"buffer\";\n\n  import themes from \"./themes\";\n  import CircleButton from \"./CircleButton.svelte\";\n  import CircleButtons from \"./CircleButtons.svelte\";\n  import { settings } from \"$lib/settings\";\n  import { TypeAheadAddon } from \"$lib/typeahead\";\n\n  /** Used to determine Cmd versus Ctrl keyboard shortcuts. */\n  const isMac = browser && navigator.platform.startsWith(\"Mac\");\n\n  const dispatch = createEventDispatcher<{\n    data: Uint8Array;\n    close: void;\n    shrink: void;\n    expand: void;\n    bringToFront: void;\n    startMove: MouseEvent;\n    focus: void;\n    blur: void;\n  }>();\n\n  const typeahead = new TypeAheadAddon();\n\n  export let rows: number, cols: number;\n  export let write: (data: string) => void; // bound function prop\n\n  export let termEl: HTMLDivElement = null as any; // suppress \"missing prop\" warning\n  let term: Terminal | null = null;\n\n  $: theme = themes[$settings.theme];\n\n  $: if (term) {\n    // If the theme changes, update existing terminals' appearance.\n    term.options.theme = theme;\n    term.options.scrollback = $settings.scrollback;\n  }\n\n  let loaded = false;\n  let focused = false;\n  let currentTitle = \"Remote Terminal\";\n\n  function handleWheelSkipXTerm(event: WheelEvent) {\n    event.preventDefault(); // Stop native macOS Chrome zooming on pinch.\n\n    // We stop the event from propagating to the main `.xterm` terminal element,\n    // so the xterm.js's event handlers do not fire and scroll the buffer.\n    event.stopPropagation();\n\n    // However, we still want it to propagate upward to our pan/zoom handlers,\n    // so we re-dispatch the event higher up, skipping xterm.\n    termEl?.dispatchEvent(new WheelEvent(event.type, event));\n  }\n\n  function setFocused(isFocused: boolean, cursorLayer: HTMLDivElement) {\n    if (isFocused && !focused) {\n      focused = isFocused;\n      cursorLayer.removeEventListener(\"wheel\", handleWheelSkipXTerm);\n      dispatch(\"focus\");\n    } else if (!isFocused && focused) {\n      focused = isFocused;\n      cursorLayer.addEventListener(\"wheel\", handleWheelSkipXTerm);\n      dispatch(\"blur\");\n    }\n  }\n\n  const preloadBuffer: string[] = [];\n\n  write = (data: string) => {\n    if (!term) {\n      // Before the terminal is loaded, push data into a buffer.\n      preloadBuffer.push(data);\n    } else {\n      if (data) data = typeahead.onBeforeProcessData(data);\n      term.write(data);\n    }\n  };\n\n  $: term?.resize(cols, rows);\n\n  onMount(async () => {\n    const [{ Terminal }, { WebLinksAddon }, { WebglAddon }, { ImageAddon }] =\n      await Promise.all([\n        import(\"sshx-xterm\"),\n        import(\"xterm-addon-web-links\"),\n        import(\"xterm-addon-webgl\"),\n        import(\"xterm-addon-image\"),\n      ]);\n\n    await waitForFonts();\n\n    term = new Terminal({\n      allowTransparency: false,\n      cursorBlink: false,\n      cursorStyle: \"block\",\n      // This is the monospace font family configured in Tailwind.\n      fontFamily:\n        '\"Fira Code VF\", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace',\n      fontSize: 14,\n      fontWeight: 400,\n      fontWeightBold: 500,\n      lineHeight: 1.06,\n      scrollback: $settings.scrollback,\n      theme,\n    });\n\n    // Keyboard shortcuts for natural text editing.\n    term.attachCustomKeyEventHandler((event) => {\n      if (\n        (isMac && event.metaKey && !event.ctrlKey && !event.altKey) ||\n        (!isMac && !event.metaKey && event.ctrlKey && !event.altKey)\n      ) {\n        if (event.key === \"ArrowLeft\") {\n          dispatch(\"data\", new Uint8Array([0x01]));\n          return false;\n        } else if (event.key === \"ArrowRight\") {\n          dispatch(\"data\", new Uint8Array([0x05]));\n          return false;\n        } else if (event.key === \"Backspace\") {\n          dispatch(\"data\", new Uint8Array([0x15]));\n          return false;\n        }\n      }\n      return true;\n    });\n\n    term.loadAddon(new WebLinksAddon());\n    term.loadAddon(new WebglAddon());\n    term.loadAddon(new ImageAddon({ enableSizeReports: false }));\n\n    term.open(termEl);\n\n    term.resize(cols, rows);\n    term.onTitleChange((title) => {\n      currentTitle = title;\n    });\n\n    // Hack: We artificially disable scrolling when the terminal is not focused.\n    // (\"termEl\" > div.terminal.xterm > div.xterm-screen)\n    const screenEl = termEl.querySelector(\".xterm-screen\")! as HTMLDivElement;\n    screenEl.addEventListener(\"wheel\", handleWheelSkipXTerm);\n\n    const focusObserver = new MutationObserver((mutations) => {\n      for (const mutation of mutations) {\n        if (\n          mutation.type === \"attributes\" &&\n          mutation.attributeName === \"class\"\n        ) {\n          // The \"focus\" class is set directly by xterm.js, but there isn't any way to listen for it.\n          const target = mutation.target as HTMLElement;\n          const isFocused = target.classList.contains(\"focus\");\n          setFocused(isFocused, screenEl);\n        }\n      }\n    });\n    focusObserver.observe(term.element!, { attributeFilter: [\"class\"] });\n\n    loaded = true;\n    for (const data of preloadBuffer) {\n      term.write(data);\n    }\n\n    typeahead.reset();\n    term.loadAddon(typeahead);\n\n    const utf8 = new TextEncoder();\n    term.onData((data: string) => {\n      dispatch(\"data\", utf8.encode(data));\n    });\n    term.onBinary((data: string) => {\n      dispatch(\"data\", Buffer.from(data, \"binary\"));\n    });\n  });\n\n  onDestroy(() => term?.dispose());\n</script>\n\n<div\n  class=\"term-container\"\n  class:focused\n  style:background={theme.background}\n  on:mousedown={() => dispatch(\"bringToFront\")}\n  on:pointerdown={(event) => event.stopPropagation()}\n>\n  <div\n    class=\"flex select-none\"\n    on:mousedown={(event) => dispatch(\"startMove\", event)}\n  >\n    <div class=\"flex-1 flex items-center px-3\">\n      <CircleButtons>\n        <!--\n          TODO: This should be on:click, but that is not working due to the\n          containing element's on:pointerdown `stopPropagation()` call.\n        -->\n        <CircleButton\n          kind=\"red\"\n          on:mousedown={(event) => event.button === 0 && dispatch(\"close\")}\n        />\n        <CircleButton\n          kind=\"yellow\"\n          on:mousedown={(event) => event.button === 0 && dispatch(\"shrink\")}\n        />\n        <CircleButton\n          kind=\"green\"\n          on:mousedown={(event) => event.button === 0 && dispatch(\"expand\")}\n        />\n      </CircleButtons>\n    </div>\n    <div\n      class=\"p-2 text-sm text-zinc-300 text-center font-medium overflow-hidden whitespace-nowrap text-ellipsis w-0 flex-grow-[4]\"\n    >\n      {currentTitle}\n    </div>\n    <div class=\"flex-1\" />\n  </div>\n  <div\n    class=\"inline-block px-4 py-2 transition-opacity duration-500\"\n    bind:this={termEl}\n    style:opacity={loaded ? 1.0 : 0.0}\n    on:wheel={(event) => {\n      if (focused) {\n        // Don't pan the page when scrolling while the terminal is selected.\n        // Conversely, we manually disable terminal scrolling unless it is currently selected.\n        event.stopPropagation();\n      }\n    }}\n  />\n</div>\n\n<style lang=\"postcss\">\n  .term-container {\n    @apply inline-block rounded-lg border border-zinc-700 opacity-90;\n    transition: transform 200ms, opacity 200ms;\n  }\n\n  .term-container:not(.focused) :global(.xterm) {\n    @apply cursor-default;\n  }\n\n  .term-container.focused {\n    @apply opacity-100;\n  }\n</style>\n"
  },
  {
    "path": "src/lib/ui/themes.ts",
    "content": "import type { ITheme } from \"sshx-xterm\";\n\n/** VSCode default dark theme, from https://glitchbone.github.io/vscode-base16-term/. */\nconst defaultDark: ITheme = {\n  foreground: \"#d8d8d8\",\n  background: \"#181818\",\n\n  cursor: \"#d8d8d8\",\n\n  black: \"#181818\",\n  red: \"#ab4642\",\n  green: \"#a1b56c\",\n  yellow: \"#f7ca88\",\n  blue: \"#7cafc2\",\n  magenta: \"#ba8baf\",\n  cyan: \"#86c1b9\",\n  white: \"#d8d8d8\",\n\n  brightBlack: \"#585858\",\n  brightRed: \"#ab4642\",\n  brightGreen: \"#a1b56c\",\n  brightYellow: \"#f7ca88\",\n  brightBlue: \"#7cafc2\",\n  brightMagenta: \"#ba8baf\",\n  brightCyan: \"#86c1b9\",\n  brightWhite: \"#f8f8f8\",\n};\n\n/** Hybrid theme from https://terminal.sexy/, using Alacritty export format. */\nconst hybrid: ITheme = {\n  foreground: \"#c5c8c6\",\n  background: \"#1d1f21\",\n\n  black: \"#282a2e\",\n  red: \"#a54242\",\n  green: \"#8c9440\",\n  yellow: \"#de935f\",\n  blue: \"#5f819d\",\n  magenta: \"#85678f\",\n  cyan: \"#5e8d87\",\n  white: \"#707880\",\n\n  brightBlack: \"#373b41\",\n  brightRed: \"#cc6666\",\n  brightGreen: \"#b5bd68\",\n  brightYellow: \"#f0c674\",\n  brightBlue: \"#81a2be\",\n  brightMagenta: \"#b294bb\",\n  brightCyan: \"#8abeb7\",\n  brightWhite: \"#c5c8c6\",\n};\n\n/** Below themes are converted from https://github.com/alacritty/alacritty-theme/. */\nconst rosePine: ITheme = {\n  foreground: \"#e0def4\",\n  background: \"#191724\",\n\n  cursor: \"#524f67\",\n\n  black: \"#26233a\",\n  red: \"#eb6f92\",\n  green: \"#31748f\",\n  yellow: \"#f6c177\",\n  blue: \"#9ccfd8\",\n  magenta: \"#c4a7e7\",\n  cyan: \"#ebbcba\",\n  white: \"#e0def4\",\n\n  brightBlack: \"#6e6a86\",\n  brightRed: \"#eb6f92\",\n  brightGreen: \"#31748f\",\n  brightYellow: \"#f6c177\",\n  brightBlue: \"#9ccfd8\",\n  brightMagenta: \"#c4a7e7\",\n  brightCyan: \"#ebbcba\",\n  brightWhite: \"#e0def4\",\n};\n\nconst ubuntu: ITheme = {\n  foreground: \"#eeeeec\",\n  background: \"#300a24\",\n  black: \"#2e3436\",\n  red: \"#cc0000\",\n  green: \"#4e9a06\",\n  yellow: \"#c4a000\",\n  blue: \"#3465a4\",\n  magenta: \"#75507b\",\n  cyan: \"#06989a\",\n  white: \"#d3d7cf\",\n  brightBlack: \"#555753\",\n  brightRed: \"#ef2929\",\n  brightGreen: \"#8ae234\",\n  brightYellow: \"#fce94f\",\n  brightBlue: \"#729fcf\",\n  brightMagenta: \"#ad7fa8\",\n  brightCyan: \"#34e2e2\",\n  brightWhite: \"#eeeeec\",\n};\n\nconst dracula: ITheme = {\n  foreground: \"#f8f8f2\",\n  background: \"#282a36\",\n  black: \"#000000\",\n  red: \"#ff5555\",\n  green: \"#50fa7b\",\n  yellow: \"#f1fa8c\",\n  blue: \"#bd93f9\",\n  magenta: \"#ff79c6\",\n  cyan: \"#8be9fd\",\n  white: \"#bbbbbb\",\n  brightBlack: \"#555555\",\n  brightRed: \"#ff5555\",\n  brightGreen: \"#50fa7b\",\n  brightYellow: \"#f1fa8c\",\n  brightBlue: \"#caa9fa\",\n  brightMagenta: \"#ff79c6\",\n  brightCyan: \"#8be9fd\",\n  brightWhite: \"#ffffff\",\n};\n\nconst githubDark: ITheme = {\n  foreground: \"#d1d5da\",\n  background: \"#24292e\",\n  black: \"#586069\",\n  red: \"#ea4a5a\",\n  green: \"#34d058\",\n  yellow: \"#ffea7f\",\n  blue: \"#2188ff\",\n  magenta: \"#b392f0\",\n  cyan: \"#39c5cf\",\n  white: \"#d1d5da\",\n  brightBlack: \"#959da5\",\n  brightRed: \"#f97583\",\n  brightGreen: \"#85e89d\",\n  brightYellow: \"#ffea7f\",\n  brightBlue: \"#79b8ff\",\n  brightMagenta: \"#b392f0\",\n  brightCyan: \"#56d4dd\",\n  brightWhite: \"#fafbfc\",\n};\n\nconst gruvboxDark: ITheme = {\n  foreground: \"#ebdbb2\",\n  background: \"#282828\",\n  black: \"#282828\",\n  red: \"#cc241d\",\n  green: \"#98971a\",\n  yellow: \"#d79921\",\n  blue: \"#458588\",\n  magenta: \"#b16286\",\n  cyan: \"#689d6a\",\n  white: \"#a89984\",\n  brightBlack: \"#928374\",\n  brightRed: \"#fb4934\",\n  brightGreen: \"#b8bb26\",\n  brightYellow: \"#fabd2f\",\n  brightBlue: \"#83a598\",\n  brightMagenta: \"#d3869b\",\n  brightCyan: \"#8ec07c\",\n  brightWhite: \"#ebdbb2\",\n};\n\nconst solarizedDark: ITheme = {\n  foreground: \"#839496\",\n  background: \"#002b36\",\n  black: \"#073642\",\n  red: \"#dc322f\",\n  green: \"#859900\",\n  yellow: \"#b58900\",\n  blue: \"#268bd2\",\n  magenta: \"#d33682\",\n  cyan: \"#2aa198\",\n  white: \"#eee8d5\",\n  brightBlack: \"#002b36\",\n  brightRed: \"#cb4b16\",\n  brightGreen: \"#586e75\",\n  brightYellow: \"#657b83\",\n  brightBlue: \"#839496\",\n  brightMagenta: \"#6c71c4\",\n  brightCyan: \"#93a1a1\",\n  brightWhite: \"#fdf6e3\",\n};\n\nconst tokyoNight: ITheme = {\n  foreground: \"#a9b1d6\",\n  background: \"#1a1b26\",\n  black: \"#32344a\",\n  red: \"#f7768e\",\n  green: \"#9ece6a\",\n  yellow: \"#e0af68\",\n  blue: \"#7aa2f7\",\n  magenta: \"#ad8ee6\",\n  cyan: \"#449dab\",\n  white: \"#787c99\",\n  brightBlack: \"#444b6a\",\n  brightRed: \"#ff7a93\",\n  brightGreen: \"#b9f27c\",\n  brightYellow: \"#ff9e64\",\n  brightBlue: \"#7da6ff\",\n  brightMagenta: \"#bb9af7\",\n  brightCyan: \"#0db9d7\",\n  brightWhite: \"#acb0d0\",\n};\n\nconst themes = {\n  \"VS Code Dark\": defaultDark,\n  Hybrid: hybrid,\n  \"Rosé Pine\": rosePine,\n  Ubuntu: ubuntu,\n  Dracula: dracula,\n  \"GitHub Dark\": githubDark,\n  \"Gruvbox Dark\": gruvboxDark,\n  \"Solarized Dark\": solarizedDark,\n  \"Tokyo Night\": tokyoNight,\n};\n\nexport type ThemeName = keyof typeof themes;\n\nexport const defaultTheme: ThemeName = \"VS Code Dark\";\n\nexport default themes;\n"
  },
  {
    "path": "src/routes/+error.svelte",
    "content": "<script lang=\"ts\">\n  import { page } from \"$app/stores\";\n\n  import logotypeDark from \"$lib/assets/logotype-dark.svg\";\n</script>\n\n<main class=\"p-4 max-w-xl mx-auto my-6 md:my-12 lg:my-24\">\n  <img class=\"h-16 -mx-2\" src={logotypeDark} alt=\"sshx logo\" />\n\n  <div class=\"space-y-4 mt-6 mb-8 text-zinc-300\">\n    <p>\n      {#if $page.status === 404}\n        <b class=\"text-white\">404 Not Found.</b> We couldn't find this page, sorry!\n      {:else}\n        <b class=\"text-white\">Error {$page.status}.</b> An unexpected error occurred.\n      {/if}\n    </p>\n    {#if $page.status !== 404}\n      <pre class=\"whitespace-pre-wrap break-all p-3 rounded bg-zinc-900\">\n{JSON.stringify($page.error, null, 2)}\n</pre>\n    {/if}\n    <p>\n      Perhaps try coming back later? If you have any feedback, please feel free\n      to reach out at\n      <a class=\"underline text-white\" href=\"mailto:ekzhang1@gmail.com\"\n        >ekzhang1@gmail.com</a\n      >.\n    </p>\n  </div>\n\n  <a\n    href=\"/\"\n    class=\"inline-block font-medium px-6 py-2 rounded-full bg-indigo-900 hover:bg-indigo-700\"\n    >Return home</a\n  >\n</main>\n"
  },
  {
    "path": "src/routes/+layout.svelte",
    "content": "<script lang=\"ts\">\n  import \"@fontsource-variable/inter\";\n\n  import \"sshx-xterm/css/xterm.css\";\n  import \"../app.css\";\n\n  import ToastContainer from \"$lib/ui/ToastContainer.svelte\";\n</script>\n\n<ToastContainer />\n\n<slot />\n"
  },
  {
    "path": "src/routes/+page.svelte",
    "content": "<script lang=\"ts\">\n  import {\n    CastIcon,\n    DownloadIcon,\n    GitBranchIcon,\n    HardDriveIcon,\n    ImageIcon,\n    LockIcon,\n    PackageIcon,\n    RefreshCwIcon,\n    Share2Icon,\n  } from \"svelte-feather-icons\";\n\n  import logotypeDark from \"$lib/assets/logotype-dark.svg\";\n  import landingGraphic from \"$lib/assets/landing-graphic.svg\";\n  import landingBackground from \"$lib/assets/landing-background.svg\";\n  import TeaserVideo from \"$lib/ui/TeaserVideo.svelte\";\n  import CopyableCode from \"$lib/ui/CopyableCode.svelte\";\n  import DownloadLink from \"$lib/ui/DownloadLink.svelte\";\n\n  let installationEl: HTMLDivElement;\n\n  const socials = [\n    {\n      title: \"🤖\\xa0 GitHub\",\n      href: \"https://github.com/ekzhang/sshx\",\n    },\n    {\n      title: \"🌸\\xa0 Twitter\",\n      href: \"https://twitter.com/ekzhang1\",\n    },\n    {\n      title: \"💌\\xa0 Email\",\n      href: \"mailto:ekzhang1@gmail.com\",\n    },\n    {\n      title: \"🌎\\xa0 Website\",\n      href: \"https://www.ekzhang.com\",\n    },\n  ];\n\n  function scrollToInstallation() {\n    installationEl.scrollIntoView({ behavior: \"smooth\" });\n  }\n</script>\n\n<main\n  class=\"max-w-screen-xl mx-auto px-4 md:px-8 lg:px-16 text-zinc-100 overflow-x-hidden\"\n>\n  <header class=\"mt-6 mb-4 sm:my-8 md:my-12\">\n    <img class=\"h-12 sm:h-16 -mx-1\" src={logotypeDark} alt=\"sshx logo\" />\n  </header>\n  <h1\n    class=\"font-medium text-3xl sm:text-4xl md:text-5xl max-w-[26ch] py-2 mb-6 md:mb-0 sm:tracking-tight leading-[1.15]\"\n  >\n    A secure web-based,\n    <span class=\"title-gradient\">collaborative</span> terminal\n  </h1>\n\n  <div class=\"relative\">\n    <div\n      class=\"absolute scale-150 md:scale-100 md:left-[180px] md:top-[-200px] md:w-[1000px] -z-10\"\n    >\n      <img class=\"select-none\" src={landingBackground} alt=\"\" />\n    </div>\n    <div class=\"md:absolute md:left-[500px] md:w-[1000px]\">\n      <img\n        class=\"mt-5 mb-8 w-[720px]\"\n        width={813}\n        height={623}\n        src={landingGraphic}\n        alt=\"two terminal windows running sshx and three live cursors\"\n      />\n    </div>\n  </div>\n\n  <section class=\"my-12 space-y-6 sm:text-lg md:max-w-[460px] text-zinc-400\">\n    <p>\n      <code class=\"name\">sshx</code> lets you share your terminal with anyone by\n      link, on a\n      <b>multiplayer infinite canvas</b>.\n    </p>\n    <p>\n      It has <b>real-time collaboration</b>, with remote cursors and chat. It's\n      also <b>fast</b> and <b>end-to-end encrypted</b>, with a lightweight\n      server written in Rust.\n    </p>\n    <p>\n      Install <code class=\"name\">sshx</code> with a single command. Use it for teaching,\n      debugging, or cloud access.\n    </p>\n  </section>\n\n  <div class=\"pb-12 md:pb-36\">\n    <button\n      class=\"bg-pink-700 hover:bg-pink-600 active:ring-4 active:ring-pink-500/50 text-lg font-medium px-8 py-2 rounded-full\"\n      on:click={scrollToInstallation}\n    >\n      Get Started\n    </button>\n  </div>\n\n  <div class=\"mt-8 md:mt-32 grid md:grid-cols-2 xl:grid-cols-3 gap-4 md:gap-6\">\n    <div class=\"feature-block\">\n      <div class=\"feature-icon\">\n        <CastIcon size=\"14\" />\n      </div>\n      <h3>Collaborative</h3>\n      <p>Invite people by sharing a secure, unique browser link.</p>\n    </div>\n    <div class=\"feature-block\">\n      <div class=\"feature-icon\">\n        <LockIcon size=\"14\" />\n      </div>\n      <h3>End-to-end encrypted</h3>\n      <p>Send data securely; the server never sees what you're typing.</p>\n    </div>\n    <div class=\"feature-block\">\n      <div class=\"feature-icon\">\n        <HardDriveIcon size=\"14\" />\n      </div>\n      <h3>Cross-platform</h3>\n      <p>Use the command-line tool on macOS, Linux, and Windows.</p>\n    </div>\n    <div class=\"feature-block\">\n      <div class=\"feature-icon\">\n        <ImageIcon size=\"14\" />\n      </div>\n      <h3>Infinite canvas</h3>\n      <p>Move and resize multiple terminals at once, in any arrangement.</p>\n    </div>\n    <div class=\"feature-block\">\n      <div class=\"feature-icon\">\n        <RefreshCwIcon size=\"14\" />\n      </div>\n      <h3>Live presence</h3>\n      <p>See other people's names and cursors within the app.</p>\n    </div>\n    <div class=\"feature-block\">\n      <div class=\"feature-icon\">\n        <Share2Icon size=\"14\" />\n      </div>\n      <h3>Ultra-fast mesh networking</h3>\n      <p>\n        Connect from anywhere to the nearest distributed peer in a global\n        network.\n      </p>\n    </div>\n  </div>\n\n  <div class=\"my-48 hidden md:block\">\n    <TeaserVideo />\n  </div>\n\n  <h2\n    bind:this={installationEl}\n    class=\"mt-32 mb-12 font-medium text-3xl sm:text-4xl md:text-center scroll-mt-16\"\n  >\n    Installation\n  </h2>\n\n  <section class=\"installation-section\">\n    <h3 class=\"text-xl sm:text-lg\">\n      <DownloadIcon size=\"20\" class=\"text-zinc-400 inline-block mr-1 mb-0.5\" />\n      macOS / Linux\n    </h3>\n    <div class=\"text-sm text-zinc-400 md:text-base md:pt-0.5\">\n      <p class=\"mb-3\">Run the following in your terminal:</p>\n      <CopyableCode value=\"curl -sSf https://sshx.io/get | sh\" />\n\n      <p class=\"mt-8 mb-3\">Or, download the binary for your platform.</p>\n      <div class=\"flex flex-wrap gap-2 mb-2\">\n        <DownloadLink\n          href=\"https://sshx.s3.amazonaws.com/sshx-aarch64-apple-darwin.tar.gz\"\n          >macOS ARM64 (Apple Silicon)</DownloadLink\n        >\n        <DownloadLink\n          href=\"https://sshx.s3.amazonaws.com/sshx-x86_64-apple-darwin.tar.gz\"\n          >macOS x86-64 (Intel)</DownloadLink\n        >\n      </div>\n      <div class=\"flex flex-wrap gap-2 mb-2\">\n        <DownloadLink\n          href=\"https://sshx.s3.amazonaws.com/sshx-aarch64-unknown-linux-musl.tar.gz\"\n          >Linux ARM64</DownloadLink\n        >\n        <DownloadLink\n          href=\"https://sshx.s3.amazonaws.com/sshx-x86_64-unknown-linux-musl.tar.gz\"\n          >Linux x86-64</DownloadLink\n        >\n        <DownloadLink\n          href=\"https://sshx.s3.amazonaws.com/sshx-arm-unknown-linux-musleabihf.tar.gz\"\n          >Linux ARMv6</DownloadLink\n        >\n        <DownloadLink\n          href=\"https://sshx.s3.amazonaws.com/sshx-armv7-unknown-linux-musleabihf.tar.gz\"\n          >Linux ARMv7</DownloadLink\n        >\n      </div>\n      <div class=\"flex flex-wrap gap-2\">\n        <DownloadLink\n          href=\"https://sshx.s3.amazonaws.com/sshx-x86_64-unknown-freebsd.tar.gz\"\n          >FreeBSD x86-64</DownloadLink\n        >\n      </div>\n    </div>\n  </section>\n\n  <section class=\"installation-section\">\n    <h3 class=\"text-xl sm:text-lg\">\n      <DownloadIcon size=\"20\" class=\"text-zinc-400 inline-block mr-1 mb-0.5\" />\n      Windows\n    </h3>\n    <div class=\"text-sm text-zinc-400 md:text-base md:pt-0.5\">\n      <p class=\"mb-3\">Download the executable for your platform.</p>\n\n      <div class=\"flex flex-wrap gap-2\">\n        <DownloadLink\n          href=\"https://sshx.s3.amazonaws.com/sshx-x86_64-pc-windows-msvc.zip\"\n          >Windows x86-64</DownloadLink\n        >\n        <DownloadLink\n          href=\"https://sshx.s3.amazonaws.com/sshx-i686-pc-windows-msvc.zip\"\n          >Windows x86</DownloadLink\n        >\n        <DownloadLink\n          href=\"https://sshx.s3.amazonaws.com/sshx-aarch64-pc-windows-msvc.zip\"\n          >Windows ARM64</DownloadLink\n        >\n      </div>\n    </div>\n  </section>\n\n  <section class=\"installation-section\">\n    <h3 class=\"text-xl sm:text-lg\">\n      <PackageIcon size=\"20\" class=\"text-zinc-400 inline-block mr-1 mb-0.5\" />\n      Build from source\n    </h3>\n    <div class=\"text-sm text-zinc-400 md:text-base md:pt-0.5\">\n      <p class=\"mb-3\">\n        Ensure you have up-to-date versions of Rust and protoc. Compile sshx and\n        add it to the system path.\n      </p>\n      <CopyableCode value=\"cargo install sshx\" />\n    </div>\n  </section>\n\n  <section class=\"installation-section\">\n    <h3 class=\"text-xl sm:text-lg\">\n      <GitBranchIcon size=\"20\" class=\"text-zinc-400 inline-block mr-1 mb-0.5\" />\n      GitHub Actions\n    </h3>\n    <div class=\"text-sm text-zinc-400 md:text-base md:pt-0.5\">\n      <p class=\"mb-3\">\n        On GitHub Actions or other CI providers, run this command. It pauses\n        your workflow and starts a collaborative session.\n      </p>\n      <CopyableCode value=\"curl -sSf https://sshx.io/get | sh -s run\" />\n    </div>\n  </section>\n\n  <hr class=\"mt-32 mb-12\" />\n\n  <div class=\"grid sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mb-6\">\n    {#each socials as social}\n      <a\n        target=\"_blank\"\n        rel=\"noreferrer\"\n        href={social.href}\n        class=\"border border-white/10 hover:border-white/30 transition-colors p-4 text-center text-lg font-medium rounded-lg\"\n      >\n        {social.title}\n      </a>\n    {/each}\n  </div>\n\n  <p class=\"mb-12 text-center text-zinc-400\">\n    open source, &copy; Eric Zhang 2023\n  </p>\n</main>\n\n<style lang=\"postcss\">\n  b {\n    @apply text-zinc-300 font-medium;\n  }\n\n  code.name {\n    @apply text-[0.9em] text-zinc-100 border border-white/25 px-1 py-0.5 rounded;\n  }\n\n  hr {\n    @apply mx-auto md:w-1/2 border-zinc-800;\n  }\n\n  .title-gradient {\n    @apply text-transparent bg-clip-text bg-gradient-to-r from-fuchsia-400 to-blue-500;\n  }\n\n  .feature-block {\n    @apply relative border rounded-lg border-transparent p-6 sm:p-8;\n    background: #111111 padding-box;\n  }\n\n  .feature-block::before {\n    content: \"\";\n    @apply absolute inset-0;\n    z-index: -1;\n    margin: -1px;\n    border-radius: inherit;\n    background: linear-gradient(\n      160deg,\n      rgba(255, 255, 255, 0.36) 5%,\n      rgba(255, 255, 255, 0.08) 25%,\n      rgba(255, 255, 255, 0.24) 50%,\n      rgba(255, 255, 255, 0.08) 75%,\n      rgba(255, 255, 255, 0.28) 95%\n    );\n    opacity: 0.5;\n    transition: opacity 200ms;\n  }\n\n  .feature-block:hover::before {\n    opacity: 1;\n  }\n\n  .feature-block h3 {\n    @apply font-medium mb-2;\n  }\n\n  .feature-block p {\n    @apply text-zinc-400;\n  }\n\n  .feature-icon {\n    @apply inline-block p-3 rounded-full mb-3 shadow-md border border-zinc-600;\n  }\n\n  .installation-section {\n    @apply grid sm:grid-cols-[200px,1fr] gap-x-10 gap-y-4 max-w-4xl mx-auto sm:border-t sm:border-white/10 sm:py-6 lg:px-2;\n    @apply mb-16 lg:mb-8;\n  }\n</style>\n"
  },
  {
    "path": "src/routes/+page.ts",
    "content": "export const prerender = true;\n"
  },
  {
    "path": "src/routes/s/[id]/+page.svelte",
    "content": "<script lang=\"ts\">\n  import { page } from \"$app/stores\";\n\n  import Session from \"$lib/Session.svelte\";\n\n  let title: string = \"Remote Terminal | sshx\";\n</script>\n\n<svelte:head>\n  <title>{title}</title>\n\n  <style>\n    body {\n      overscroll-behavior: none;\n    }\n  </style>\n</svelte:head>\n\n<Session\n  id={$page.params.id}\n  on:receiveName={({ detail: sessionName }) => {\n    if (sessionName) {\n      title = `${sessionName} | sshx`;\n    }\n  }}\n/>\n"
  },
  {
    "path": "static/get",
    "content": "#!/bin/sh\n\n# This is a short script to install the latest version of the sshx binary.\n#\n# It's meant to be as simple as possible, so if you're not happy hardcoding a\n# `curl | sh` pipe in your application, you can just download the binary\n# directly with the appropriate URL for your architecture.\n#\n# If you'd like to run it without installing to /usr/local/bin, use `sh -s run`.\n# To download to the current directory, use `sh -s download`.\n\nset +e\n\ncase \"$(uname -s)\" in\n  Linux*) suffix=\"-unknown-linux-musl\";;\n  Darwin*) suffix=\"-apple-darwin\";;\n  FreeBSD*) suffix=\"-unknown-freebsd\";;\n  MINGW*|MSYS*|CYGWIN*)\n    echo \"You are on Windows. Please visit sshx.io to download the executable.\";\n    exit 1;;\n  *) echo \"Unsupported OS $(uname -s)\"; exit 1;;\nesac\n\ncase \"$(uname -m)\" in\n  aarch64 | aarch64_be | arm64 | armv8b | armv8l) arch=\"aarch64\";;\n  x86_64 | x64 | amd64) arch=\"x86_64\";;\n  armv6l) arch=\"arm\"; suffix=\"${suffix}eabihf\";;\n  armv7l) arch=\"armv7\"; suffix=\"${suffix}eabihf\";;\n  *) echo \"Unsupported arch $(uname -m)\"; exit 1;;\nesac\n\nurl=\"https://s3.amazonaws.com/sshx/sshx-${arch}${suffix}.tar.gz\"\n\nif [ -z \"$NO_COLOR\" ]; then\n  ansi_reset=\"\\033[0m\"\n  ansi_info=\"\\033[35;1m\"\n  ansi_error=\"\\033[31m\"\n  ansi_underline=\"\\033[4m\"\nfi\n\ncmd=${1:-install}\ntemp=$(mktemp)\n\ncase $cmd in\n  \"run\")\n    path=$(mktemp -d)\n    will_run=1\n    ;;\n  \"download\")\n    path=$(pwd)\n    ;;\n  \"install\")\n    path=/usr/local/bin\n    ;;\n  *)\n    printf \"${ansi_error}Error: Invalid command. Please use 'run', 'download', or 'install'.\\n\"\n    exit 1\n    ;;\nesac\n\nprintf \"${ansi_reset}${ansi_info}↯ Downloading sshx from ${ansi_underline}%s${ansi_reset}\\n\" \"$url\"\nhttp_code=$(curl \"$url\" -o \"$temp\" -w \"%{http_code}\")\nif [ \"$http_code\" -lt 200 ] || [ \"$http_code\" -gt 299 ]; then\n  printf \"${ansi_error}Error: Request had status code ${http_code}.\\n\"\n  cat \"$temp\" 1>&2\n  printf \"${ansi_reset}\\n\"\n  exit 1\nfi\n\nprintf \"\\n${ansi_reset}${ansi_info}↯ Adding sshx binary to ${ansi_underline}%s${ansi_reset}\\n\" \"$path\"\nif [ \"$(id -u)\" -ne 0 ] && [ \"$path\" = \"/usr/local/bin\" ]; then\n  sudo tar xf \"$temp\" -C \"$path\" || exit 1\nelse\n  tar xf \"$temp\" -C \"$path\" || exit 1\nfi\n\nprintf \"\\n${ansi_reset}${ansi_info}↯ Done! You can now run sshx.${ansi_reset}\\n\"\n\nif [ -n \"$will_run\" ]; then\n  \"$path/sshx\"\n  rm -f \"$path/sshx\"\nfi\n"
  },
  {
    "path": "svelte.config.js",
    "content": "import adapter from \"@sveltejs/adapter-static\";\nimport preprocess from \"svelte-preprocess\";\n\n/** @type {import('@sveltejs/kit').Config} */\nconst config = {\n  // Consult https://github.com/sveltejs/svelte-preprocess\n  // for more information about preprocessors\n  preprocess: [\n    preprocess({\n      postcss: true,\n    }),\n  ],\n\n  kit: {\n    adapter: adapter({\n      fallback: \"spa.html\", // SPA mode\n      precompress: true,\n    }),\n  },\n};\n\nexport default config;\n"
  },
  {
    "path": "tailwind.config.cjs",
    "content": "const defaultTheme = require(\"tailwindcss/defaultTheme\");\n\n/** @type {import(\"tailwindcss\").Config} */\nconst config = {\n  content: [\"./src/**/*.{html,js,svelte,ts}\"],\n\n  darkMode: \"class\",\n  theme: {\n    extend: {\n      fontFamily: {\n        sans: [\"Inter Variable\", ...defaultTheme.fontFamily.sans],\n        mono: [\"Fira Code VF\", ...defaultTheme.fontFamily.mono],\n      },\n    },\n  },\n\n  plugins: [],\n};\n\nmodule.exports = config;\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"extends\": \"./.svelte-kit/tsconfig.json\",\n  \"compilerOptions\": {\n    \"strict\": true\n  }\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { execSync } from \"node:child_process\";\n\nimport { defineConfig } from \"vite\";\nimport { sveltekit } from \"@sveltejs/kit/vite\";\n\nconst commitHash = execSync(\"git rev-parse --short HEAD\").toString().trim();\n\nexport default defineConfig({\n  define: {\n    __APP_VERSION__: JSON.stringify(\"0.4.1-\" + commitHash),\n  },\n\n  plugins: [sveltekit()],\n\n  server: {\n    proxy: {\n      \"/api\": {\n        target: \"http://[::1]:8051\",\n        changeOrigin: true,\n        ws: true,\n      },\n    },\n  },\n});\n"
  }
]