mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge remote-tracking branch 'private/cloud/integration' into cloud/w2-fastwake
This commit is contained in:
@@ -157,3 +157,6 @@ ci-shards/
|
||||
/.build/windows-native-benchmark/
|
||||
|
||||
.local/static-analysis-research/
|
||||
|
||||
# Python bytecode (VM entry points tested locally)
|
||||
__pycache__/
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// The remote desktop's ticket + origin gate (vm/orca-desktop-proxy), exercised in python3 with a
|
||||
// stub websockify. The real thing runs on a VM in the W3 desktop e2e (see the w3-desktop report).
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const proxy = fileURLToPath(new URL('./vm/orca-desktop-proxy', import.meta.url).href)
|
||||
const hasPython = spawnSync('python3', ['--version']).status === 0
|
||||
|
||||
function runPython(body: string): { status: number | null; stdout: string; stderr: string } {
|
||||
const stub = mkdtempSync(join(tmpdir(), 'orca-desktop-proxy-'))
|
||||
mkdirSync(join(stub, 'websockify'))
|
||||
writeFileSync(join(stub, 'websockify', '__init__.py'), '')
|
||||
writeFileSync(join(stub, 'websockify', 'websocketproxy.py'), 'class ProxyRequestHandler:\n pass\n')
|
||||
const prelude = [
|
||||
'import importlib.machinery, importlib.util, os, sys, tempfile, hashlib',
|
||||
`loader = importlib.machinery.SourceFileLoader('proxy', ${JSON.stringify(proxy)})`,
|
||||
'spec = importlib.util.spec_from_loader("proxy", loader)',
|
||||
'p = importlib.util.module_from_spec(spec); loader.exec_module(p)',
|
||||
'd = tempfile.mkdtemp()',
|
||||
'def mint(t, expires):',
|
||||
' open(os.path.join(d, hashlib.sha256(t.encode()).hexdigest()), "w").write(str(expires))',
|
||||
''
|
||||
].join('\n')
|
||||
const run = spawnSync('python3', ['-c', `${prelude}\n${body}`], {
|
||||
env: { ...process.env, PYTHONPATH: stub, PYTHONDONTWRITEBYTECODE: '1' },
|
||||
encoding: 'utf8'
|
||||
})
|
||||
return { status: run.status, stdout: run.stdout, stderr: run.stderr }
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPython)('orca-desktop-proxy', () => {
|
||||
it('admits a ticket once, before it expires, and never a malformed one', () => {
|
||||
const run = runPython(
|
||||
[
|
||||
'T = "A" * 43; U = "B" * 43; X = "C" * 43',
|
||||
'mint(T, 2000); mint(U, 2000); mint(X, 999)',
|
||||
'print(p.consume_ticket(d, T, now=1000))', // fresh
|
||||
'print(p.consume_ticket(d, T, now=1000))', // replay
|
||||
'print(p.consume_ticket(d, X, now=1000))', // expired
|
||||
'print(p.consume_ticket(d, X, now=1000))', // and it was still spent
|
||||
'print(p.consume_ticket(d, "B" * 42, now=1000))', // malformed
|
||||
'print(p.consume_ticket(d, "../" + "B" * 40, now=1000))', // path tricks never reach the disk
|
||||
'print(p.consume_ticket(d, U, now=1000))',
|
||||
'print(sorted(os.listdir(d)))'
|
||||
].join('\n')
|
||||
)
|
||||
expect(run.stderr).toBe('')
|
||||
expect(run.stdout.trim().split('\n')).toEqual(['True', 'False', 'False', 'False', 'False', 'False', 'True', '[]'])
|
||||
})
|
||||
|
||||
it('wants the page and the WebSocket on one localhost origin', () => {
|
||||
const run = runPython(
|
||||
[
|
||||
'cases = [',
|
||||
' ({"Host": "localhost:6081", "Origin": "http://localhost:6081"}, True),',
|
||||
' ({"Host": "127.0.0.1:6091", "Origin": "http://127.0.0.1:6091"}, True),',
|
||||
' ({"Host": "localhost:6081", "Origin": "http://localhost:3000"}, False),',
|
||||
' ({"Host": "localhost:6081", "Origin": "http://evil.example"}, False),',
|
||||
' ({"Host": "localhost:6081"}, False),',
|
||||
' ({"Host": "evil.example:6081", "Origin": "http://evil.example:6081"}, False),',
|
||||
' ({"Host": "localhost.evil.example", "Origin": "http://localhost.evil.example"}, False),',
|
||||
' ({"Host": "localhost:6081", "Origin": "https://localhost:6081"}, False),',
|
||||
']',
|
||||
'print(all(p.same_loopback_origin(h) == want for h, want in cases))'
|
||||
].join('\n')
|
||||
)
|
||||
expect(run.stderr).toBe('')
|
||||
expect(run.stdout.trim()).toBe('True')
|
||||
})
|
||||
|
||||
it('scrubs tickets from anything it logs', () => {
|
||||
const run = runPython('print(p.scrub("GET /websockify?ticket=SECRETSECRET&x=1 HTTP/1.1"))')
|
||||
expect(run.stdout.trim()).toBe('GET /websockify?ticket=<redacted>&x=1 HTTP/1.1')
|
||||
})
|
||||
})
|
||||
@@ -101,9 +101,19 @@ describe('golden image build', () => {
|
||||
it('ships VM scripts that parse', () => {
|
||||
for (const script of ['provision-golden.sh', ...VM_SCRIPTS.map((name) => `vm/${name}`)]) {
|
||||
const path = fileURLToPath(new URL(`./${script}`, import.meta.url).href)
|
||||
// Node entry points (shebang `env node`) parse with node, the rest with bash.
|
||||
const isNode = readFileSync(path, 'utf8').startsWith('#!/usr/bin/env node')
|
||||
expect(spawnSync(isNode ? process.execPath : 'bash', isNode ? ['--check', path] : ['-n', path]).status, script).toBe(0)
|
||||
// Node entry points (shebang `env node`) and page modules (.js) parse with node, Python ones
|
||||
// with python3 (when this machine has it), the rest with bash.
|
||||
const source = readFileSync(path, 'utf8')
|
||||
const isNode = source.startsWith('#!/usr/bin/env node')
|
||||
if (script.endsWith('.js')) {
|
||||
const check = spawnSync(process.execPath, ['--input-type=module', '--check'], { input: source })
|
||||
expect(check.status, script).toBe(0)
|
||||
} else if (source.startsWith('#!/usr/bin/env python3')) {
|
||||
const check = spawnSync('python3', ['-c', 'import ast, sys; ast.parse(open(sys.argv[1]).read())', path])
|
||||
expect(check.error ? 0 : check.status, script).toBe(0)
|
||||
} else {
|
||||
expect(spawnSync(isNode ? process.execPath : 'bash', isNode ? ['--check', path] : ['-n', path]).status, script).toBe(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export const VM_SCRIPTS = [
|
||||
'orca-sshd',
|
||||
'orca-ssh-keys',
|
||||
'orca-desktop-start',
|
||||
'orca-desktop-proxy',
|
||||
'orca-desktop-page.js',
|
||||
'orca-sandbox-grant',
|
||||
'orca-sandbox-revoke',
|
||||
'orca-sandbox-members',
|
||||
|
||||
@@ -155,7 +155,7 @@ if [ -n "${NOVNC_VERSION:-}" ]; then
|
||||
fi
|
||||
|
||||
log 'Orca entry points'
|
||||
for name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-egress-probe orca-image-wipe orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-git-as orca-git-identity git; do
|
||||
for name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-egress-probe orca-image-wipe orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-desktop-proxy orca-desktop-page.js orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-git-as orca-git-identity git; do
|
||||
var="ORCA_VM_SCRIPT_${name//[-.]/_}"
|
||||
printf '%s' "${!var}" | base64 -d >"$ORCA_HOME/bin/$name"
|
||||
chmod 0755 "$ORCA_HOME/bin/$name"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,224 @@
|
||||
// Orca's glue for noVNC, injected into vnc.html by orca-desktop-proxy. Orca (the desktop app's
|
||||
// main process) drives it through window.orcaDesktop:
|
||||
// - tickets: the page never connects by itself. Each WebSocket needs a fresh single-use ticket,
|
||||
// which Orca hands over with connect(ticket) after nextTicketRequest() resolves: 'ticket' on
|
||||
// load or after a session dropped, 'retry' after an attempt that never connected. Tickets live
|
||||
// only in memory here: never in the URL, storage or noVNC's settings.
|
||||
// - pause(message, label): Orca stopped retrying; show why, with a Reconnect button. resume()
|
||||
// (Orca reopened the desktop) or the button asks for a ticket again.
|
||||
// - fit: resize=remote makes Xvnc follow the tab; a server without SetDesktopSize gets scaling.
|
||||
// - clipboard: a copy inside the desktop right after user input there goes to this computer's
|
||||
// clipboard; paste(text, chord) sets the sandbox's clipboard first, then replays the paste key.
|
||||
import UI from './app/ui.js';
|
||||
import * as WebUtil from './app/webutil.js';
|
||||
|
||||
const WS_PATH = 'websockify';
|
||||
const RESIZE_FALLBACK_MS = 1500;
|
||||
// A copy inside the desktop follows a copy key or a selection (drag or double-click) there; other
|
||||
// clipboard changes (an agent in the sandbox running xclip) must not overwrite this computer's
|
||||
// clipboard. Plain typing does not open the window.
|
||||
const USER_COPY_WINDOW_MS = 1500;
|
||||
const DRAG_PX = 4;
|
||||
const MAX_CLIPBOARD_CHARS = 1 << 20;
|
||||
const TICKET_RE = /^[A-Za-z0-9_-]{43}$/;
|
||||
const XK = { v: 0x0076, V: 0x0056, Insert: 0xff63, Shift_L: 0xffe1, Super_L: 0xffeb, Super_R: 0xffec };
|
||||
|
||||
let state = 'idle'; // idle | connecting | connected | disconnected | paused
|
||||
let attemptConnected = true;
|
||||
let ticketWaiters = [];
|
||||
let lastCopyIntent = 0;
|
||||
let pointerStart = null;
|
||||
|
||||
const style = document.createElement('style');
|
||||
// Orca owns connecting and the chrome: noVNC's Connect button would only try without a ticket,
|
||||
// and its control bar duplicates what the Orca tab does (fit, clipboard, keyboard release).
|
||||
style.textContent = `
|
||||
#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }
|
||||
#orca_pause { position: fixed; inset: 0; display: none; align-items: center; justify-content: center;
|
||||
background: rgba(30, 31, 34, 0.92); z-index: 1000; font: 13px/1.5 system-ui, sans-serif; }
|
||||
#orca_pause.open { display: flex; }
|
||||
#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid #3a3b3f; border-radius: 8px;
|
||||
background: #26272b; color: #d4d4d4; text-align: center; }
|
||||
#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid #4a4b50; border-radius: 6px;
|
||||
background: transparent; color: #f2f2f2; font: inherit; cursor: pointer; }
|
||||
#orca_pause button:focus-visible { outline: 2px solid #6ea8fe; outline-offset: 2px; }
|
||||
/* The VM page cannot read Orca's --focus-ring token; this matches its dark value. */
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'orca_pause';
|
||||
overlay.innerHTML = '<div role="alert"><p></p><button type="button"></button></div>';
|
||||
overlay.querySelector('button').addEventListener('click', () => resume());
|
||||
document.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key === 'c' || event.key === 'C' || event.key === 'Insert')) {
|
||||
lastCopyIntent = Date.now();
|
||||
}
|
||||
}, true);
|
||||
window.addEventListener('pointerdown', (event) => {
|
||||
pointerStart = { x: event.clientX, y: event.clientY };
|
||||
}, true);
|
||||
// A double- or triple-click selects a word or line (pointer events carry no click count).
|
||||
window.addEventListener('mousedown', (event) => {
|
||||
if (event.detail >= 2) {
|
||||
lastCopyIntent = Date.now();
|
||||
}
|
||||
}, true);
|
||||
window.addEventListener('pointerup', (event) => {
|
||||
if (pointerStart && Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >= DRAG_PX) {
|
||||
lastCopyIntent = Date.now();
|
||||
}
|
||||
pointerStart = null;
|
||||
}, true);
|
||||
|
||||
function requestTicket() {
|
||||
const answer = attemptConnected ? 'ticket' : 'retry';
|
||||
const waiters = ticketWaiters;
|
||||
ticketWaiters = [];
|
||||
waiters.forEach((resolve) => resolve(answer));
|
||||
}
|
||||
|
||||
async function whenStarted() {
|
||||
while (document.documentElement.classList.contains('noVNC_loading')) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
||||
function onConnect() {
|
||||
state = 'connected';
|
||||
attemptConnected = true;
|
||||
UI.hideStatus();
|
||||
setTimeout(() => {
|
||||
// Xvnc answers SetDesktopSize; anything that does not gets scaled into the tab instead.
|
||||
if (UI.rfb && state === 'connected' && !UI.rfb._supportsSetDesktopSize) {
|
||||
UI.rfb.scaleViewport = true;
|
||||
}
|
||||
}, RESIZE_FALLBACK_MS);
|
||||
}
|
||||
|
||||
function onDisconnect() {
|
||||
if (state === 'paused') {
|
||||
return;
|
||||
}
|
||||
state = 'disconnected';
|
||||
// The ticket is spent: ask Orca for a new one (a dropped forward, a restart of the desktop).
|
||||
requestTicket();
|
||||
}
|
||||
|
||||
function onClipboard(event) {
|
||||
const text = event.detail && event.detail.text;
|
||||
if (typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastCopyIntent > USER_COPY_WINDOW_MS || !document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
}
|
||||
|
||||
async function connect(ticket) {
|
||||
if (typeof ticket !== 'string' || !TICKET_RE.test(ticket)) {
|
||||
return 'invalid';
|
||||
}
|
||||
if (state === 'connecting' || state === 'connected' || state === 'paused') {
|
||||
return state;
|
||||
}
|
||||
await whenStarted();
|
||||
if (UI.rfb) {
|
||||
return 'connecting';
|
||||
}
|
||||
state = 'connecting';
|
||||
attemptConnected = false;
|
||||
// UI.connect reads the path setting synchronously and opens the WebSocket in RFB's constructor;
|
||||
// the in-memory setting is restored right after, so the ticket is never kept or shown.
|
||||
// Only ever this page's own origin: noVNC also reads host, port, encrypt, repeater and logging
|
||||
// from the URL, which must never send a ticketed session (and the user's keys) elsewhere.
|
||||
WebUtil.setSetting('host', '');
|
||||
WebUtil.setSetting('port', '');
|
||||
WebUtil.setSetting('encrypt', false);
|
||||
WebUtil.setSetting('repeaterID', '');
|
||||
WebUtil.setSetting('path', `${WS_PATH}?ticket=${ticket}`);
|
||||
try {
|
||||
UI.connect();
|
||||
} finally {
|
||||
WebUtil.setSetting('path', WS_PATH);
|
||||
}
|
||||
const rfb = UI.rfb;
|
||||
if (!rfb) {
|
||||
state = 'disconnected';
|
||||
requestTicket();
|
||||
return 'failed';
|
||||
}
|
||||
rfb.addEventListener('connect', onConnect);
|
||||
rfb.addEventListener('disconnect', onDisconnect);
|
||||
rfb.addEventListener('clipboard', onClipboard);
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
/** Pastes this computer's clipboard: the sandbox's clipboard first, then the paste key itself. */
|
||||
function paste(text, chord) {
|
||||
const rfb = UI.rfb;
|
||||
if (!rfb || state !== 'connected' || typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {
|
||||
return false;
|
||||
}
|
||||
rfb.clipboardPasteFrom(text);
|
||||
if (chord && chord.kind === 'mac') {
|
||||
// Cmd+V: Linux apps do not paste on Super+V, so release Super and send Shift+Insert.
|
||||
rfb.sendKey(XK.Super_L, 'MetaLeft', false);
|
||||
rfb.sendKey(XK.Super_R, 'MetaRight', false);
|
||||
rfb.sendKey(XK.Shift_L, 'ShiftLeft', true);
|
||||
rfb.sendKey(XK.Insert, 'Insert');
|
||||
rfb.sendKey(XK.Shift_L, 'ShiftLeft', false);
|
||||
} else if (chord && chord.kind === 'replay') {
|
||||
// Ctrl+V, Ctrl+Shift+V, Shift+Insert: the held modifiers already reached the sandbox.
|
||||
const insert = chord.key === 'Insert';
|
||||
rfb.sendKey(insert ? XK.Insert : chord.key === 'V' ? XK.V : XK.v, insert ? 'Insert' : 'KeyV');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function pause(message, label, tone) {
|
||||
state = 'paused';
|
||||
if (UI.rfb) {
|
||||
UI.rfb.disconnect();
|
||||
}
|
||||
UI.hideStatus();
|
||||
overlay.querySelector('p').textContent = String(message || '');
|
||||
overlay.querySelector('button').textContent = String(label || 'Reconnect');
|
||||
// Asleep and starting are states, not errors: announce them politely.
|
||||
overlay.firstElementChild.setAttribute('role', tone === 'neutral' ? 'status' : 'alert');
|
||||
overlay.classList.add('open');
|
||||
overlay.querySelector('button').focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (state !== 'paused') {
|
||||
return false;
|
||||
}
|
||||
overlay.classList.remove('open');
|
||||
state = 'disconnected';
|
||||
attemptConnected = true; // a person asked: start the retry budget over
|
||||
requestTicket();
|
||||
return true;
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'orcaDesktop', {
|
||||
value: Object.freeze({
|
||||
version: 1,
|
||||
state: () => state,
|
||||
/** Resolves when the page needs a ticket: now if it is not connected, else after a drop. */
|
||||
nextTicketRequest: () => new Promise((resolve) => {
|
||||
ticketWaiters.push(resolve);
|
||||
if (state === 'idle' || state === 'disconnected') {
|
||||
requestTicket();
|
||||
}
|
||||
}),
|
||||
connect,
|
||||
paste,
|
||||
pause,
|
||||
resume
|
||||
})
|
||||
});
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
# Remote desktop gateway, started by orca-desktop-start: serves noVNC on 127.0.0.1 and bridges one
|
||||
# WebSocket per single-use ticket to Xvnc's 0600 unix socket (Xvnc itself has no VNC auth: only this
|
||||
# uid can open the socket). The port is reached only through Orca's localhost forward on the user's
|
||||
# computer, where any local process or web page could knock, so every WebSocket must carry an
|
||||
# unused, unexpired ticket minted by orca-desktop-start (see consume_ticket), from a page on the same
|
||||
# localhost origin. Tickets never reach a log: paths are scrubbed before websockify logs them.
|
||||
# orca-desktop-proxy --listen PORT --web DIR --unix-target SOCKET --tickets DIR
|
||||
# stdlib + websockify (pinned tarball in /opt/orca/desktop/websockify, on PYTHONPATH) only.
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from websockify import websocketproxy
|
||||
|
||||
TICKET_RE = re.compile(r'^[A-Za-z0-9_-]{43}$') # 32 random bytes, base64url without padding
|
||||
SCRUB_RE = re.compile(r'([?&]ticket=)[^&\s\'"]*')
|
||||
LOOPBACK_HOST_RE = re.compile(r'^(localhost|127\.0\.0\.1|\[::1\])(:[0-9]{1,5})?$')
|
||||
WS_PATH = '/websockify'
|
||||
PAGE_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'orca-desktop-page.js')
|
||||
# The page may only talk to its own origin (its WebSocket included): even a URL that tries to point
|
||||
# noVNC elsewhere cannot carry a ticket or the user's keys off this forward.
|
||||
PAGE_CSP = "connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
|
||||
PAGE_TAG = b'<script type="module" src="orca-desktop-page.js"></script>\n</head>'
|
||||
|
||||
|
||||
def scrub(text):
|
||||
return SCRUB_RE.sub(r'\1<redacted>', str(text))
|
||||
|
||||
|
||||
def ticket_digest(ticket):
|
||||
return hashlib.sha256(ticket.encode('ascii')).hexdigest()
|
||||
|
||||
|
||||
def consume_ticket(directory, ticket, now=None):
|
||||
"""True once per minted ticket, within its lifetime. The ticket file (named by the ticket's
|
||||
sha256, holding its expiry) is unlinked on first use, so a replay or a racing second
|
||||
connection finds nothing; each request runs in its own forked process, so state is on disk."""
|
||||
if not isinstance(ticket, str) or not TICKET_RE.match(ticket):
|
||||
return False
|
||||
digest = ticket_digest(ticket)
|
||||
match = None
|
||||
try:
|
||||
names = os.listdir(directory)
|
||||
except OSError:
|
||||
return False
|
||||
for name in names:
|
||||
# Compare every entry in constant time; never short-circuit on a prefix.
|
||||
if hmac.compare_digest(name.encode('ascii', 'replace'), digest.encode('ascii')):
|
||||
match = name
|
||||
if match is None:
|
||||
return False
|
||||
path = os.path.join(directory, match)
|
||||
try:
|
||||
with open(path, 'r', encoding='ascii') as handle:
|
||||
expires_at = int(handle.read().strip() or '0')
|
||||
except (OSError, ValueError):
|
||||
expires_at = 0
|
||||
try:
|
||||
os.unlink(path) # the consume: only one caller can win this
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return (now if now is not None else time.time()) <= expires_at
|
||||
|
||||
|
||||
def same_loopback_origin(headers):
|
||||
"""The page and the WebSocket share one localhost origin (blocks other local sites and DNS
|
||||
rebinding). The port is the user's local forward, which may differ from ours, so compare the
|
||||
Origin with the Host the browser dialed rather than with a fixed port."""
|
||||
host = (headers.get('Host') or '').strip().lower()
|
||||
origin = (headers.get('Origin') or '').strip().lower()
|
||||
return loopback_host(host) and origin == 'http://' + host
|
||||
|
||||
|
||||
def loopback_host(host):
|
||||
return bool(LOOPBACK_HOST_RE.match(host or ''))
|
||||
|
||||
|
||||
class TicketedProxyHandler(websocketproxy.ProxyRequestHandler):
|
||||
"""websockify's proxy handler plus the ticket + origin gate. Module level (not a closure) so
|
||||
websockify's per-connection multiprocessing can pickle it under any start method."""
|
||||
|
||||
def log_message(self, format, *args): # noqa: A002 (websockify's signature)
|
||||
super().log_message('%s', scrub(format % args if args else format))
|
||||
|
||||
def reject(self, code, reason):
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Length', '0')
|
||||
self.send_header('Connection', 'close')
|
||||
self.end_headers()
|
||||
self.close_connection = True
|
||||
raise self.server.EClose('desktop: websocket refused (%s)' % reason)
|
||||
|
||||
def validate_connection(self):
|
||||
parts = urlsplit(self.path)
|
||||
ticket = (parse_qs(parts.query).get('ticket') or [''])[0]
|
||||
# Drop it before anything below (websockify's own logging included) can see self.path.
|
||||
self.path = parts.path
|
||||
if parts.path != WS_PATH:
|
||||
self.reject(404, 'path')
|
||||
if not same_loopback_origin(self.headers):
|
||||
self.reject(403, 'origin')
|
||||
if not consume_ticket(self.server.orca_tickets_dir, ticket):
|
||||
self.reject(403, 'ticket')
|
||||
|
||||
def loopback_request(self):
|
||||
if loopback_host((self.headers.get('Host') or '').strip().lower()):
|
||||
return True
|
||||
self.send_error(403)
|
||||
return False
|
||||
|
||||
def do_GET(self):
|
||||
if not self.loopback_request():
|
||||
return None
|
||||
path = urlsplit(self.path).path
|
||||
if path == '/orca-desktop-page.js':
|
||||
return self.send_bytes(read_file(PAGE_SCRIPT), 'text/javascript; charset=utf-8')
|
||||
if path == '/vnc.html':
|
||||
page = read_file(os.path.join(self.server.web, 'vnc.html'))
|
||||
return self.send_bytes(page.replace(b'</head>', PAGE_TAG, 1), 'text/html; charset=utf-8', PAGE_CSP)
|
||||
return super().do_GET()
|
||||
|
||||
def do_HEAD(self):
|
||||
if not self.loopback_request():
|
||||
return None
|
||||
return super().do_HEAD()
|
||||
|
||||
def send_bytes(self, body, content_type, csp=None):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', content_type)
|
||||
if csp:
|
||||
self.send_header('Content-Security-Policy', csp)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Referrer-Policy', 'no-referrer')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return None
|
||||
|
||||
|
||||
def read_file(path):
|
||||
with open(path, 'rb') as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
def main(argv):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--listen', type=int, required=True)
|
||||
parser.add_argument('--web', required=True)
|
||||
parser.add_argument('--unix-target', required=True)
|
||||
parser.add_argument('--tickets', required=True)
|
||||
args = parser.parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||||
server = websocketproxy.WebSocketProxy(
|
||||
RequestHandlerClass=TicketedProxyHandler,
|
||||
listen_host='127.0.0.1',
|
||||
listen_port=args.listen,
|
||||
web=args.web,
|
||||
file_only=True,
|
||||
unix_target=args.unix_target,
|
||||
)
|
||||
server.orca_tickets_dir = os.path.abspath(args.tickets)
|
||||
server.start_server()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv[1:])
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts the on-demand remote desktop (idempotent) and prints one JSON line:
|
||||
# {"schemaVersion":1,"port":6080,"display":":1","path":"vnc.html","password":"<8 chars>"}
|
||||
# Xvnc (X server + VNC in one) listens on a unix socket only; websockify serves noVNC and bridges
|
||||
# WebSockets to that socket on 127.0.0.1 only. Nothing is reachable from outside the VM: the
|
||||
# desktop reaches it through Orca's port forward. The forward is a localhost port on the user's
|
||||
# computer, which any local process or web page can reach, so VNC auth with a per-boot random
|
||||
# password guards it (Xvnc blacklists repeated failures). Never started at boot.
|
||||
# Starts the on-demand remote desktop (idempotent) and mints one connection ticket. Prints one line:
|
||||
# {"schemaVersion":2,"port":6080,"display":":1","path":"vnc.html","ticket":"<43 chars>","expiresInMs":60000}
|
||||
# Xvnc (X server + VNC in one) listens on a 0600 unix socket only, with no VNC auth: only this uid
|
||||
# can open it. orca-desktop-proxy (websockify + a ticket gate) serves noVNC on 127.0.0.1 and
|
||||
# bridges WebSockets to that socket. Nothing is reachable from outside the VM: the desktop app
|
||||
# reaches it through Orca's port forward, a localhost port on the user's computer that any local
|
||||
# process or web page can reach, so each WebSocket needs a ticket from here: 32 random bytes,
|
||||
# single use, valid for 60 s, stored only as its sha256 (the file name; its content is the expiry).
|
||||
# The ticket goes to stdout only (the control plane hands it to the desktop app). Never started at boot.
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
DESKTOP=/opt/orca/desktop
|
||||
@@ -23,8 +25,13 @@ case "$PORT" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_PORT must be a number' ;; esac
|
||||
case "$DISPLAY_NUM" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_DISPLAY must be a number' ;; esac
|
||||
case "$GEOMETRY" in [0-9]*x[0-9]*) ;; *) fail 'ORCA_DESKTOP_GEOMETRY must look like 1280x800' ;; esac
|
||||
command -v Xvnc >/dev/null || fail 'not installed in this image (Xvnc missing)' 3
|
||||
command -v vncpasswd >/dev/null || fail 'not installed in this image (vncpasswd missing)' 3
|
||||
[ -s "$DESKTOP/novnc/vnc.html" ] || fail 'not installed in this image (noVNC missing)' 3
|
||||
PROXY="$(dirname "$(readlink -f "$0")")/orca-desktop-proxy"
|
||||
[ -x "$PROXY" ] || fail 'orca-desktop-proxy missing next to this script'
|
||||
TICKETS="$STATE/tickets"
|
||||
TICKET_TTL_S=60
|
||||
# Xvnc's mode: a previous version of this script ran it with VncAuth; restart it in this mode.
|
||||
MODE=ticket-v1
|
||||
|
||||
mkdir -p "$STATE"
|
||||
exec 9>"$STATE/start.lock"
|
||||
@@ -37,22 +44,47 @@ pid_of() { # never "0": kill 0 targets our own process group
|
||||
printf '%s' "$pid"
|
||||
}
|
||||
alive() { local pid; pid="$(pid_of "$1")" && kill -0 "$pid" 2>/dev/null; }
|
||||
ready_line() {
|
||||
printf '{"schemaVersion":1,"port":%s,"display":":%s","path":"vnc.html","password":"%s"}\n' \
|
||||
"$PORT" "$DISPLAY_NUM" "$(cat "$STATE/password")"
|
||||
mint_ticket() { # prints the ticket; stores only its sha256, with the expiry as content
|
||||
local ticket now digest
|
||||
mkdir -p "$TICKETS"
|
||||
now="$(date +%s)"
|
||||
# Expired or spent-but-unswept tickets; keep at most 16 live ones (oldest go first).
|
||||
for file in "$TICKETS"/*; do
|
||||
[ -f "$file" ] || continue
|
||||
[ "$(cat "$file" 2>/dev/null || echo 0)" -ge "$now" ] 2>/dev/null || rm -f "$file"
|
||||
done
|
||||
# shellcheck disable=SC2012 # names are hex digests
|
||||
ls -1t "$TICKETS" 2>/dev/null | tail -n +16 | while read -r old; do rm -f "$TICKETS/$old"; done
|
||||
ticket="$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')"
|
||||
[ "${#ticket}" = 43 ] || fail 'could not generate a ticket'
|
||||
digest="$(printf '%s' "$ticket" | sha256sum | cut -d' ' -f1)"
|
||||
printf '%s' "$((now + TICKET_TTL_S))" >"$TICKETS/.$digest" && mv "$TICKETS/.$digest" "$TICKETS/$digest"
|
||||
printf '%s' "$ticket"
|
||||
}
|
||||
listening() { # websockify answers HTTP on loopback
|
||||
ready_line() {
|
||||
local ticket
|
||||
ticket="$(mint_ticket)" || exit 1
|
||||
printf '{"schemaVersion":2,"port":%s,"display":":%s","path":"vnc.html","ticket":"%s","expiresInMs":%s}\n' \
|
||||
"$PORT" "$DISPLAY_NUM" "$ticket" "$((TICKET_TTL_S * 1000))"
|
||||
}
|
||||
listening() { # the proxy answers HTTP on loopback
|
||||
curl -fsS -o /dev/null --max-time 1 "http://127.0.0.1:$PORT/vnc.html" 2>/dev/null
|
||||
}
|
||||
|
||||
# Pids from a previous boot (resume = reboot from disk) name dead or unrelated processes.
|
||||
boot_id="$(cat /proc/sys/kernel/random/boot_id)"
|
||||
if [ "$(cat "$STATE/boot-id" 2>/dev/null || true)" != "$boot_id" ]; then
|
||||
rm -f "$STATE"/*.pid "$SOCKET" "$STATE/password" "$STATE/passwd"
|
||||
rm -rf "$STATE"/*.pid "$SOCKET" "$STATE/password" "$STATE/passwd" "$STATE/mode" "$TICKETS"
|
||||
printf '%s' "$boot_id" >"$STATE/boot-id"
|
||||
fi
|
||||
|
||||
if alive xvnc && alive websockify && listening && [ -s "$STATE/password" ]; then
|
||||
# Same boot, older script: its websockify had no ticket gate and its Xvnc wanted a VNC password.
|
||||
rm -f "$STATE/password" "$STATE/passwd"
|
||||
if alive websockify; then kill "$(pid_of websockify)" 2>/dev/null || true; fi
|
||||
rm -f "$STATE/websockify.pid"
|
||||
current_mode() { cat "$STATE/mode" 2>/dev/null || true; }
|
||||
|
||||
if alive xvnc && alive proxy && listening && [ "$(current_mode)" = "$MODE" ]; then
|
||||
ready_line
|
||||
exit 0
|
||||
fi
|
||||
@@ -64,19 +96,23 @@ start() { # name cmd... — detached, logs in $STATE/<name>.log
|
||||
echo $! >"$STATE/$name.pid"
|
||||
}
|
||||
|
||||
if ! alive xvnc || [ ! -s "$STATE/password" ]; then
|
||||
if ! alive xvnc || [ "$(current_mode)" != "$MODE" ]; then
|
||||
if alive xvnc; then kill "$(pid_of xvnc)" 2>/dev/null || true; sleep 0.2; fi
|
||||
rm -f "$SOCKET"
|
||||
# VNC auth uses 8 password bytes; 8 random alphanumerics (~48 bits) plus Xvnc's blacklist.
|
||||
password="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 8 || true)"
|
||||
[ "${#password}" = 8 ] || fail 'could not generate a password'
|
||||
printf '%s' "$password" | vncpasswd -f >"$STATE/passwd"
|
||||
printf '%s' "$password" >"$STATE/password"
|
||||
# -rfbport -1: no TCP VNC port at all, only a 0600 unix socket for websockify.
|
||||
start xvnc Xvnc ":$DISPLAY_NUM" -geometry "$GEOMETRY" -depth 24 -nolisten tcp \
|
||||
# -rfbport -1: no TCP VNC port at all, only a 0600 unix socket (in the 0700 state dir) for the
|
||||
# proxy. SecurityTypes None is safe only because of that: the ticket gate is in the proxy.
|
||||
# SendPrimary=0: only explicit copies (xterm copies its selection, below) leave the sandbox.
|
||||
# The X display itself takes a cookie from ~/.Xauthority (Xlib's default for this user), so only
|
||||
# this uid's processes (agents, apps they start on DISPLAY=:1) can open it.
|
||||
auth=()
|
||||
if command -v xauth >/dev/null && command -v mcookie >/dev/null; then
|
||||
xauth -q -f "$HOME/.Xauthority" add ":$DISPLAY_NUM" . "$(mcookie)" 9>&- && auth=(-auth "$HOME/.Xauthority")
|
||||
fi
|
||||
start xvnc Xvnc ":$DISPLAY_NUM" "${auth[@]}" -geometry "$GEOMETRY" -depth 24 -nolisten tcp \
|
||||
-rfbport -1 -rfbunixpath "$SOCKET" -rfbunixmode 0600 \
|
||||
-SecurityTypes VncAuth -PasswordFile "$STATE/passwd" \
|
||||
-SecurityTypes None -SendPrimary=0 \
|
||||
-AlwaysShared -AcceptSetDesktopSize -desktop 'Remote desktop'
|
||||
printf '%s' "$MODE" >"$STATE/mode"
|
||||
for _ in $(seq 1 100); do
|
||||
[ -S "$SOCKET" ] && break
|
||||
alive xvnc || { tail -n 20 "$STATE/xvnc.log" >&2; fail 'Xvnc exited'; }
|
||||
@@ -91,23 +127,28 @@ if ! alive xvnc || [ ! -s "$STATE/password" ]; then
|
||||
'<applications><application class="XTerm"><maximized>yes</maximized></application></applications>' \
|
||||
'</openbox_config>' >"$STATE/openbox.xml"
|
||||
start wm openbox --config-file "$STATE/openbox.xml"
|
||||
# Let it manage the screen before the terminal maps, so its maximize rule applies.
|
||||
sleep 0.2
|
||||
elif command -v twm >/dev/null; then
|
||||
start wm twm
|
||||
fi
|
||||
if command -v xterm >/dev/null; then
|
||||
start xterm xterm -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11
|
||||
# selectToClipboard: a selection is a copy (to CLIPBOARD, which reaches this computer).
|
||||
start xterm xterm -maximized -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11 \
|
||||
-xrm 'XTerm*selectToClipboard: true'
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! alive websockify || ! listening; then
|
||||
if alive websockify; then kill "$(pid_of websockify)" 2>/dev/null || true; fi
|
||||
PYTHONPATH="$DESKTOP/websockify" start websockify python3 -P -W ignore::UserWarning -m websockify \
|
||||
--web "$DESKTOP/novnc" --unix-target "$SOCKET" "127.0.0.1:$PORT"
|
||||
if ! alive proxy || ! listening; then
|
||||
if alive proxy; then kill "$(pid_of proxy)" 2>/dev/null || true; sleep 0.1; fi
|
||||
mkdir -p "$TICKETS"
|
||||
PYTHONPATH="$DESKTOP/websockify" start proxy python3 -P -W ignore::UserWarning "$PROXY" \
|
||||
--listen "$PORT" --web "$DESKTOP/novnc" --unix-target "$SOCKET" --tickets "$TICKETS"
|
||||
for _ in $(seq 1 100); do
|
||||
listening && break
|
||||
alive websockify || { tail -n 20 "$STATE/websockify.log" >&2; fail 'websockify exited'; }
|
||||
alive proxy || { tail -n 20 "$STATE/proxy.log" >&2; fail 'orca-desktop-proxy exited'; }
|
||||
sleep 0.05
|
||||
done
|
||||
listening || fail "websockify is not answering on 127.0.0.1:$PORT"
|
||||
listening || fail "orca-desktop-proxy is not answering on 127.0.0.1:$PORT"
|
||||
fi
|
||||
ready_line
|
||||
|
||||
@@ -34,6 +34,8 @@ const SNAPSHOTTING_STUCK_MS = 3 * 60_000
|
||||
const MIN_REDACTED_SECRET = 6
|
||||
/** Event-stream keepalive; desktop watchers treat ~90 s of silence as a dead connection. */
|
||||
const HEARTBEAT_MS = 20_000
|
||||
/** How long one advance waits for a running command's exit before calling it still running. */
|
||||
const EXIT_PROBE_MS = 4_000
|
||||
/** Succeeded builds whose snapshot is kept (rollback window); older ones keep their record and log. */
|
||||
export const KEPT_BUILDS = 5
|
||||
const MAX_LOG_BYTES = 2 * 1024 * 1024
|
||||
@@ -371,7 +373,14 @@ export async function advanceBuild(deps: BuildDeps, row: BuildRow, options: { wa
|
||||
}
|
||||
let exitCode: number | null
|
||||
try {
|
||||
exitCode = (await deps.provider.getCommand(row.session_id, row.command_id, { wait: options.waitForExit })).exitCode
|
||||
// Always a (bounded) wait: the provider reports an exit code only to waiting readers, so a plain
|
||||
// poll would keep a finished build "running" until a log stream happened to ask (W9 stall).
|
||||
exitCode = (
|
||||
await deps.provider.getCommand(row.session_id, row.command_id, {
|
||||
wait: true,
|
||||
waitMs: options.waitForExit ? 30_000 : EXIT_PROBE_MS
|
||||
})
|
||||
).exitCode
|
||||
} catch (error) {
|
||||
if (error instanceof ProviderError && (error.status === 404 || error.status === 410)) {
|
||||
return (await failBuild(deps, row, 'install', 'vm_lost')) ?? row
|
||||
|
||||
@@ -22,7 +22,12 @@ export type GoldenImageProviderApi = {
|
||||
params: { command: string; args?: string[]; env?: Record<string, string>; timeoutMs: number }
|
||||
): Promise<string>
|
||||
/** Null exit code while it runs; `wait` blocks (provider-side) until it ends. */
|
||||
getCommand(sessionId: string, commandId: string, options?: { wait?: boolean }): Promise<{ exitCode: number | null }>
|
||||
getCommand(
|
||||
sessionId: string,
|
||||
commandId: string,
|
||||
/** `wait`: block until it exits (the only way the provider reports an exit code); `waitMs` caps that. */
|
||||
options?: { wait?: boolean; waitMs?: number }
|
||||
): Promise<{ exitCode: number | null }>
|
||||
/**
|
||||
* The command's output as provider ndjson (`{"stream":"stdout"|"stderr","data":"..."}` lines), from
|
||||
* the first byte; follows live output until the command ends.
|
||||
|
||||
@@ -1149,14 +1149,18 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
|
||||
}
|
||||
case 'POST desktop': {
|
||||
// Runs only the image's fixed entry point; the desktop listens on the VM's loopback and is
|
||||
// reached through the paired runtime's port forward, never a public route.
|
||||
// reached through the paired runtime's port forward, never a public route. Every call mints
|
||||
// a fresh single-use ticket (each WebSocket, including a reconnect, needs its own); it is
|
||||
// never logged here.
|
||||
if (row.status !== 'running' || !row.session_id) {
|
||||
return fail('conflict', 409, `sandbox_${row.status}`)
|
||||
}
|
||||
const started = Date.now()
|
||||
const desktop = await startDesktop(life.provider, row.session_id)
|
||||
console.log(JSON.stringify({ event: 'desktop_start', ms: Date.now() - started }))
|
||||
return json({ desktop: { port: desktop.port, path: desktop.path, password: desktop.password } })
|
||||
return json({
|
||||
desktop: { port: desktop.port, path: desktop.path, ticket: desktop.ticket, expiresInMs: desktop.expiresInMs }
|
||||
})
|
||||
}
|
||||
case 'POST lease': {
|
||||
const body = LeaseRequestSchema.safeParse(await readJson(request))
|
||||
|
||||
@@ -196,12 +196,16 @@ const DESKTOP_NOT_INSTALLED_EXIT = 3
|
||||
|
||||
const DesktopReadySchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
schemaVersion: z.literal(2),
|
||||
port: z.number().int().min(1024).max(65_535),
|
||||
display: z.string().regex(/^:\d{1,3}$/),
|
||||
path: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/),
|
||||
/** Per-boot VNC password: the desktop's localhost forward is reachable by any local process. */
|
||||
password: z.string().regex(/^[A-Za-z0-9]{8}$/)
|
||||
/**
|
||||
* Single-use connection ticket (32 random bytes, base64url): the desktop's localhost forward is
|
||||
* reachable by any local process, so the VM's proxy admits one WebSocket per unexpired ticket.
|
||||
*/
|
||||
ticket: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
|
||||
expiresInMs: z.number().int().min(1_000).max(300_000)
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -214,7 +218,7 @@ export class DesktopCommandError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the VM's remote desktop (idempotent per boot): Xvnc + noVNC on the VM's loopback only. */
|
||||
/** Starts the VM's remote desktop (idempotent per boot) and mints one ticket: Xvnc + noVNC on the VM's loopback only. */
|
||||
export async function startDesktop(provider: SandboxProviderApi, sessionId: string): Promise<DesktopReady> {
|
||||
let result: Awaited<ReturnType<SandboxProviderApi['runCommand']>>
|
||||
try {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function createVercelSandboxApi(
|
||||
async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
options: { query?: Record<string, string | undefined>; body?: unknown } = {}
|
||||
options: { query?: Record<string, string | undefined>; body?: unknown; signal?: AbortSignal } = {}
|
||||
): Promise<Response> {
|
||||
const url = new URL(`${base}${path}`)
|
||||
url.searchParams.set('teamId', config.teamId)
|
||||
@@ -80,7 +80,8 @@ export function createVercelSandboxApi(
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
redirect: 'manual'
|
||||
redirect: 'manual',
|
||||
signal: options.signal
|
||||
})
|
||||
if (!response.ok) {
|
||||
const parsed = ErrorBody.safeParse(await response.json().catch(() => null))
|
||||
@@ -280,11 +281,22 @@ export function createVercelSandboxApi(
|
||||
},
|
||||
|
||||
async getCommand(sessionId, commandId, options) {
|
||||
const response = await request(
|
||||
'GET',
|
||||
`/v2/sandboxes/sessions/${encodeURIComponent(sessionId)}/cmd/${encodeURIComponent(commandId)}`,
|
||||
{ query: { wait: options?.wait ? 'true' : undefined } }
|
||||
)
|
||||
// Found live: a detached command's exitCode stays null on plain GETs even after it exited;
|
||||
// only `wait=true` reports it. `waitMs` bounds that wait so a running command reads as null.
|
||||
const signal = options?.wait && options.waitMs ? AbortSignal.timeout(options.waitMs) : undefined
|
||||
let response: Response
|
||||
try {
|
||||
response = await request(
|
||||
'GET',
|
||||
`/v2/sandboxes/sessions/${encodeURIComponent(sessionId)}/cmd/${encodeURIComponent(commandId)}`,
|
||||
{ query: { wait: options?.wait ? 'true' : undefined }, signal }
|
||||
)
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
return { exitCode: null }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const body = await json(z.looseObject({ command: z.looseObject({ exitCode: z.number().nullable().optional() }) }), response)
|
||||
return { exitCode: body.command.exitCode ?? null }
|
||||
},
|
||||
|
||||
@@ -14,7 +14,10 @@ describe('POST /v1/sandboxes/:id/desktop', () => {
|
||||
it("runs only the image's desktop entry point and returns its loopback port", async () => {
|
||||
const { h, id } = await running()
|
||||
const response = await h.call('POST', `/v1/sandboxes/${id}/desktop`)
|
||||
expect(response).toEqual({ status: 200, body: { desktop: { port: 6080, path: 'vnc.html', password: 'Ab3dE6gH' } } })
|
||||
expect(response).toEqual({
|
||||
status: 200,
|
||||
body: { desktop: { port: 6080, path: 'vnc.html', ticket: 't'.repeat(43), expiresInMs: 60000 } }
|
||||
})
|
||||
const ran = h.vercel.commands.filter((c) => c.command === DESKTOP_COMMAND)
|
||||
expect(ran).toHaveLength(1)
|
||||
expect(ran[0]!.env).toEqual({})
|
||||
@@ -36,11 +39,24 @@ describe('POST /v1/sandboxes/:id/desktop', () => {
|
||||
status: 502,
|
||||
body: { error: 'provider_unavailable', reason: 'desktop_start_failed' }
|
||||
})
|
||||
h.vercel.desktop = { exitCode: 0, stdout: '{"schemaVersion":1,"port":6080,"display":":1","path":"vnc.html","password":"short"}' }
|
||||
h.vercel.desktop = { exitCode: 0, stdout: '{"schemaVersion":2,"port":6080,"display":":1","path":"vnc.html","ticket":"short","expiresInMs":60000}' }
|
||||
expect(await h.call('POST', `/v1/sandboxes/${id}/desktop`)).toEqual({
|
||||
status: 502,
|
||||
body: { error: 'provider_unavailable', reason: 'desktop_ready_invalid' }
|
||||
})
|
||||
// An older entry point (VNC password, no ticket) is never passed through.
|
||||
h.vercel.desktop = { exitCode: 0, stdout: '{"schemaVersion":1,"port":6080,"display":":1","path":"vnc.html","password":"Ab3dE6gH"}' }
|
||||
expect((await h.call('POST', `/v1/sandboxes/${id}/desktop`)).body).toEqual({
|
||||
error: 'provider_unavailable',
|
||||
reason: 'desktop_ready_invalid'
|
||||
})
|
||||
})
|
||||
|
||||
it('mints a fresh ticket on every call (a reconnect needs its own)', async () => {
|
||||
const { h, id } = await running()
|
||||
await h.call('POST', `/v1/sandboxes/${id}/desktop`)
|
||||
await h.call('POST', `/v1/sandboxes/${id}/desktop`)
|
||||
expect(h.vercel.commands.filter((c) => c.command === DESKTOP_COMMAND)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('refuses a suspended sandbox and hides other accounts\' sandboxes', async () => {
|
||||
|
||||
@@ -145,12 +145,13 @@ export function createFakeVercel(now: () => number): FakeVercel {
|
||||
buildCommands.set(id, { sessionId, env: params.env ?? {}, args: params.args ?? [], exitCode: null, logs: '' })
|
||||
return id
|
||||
},
|
||||
async getCommand(_sessionId, commandId) {
|
||||
async getCommand(_sessionId, commandId, options) {
|
||||
const command = buildCommands.get(commandId)
|
||||
if (!command) {
|
||||
throw new ProviderError(404, 'not_found')
|
||||
}
|
||||
return { exitCode: command.exitCode }
|
||||
// As Vercel (found live): a detached command's exit code is reported only to `wait=true` reads.
|
||||
return { exitCode: options?.wait ? command.exitCode : null }
|
||||
},
|
||||
async streamCommandLogs(_sessionId, commandId) {
|
||||
const command = buildCommands.get(commandId)
|
||||
@@ -167,7 +168,7 @@ export function createFakeVercel(now: () => number): FakeVercel {
|
||||
failEnvRefresh: false,
|
||||
sshKeysExit: 0,
|
||||
authorizedKeys: new Map(),
|
||||
desktop: { exitCode: 0, stdout: '{"schemaVersion":1,"port":6080,"display":":1","path":"vnc.html","password":"Ab3dE6gH"}\n' },
|
||||
desktop: { exitCode: 0, stdout: `{"schemaVersion":2,"port":6080,"display":":1","path":"vnc.html","ticket":"${'t'.repeat(43)}","expiresInMs":60000}\n` },
|
||||
failCreate: false,
|
||||
snapshotExpirations: new Map(),
|
||||
expire(name) {
|
||||
|
||||
@@ -4,6 +4,32 @@ import { createVercelSandboxApi } from '../src/vercel-sandbox-api'
|
||||
|
||||
type Seen = { method: string; url: URL; headers: Headers; body: any }
|
||||
|
||||
describe('command exit codes', () => {
|
||||
it('asks with wait=true and treats a bounded wait that runs out as still running', async () => {
|
||||
const seen: URL[] = []
|
||||
const client = createVercelSandboxApi({
|
||||
token: 'vercel_test_token',
|
||||
teamId: 'team_orca',
|
||||
projectId: 'prj_orca',
|
||||
fetch: (input, init) => {
|
||||
seen.push(new URL(String(input)))
|
||||
// A running command: the provider holds the wait open until the caller gives up.
|
||||
return new Promise<Response>((_, reject) =>
|
||||
init?.signal?.addEventListener('abort', () => reject(init.signal!.reason))
|
||||
)
|
||||
}
|
||||
})
|
||||
expect(await client.getCommand('sess_1', 'cmd_1', { wait: true, waitMs: 20 })).toEqual({ exitCode: null })
|
||||
expect(seen[0]!.searchParams.get('wait')).toBe('true')
|
||||
})
|
||||
|
||||
it('reports the exit code a finished command answers the wait with', async () => {
|
||||
const { client, seen } = api(() => Response.json({ command: { id: 'cmd_1', exitCode: 0 } }))
|
||||
expect(await client.getCommand('sess_1', 'cmd_1', { wait: true, waitMs: 4000 })).toEqual({ exitCode: 0 })
|
||||
expect(seen[0]!.url.searchParams.get('wait')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
function api(answer: (seen: Seen) => Response) {
|
||||
const seen: Seen[] = []
|
||||
const client = createVercelSandboxApi({
|
||||
|
||||
+5
-2
@@ -33,6 +33,8 @@ export function setupGuestShortcutForwarding(args: {
|
||||
resolveWorkspaceId?: (browserTabId: string) => string | null
|
||||
/** False for plugin frames (see GuestShortcutForwardContext). */
|
||||
browserPage?: boolean
|
||||
/** True while the page owns every key (a focused remote desktop): Orca forwards nothing. */
|
||||
isKeyboardCaptured?: () => boolean
|
||||
}): () => void {
|
||||
const {
|
||||
browserTabId,
|
||||
@@ -43,7 +45,8 @@ export function setupGuestShortcutForwarding(args: {
|
||||
getKeybindings,
|
||||
resolveWorktreeId,
|
||||
resolveWorkspaceId,
|
||||
browserPage
|
||||
browserPage,
|
||||
isKeyboardCaptured
|
||||
} = args
|
||||
let ctrlTabSwitching = false
|
||||
const doubleTapDetector = new ModifierDoubleTapDetector()
|
||||
@@ -72,7 +75,7 @@ export function setupGuestShortcutForwarding(args: {
|
||||
|
||||
const handler = (event: Electron.Event, input: Electron.Input): void => {
|
||||
// Agent-injected keys must not trigger Orca actions (#20595).
|
||||
if (isAgentInjectingInput(guest)) {
|
||||
if (isAgentInjectingInput(guest) || isKeyboardCaptured?.()) {
|
||||
return
|
||||
}
|
||||
const keybindings = getKeybindings?.()
|
||||
|
||||
@@ -5,6 +5,10 @@ import { setupGuestMouseWheelZoomForwarding } from '../input/browser-guest-wheel
|
||||
import { setupGuestShortcutForwarding } from '../input/shortcuts/browser-guest-shortcut-forwarding'
|
||||
import { setupGuestTargetUrlForwarding } from '../input/browser-guest-target-url'
|
||||
import { BrowserManagerGrab } from './grab/browser-manager-grab'
|
||||
import {
|
||||
isRemoteDesktopGuest,
|
||||
setupRemoteDesktopGuest
|
||||
} from '../../../hosted-sandbox/remote-desktop-guest'
|
||||
|
||||
export abstract class BrowserManagerBindings extends BrowserManagerGrab {
|
||||
protected setupContextMenu(browserTabId: string, guest: Electron.WebContents): void {
|
||||
@@ -56,7 +60,9 @@ export abstract class BrowserManagerBindings extends BrowserManagerGrab {
|
||||
isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false,
|
||||
getKeybindings: () => this.settingsResolver?.().keybindings,
|
||||
resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null,
|
||||
resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null
|
||||
resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null,
|
||||
// A focused remote desktop keeps every key (VM-console style) until its release chord.
|
||||
isKeyboardCaptured: () => isRemoteDesktopGuest(guest)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -83,6 +89,28 @@ export abstract class BrowserManagerBindings extends BrowserManagerGrab {
|
||||
)
|
||||
}
|
||||
|
||||
protected setupRemoteDesktopGuest(browserTabId: string, guest: Electron.WebContents): void {
|
||||
this.remoteDesktopCleanupByTabId.get(browserTabId)?.()
|
||||
this.remoteDesktopCleanupByTabId.set(
|
||||
browserTabId,
|
||||
setupRemoteDesktopGuest({
|
||||
browserPageId: browserTabId,
|
||||
guest,
|
||||
notify: (event) => {
|
||||
const renderer = this.resolveRendererForBrowserTab(browserTabId)
|
||||
if (!renderer || renderer.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (event.kind === 'release') {
|
||||
renderer.send('browser:remoteDesktopRelease', { browserPageId: browserTabId })
|
||||
} else {
|
||||
renderer.send('browser:remoteDesktopState', event.state)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
protected setupTargetUrlForwarding(browserTabId: string, guest: Electron.WebContents): void {
|
||||
this.targetUrlCleanupByTabId.get(browserTabId)?.()
|
||||
this.targetUrlCleanupByTabId.set(
|
||||
|
||||
@@ -89,6 +89,10 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
|
||||
browserTabId: string,
|
||||
guest: Electron.WebContents
|
||||
): void
|
||||
protected abstract setupRemoteDesktopGuest(
|
||||
browserTabId: string,
|
||||
guest: Electron.WebContents
|
||||
): void
|
||||
protected abstract cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void
|
||||
protected abstract hasActiveGrabOp(browserTabId: string): boolean
|
||||
protected abstract unregisterGuest(
|
||||
@@ -155,6 +159,7 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
|
||||
protected readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
|
||||
protected readonly mouseWheelZoomCleanupByTabId = new Map<string, () => void>()
|
||||
protected readonly targetUrlCleanupByTabId = new Map<string, () => void>()
|
||||
protected readonly remoteDesktopCleanupByTabId = new Map<string, () => void>()
|
||||
protected readonly annotationViewportBridgeOpsByTabId = new Map<string, Promise<unknown>>()
|
||||
protected readonly worktreeIdByTabId = new Map<string, string>()
|
||||
protected readonly policyAttachedGuestIds = new Set<number>()
|
||||
|
||||
@@ -89,6 +89,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
|
||||
this.setupShortcutForwarding(browserTabId, guest)
|
||||
this.setupMouseWheelZoomForwarding(browserTabId, guest)
|
||||
this.setupTargetUrlForwarding(browserTabId, guest)
|
||||
this.setupRemoteDesktopGuest(browserTabId, guest)
|
||||
this.flushPendingLoadFailure(browserTabId, webContentsId)
|
||||
this.flushPendingPermissionEvents(browserTabId, webContentsId)
|
||||
this.flushPendingPopupEvents(browserTabId, webContentsId)
|
||||
@@ -141,6 +142,8 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
|
||||
}
|
||||
this.targetUrlCleanupByTabId.get(browserTabId)?.()
|
||||
this.targetUrlCleanupByTabId.delete(browserTabId)
|
||||
this.remoteDesktopCleanupByTabId.get(browserTabId)?.()
|
||||
this.remoteDesktopCleanupByTabId.delete(browserTabId)
|
||||
let hasActiveDownloads = false
|
||||
for (const [downloadId, download] of this.downloadsById.entries()) {
|
||||
if (download.browserTabId === browserTabId && !download.terminalEvent) {
|
||||
@@ -243,6 +246,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
|
||||
this.pendingDownloadIdsByGuestId.clear()
|
||||
this.mouseWheelZoomCleanupByTabId.clear()
|
||||
this.targetUrlCleanupByTabId.clear()
|
||||
this.remoteDesktopCleanupByTabId.clear()
|
||||
this.annotationViewportBridgeOpsByTabId.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export type FakeHostedControlPlane = {
|
||||
requests: { method: string; path: string; authorization: string | null }[]
|
||||
/** False models an image built before the remote desktop. */
|
||||
desktopInstalled: boolean
|
||||
/** Desktop starts served (each mints one ticket). */
|
||||
desktopTickets: number
|
||||
}
|
||||
|
||||
export function createFakeHostedControlPlane(
|
||||
@@ -143,8 +145,17 @@ export function createFakeHostedControlPlane(
|
||||
if (sandbox.status !== 'running') {
|
||||
return fail('conflict', 409, `sandbox_${sandbox.status}`)
|
||||
}
|
||||
fake.desktopTickets += 1
|
||||
return fake.desktopInstalled
|
||||
? json({ desktop: { port: 6080, path: 'vnc.html', password: 'Ab3dE6gH' } })
|
||||
? json({
|
||||
desktop: {
|
||||
port: 6080,
|
||||
path: 'vnc.html',
|
||||
// A fresh single-use ticket per call, as orca-desktop-start mints them.
|
||||
ticket: String(fake.desktopTickets).padStart(43, 't'),
|
||||
expiresInMs: 60_000
|
||||
}
|
||||
})
|
||||
: fail('conflict', 409, 'desktop_not_installed')
|
||||
case 'POST /rollover':
|
||||
if (sandbox.status !== 'running') {
|
||||
@@ -180,7 +191,8 @@ export function createFakeHostedControlPlane(
|
||||
pendingDeny = reason
|
||||
},
|
||||
requests,
|
||||
desktopInstalled: true
|
||||
desktopInstalled: true,
|
||||
desktopTickets: 0
|
||||
}
|
||||
return fake
|
||||
}
|
||||
|
||||
@@ -9,10 +9,16 @@ import { createHostedSandboxDriver } from './hosted-sandbox-driver'
|
||||
import { createHostedSandboxStepProvider } from './hosted-sandbox-recipe'
|
||||
import { createFakeHostedControlPlane } from './fake-hosted-control-plane.test-support'
|
||||
import { cloudSandboxIdForEnvironment, openCloudRemoteDesktop } from './hosted-remote-desktop'
|
||||
import {
|
||||
remoteDesktopSessionForUrl,
|
||||
resetRemoteDesktopSessionsForTest,
|
||||
takeRemoteDesktopTicket
|
||||
} from './remote-desktop-sessions'
|
||||
|
||||
const ENV = 'env-cloud-1'
|
||||
|
||||
async function setup() {
|
||||
resetRemoteDesktopSessionsForTest()
|
||||
const plane = createFakeHostedControlPlane()
|
||||
const client = createHostedSandboxControlPlaneClient({
|
||||
baseUrl: 'https://sandbox.cloud.test',
|
||||
@@ -55,12 +61,31 @@ describe('Open Remote Desktop for an Orca Cloud workspace', () => {
|
||||
const { plane, client, userDataPath, openForward } = await setup()
|
||||
const sandboxId = cloudSandboxIdForEnvironment(userDataPath, ENV)
|
||||
expect(sandboxId).toBe([...plane.sandboxes.keys()][0])
|
||||
const result = await openCloudRemoteDesktop({ userDataPath, client, openForward }, ENV)
|
||||
const now = () => 1_000
|
||||
const result = await openCloudRemoteDesktop({ userDataPath, client, openForward, now }, ENV)
|
||||
// No secret in the URL: the page gets single-use tickets from main after it loads.
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
localPort: 6081,
|
||||
url: 'http://localhost:6081/vnc.html?autoconnect=1&resize=remote&reconnect=1&reconnect_delay=1000#password=Ab3dE6gH'
|
||||
url: 'http://localhost:6081/vnc.html?autoconnect=0&reconnect=0&resize=remote'
|
||||
})
|
||||
const session = remoteDesktopSessionForUrl(result.ok ? result.url : '')
|
||||
expect(session).toMatchObject({ environmentId: ENV, localPort: 6081, path: 'vnc.html' })
|
||||
// The first ticket rides with the open; the next one is minted fresh (a reconnect).
|
||||
expect(await takeRemoteDesktopTicket(session!, now)).toEqual({
|
||||
ok: true,
|
||||
ticket: '1'.padStart(43, 't'),
|
||||
expiresAt: 61_000
|
||||
})
|
||||
expect(await takeRemoteDesktopTicket(session!, now)).toEqual({
|
||||
ok: true,
|
||||
ticket: '2'.padStart(43, 't'),
|
||||
expiresAt: 61_000
|
||||
})
|
||||
expect(remoteDesktopSessionForUrl('http://localhost:6082/vnc.html')).toBeNull()
|
||||
expect(remoteDesktopSessionForUrl('http://localhost:6081/other.html')).toBeNull()
|
||||
expect(remoteDesktopSessionForUrl('https://localhost:6081/vnc.html')).toBeNull()
|
||||
expect(remoteDesktopSessionForUrl('http://example.com:6081/vnc.html')).toBeNull()
|
||||
expect(plane.requests.at(-1)).toMatchObject({
|
||||
method: 'POST',
|
||||
path: `/v1/sandboxes/${sandboxId}/desktop`
|
||||
@@ -102,6 +127,13 @@ describe('Open Remote Desktop for an Orca Cloud workspace', () => {
|
||||
ok: false,
|
||||
code: 'not_running'
|
||||
})
|
||||
for (const sandbox of plane.sandboxes.values()) {
|
||||
Object.assign(sandbox, { status: 'resuming' })
|
||||
}
|
||||
expect(await openCloudRemoteDesktop({ userDataPath, client, openForward }, ENV)).toEqual({
|
||||
ok: false,
|
||||
code: 'starting'
|
||||
})
|
||||
expect(openForward).not.toHaveBeenCalled()
|
||||
for (const sandbox of plane.sandboxes.values()) {
|
||||
Object.assign(sandbox, { status: 'running' })
|
||||
@@ -112,6 +144,17 @@ describe('Open Remote Desktop for an Orca Cloud workspace', () => {
|
||||
).toEqual({ ok: false, code: 'forward_failed' })
|
||||
})
|
||||
|
||||
it('says why a later ticket could not be minted (the sandbox went to sleep)', async () => {
|
||||
const { plane, client, userDataPath, openForward } = await setup()
|
||||
const result = await openCloudRemoteDesktop({ userDataPath, client, openForward }, ENV)
|
||||
const session = remoteDesktopSessionForUrl(result.ok ? result.url : '')!
|
||||
await takeRemoteDesktopTicket(session) // the open's own ticket
|
||||
for (const sandbox of plane.sandboxes.values()) {
|
||||
Object.assign(sandbox, { status: 'suspended', session: null })
|
||||
}
|
||||
expect(await takeRemoteDesktopTicket(session)).toEqual({ ok: false, reason: 'asleep' })
|
||||
})
|
||||
|
||||
it('asks for sign-in when the control plane refuses the token', async () => {
|
||||
const { userDataPath, openForward } = await setup()
|
||||
const plane = createFakeHostedControlPlane()
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
// "Open Remote Desktop" for an Orca Cloud workspace: the control plane starts the VM's desktop
|
||||
// (Xvnc + noVNC on the VM's loopback), then the paired runtime connection forwards its port here.
|
||||
// No public route: the desktop is reachable only through this computer's localhost.
|
||||
// No public route: the desktop is reachable only through this computer's localhost, and each
|
||||
// WebSocket to it needs a single-use ticket that main hands the page (remote-desktop-guest.ts).
|
||||
import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store'
|
||||
import {
|
||||
cloudRemoteDesktopUrl,
|
||||
type CloudRemoteDesktopFailureCode,
|
||||
type CloudRemoteDesktopResult
|
||||
} from '../../shared/hosted-sandbox/cloud-remote-desktop'
|
||||
import type { HostedSandboxDesktop } from '../../shared/hosted-sandbox/hosted-sandbox-wire'
|
||||
import type { RuntimePortForwardOpenRequest } from '../../shared/runtime-port-forwards'
|
||||
import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver'
|
||||
import { environmentHandleFromRecipeResult } from '../environment-providers/environment-provider-recipe-adapter'
|
||||
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
|
||||
import { HOSTED_SANDBOX_DRIVER_ID } from './hosted-sandbox-driver'
|
||||
import {
|
||||
registerRemoteDesktopSession,
|
||||
type RemoteDesktopMintFailure,
|
||||
type RemoteDesktopMintResult
|
||||
} from './remote-desktop-sessions'
|
||||
|
||||
export type CloudRemoteDesktopDeps = {
|
||||
userDataPath: string
|
||||
@@ -19,6 +26,13 @@ export type CloudRemoteDesktopDeps = {
|
||||
openForward: (
|
||||
request: RuntimePortForwardOpenRequest
|
||||
) => Promise<{ ok: true; localPort: number } | { ok: false }>
|
||||
now?: () => number
|
||||
/** Whether this computer still forwards localPort to the environment's remotePort. */
|
||||
isForwardLive?: (forward: {
|
||||
environmentId: string
|
||||
localPort: number
|
||||
remotePort: number
|
||||
}) => boolean
|
||||
}
|
||||
|
||||
/** The Orca Cloud sandbox behind a paired runtime environment, from the local runtime records. */
|
||||
@@ -54,14 +68,32 @@ function failure(code: CloudRemoteDesktopFailureCode): CloudRemoteDesktopResult
|
||||
return { ok: false, code }
|
||||
}
|
||||
|
||||
function startFailure(error: unknown): CloudRemoteDesktopResult {
|
||||
const ASLEEP_REASONS = new Set(['sandbox_suspended', 'sandbox_suspending'])
|
||||
const STARTING_REASONS = new Set(['sandbox_creating', 'sandbox_resuming'])
|
||||
|
||||
/** How a desktop start (or ticket) failure reads; a conflict names the sandbox's state. */
|
||||
function startFailureCode(error: unknown): CloudRemoteDesktopFailureCode {
|
||||
if (isEnvironmentProviderError(error, 'conflict')) {
|
||||
return failure(error.reason === 'desktop_not_installed' ? 'not_installed' : 'not_running')
|
||||
const reason = error.reason ?? ''
|
||||
if (reason === 'desktop_not_installed') {
|
||||
return 'not_installed'
|
||||
}
|
||||
if (reason === 'sandbox_archived') {
|
||||
return 'archived'
|
||||
}
|
||||
if (ASLEEP_REASONS.has(reason)) {
|
||||
return 'not_running'
|
||||
}
|
||||
return STARTING_REASONS.has(reason) ? 'starting' : 'unavailable'
|
||||
}
|
||||
if (isEnvironmentProviderError(error, 'unauthorized')) {
|
||||
return failure('signed_out')
|
||||
}
|
||||
return failure('unavailable')
|
||||
return isEnvironmentProviderError(error, 'unauthorized') ? 'signed_out' : 'unavailable'
|
||||
}
|
||||
|
||||
const MINT_FAILURE: Partial<Record<CloudRemoteDesktopFailureCode, RemoteDesktopMintFailure>> = {
|
||||
not_running: 'asleep',
|
||||
archived: 'archived',
|
||||
starting: 'starting',
|
||||
signed_out: 'signed_out'
|
||||
}
|
||||
|
||||
export async function openCloudRemoteDesktop(
|
||||
@@ -69,14 +101,16 @@ export async function openCloudRemoteDesktop(
|
||||
environmentId: string
|
||||
): Promise<CloudRemoteDesktopResult> {
|
||||
const sandboxId = cloudSandboxIdForEnvironment(deps.userDataPath, environmentId)
|
||||
if (!sandboxId || !deps.client) {
|
||||
const client = deps.client
|
||||
if (!sandboxId || !client) {
|
||||
return failure('not_cloud')
|
||||
}
|
||||
let desktop: { port: number; path: string; password: string }
|
||||
const now = deps.now ?? Date.now
|
||||
let desktop: HostedSandboxDesktop
|
||||
try {
|
||||
desktop = await deps.client.startDesktop(sandboxId)
|
||||
desktop = await client.startDesktop(sandboxId)
|
||||
} catch (error) {
|
||||
return startFailure(error)
|
||||
return failure(startFailureCode(error))
|
||||
}
|
||||
// Same local number when free (stable bookmarks), else the next free one.
|
||||
const forward = await deps.openForward({
|
||||
@@ -88,9 +122,30 @@ export async function openCloudRemoteDesktop(
|
||||
if (!forward.ok) {
|
||||
return failure('forward_failed')
|
||||
}
|
||||
// Each later WebSocket (a reload, a reconnect after a drop or a wake) gets its own ticket.
|
||||
const mint = async (): Promise<RemoteDesktopMintResult> => {
|
||||
try {
|
||||
const next = await client.startDesktop(sandboxId)
|
||||
return { ok: true, ticket: next.ticket, expiresAt: now() + next.expiresInMs }
|
||||
} catch (error) {
|
||||
return { ok: false, reason: MINT_FAILURE[startFailureCode(error)] ?? 'unavailable' }
|
||||
}
|
||||
}
|
||||
const isForwardLive = deps.isForwardLive
|
||||
const live = { environmentId, localPort: forward.localPort, remotePort: desktop.port }
|
||||
registerRemoteDesktopSession(
|
||||
{
|
||||
environmentId,
|
||||
localPort: forward.localPort,
|
||||
path: desktop.path,
|
||||
mint,
|
||||
...(isForwardLive ? { isLive: () => isForwardLive(live) } : {})
|
||||
},
|
||||
{ ticket: desktop.ticket, expiresAt: now() + desktop.expiresInMs }
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
localPort: forward.localPort,
|
||||
url: cloudRemoteDesktopUrl(forward.localPort, desktop.path, desktop.password)
|
||||
url: cloudRemoteDesktopUrl(forward.localPort, desktop.path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({ clipboard: { readText: () => '' } }))
|
||||
vi.mock('../i18n/main-i18n', () => ({
|
||||
translateMain: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
vi.mock('../browser/cdp/input/agent-input-injection', () => ({
|
||||
isAgentInjectingInput: () => false
|
||||
}))
|
||||
|
||||
import {
|
||||
registerRemoteDesktopSession,
|
||||
resetRemoteDesktopSessionsForTest,
|
||||
type RemoteDesktopMintResult
|
||||
} from './remote-desktop-sessions'
|
||||
import { isRemoteDesktopGuest, setupRemoteDesktopGuest } from './remote-desktop-guest'
|
||||
|
||||
const DESKTOP_URL = 'http://localhost:6081/vnc.html?autoconnect=0&reconnect=0&resize=remote'
|
||||
const ticket = (n: number): string => String(n).padStart(43, 't')
|
||||
/** Fixed, far-future expiry: these tests never depend on the clock. */
|
||||
const FAR_FUTURE = 4_000_000_000_000
|
||||
|
||||
/** A guest whose page answers orcaDesktop calls from a script the test controls. */
|
||||
class FakeGuest extends EventEmitter {
|
||||
url = DESKTOP_URL
|
||||
focused = false
|
||||
destroyed = false
|
||||
calls: string[] = []
|
||||
/** Pending nextTicketRequest() answers, resolved by the test. */
|
||||
private waiters: ((answer: string) => void)[] = []
|
||||
connectAnswer = 'connecting'
|
||||
pasteAnswer = true
|
||||
|
||||
getURL(): string {
|
||||
return this.url
|
||||
}
|
||||
isLoading(): boolean {
|
||||
return false
|
||||
}
|
||||
isFocused(): boolean {
|
||||
return this.focused
|
||||
}
|
||||
isDestroyed(): boolean {
|
||||
return this.destroyed
|
||||
}
|
||||
async executeJavaScript(code: string): Promise<unknown> {
|
||||
this.calls.push(code)
|
||||
if (code.includes('nextTicketRequest')) {
|
||||
return new Promise((resolve) => this.waiters.push(resolve))
|
||||
}
|
||||
if (code.includes('orcaDesktop.connect(')) {
|
||||
return this.connectAnswer
|
||||
}
|
||||
if (code.includes('orcaDesktop.paste(')) {
|
||||
return this.pasteAnswer
|
||||
}
|
||||
return true
|
||||
}
|
||||
/** The page asks for a ticket ('ticket') or reports a failed attempt ('retry'). */
|
||||
ask(answer: 'ticket' | 'retry'): void {
|
||||
this.waiters.shift()?.(answer)
|
||||
}
|
||||
get waiting(): number {
|
||||
return this.waiters.length
|
||||
}
|
||||
key(input: Partial<Electron.Input>): { prevented: boolean } {
|
||||
const event = { prevented: false, preventDefault: () => (event.prevented = true) }
|
||||
this.emit('before-input-event', event, {
|
||||
type: 'keyDown',
|
||||
key: '',
|
||||
code: '',
|
||||
control: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
isAutoRepeat: false,
|
||||
...input
|
||||
})
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
function asWebContents(guest: FakeGuest): Electron.WebContents {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeGuest implements every WebContents member the driver calls (on/off, getURL, isLoading, isFocused, isDestroyed, executeJavaScript).
|
||||
return guest as unknown as Electron.WebContents
|
||||
}
|
||||
|
||||
const flush = async (): Promise<void> => {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
function setup(options: { clipboard?: string; platform?: NodeJS.Platform } = {}) {
|
||||
const guest = new FakeGuest()
|
||||
const minted: string[] = []
|
||||
let next = 1
|
||||
let mintResult: () => RemoteDesktopMintResult = () => {
|
||||
const value = ticket((next += 1))
|
||||
minted.push(value)
|
||||
return { ok: true, ticket: value, expiresAt: FAR_FUTURE }
|
||||
}
|
||||
registerRemoteDesktopSession(
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
localPort: 6081,
|
||||
path: 'vnc.html',
|
||||
mint: async () => mintResult()
|
||||
},
|
||||
{ ticket: ticket(1), expiresAt: FAR_FUTURE }
|
||||
)
|
||||
const events: unknown[] = []
|
||||
const stop = setupRemoteDesktopGuest({
|
||||
browserPageId: 'page-1',
|
||||
guest: asWebContents(guest),
|
||||
platform: options.platform ?? 'darwin',
|
||||
notify: (event) => events.push(event),
|
||||
readClipboard: () => options.clipboard ?? '',
|
||||
sleep: async () => {}
|
||||
})
|
||||
return {
|
||||
guest,
|
||||
minted,
|
||||
events,
|
||||
stop,
|
||||
failMint: (reason: 'asleep' | 'unavailable') => {
|
||||
mintResult = () => ({ ok: false, reason })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connects = (guest: FakeGuest): string[] =>
|
||||
guest.calls.filter((c) => c.includes('orcaDesktop.connect('))
|
||||
|
||||
describe('remote desktop page driver', () => {
|
||||
beforeEach(() => resetRemoteDesktopSessionsForTest())
|
||||
|
||||
it('hands the page the open’s ticket first, then a fresh one for each reconnect', async () => {
|
||||
const { guest } = setup()
|
||||
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(true)
|
||||
await flush()
|
||||
guest.ask('ticket')
|
||||
await flush()
|
||||
expect(connects(guest)).toEqual([`window.orcaDesktop.connect("${ticket(1)}")`])
|
||||
// The session dropped (forward re-dial, desktop restart): a new ticket, never the spent one.
|
||||
guest.ask('ticket')
|
||||
await flush()
|
||||
expect(connects(guest).at(-1)).toBe(`window.orcaDesktop.connect("${ticket(2)}")`)
|
||||
})
|
||||
|
||||
it('pauses with Reconnect after repeated failed attempts instead of spinning', async () => {
|
||||
const { guest } = setup()
|
||||
await flush()
|
||||
// One try and three retries, then the page waits for a person.
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
guest.ask(i === 0 ? 'ticket' : 'retry')
|
||||
await flush()
|
||||
}
|
||||
expect(connects(guest)).toHaveLength(4)
|
||||
expect(guest.calls.at(-2)).toContain('window.orcaDesktop.pause(')
|
||||
expect(guest.calls.at(-2)).toContain('The remote desktop disconnected.')
|
||||
})
|
||||
|
||||
it('says the sandbox is asleep when no ticket can be minted, and resumes on reopen', async () => {
|
||||
const { guest, failMint } = setup()
|
||||
await flush()
|
||||
guest.ask('ticket') // uses the open's ticket
|
||||
await flush()
|
||||
failMint('asleep')
|
||||
guest.ask('ticket')
|
||||
await flush()
|
||||
expect(guest.calls.at(-2)).toContain('This sandbox is asleep.')
|
||||
// Open remote desktop again: main wakes the paused page.
|
||||
registerRemoteDesktopSession(
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
localPort: 6081,
|
||||
path: 'vnc.html',
|
||||
mint: async () => ({ ok: false, reason: 'asleep' })
|
||||
},
|
||||
null
|
||||
)
|
||||
expect(guest.calls.at(-1)).toContain('orcaDesktop.resume()')
|
||||
})
|
||||
|
||||
it('ignores pages that are not this computer’s desktop forwards', async () => {
|
||||
resetRemoteDesktopSessionsForTest()
|
||||
const guest = new FakeGuest()
|
||||
guest.url = 'http://localhost:6081/vnc.html'
|
||||
const stop = setupRemoteDesktopGuest({
|
||||
browserPageId: 'page-2',
|
||||
guest: asWebContents(guest),
|
||||
notify: () => {},
|
||||
readClipboard: () => 'secret',
|
||||
sleep: async () => {}
|
||||
})
|
||||
await flush()
|
||||
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(false)
|
||||
expect(guest.calls).toEqual([])
|
||||
expect(guest.key({ key: 'v', code: 'KeyV', meta: true }).prevented).toBe(false)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('reads the clipboard only on a paste key, and pushes it on every paste', async () => {
|
||||
const { guest } = setup({ clipboard: 'from this computer', platform: 'linux' })
|
||||
await flush()
|
||||
guest.ask('ticket')
|
||||
await flush()
|
||||
expect(guest.key({ key: 'a', code: 'KeyA', control: true }).prevented).toBe(false)
|
||||
expect(guest.calls.some((c) => c.includes('paste('))).toBe(false)
|
||||
expect(guest.key({ key: 'v', code: 'KeyV', control: true }).prevented).toBe(true)
|
||||
await flush()
|
||||
expect(guest.calls.at(-1)).toBe(
|
||||
'window.orcaDesktop.paste("from this computer", {"kind":"replay","key":"v"})'
|
||||
)
|
||||
// Same text again is pushed again: the sandbox's clipboard may have changed meanwhile.
|
||||
expect(guest.key({ key: 'v', code: 'KeyV', control: true }).prevented).toBe(true)
|
||||
// Auto-repeat of a Linux paste key passes through untouched.
|
||||
expect(guest.key({ key: 'v', code: 'KeyV', control: true, isAutoRepeat: true }).prevented).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('turns Cmd+V into a sandbox paste on macOS', async () => {
|
||||
const { guest } = setup({ clipboard: 'hi' })
|
||||
await flush()
|
||||
expect(guest.key({ key: 'v', code: 'KeyV', meta: true }).prevented).toBe(true)
|
||||
await flush()
|
||||
expect(guest.calls.at(-1)).toBe('window.orcaDesktop.paste("hi", {"kind":"mac"})')
|
||||
})
|
||||
|
||||
it('reports keyboard capture and the release chord to the renderer', async () => {
|
||||
const { guest, events } = setup()
|
||||
guest.focused = true
|
||||
guest.emit('focus')
|
||||
expect(events.at(-1)).toEqual({
|
||||
kind: 'state',
|
||||
state: { browserPageId: 'page-1', desktop: true, captured: true }
|
||||
})
|
||||
guest.key({ type: 'keyDown', key: 'Control', control: true })
|
||||
guest.key({ type: 'keyDown', key: 'Meta', control: true, meta: true })
|
||||
guest.key({ type: 'keyUp', key: 'Meta', control: true })
|
||||
expect(events.at(-1)).toEqual({ kind: 'release' })
|
||||
})
|
||||
|
||||
it('never strands a page call when the page goes away mid-call', async () => {
|
||||
const { guest } = setup()
|
||||
await flush()
|
||||
expect(guest.waiting).toBe(1) // nextTicketRequest() pending in the page
|
||||
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
await flush()
|
||||
// The pending call settled (cancelled) and the driver stopped: no connect, no new request.
|
||||
expect(connects(guest)).toEqual([])
|
||||
expect(guest.calls.filter((c) => c.includes('nextTicketRequest'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stops driving the page when it navigates away or the tab closes', async () => {
|
||||
const { guest, events, stop } = setup()
|
||||
await flush()
|
||||
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(false)
|
||||
guest.ask('ticket')
|
||||
await flush()
|
||||
expect(connects(guest)).toEqual([])
|
||||
guest.url = DESKTOP_URL
|
||||
guest.emit('did-finish-load')
|
||||
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(true)
|
||||
stop()
|
||||
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(false)
|
||||
expect(events.at(-1)).toEqual({
|
||||
kind: 'state',
|
||||
state: { browserPageId: 'page-1', desktop: false, captured: false }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,299 @@
|
||||
// Main's side of a remote desktop browser page (a page whose URL names a desktop forward this
|
||||
// computer opened; see remote-desktop-sessions.ts). Main, not the page, holds the means to mint
|
||||
// tickets, so it:
|
||||
// - hands the page one single-use ticket per WebSocket (on load, and after each drop), backing off
|
||||
// and pausing on repeated failures; the page offers Reconnect, and reopening from the menu
|
||||
// resumes it. Tickets never touch the URL, the tab state or a log.
|
||||
// - keeps the keyboard in the desktop while it has focus and gives it back to Orca on the release
|
||||
// chord (remote-desktop-keyboard.ts), telling the renderer so it can show the hint.
|
||||
// - pastes this computer's clipboard into the sandbox only on a paste key in the desktop.
|
||||
import { clipboard } from 'electron'
|
||||
import { translateMain } from '../i18n/main-i18n'
|
||||
import { isAgentInjectingInput } from '../browser/cdp/input/agent-input-injection'
|
||||
import {
|
||||
onRemoteDesktopSessionRefreshed,
|
||||
remoteDesktopSessionForUrl,
|
||||
takeRemoteDesktopTicket,
|
||||
type RemoteDesktopMintFailure,
|
||||
type RemoteDesktopSession
|
||||
} from './remote-desktop-sessions'
|
||||
import {
|
||||
createReleaseChordTracker,
|
||||
remoteDesktopPasteChord,
|
||||
type RemoteDesktopPasteChord
|
||||
} from './remote-desktop-keyboard'
|
||||
|
||||
/** Consecutive connects that never reached the desktop before the page pauses with Reconnect. */
|
||||
const MAX_FAILED_CONNECTS = 4
|
||||
const RETRY_DELAYS_MS = [0, 1_000, 2_000, 4_000]
|
||||
/** Floor between tickets, and a cap per minute, so a page that keeps dropping never spins mints. */
|
||||
const MIN_MINT_INTERVAL_MS = 1_000
|
||||
const MAX_MINTS_PER_MINUTE = 10
|
||||
const MAX_PASTE_CHARS = 1 << 20
|
||||
const PAGE_READY_TIMEOUT_MS = 10_000
|
||||
|
||||
export type RemoteDesktopGuestState = {
|
||||
browserPageId: string
|
||||
/** The page is a remote desktop with Orca's page glue. */
|
||||
desktop: boolean
|
||||
/** It has keyboard focus: Orca's shortcuts are off until the release chord. */
|
||||
captured: boolean
|
||||
}
|
||||
|
||||
export type RemoteDesktopGuestDeps = {
|
||||
browserPageId: string
|
||||
guest: Electron.WebContents
|
||||
platform?: NodeJS.Platform
|
||||
/** Renderer notices: hint visibility, and the release chord (the renderer moves focus out). */
|
||||
notify: (event: { kind: 'state'; state: RemoteDesktopGuestState } | { kind: 'release' }) => void
|
||||
readClipboard?: () => string
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
const guestsWithDesktop = new WeakSet<Electron.WebContents>()
|
||||
|
||||
/** True while this guest shows a remote desktop: Orca's guest shortcut forwarding stands down. */
|
||||
export function isRemoteDesktopGuest(guest: Electron.WebContents): boolean {
|
||||
return guestsWithDesktop.has(guest)
|
||||
}
|
||||
|
||||
const WAIT_FOR_REQUEST = `(async () => {
|
||||
for (let i = 0; i < ${PAGE_READY_TIMEOUT_MS / 25} && !window.orcaDesktop; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
return window.orcaDesktop ? window.orcaDesktop.nextTicketRequest() : 'missing'
|
||||
})()`
|
||||
|
||||
function pauseMessage(reason: RemoteDesktopMintFailure | 'failed'): string {
|
||||
switch (reason) {
|
||||
case 'asleep':
|
||||
return translateMain(
|
||||
'components.cloudRemoteDesktop.pageAsleep',
|
||||
'This sandbox is asleep. Wake it in Orca, then reconnect.'
|
||||
)
|
||||
case 'archived':
|
||||
return translateMain(
|
||||
'components.cloudRemoteDesktop.pageArchived',
|
||||
'This sandbox is archived. Unarchive the workspace in Orca, then reconnect.'
|
||||
)
|
||||
case 'starting':
|
||||
return translateMain(
|
||||
'components.cloudRemoteDesktop.pageStarting',
|
||||
'The sandbox is starting or waking. Reconnect in a moment.'
|
||||
)
|
||||
case 'signed_out':
|
||||
return translateMain(
|
||||
'components.cloudRemoteDesktop.pageSignedOut',
|
||||
'You’re signed out of Orca Cloud. Sign in, then reconnect.'
|
||||
)
|
||||
case 'failed':
|
||||
case 'unavailable':
|
||||
return translateMain(
|
||||
'components.cloudRemoteDesktop.pageDisconnected',
|
||||
'The remote desktop disconnected. Your files are safe.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function setupRemoteDesktopGuest(deps: RemoteDesktopGuestDeps): () => void {
|
||||
const { guest, browserPageId, notify } = deps
|
||||
const platform = deps.platform ?? process.platform
|
||||
const readClipboard = deps.readClipboard ?? (() => clipboard.readText())
|
||||
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
|
||||
const releaseChord = createReleaseChordTracker(platform)
|
||||
let generation = 0
|
||||
let session: RemoteDesktopSession | null = null
|
||||
let stopRefreshListener = (): void => {}
|
||||
let disposed = false
|
||||
let published = ''
|
||||
/** Settles every in-flight page call when the page goes away (a torn-down page may never answer). */
|
||||
let cancelInFlight = (): void => {}
|
||||
let cancelled = new Promise<undefined>((resolve) => {
|
||||
cancelInFlight = () => resolve(undefined)
|
||||
})
|
||||
const cancelPageCalls = (): void => {
|
||||
cancelInFlight()
|
||||
cancelled = new Promise<undefined>((resolve) => {
|
||||
cancelInFlight = () => resolve(undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const alive = (gen: number): boolean => !disposed && gen === generation && !guest.isDestroyed()
|
||||
const publish = (): void => {
|
||||
const state = {
|
||||
browserPageId,
|
||||
desktop: session !== null,
|
||||
captured: session !== null && !guest.isDestroyed() && guest.isFocused()
|
||||
}
|
||||
// Only changes, and nothing at all for ordinary pages' focus churn.
|
||||
const key = `${state.desktop}:${state.captured}`
|
||||
if (key === published || (!state.desktop && published === '')) {
|
||||
return
|
||||
}
|
||||
published = key
|
||||
notify({ kind: 'state', state })
|
||||
}
|
||||
const run = async (gen: number, code: string): Promise<unknown> => {
|
||||
if (!alive(gen)) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
return await Promise.race([guest.executeJavaScript(code), cancelled])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function driveTickets(gen: number, current: RemoteDesktopSession): Promise<void> {
|
||||
let failures = 0
|
||||
let refused = false
|
||||
let lastMintAt = 0
|
||||
let mintTimes: number[] = []
|
||||
while (alive(gen)) {
|
||||
const ask = await run(gen, WAIT_FOR_REQUEST)
|
||||
if (!alive(gen) || (ask !== 'ticket' && ask !== 'retry')) {
|
||||
return
|
||||
}
|
||||
// 'retry': the last ticket never got a session (refused, or the forward was down).
|
||||
failures = ask === 'retry' || refused ? failures + 1 : 0
|
||||
refused = false
|
||||
if (failures >= MAX_FAILED_CONNECTS) {
|
||||
failures = 0
|
||||
await pause(gen, 'failed')
|
||||
continue
|
||||
}
|
||||
// Even sessions that connect and then drop at once never mint faster than this.
|
||||
const sinceLast = Date.now() - lastMintAt
|
||||
await sleep(Math.max(RETRY_DELAYS_MS[failures] ?? 4_000, MIN_MINT_INTERVAL_MS - sinceLast))
|
||||
// The forward may have closed meanwhile; a page now on that port gets nothing.
|
||||
if (!alive(gen) || remoteDesktopSessionForUrl(guest.getURL()) !== current) {
|
||||
return
|
||||
}
|
||||
mintTimes = mintTimes.filter((at) => Date.now() - at < 60_000)
|
||||
if (mintTimes.length >= MAX_MINTS_PER_MINUTE) {
|
||||
mintTimes = []
|
||||
await pause(gen, 'failed')
|
||||
continue
|
||||
}
|
||||
lastMintAt = Date.now()
|
||||
mintTimes.push(lastMintAt)
|
||||
const minted = await takeRemoteDesktopTicket(current)
|
||||
if (!minted.ok) {
|
||||
failures = 0
|
||||
await pause(gen, minted.reason)
|
||||
continue
|
||||
}
|
||||
const started = await run(gen, `window.orcaDesktop.connect(${JSON.stringify(minted.ticket)})`)
|
||||
// Anything but a started attempt still counts against the budget, so this never spins.
|
||||
refused = started !== 'connecting'
|
||||
}
|
||||
}
|
||||
|
||||
async function pause(gen: number, reason: RemoteDesktopMintFailure | 'failed'): Promise<void> {
|
||||
const label = translateMain('components.cloudRemoteDesktop.reconnect', 'Reconnect')
|
||||
await run(
|
||||
gen,
|
||||
`window.orcaDesktop.pause(${JSON.stringify(pauseMessage(reason))}, ${JSON.stringify(label)}, ${JSON.stringify(reason === 'asleep' || reason === 'archived' || reason === 'starting' ? 'neutral' : 'error')})`
|
||||
)
|
||||
}
|
||||
|
||||
const onLoad = (): void => {
|
||||
generation += 1
|
||||
cancelPageCalls()
|
||||
const gen = generation
|
||||
stopRefreshListener()
|
||||
stopRefreshListener = () => {}
|
||||
session = remoteDesktopSessionForUrl(guest.getURL())
|
||||
if (session) {
|
||||
guestsWithDesktop.add(guest)
|
||||
// Reopening from the menu resumes a page that stopped retrying (asleep, then woken).
|
||||
stopRefreshListener = onRemoteDesktopSessionRefreshed(session.localPort, () => {
|
||||
void run(gen, 'window.orcaDesktop && window.orcaDesktop.resume()')
|
||||
})
|
||||
void driveTickets(gen, session)
|
||||
} else {
|
||||
guestsWithDesktop.delete(guest)
|
||||
}
|
||||
publish()
|
||||
}
|
||||
|
||||
const onNavigate = (
|
||||
details: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>
|
||||
): void => {
|
||||
if (details.isMainFrame && !details.isSameDocument && session) {
|
||||
// Leaving the page: stop handing it tickets until the next load says what it is.
|
||||
generation += 1
|
||||
cancelPageCalls()
|
||||
session = null
|
||||
guestsWithDesktop.delete(guest)
|
||||
stopRefreshListener()
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
// Async by nature: keys typed in the same instant can reach the sandbox before the replayed one.
|
||||
const pasteInto = async (
|
||||
gen: number,
|
||||
text: string,
|
||||
chord: RemoteDesktopPasteChord
|
||||
): Promise<void> => {
|
||||
await run(gen, `window.orcaDesktop.paste(${JSON.stringify(text)}, ${JSON.stringify(chord)})`)
|
||||
}
|
||||
|
||||
const onInput = (event: Electron.Event, input: Electron.Input): void => {
|
||||
if (!session || isAgentInjectingInput(guest)) {
|
||||
return
|
||||
}
|
||||
if (releaseChord(input)) {
|
||||
notify({ kind: 'release' })
|
||||
return
|
||||
}
|
||||
const chord = remoteDesktopPasteChord(input, platform)
|
||||
if (!chord) {
|
||||
return
|
||||
}
|
||||
if (input.isAutoRepeat) {
|
||||
if (chord.kind === 'mac') {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Read only on a paste key in the desktop: the sandbox never sees the clipboard otherwise.
|
||||
const text = readClipboard()
|
||||
if (!text || text.length > MAX_PASTE_CHARS) {
|
||||
return
|
||||
}
|
||||
// Always pushed: the sandbox's clipboard may have changed by a route that never reached
|
||||
// this computer (an agent's xclip), so "it already has this text" cannot be assumed.
|
||||
event.preventDefault()
|
||||
void pasteInto(generation, text, chord)
|
||||
}
|
||||
|
||||
guest.on('did-finish-load', onLoad)
|
||||
guest.on('did-start-navigation', onNavigate)
|
||||
guest.on('before-input-event', onInput)
|
||||
guest.on('focus', publish)
|
||||
guest.on('blur', publish)
|
||||
if (!guest.isLoading() && guest.getURL()) {
|
||||
onLoad()
|
||||
}
|
||||
return () => {
|
||||
disposed = true
|
||||
cancelPageCalls()
|
||||
stopRefreshListener()
|
||||
guestsWithDesktop.delete(guest)
|
||||
try {
|
||||
guest.off('did-finish-load', onLoad)
|
||||
guest.off('did-start-navigation', onNavigate)
|
||||
guest.off('before-input-event', onInput)
|
||||
guest.off('focus', publish)
|
||||
guest.off('blur', publish)
|
||||
} catch {
|
||||
// The guest may already be destroyed.
|
||||
}
|
||||
if (session) {
|
||||
session = null
|
||||
publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createReleaseChordTracker,
|
||||
remoteDesktopPasteChord,
|
||||
type RemoteDesktopKeyInput
|
||||
} from './remote-desktop-keyboard'
|
||||
|
||||
function key(
|
||||
type: 'keyDown' | 'keyUp',
|
||||
keyName: string,
|
||||
mods: Partial<Pick<RemoteDesktopKeyInput, 'control' | 'alt' | 'meta' | 'shift'>> = {}
|
||||
): RemoteDesktopKeyInput {
|
||||
return {
|
||||
type,
|
||||
key: keyName,
|
||||
code: keyName === 'v' || keyName === 'V' ? 'KeyV' : keyName,
|
||||
control: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
isAutoRepeat: false,
|
||||
...mods
|
||||
}
|
||||
}
|
||||
|
||||
describe('remote desktop release chord', () => {
|
||||
it('releases on macOS when Control+Command are pressed and let go with nothing else', () => {
|
||||
const track = createReleaseChordTracker('darwin')
|
||||
expect(track(key('keyDown', 'Control', { control: true }))).toBe(false)
|
||||
expect(track(key('keyDown', 'Meta', { control: true, meta: true }))).toBe(false)
|
||||
expect(track(key('keyUp', 'Meta', { control: true }))).toBe(true)
|
||||
// Its other key going up does not release twice.
|
||||
expect(track(key('keyUp', 'Control'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps Ctrl+Alt+T and other chords for the desktop', () => {
|
||||
const track = createReleaseChordTracker('linux')
|
||||
track(key('keyDown', 'Control', { control: true }))
|
||||
track(key('keyDown', 'Alt', { control: true, alt: true }))
|
||||
expect(track(key('keyDown', 't', { control: true, alt: true }))).toBe(false)
|
||||
expect(track(key('keyUp', 'Alt', { control: true }))).toBe(false)
|
||||
// Shift joining the chord is something else too.
|
||||
track(key('keyDown', 'Control', { control: true }))
|
||||
track(key('keyDown', 'Alt', { control: true, alt: true }))
|
||||
track(key('keyDown', 'Shift', { control: true, alt: true, shift: true }))
|
||||
expect(track(key('keyUp', 'Alt', { control: true, shift: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('uses Ctrl+Alt off macOS and ignores Control+Command there', () => {
|
||||
const win = createReleaseChordTracker('win32')
|
||||
win(key('keyDown', 'Control', { control: true }))
|
||||
win(key('keyDown', 'Meta', { control: true, meta: true }))
|
||||
expect(win(key('keyUp', 'Meta', { control: true }))).toBe(false)
|
||||
win(key('keyDown', 'Alt', { control: true, alt: true }))
|
||||
expect(win(key('keyUp', 'Control', { alt: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores AltGr (reported as Ctrl+Alt) on Windows', () => {
|
||||
const win = createReleaseChordTracker('win32')
|
||||
win({ ...key('keyDown', 'Control', { control: true }), code: 'ControlLeft' })
|
||||
win({ ...key('keyDown', 'Alt', { control: true, alt: true }), code: 'AltRight' })
|
||||
expect(win({ ...key('keyUp', 'Alt', { control: true }), code: 'AltRight' })).toBe(false)
|
||||
})
|
||||
|
||||
it('never releases on plain typing or a lone modifier', () => {
|
||||
const track = createReleaseChordTracker('darwin')
|
||||
for (const input of [
|
||||
key('keyDown', 'Meta', { meta: true }),
|
||||
key('keyDown', 'c', { meta: true }),
|
||||
key('keyUp', 'c', { meta: true }),
|
||||
key('keyUp', 'Meta'),
|
||||
key('keyDown', 'Control', { control: true }),
|
||||
key('keyUp', 'Control')
|
||||
]) {
|
||||
expect(track(input)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('remote desktop paste keys', () => {
|
||||
it('turns Cmd+V on macOS into a sandbox paste', () => {
|
||||
expect(remoteDesktopPasteChord(key('keyDown', 'v', { meta: true }), 'darwin')).toEqual({
|
||||
kind: 'mac'
|
||||
})
|
||||
expect(remoteDesktopPasteChord(key('keyDown', 'v', { meta: true }), 'linux')).toBeNull()
|
||||
})
|
||||
|
||||
it('replays the Linux paste keys after setting the clipboard', () => {
|
||||
expect(remoteDesktopPasteChord(key('keyDown', 'v', { control: true }), 'linux')).toEqual({
|
||||
kind: 'replay',
|
||||
key: 'v'
|
||||
})
|
||||
expect(
|
||||
remoteDesktopPasteChord(key('keyDown', 'V', { control: true, shift: true }), 'darwin')
|
||||
).toEqual({ kind: 'replay', key: 'V' })
|
||||
expect(remoteDesktopPasteChord(key('keyDown', 'Insert', { shift: true }), 'win32')).toEqual({
|
||||
kind: 'replay',
|
||||
key: 'Insert'
|
||||
})
|
||||
})
|
||||
|
||||
it('follows the keyboard layout, not the physical key', () => {
|
||||
// Dvorak: Ctrl+K is typed on the physical V key and must reach the sandbox untouched.
|
||||
expect(
|
||||
remoteDesktopPasteChord({ ...key('keyDown', 'k', { control: true }), code: 'KeyV' }, 'linux')
|
||||
).toBeNull()
|
||||
// Cyrillic: the V key types 'м'; Ctrl+V there is still paste.
|
||||
expect(
|
||||
remoteDesktopPasteChord({ ...key('keyDown', 'м', { control: true }), code: 'KeyV' }, 'linux')
|
||||
).toEqual({ kind: 'replay', key: 'v' })
|
||||
})
|
||||
|
||||
it('leaves other keys and key-ups alone', () => {
|
||||
expect(remoteDesktopPasteChord(key('keyUp', 'v', { control: true }), 'linux')).toBeNull()
|
||||
expect(remoteDesktopPasteChord(key('keyDown', 'c', { control: true }), 'linux')).toBeNull()
|
||||
expect(remoteDesktopPasteChord(key('keyDown', 'v'), 'linux')).toBeNull()
|
||||
expect(
|
||||
remoteDesktopPasteChord(key('keyDown', 'v', { control: true, alt: true }), 'linux')
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
// Keyboard rules for a remote desktop page. While it has focus every key goes to the sandbox
|
||||
// (Orca's shortcuts included), like a VM console, so Ctrl/Cmd combos type normally there. One
|
||||
// chord gives the keyboard back to Orca: Control+Command on macOS, Ctrl+Alt elsewhere, pressed
|
||||
// together and released with no other key in between (so Ctrl+Alt+T still reaches the sandbox).
|
||||
|
||||
export type RemoteDesktopKeyInput = Pick<
|
||||
Electron.Input,
|
||||
'type' | 'key' | 'code' | 'control' | 'alt' | 'meta' | 'shift' | 'isAutoRepeat'
|
||||
>
|
||||
|
||||
const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Meta', 'Shift', 'AltGraph', 'OS'])
|
||||
|
||||
/** Feeds every key event; returns true on the event that completes the release chord. */
|
||||
export function createReleaseChordTracker(
|
||||
platform: NodeJS.Platform
|
||||
): (input: RemoteDesktopKeyInput) => boolean {
|
||||
const mac = platform === 'darwin'
|
||||
const chordKeys = mac ? new Set(['Control', 'Meta']) : new Set(['Control', 'Alt'])
|
||||
let armed = false
|
||||
return (input) => {
|
||||
// Windows reports AltGr as Ctrl+Alt: a lone AltGr tap must not release the keyboard.
|
||||
if (!mac && input.code === 'AltRight') {
|
||||
armed = false
|
||||
return false
|
||||
}
|
||||
if (input.type === 'keyDown') {
|
||||
if (!MODIFIER_KEYS.has(input.key)) {
|
||||
armed = false
|
||||
return false
|
||||
}
|
||||
const held = mac
|
||||
? input.control && input.meta && !input.alt && !input.shift
|
||||
: input.control && input.alt && !input.meta && !input.shift
|
||||
armed = held && chordKeys.has(input.key) ? true : armed && held
|
||||
return false
|
||||
}
|
||||
if (input.type === 'keyUp' && armed && chordKeys.has(input.key)) {
|
||||
armed = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a paste key reaches the sandbox after its clipboard is set: Cmd+V on macOS becomes
|
||||
* Shift+Insert (Linux apps ignore Super+V), Ctrl+V / Ctrl+Shift+V / Shift+Insert are replayed.
|
||||
*/
|
||||
export type RemoteDesktopPasteChord =
|
||||
| { kind: 'mac' }
|
||||
| { kind: 'replay'; key: 'v' | 'V' | 'Insert' }
|
||||
|
||||
export function remoteDesktopPasteChord(
|
||||
input: RemoteDesktopKeyInput,
|
||||
platform: NodeJS.Platform
|
||||
): RemoteDesktopPasteChord | null {
|
||||
if (input.type !== 'keyDown' || input.alt) {
|
||||
return null
|
||||
}
|
||||
// The letter the layout produces (Dvorak Ctrl+K sits on the physical V key); the physical key
|
||||
// only when the layout has no Latin letter there (Cyrillic, Greek).
|
||||
const latin = /^[a-z]$/i.test(input.key)
|
||||
const v = latin ? input.key === 'v' || input.key === 'V' : input.code === 'KeyV'
|
||||
if (platform === 'darwin' && input.meta && !input.control && v) {
|
||||
return { kind: 'mac' }
|
||||
}
|
||||
if (input.control && !input.meta && v) {
|
||||
return { kind: 'replay', key: input.shift ? 'V' : 'v' }
|
||||
}
|
||||
if (input.shift && !input.control && !input.meta && input.key === 'Insert') {
|
||||
return { kind: 'replay', key: 'Insert' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
registerRemoteDesktopSession,
|
||||
remoteDesktopSessionForUrl,
|
||||
resetRemoteDesktopSessionsForTest,
|
||||
takeRemoteDesktopTicket
|
||||
} from './remote-desktop-sessions'
|
||||
|
||||
const URL_6081 = 'http://localhost:6081/vnc.html?autoconnect=0&reconnect=0&resize=remote'
|
||||
const ticket = (n: number): string => String(n).padStart(43, 't')
|
||||
|
||||
function register(options: { live?: () => boolean; firstExpiresAt?: number } = {}): void {
|
||||
let minted = 0
|
||||
registerRemoteDesktopSession(
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
localPort: 6081,
|
||||
path: 'vnc.html',
|
||||
mint: async () => ({ ok: true, ticket: ticket((minted += 1) + 100), expiresAt: 999_999 }),
|
||||
...(options.live ? { isLive: options.live } : {})
|
||||
},
|
||||
{ ticket: ticket(1), expiresAt: options.firstExpiresAt ?? 100_000 }
|
||||
)
|
||||
}
|
||||
|
||||
describe('remote desktop sessions', () => {
|
||||
beforeEach(() => resetRemoteDesktopSessionsForTest())
|
||||
|
||||
it('trusts only the exact URL Orca built, on localhost or 127.0.0.1', () => {
|
||||
register()
|
||||
expect(remoteDesktopSessionForUrl(URL_6081)).not.toBeNull()
|
||||
expect(remoteDesktopSessionForUrl(URL_6081.replace('localhost', '127.0.0.1'))).not.toBeNull()
|
||||
for (const url of [
|
||||
`${URL_6081}&host=evil.example`,
|
||||
`${URL_6081}#host=evil.example&encrypt=1`,
|
||||
`${URL_6081}&logging=debug`,
|
||||
'http://localhost:6081/vnc.html',
|
||||
'http://localhost:6081/vnc_lite.html?autoconnect=0&reconnect=0&resize=remote',
|
||||
URL_6081.replace('http:', 'https:'),
|
||||
URL_6081.replace('localhost', 'localhost.evil.example'),
|
||||
URL_6081.replace('6081', '6082'),
|
||||
URL_6081.replace('http://', 'http://user:pw@')
|
||||
]) {
|
||||
expect(remoteDesktopSessionForUrl(url), url).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('forgets a session once its forward is gone', () => {
|
||||
let live = true
|
||||
register({ live: () => live })
|
||||
expect(remoteDesktopSessionForUrl(URL_6081)).not.toBeNull()
|
||||
live = false
|
||||
expect(remoteDesktopSessionForUrl(URL_6081)).toBeNull()
|
||||
live = true
|
||||
// Pruned: a later page on that port is not trusted until Orca opens the desktop again.
|
||||
expect(remoteDesktopSessionForUrl(URL_6081)).toBeNull()
|
||||
})
|
||||
|
||||
it('hands out the open’s ticket once, and never within 5 s of its expiry', async () => {
|
||||
register({ firstExpiresAt: 10_000 })
|
||||
const session = remoteDesktopSessionForUrl(URL_6081)!
|
||||
expect(await takeRemoteDesktopTicket(session, () => 1_000)).toMatchObject({ ticket: ticket(1) })
|
||||
expect(await takeRemoteDesktopTicket(session, () => 1_000)).toMatchObject({
|
||||
ticket: ticket(101)
|
||||
})
|
||||
register({ firstExpiresAt: 10_000 })
|
||||
const fresh = remoteDesktopSessionForUrl(URL_6081)!
|
||||
expect(await takeRemoteDesktopTicket(fresh, () => 6_000)).toMatchObject({ ticket: ticket(101) })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
// Remote desktop sessions this computer opened: which localhost forward port carries which cloud
|
||||
// workspace's desktop, and how to mint that desktop a connection ticket. A browser page is a
|
||||
// remote desktop only when its URL names one of these ports (main decides, never the page), and
|
||||
// only such a page is handed tickets, keyboard capture and the clipboard bridge. The URL must be
|
||||
// exactly the one Orca built (no host/port/encrypt overrides for noVNC), and the session ends with
|
||||
// its forward: a later forward or local server on the same port is never trusted.
|
||||
import { cloudRemoteDesktopUrl } from '../../shared/hosted-sandbox/cloud-remote-desktop'
|
||||
|
||||
export type RemoteDesktopMintFailure =
|
||||
| 'asleep'
|
||||
| 'archived'
|
||||
| 'starting'
|
||||
| 'signed_out'
|
||||
| 'unavailable'
|
||||
|
||||
export type RemoteDesktopMintResult =
|
||||
| { ok: true; ticket: string; expiresAt: number }
|
||||
| { ok: false; reason: RemoteDesktopMintFailure }
|
||||
|
||||
export type RemoteDesktopSession = {
|
||||
environmentId: string
|
||||
localPort: number
|
||||
/** noVNC's page on the VM ("vnc.html"). */
|
||||
path: string
|
||||
/** Asks the sandbox for a fresh single-use ticket (starts the desktop if it stopped). */
|
||||
mint: () => Promise<RemoteDesktopMintResult>
|
||||
/** False once the forward this session opened is gone (closed, slept, re-dialed elsewhere). */
|
||||
isLive?: () => boolean
|
||||
}
|
||||
|
||||
type Entry = RemoteDesktopSession & {
|
||||
/** The ticket minted with the open itself, saved for the page load that follows. */
|
||||
pending: { ticket: string; expiresAt: number } | null
|
||||
listeners: Set<() => void>
|
||||
}
|
||||
|
||||
/** A ticket this close to expiry is not handed out: the page still has to dial the forward. */
|
||||
const TICKET_MARGIN_MS = 5_000
|
||||
|
||||
const sessionsByPort = new Map<number, Entry>()
|
||||
|
||||
/** Records (or refreshes) a session and wakes any open page for it that stopped retrying. */
|
||||
export function registerRemoteDesktopSession(
|
||||
session: RemoteDesktopSession,
|
||||
firstTicket: { ticket: string; expiresAt: number } | null
|
||||
): void {
|
||||
const previous = sessionsByPort.get(session.localPort)
|
||||
const entry: Entry = {
|
||||
...session,
|
||||
pending: firstTicket,
|
||||
listeners: previous?.listeners ?? new Set()
|
||||
}
|
||||
sessionsByPort.set(session.localPort, entry)
|
||||
for (const listener of entry.listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
/** The session behind a desktop page URL, or null for any other page. */
|
||||
export function remoteDesktopSessionForUrl(url: string): RemoteDesktopSession | null {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
|
||||
return null
|
||||
}
|
||||
const port = Number(parsed.port)
|
||||
const entry = sessionsByPort.get(port)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
if (entry.isLive && !entry.isLive()) {
|
||||
sessionsByPort.delete(port)
|
||||
return null
|
||||
}
|
||||
// Exactly Orca's URL: noVNC reads host, port, encrypt and logging from the query and the hash,
|
||||
// which would let a page point a ticketed session (and the user's keys) somewhere else.
|
||||
const expected = new URL(cloudRemoteDesktopUrl(port, entry.path))
|
||||
return parsed.protocol === 'http:' &&
|
||||
!parsed.username &&
|
||||
!parsed.password &&
|
||||
parsed.pathname === expected.pathname &&
|
||||
parsed.search === expected.search &&
|
||||
parsed.hash === ''
|
||||
? entry
|
||||
: null
|
||||
}
|
||||
|
||||
/** A ticket for the next WebSocket: the one minted with the open if still fresh, else a new one. */
|
||||
export async function takeRemoteDesktopTicket(
|
||||
session: RemoteDesktopSession,
|
||||
now: () => number = Date.now
|
||||
): Promise<RemoteDesktopMintResult> {
|
||||
const entry = sessionsByPort.get(session.localPort)
|
||||
const pending = entry?.pending
|
||||
if (entry && pending) {
|
||||
entry.pending = null
|
||||
if (pending.expiresAt - TICKET_MARGIN_MS > now()) {
|
||||
return { ok: true, ...pending }
|
||||
}
|
||||
}
|
||||
return (entry ?? session).mint()
|
||||
}
|
||||
|
||||
/** Called when the session for this port is opened again (the user chose Open remote desktop). */
|
||||
export function onRemoteDesktopSessionRefreshed(
|
||||
localPort: number,
|
||||
listener: () => void
|
||||
): () => void {
|
||||
let entry = sessionsByPort.get(localPort)
|
||||
if (!entry) {
|
||||
return () => {}
|
||||
}
|
||||
entry.listeners.add(listener)
|
||||
return () => {
|
||||
entry?.listeners.delete(listener)
|
||||
entry = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRemoteDesktopSessionsForTest(): void {
|
||||
sessionsByPort.clear()
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
openCloudRemoteDesktop
|
||||
} from '../hosted-sandbox/hosted-remote-desktop'
|
||||
import type { HostedSandboxControlPlaneClient } from '../hosted-sandbox/hosted-sandbox-control-plane-client'
|
||||
import { openRuntimePortForward } from '../runtime-port-forward/runtime-port-forward-manager'
|
||||
import {
|
||||
getRuntimePortForwardManager,
|
||||
openRuntimePortForward
|
||||
} from '../runtime-port-forward/runtime-port-forward-service'
|
||||
|
||||
function environmentIdOf(value: unknown): string | null {
|
||||
const id =
|
||||
@@ -33,7 +36,11 @@ export function registerCloudRemoteDesktopHandlers(client: HostedSandboxControlP
|
||||
{
|
||||
userDataPath: app.getPath('userData'),
|
||||
client,
|
||||
openForward: openRuntimePortForward
|
||||
openForward: openRuntimePortForward,
|
||||
isForwardLive: ({ environmentId: env, localPort, remotePort }) =>
|
||||
getRuntimePortForwardManager()
|
||||
?.list(env)
|
||||
.some((f) => f.localPort === localPort && f.remotePort === remotePort) ?? false
|
||||
},
|
||||
environmentId
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from './filesystem-handler-context'
|
||||
import { registerFilesystemReadHandlers } from './filesystem-read-handlers'
|
||||
import { registerFilesystemDownloadHandlers } from './filesystem-download-handlers'
|
||||
import { registerRuntimeDownloadHandlers } from '../../runtime-downloads/runtime-download-handlers'
|
||||
import { registerFilesystemWriteHandlers } from './filesystem-write-handlers'
|
||||
import { registerFilesystemSearchHandlers } from './filesystem-search-handlers'
|
||||
import { registerFilesystemGitStatusHandlers } from './filesystem-git-status-handlers'
|
||||
@@ -33,6 +34,7 @@ export function registerFilesystemHandlers(
|
||||
|
||||
registerFilesystemReadHandlers(context)
|
||||
registerFilesystemDownloadHandlers(context)
|
||||
registerRuntimeDownloadHandlers()
|
||||
registerFilesystemWriteHandlers(context)
|
||||
registerFilesystemSearchHandlers(context)
|
||||
registerFilesystemGitStatusHandlers(context)
|
||||
|
||||
@@ -2,8 +2,11 @@ const WINDOWS_RESERVED_LOCAL_BASENAME =
|
||||
/^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9\u00B9\u00B2\u00B3]|lpt[1-9\u00B9\u00B2\u00B3])(?:\..*)?$/i
|
||||
const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*'])
|
||||
|
||||
// Bidi overrides and zero-width characters let `exe.txt` display as `txt.exe` in a save dialog.
|
||||
const INVISIBLE_FORMAT_CHARS = /[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g
|
||||
|
||||
export function sanitizeLocalDownloadFilename(remoteBasename: string): string {
|
||||
const sanitized = Array.from(remoteBasename, (char) =>
|
||||
const sanitized = Array.from(remoteBasename.replace(INVISIBLE_FORMAT_CHARS, ''), (char) =>
|
||||
char.charCodeAt(0) < 32 || LOCAL_FILENAME_REPLACEMENT_CHARS.has(char) ? '_' : char
|
||||
)
|
||||
.join('')
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue'
|
||||
import {
|
||||
RuntimeRpcCallQueuePool,
|
||||
type RuntimeRpcCallLane
|
||||
} from '../../../shared/runtime-rpc-call-queue'
|
||||
|
||||
const runtimeCallQueuePool = new RuntimeRpcCallQueuePool()
|
||||
|
||||
@@ -6,7 +9,8 @@ export function enqueueRuntimeCall<T>(
|
||||
selector: string,
|
||||
method: string,
|
||||
run: () => Promise<T>,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
lane?: RuntimeRpcCallLane
|
||||
): Promise<T> {
|
||||
return runtimeCallQueuePool.enqueue(selector, method, run, 0, signal)
|
||||
return runtimeCallQueuePool.enqueue(selector, method, run, 0, signal, lane)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { RemoteRuntimeTransportSubscription } from '../../../shared/remote-
|
||||
import { withRuntimeUnreachableHint } from './runtime-environment-unreachable-hint'
|
||||
import { decodeRuntimeEnvironmentSubscriptionEvents } from './runtime-environment-subscription-events'
|
||||
import { enqueueRuntimeCall } from './runtime-environment-call-queue'
|
||||
import type { RuntimeRpcCallLane } from '../../../shared/runtime-rpc-call-queue'
|
||||
import { holdForInFlightRecipeVmWake } from './runtime-environment-recipe-vm-wake'
|
||||
import {
|
||||
sendRemoteRuntimeConnectionRequestAbortable,
|
||||
@@ -54,6 +55,8 @@ export async function callRuntimeEnvironment(
|
||||
signal?: AbortSignal
|
||||
expectedEnvironmentRuntimeId?: string
|
||||
preparation?: ManagedRuntimeCallPreparation
|
||||
/** Queue lane override; see RuntimeRpcCallLane. */
|
||||
lane?: RuntimeRpcCallLane
|
||||
}
|
||||
): Promise<Rpc.RuntimeRpcResponse<unknown>> {
|
||||
if (method === 'status.get') {
|
||||
@@ -168,7 +171,8 @@ export async function callRuntimeEnvironment(
|
||||
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
|
||||
return response
|
||||
},
|
||||
options?.signal
|
||||
options?.signal,
|
||||
options?.lane
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { RuntimeDownloadErrorCode } from '../../shared/runtime-downloads/runtime-download-contract'
|
||||
import { RuntimeDownloadError } from './runtime-download-transfer'
|
||||
|
||||
const HOST_MESSAGE_CODES: Record<string, RuntimeDownloadErrorCode> = {
|
||||
archive_too_large: 'too_large',
|
||||
archive_too_many_entries: 'too_many_entries',
|
||||
archive_busy: 'host_busy',
|
||||
archive_not_directory: 'not_a_directory',
|
||||
archive_canceled: 'canceled',
|
||||
file_too_large: 'too_large'
|
||||
}
|
||||
|
||||
/** Maps a failed host reply onto a stable code the renderer can word. */
|
||||
export function runtimeDownloadErrorFromResponse(error: {
|
||||
code: string
|
||||
message: string
|
||||
}): RuntimeDownloadError {
|
||||
if (error.code === 'method_not_found') {
|
||||
return new RuntimeDownloadError('host_update_required', error.message)
|
||||
}
|
||||
const mapped = HOST_MESSAGE_CODES[error.message]
|
||||
if (mapped) {
|
||||
return new RuntimeDownloadError(mapped, error.message)
|
||||
}
|
||||
if (/ENOENT|not[_ ]found|no such file/i.test(error.message)) {
|
||||
return new RuntimeDownloadError('not_found', error.message)
|
||||
}
|
||||
if (/disconnect|unreachable|closed|timed? ?out|offline/i.test(`${error.code} ${error.message}`)) {
|
||||
return new RuntimeDownloadError('disconnected', error.message)
|
||||
}
|
||||
return new RuntimeDownloadError('transfer_failed', error.message)
|
||||
}
|
||||
|
||||
export function toRuntimeDownloadError(error: unknown, signal: AbortSignal): RuntimeDownloadError {
|
||||
if (signal.aborted) {
|
||||
return new RuntimeDownloadError('canceled')
|
||||
}
|
||||
if (error instanceof RuntimeDownloadError) {
|
||||
return error
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const rawCode =
|
||||
typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined
|
||||
const code = typeof rawCode === 'string' ? rawCode : 'runtime_error'
|
||||
return runtimeDownloadErrorFromResponse({ code, message })
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell, type WebContents } from 'electron'
|
||||
import { open, rm } from 'node:fs/promises'
|
||||
import { z } from 'zod'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
canOpenRuntimeDownloadLocally,
|
||||
RUNTIME_OPEN_LOCALLY_MAX_BYTES,
|
||||
type RuntimeDownloadProgressEvent,
|
||||
type RuntimeDownloadResult,
|
||||
type RuntimeDownloadStartArgs
|
||||
} from '../../shared/runtime-downloads/runtime-download-contract'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-transport-routing'
|
||||
import { getRuntimeEnvironmentRegistryRoot } from '../ipc/runtime-environments/runtime-environment-registry-root'
|
||||
import { sanitizeLocalDownloadFilename } from '../ipc/filesystem/local-download-filename'
|
||||
import {
|
||||
cleanupLocalTransferPath,
|
||||
createSiblingTransferPath,
|
||||
inspectDownloadDestination,
|
||||
promoteDownloadedFile
|
||||
} from '../ipc/filesystem/filesystem-download-promotion'
|
||||
import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort'
|
||||
import { markPluginSavedFileAsDownloaded as markFileAsDownloaded } from '../plugins/panels/plugin-save-as-download-mark'
|
||||
import {
|
||||
RuntimeDownloadError,
|
||||
transferRuntimeFile,
|
||||
transferRuntimeFolder,
|
||||
type RuntimeDownloadCall,
|
||||
type RuntimeDownloadProgress
|
||||
} from './runtime-download-transfer'
|
||||
import { runtimeDownloadErrorFromResponse, toRuntimeDownloadError } from './runtime-download-errors'
|
||||
import {
|
||||
createRuntimeOpenLocalDirectory,
|
||||
pruneRuntimeOpenLocalCache
|
||||
} from './runtime-open-local-cache'
|
||||
|
||||
export const RUNTIME_DOWNLOAD_PROGRESS_CHANNEL = 'runtimeDownloads:progress'
|
||||
// A toast cannot show finer progress than this, and every event crosses IPC.
|
||||
const PROGRESS_INTERVAL_MS = 120
|
||||
const MAX_ACTIVE_DOWNLOADS = 8
|
||||
|
||||
const activeDownloads = new Map<string, AbortController>()
|
||||
// Counted from the moment a destination is chosen: a download still at its save dialog isn't
|
||||
// using the link and has no toast to cancel it from.
|
||||
let transferringCount = 0
|
||||
|
||||
const Text = z.string().max(4096)
|
||||
const RequiredText = Text.refine((value) => value.trim() !== '')
|
||||
const StartArgs = z.object({
|
||||
downloadId: RequiredText,
|
||||
environmentId: RequiredText,
|
||||
worktree: RequiredText,
|
||||
relativePath: Text,
|
||||
name: RequiredText,
|
||||
kind: z.enum(['file', 'folder']),
|
||||
intent: z.enum(['save', 'open']),
|
||||
excludeDefaultDirs: z.boolean().optional(),
|
||||
expectedEnvironmentPairingRevision: z.number().int().optional()
|
||||
}) satisfies z.ZodType<RuntimeDownloadStartArgs>
|
||||
|
||||
function validateArgs(args: unknown): RuntimeDownloadStartArgs {
|
||||
const parsed = StartArgs.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid runtime download request')
|
||||
}
|
||||
if (parsed.data.intent === 'open' && parsed.data.kind !== 'file') {
|
||||
throw new Error('Only files can be opened locally')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
function createCall(args: RuntimeDownloadStartArgs): RuntimeDownloadCall {
|
||||
return async <T>(
|
||||
method: string,
|
||||
params: unknown,
|
||||
options: { timeoutMs: number; signal: AbortSignal }
|
||||
): Promise<T> => {
|
||||
const response = await callRuntimeEnvironment(
|
||||
getRuntimeEnvironmentRegistryRoot(),
|
||||
args.environmentId,
|
||||
method,
|
||||
params,
|
||||
options.timeoutMs,
|
||||
args.expectedEnvironmentPairingRevision,
|
||||
undefined,
|
||||
{ signal: options.signal, lane: 'download' }
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw runtimeDownloadErrorFromResponse(response.error)
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each caller names the result type of the host method it calls; the host's RPC contract defines that shape.
|
||||
return response.result as T
|
||||
}
|
||||
}
|
||||
|
||||
function createProgressEmitter(sender: WebContents, downloadId: string) {
|
||||
let lastSentAt = 0
|
||||
let destinationPath: string | undefined
|
||||
const send = (event: Omit<RuntimeDownloadProgressEvent, 'downloadId' | 'destinationPath'>) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send(RUNTIME_DOWNLOAD_PROGRESS_CHANNEL, {
|
||||
downloadId,
|
||||
...event,
|
||||
...(destinationPath ? { destinationPath } : {})
|
||||
} satisfies RuntimeDownloadProgressEvent)
|
||||
}
|
||||
}
|
||||
return {
|
||||
setDestination(path: string) {
|
||||
destinationPath = path
|
||||
},
|
||||
preparing() {
|
||||
send({ phase: 'preparing', receivedBytes: 0, totalBytes: null, processedBytes: 0 })
|
||||
},
|
||||
progress(progress: RuntimeDownloadProgress) {
|
||||
const now = Date.now()
|
||||
if (now - lastSentAt < PROGRESS_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
lastSentAt = now
|
||||
send({ phase: 'transferring', ...progress })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseDestination(
|
||||
sender: WebContents,
|
||||
args: RuntimeDownloadStartArgs
|
||||
): Promise<string | null> {
|
||||
const baseName = sanitizeLocalDownloadFilename(args.name)
|
||||
if (args.intent === 'open') {
|
||||
void pruneRuntimeOpenLocalCache()
|
||||
return join(await createRuntimeOpenLocalDirectory(), baseName)
|
||||
}
|
||||
const suggestedName = args.kind === 'folder' ? `${baseName}.zip` : baseName
|
||||
const parentWindow = BrowserWindow.fromWebContents(sender) ?? undefined
|
||||
const options: Electron.SaveDialogOptions = {
|
||||
defaultPath: join(app.getPath('downloads'), suggestedName),
|
||||
...(args.kind === 'folder' ? { filters: [{ name: 'ZIP archive', extensions: ['zip'] }] } : {})
|
||||
}
|
||||
const result = parentWindow
|
||||
? await dialog.showSaveDialog(parentWindow, options)
|
||||
: await dialog.showSaveDialog(options)
|
||||
return result.canceled || !result.filePath ? null : result.filePath
|
||||
}
|
||||
|
||||
async function runDownload(
|
||||
sender: WebContents,
|
||||
args: RuntimeDownloadStartArgs,
|
||||
controller: AbortController
|
||||
): Promise<RuntimeDownloadResult> {
|
||||
if (
|
||||
args.intent === 'open' &&
|
||||
!canOpenRuntimeDownloadLocally(sanitizeLocalDownloadFilename(args.name))
|
||||
) {
|
||||
throw new RuntimeDownloadError('open_refused')
|
||||
}
|
||||
const destinationPath = await chooseDestination(sender, args)
|
||||
if (!destinationPath || controller.signal.aborted) {
|
||||
return { status: 'canceled' }
|
||||
}
|
||||
if (transferringCount >= MAX_ACTIVE_DOWNLOADS) {
|
||||
if (args.intent === 'open') {
|
||||
await rm(dirname(destinationPath), { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'host_busy',
|
||||
message: 'Too many downloads are running; wait for one to finish.'
|
||||
}
|
||||
}
|
||||
transferringCount += 1
|
||||
try {
|
||||
return await transferToDestination(sender, args, controller, destinationPath)
|
||||
} finally {
|
||||
transferringCount -= 1
|
||||
}
|
||||
}
|
||||
|
||||
async function transferToDestination(
|
||||
sender: WebContents,
|
||||
args: RuntimeDownloadStartArgs,
|
||||
controller: AbortController,
|
||||
destinationPath: string
|
||||
): Promise<RuntimeDownloadResult> {
|
||||
const emitter = createProgressEmitter(sender, args.downloadId)
|
||||
emitter.setDestination(destinationPath)
|
||||
emitter.preparing()
|
||||
const { existed } = await inspectDownloadDestination(destinationPath)
|
||||
const tempPath = createSiblingTransferPath(destinationPath, 'download')
|
||||
const sink = await open(tempPath, 'wx')
|
||||
let promoted = false
|
||||
try {
|
||||
const transferInput = {
|
||||
call: createCall(args),
|
||||
worktree: args.worktree,
|
||||
relativePath: args.relativePath,
|
||||
sink,
|
||||
signal: controller.signal,
|
||||
onProgress: (progress: RuntimeDownloadProgress) => emitter.progress(progress)
|
||||
}
|
||||
const outcome =
|
||||
args.kind === 'folder'
|
||||
? await transferRuntimeFolder({
|
||||
...transferInput,
|
||||
excludeDefaultDirs: args.excludeDefaultDirs !== false
|
||||
})
|
||||
: await transferRuntimeFile({
|
||||
...transferInput,
|
||||
...(args.intent === 'open' ? { maxBytes: RUNTIME_OPEN_LOCALLY_MAX_BYTES } : {})
|
||||
})
|
||||
await sink.close()
|
||||
if (controller.signal.aborted) {
|
||||
throw new RuntimeDownloadError('canceled')
|
||||
}
|
||||
await promoteDownloadedFile(tempPath, destinationPath, existed)
|
||||
promoted = true
|
||||
// The bytes come from a remote machine: mark them like a browser download so Gatekeeper,
|
||||
// SmartScreen and Protected View still apply (a zip's mark carries over to what it extracts).
|
||||
await markFileAsDownloaded(destinationPath)
|
||||
let openError: string | undefined
|
||||
if (args.intent === 'open') {
|
||||
const message = await shell.openPath(destinationPath)
|
||||
openError = message === '' ? undefined : message
|
||||
}
|
||||
return {
|
||||
status: 'done',
|
||||
destinationPath,
|
||||
...outcome,
|
||||
...(openError ? { openError } : {})
|
||||
}
|
||||
} finally {
|
||||
await sink.close().catch(() => {})
|
||||
if (!promoted) {
|
||||
await cleanupLocalTransferPath(tempPath)
|
||||
if (args.intent === 'open') {
|
||||
// The per-open folder holds nothing else; don't leave it behind empty.
|
||||
await rm(dirname(destinationPath), { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerRuntimeDownloadHandlers(): void {
|
||||
ipcMain.handle(
|
||||
'runtimeDownloads:start',
|
||||
async (event, rawArgs: unknown): Promise<RuntimeDownloadResult> => {
|
||||
const args = validateArgs(rawArgs)
|
||||
if (activeDownloads.has(args.downloadId)) {
|
||||
throw new Error('Download already running')
|
||||
}
|
||||
const controller = new AbortController()
|
||||
activeDownloads.set(args.downloadId, controller)
|
||||
// Closing the window must not leave a transfer running nobody can cancel.
|
||||
const lifetime = abortWhenRendererGone(event.sender)
|
||||
const onGone = () => controller.abort()
|
||||
lifetime.signal.addEventListener('abort', onGone, { once: true })
|
||||
try {
|
||||
return await runDownload(event.sender, args, controller)
|
||||
} catch (error) {
|
||||
const failure = toRuntimeDownloadError(error, controller.signal)
|
||||
return failure.code === 'canceled'
|
||||
? { status: 'canceled' }
|
||||
: { status: 'error', code: failure.code, message: failure.message }
|
||||
} finally {
|
||||
lifetime.signal.removeEventListener('abort', onGone)
|
||||
lifetime.dispose()
|
||||
activeDownloads.delete(args.downloadId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('runtimeDownloads:cancel', (_event, args: { downloadId?: unknown }) => {
|
||||
const controller =
|
||||
typeof args?.downloadId === 'string' ? activeDownloads.get(args.downloadId) : undefined
|
||||
controller?.abort()
|
||||
return { canceled: controller !== undefined }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { mkdtemp, open, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
RuntimeDownloadError,
|
||||
transferRuntimeFile,
|
||||
transferRuntimeFolder,
|
||||
type RuntimeDownloadCall
|
||||
} from './runtime-download-transfer'
|
||||
|
||||
let dir: string
|
||||
|
||||
/** A scripted host; the transfer code names each method's result type itself. */
|
||||
function fakeCall<P>(impl: (method: string, params: P) => Promise<unknown>): RuntimeDownloadCall {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each scripted reply matches the result type the transfer code expects for that method.
|
||||
return impl as RuntimeDownloadCall
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'orca-runtime-download-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fileHost(content: Buffer, statSize = content.byteLength): RuntimeDownloadCall {
|
||||
return fakeCall(async (method: string, params: { offset: number; length: number }) => {
|
||||
if (method === 'files.stat') {
|
||||
return { size: statSize, isDirectory: false }
|
||||
}
|
||||
const bytes = content.subarray(params.offset, params.offset + params.length)
|
||||
// Out-of-order replies: later pages may land first.
|
||||
for (let turn = 0; turn < (params.offset / 7) % 3; turn += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
return {
|
||||
contentBase64: bytes.toString('base64'),
|
||||
bytesRead: bytes.byteLength,
|
||||
eof: params.offset + bytes.byteLength >= content.byteLength
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function runFile(call: RuntimeDownloadCall, extra: { maxBytes?: number } = {}) {
|
||||
const sink = await open(join(dir, 'out'), 'w+')
|
||||
try {
|
||||
const result = await transferRuntimeFile({
|
||||
call,
|
||||
worktree: 'wt',
|
||||
relativePath: 'a.bin',
|
||||
sink,
|
||||
signal: new AbortController().signal,
|
||||
onProgress: () => {},
|
||||
chunkBytes: 7,
|
||||
concurrency: 3,
|
||||
...extra
|
||||
})
|
||||
return { result, bytes: await readFile(join(dir, 'out')) }
|
||||
} finally {
|
||||
await sink.close()
|
||||
}
|
||||
}
|
||||
|
||||
describe('transferRuntimeFile', () => {
|
||||
it('reassembles pages fetched in parallel in order', async () => {
|
||||
const content = Buffer.from('the quick brown fox jumps over the lazy dog, twice over')
|
||||
const { result, bytes } = await runFile(fileHost(content))
|
||||
expect(result.bytes).toBe(content.byteLength)
|
||||
expect(bytes.equals(content)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a file that shrank after stat to its final length', async () => {
|
||||
const content = Buffer.from('short file')
|
||||
const { bytes } = await runFile(fileHost(content, 1000))
|
||||
expect(bytes.toString()).toBe('short file')
|
||||
})
|
||||
|
||||
it('downloads an empty file', async () => {
|
||||
const { bytes } = await runFile(fileHost(Buffer.alloc(0)))
|
||||
expect(bytes.byteLength).toBe(0)
|
||||
})
|
||||
|
||||
it('refuses a file over the caller limit before reading it', async () => {
|
||||
await expect(runFile(fileHost(Buffer.alloc(20)), { maxBytes: 10 })).rejects.toEqual(
|
||||
new RuntimeDownloadError('too_large')
|
||||
)
|
||||
})
|
||||
|
||||
it('stops a host that keeps sending pages past the limit', async () => {
|
||||
const endless = fakeCall(async (method: string, params: { length: number }) =>
|
||||
method === 'files.stat'
|
||||
? { size: 10, isDirectory: false }
|
||||
: {
|
||||
contentBase64: Buffer.alloc(params.length, 1).toString('base64'),
|
||||
bytesRead: params.length,
|
||||
eof: false
|
||||
}
|
||||
)
|
||||
await expect(runFile(endless, { maxBytes: 50 })).rejects.toEqual(
|
||||
new RuntimeDownloadError('too_large')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transferRuntimeFolder', () => {
|
||||
it('releases the host archive when the download is canceled', async () => {
|
||||
const controller = new AbortController()
|
||||
const calls: string[] = []
|
||||
const call = fakeCall(async (method: string) => {
|
||||
calls.push(method)
|
||||
if (method === 'files.archiveStart') {
|
||||
return { archiveId: 'z1', totalBytes: 10, excludedDirCount: 0, fileCount: 1 }
|
||||
}
|
||||
if (method === 'files.archiveRead') {
|
||||
controller.abort()
|
||||
return {
|
||||
contentBase64: 'AA==',
|
||||
bytesRead: 1,
|
||||
eof: false,
|
||||
processedBytes: 1,
|
||||
skippedCount: 0
|
||||
}
|
||||
}
|
||||
return { released: true }
|
||||
})
|
||||
const sink = await open(join(dir, 'out.zip'), 'w+')
|
||||
try {
|
||||
await expect(
|
||||
transferRuntimeFolder({
|
||||
call,
|
||||
worktree: 'wt',
|
||||
relativePath: 'src',
|
||||
sink,
|
||||
signal: controller.signal,
|
||||
onProgress: () => {},
|
||||
excludeDefaultDirs: true
|
||||
})
|
||||
).rejects.toEqual(new RuntimeDownloadError('canceled'))
|
||||
} finally {
|
||||
await sink.close()
|
||||
}
|
||||
expect(calls).toEqual(['files.archiveStart', 'files.archiveRead', 'files.archiveCancel'])
|
||||
})
|
||||
|
||||
it('releases an archive whose start reply lands after the user canceled', async () => {
|
||||
const controller = new AbortController()
|
||||
const calls: { method: string; params: unknown }[] = []
|
||||
let finishStart: (value: unknown) => void = () => {}
|
||||
const call = fakeCall(async (method: string, params: unknown) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'files.archiveStart') {
|
||||
return new Promise<unknown>((resolve) => {
|
||||
finishStart = resolve
|
||||
})
|
||||
}
|
||||
return { released: true }
|
||||
})
|
||||
const sink = await open(join(dir, 'late.zip'), 'w+')
|
||||
try {
|
||||
const transfer = transferRuntimeFolder({
|
||||
call,
|
||||
worktree: 'wt',
|
||||
relativePath: 'src',
|
||||
sink,
|
||||
signal: controller.signal,
|
||||
onProgress: () => {},
|
||||
excludeDefaultDirs: true
|
||||
})
|
||||
controller.abort()
|
||||
// Canceled at once, before the host has even finished walking.
|
||||
await expect(transfer).rejects.toEqual(new RuntimeDownloadError('canceled'))
|
||||
finishStart({ archiveId: 'late', totalBytes: 1, excludedDirCount: 0, fileCount: 1 })
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(2))
|
||||
} finally {
|
||||
await sink.close()
|
||||
}
|
||||
expect(calls.map((entry) => entry.method)).toEqual([
|
||||
'files.archiveStart',
|
||||
'files.archiveCancel'
|
||||
])
|
||||
expect(calls[1].params).toEqual({ archiveId: 'late' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import type { RuntimeFileReadChunkResult } from '../../shared/file-content/runtime-file-read'
|
||||
import {
|
||||
FOLDER_ARCHIVE_MAX_ENTRIES,
|
||||
FOLDER_ARCHIVE_MAX_READ_BYTES,
|
||||
FOLDER_ARCHIVE_MAX_TOTAL_BYTES,
|
||||
type FileArchiveReadResult,
|
||||
type FileArchiveStartResult
|
||||
} from '../../shared/rpc-contract/files-archive-params'
|
||||
import type { RuntimeDownloadErrorCode } from '../../shared/runtime-downloads/runtime-download-contract'
|
||||
|
||||
/** files.readChunk's schema ceiling; older hosts accept nothing larger. */
|
||||
const FILE_CHUNK_BYTES = 512 * 1024
|
||||
// Matches the main-process download lane: four pages in flight hide ~65 ms of RTT.
|
||||
const FILE_CHUNK_CONCURRENCY = 4
|
||||
const CHUNK_TIMEOUT_MS = 60_000
|
||||
// The walk of a large tree runs before the first byte; give it room.
|
||||
const ARCHIVE_START_TIMEOUT_MS = 120_000
|
||||
// The host is not trusted to stop. A file may grow a little while it is copied; past this much over
|
||||
// its announced size the download stops instead of filling this computer's disk.
|
||||
const FILE_GROWTH_ALLOWANCE_BYTES = 64 * 1024 * 1024
|
||||
// A ZIP of at most 2 GiB of files: deflate never grows data by more than ~0.1 %, plus local and
|
||||
// central headers (well under 1 KiB each for 65,000 entries).
|
||||
const FOLDER_ARCHIVE_MAX_ZIP_BYTES =
|
||||
FOLDER_ARCHIVE_MAX_TOTAL_BYTES * 1.01 + FOLDER_ARCHIVE_MAX_ENTRIES * 1024
|
||||
|
||||
export class RuntimeDownloadError extends Error {
|
||||
constructor(
|
||||
readonly code: RuntimeDownloadErrorCode,
|
||||
message: string = code
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'RuntimeDownloadError'
|
||||
}
|
||||
}
|
||||
|
||||
export type RuntimeDownloadCall = <T>(
|
||||
method: string,
|
||||
params: unknown,
|
||||
options: { timeoutMs: number; signal: AbortSignal }
|
||||
) => Promise<T>
|
||||
|
||||
export type RuntimeDownloadProgress = {
|
||||
receivedBytes: number
|
||||
/** Expected bytes; for folders this counts uncompressed source bytes, not ZIP bytes. */
|
||||
totalBytes: number | null
|
||||
/** Source bytes covered so far, comparable with totalBytes. */
|
||||
processedBytes: number
|
||||
/** Folder downloads only. */
|
||||
fileCount?: number
|
||||
}
|
||||
|
||||
type TransferInput = {
|
||||
call: RuntimeDownloadCall
|
||||
worktree: string
|
||||
relativePath: string
|
||||
sink: FileHandle
|
||||
signal: AbortSignal
|
||||
onProgress: (progress: RuntimeDownloadProgress) => void
|
||||
}
|
||||
|
||||
/** Pages a remote file into `sink` with a few positional reads in flight to hide link latency. */
|
||||
export async function transferRuntimeFile(
|
||||
input: TransferInput & { maxBytes?: number; chunkBytes?: number; concurrency?: number }
|
||||
): Promise<{ bytes: number }> {
|
||||
const chunkBytes = input.chunkBytes ?? FILE_CHUNK_BYTES
|
||||
const stat = await input.call<{ size: number; isDirectory: boolean }>(
|
||||
'files.stat',
|
||||
{ worktree: input.worktree, relativePath: input.relativePath },
|
||||
{ timeoutMs: CHUNK_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
if (stat.isDirectory) {
|
||||
throw new RuntimeDownloadError('not_a_file')
|
||||
}
|
||||
if (input.maxBytes !== undefined && stat.size > input.maxBytes) {
|
||||
throw new RuntimeDownloadError('too_large')
|
||||
}
|
||||
// Hard stop for a host that keeps answering full pages past the size it announced.
|
||||
const byteLimit = Math.min(
|
||||
input.maxBytes ?? Number.POSITIVE_INFINITY,
|
||||
stat.size + FILE_GROWTH_ALLOWANCE_BYTES
|
||||
)
|
||||
let nextOffset = 0
|
||||
// The file may change size mid-transfer; the first EOF page fixes the final length.
|
||||
let endOffset: number | null = null
|
||||
let received = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
if (input.signal.aborted) {
|
||||
throw new RuntimeDownloadError('canceled')
|
||||
}
|
||||
if (endOffset !== null && nextOffset >= endOffset) {
|
||||
return
|
||||
}
|
||||
const offset = nextOffset
|
||||
nextOffset += chunkBytes
|
||||
const chunk = await input.call<RuntimeFileReadChunkResult>(
|
||||
'files.readChunk',
|
||||
{
|
||||
worktree: input.worktree,
|
||||
relativePath: input.relativePath,
|
||||
offset,
|
||||
length: chunkBytes
|
||||
},
|
||||
{ timeoutMs: CHUNK_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
if (offset + chunk.bytesRead > byteLimit) {
|
||||
throw new RuntimeDownloadError('too_large')
|
||||
}
|
||||
if (chunk.bytesRead > 0) {
|
||||
const bytes = Buffer.from(chunk.contentBase64, 'base64')
|
||||
if (bytes.byteLength !== chunk.bytesRead) {
|
||||
throw new RuntimeDownloadError('transfer_failed', 'Remote page length mismatch')
|
||||
}
|
||||
await input.sink.write(bytes, 0, bytes.byteLength, offset)
|
||||
received += bytes.byteLength
|
||||
input.onProgress({
|
||||
receivedBytes: received,
|
||||
totalBytes: stat.size,
|
||||
processedBytes: received
|
||||
})
|
||||
}
|
||||
if (chunk.eof || chunk.bytesRead < chunkBytes) {
|
||||
const end = offset + chunk.bytesRead
|
||||
endOffset = endOffset === null ? end : Math.min(endOffset, end)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
const concurrency = Math.max(1, input.concurrency ?? FILE_CHUNK_CONCURRENCY)
|
||||
await Promise.all(Array.from({ length: concurrency }, worker))
|
||||
const finalLength = endOffset ?? received
|
||||
// A page past a shrunken EOF may have landed; cut the file back to the agreed length.
|
||||
await input.sink.truncate(finalLength)
|
||||
return { bytes: finalLength }
|
||||
}
|
||||
|
||||
export type FolderTransferResult = {
|
||||
bytes: number
|
||||
skippedCount: number
|
||||
excludedDirCount: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
/** Asks the host to zip a folder, then appends its pages to `sink` until EOF. */
|
||||
export async function transferRuntimeFolder(
|
||||
input: TransferInput & { excludeDefaultDirs: boolean }
|
||||
): Promise<FolderTransferResult> {
|
||||
// Not tied to the user's cancel: the host only learns of a cancel through archiveCancel, so a
|
||||
// start canceled mid-walk must still deliver its id for release (else it holds a slot until idle).
|
||||
const startRequest = input.call<FileArchiveStartResult>(
|
||||
'files.archiveStart',
|
||||
{
|
||||
worktree: input.worktree,
|
||||
relativePath: input.relativePath,
|
||||
excludeDefaultDirs: input.excludeDefaultDirs
|
||||
},
|
||||
{ timeoutMs: ARCHIVE_START_TIMEOUT_MS, signal: new AbortController().signal }
|
||||
)
|
||||
const started = await raceAbort(startRequest, input.signal, (late) =>
|
||||
releaseArchive(input.call, late.archiveId)
|
||||
)
|
||||
let finished = false
|
||||
let received = 0
|
||||
try {
|
||||
for (;;) {
|
||||
if (input.signal.aborted) {
|
||||
throw new RuntimeDownloadError('canceled')
|
||||
}
|
||||
const page = await input.call<FileArchiveReadResult>(
|
||||
'files.archiveRead',
|
||||
{ archiveId: started.archiveId, maxBytes: FOLDER_ARCHIVE_MAX_READ_BYTES },
|
||||
{ timeoutMs: CHUNK_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
if (page.bytesRead > 0) {
|
||||
const bytes = Buffer.from(page.contentBase64, 'base64')
|
||||
if (received + bytes.byteLength > FOLDER_ARCHIVE_MAX_ZIP_BYTES) {
|
||||
throw new RuntimeDownloadError('too_large')
|
||||
}
|
||||
await input.sink.write(bytes, 0, bytes.byteLength, received)
|
||||
received += bytes.byteLength
|
||||
}
|
||||
input.onProgress({
|
||||
receivedBytes: received,
|
||||
totalBytes: started.totalBytes,
|
||||
processedBytes: page.processedBytes,
|
||||
fileCount: started.fileCount
|
||||
})
|
||||
if (page.eof) {
|
||||
finished = true
|
||||
return {
|
||||
bytes: received,
|
||||
skippedCount: page.skippedCount,
|
||||
excludedDirCount: started.excludedDirCount,
|
||||
fileCount: started.fileCount
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!finished) {
|
||||
releaseArchive(input.call, started.archiveId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Best effort: the host's idle timer frees the session if this never arrives. */
|
||||
function releaseArchive(call: RuntimeDownloadCall, archiveId: string): void {
|
||||
void call(
|
||||
'files.archiveCancel',
|
||||
{ archiveId },
|
||||
{ timeoutMs: 10_000, signal: new AbortController().signal }
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
/** Rejects with `canceled` as soon as `signal` aborts; a result that lands later goes to `onLate`. */
|
||||
function raceAbort<T>(
|
||||
request: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
onLate: (value: T) => void
|
||||
): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
void request.then(onLate, () => {})
|
||||
return Promise.reject(new RuntimeDownloadError('canceled'))
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
void request.then(onLate, () => {})
|
||||
reject(new RuntimeDownloadError('canceled'))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
request.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
|
||||
// Opened copies only need to outlive the app that opens them; a day covers a long session.
|
||||
const OPEN_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
// Under userData, not the OS temp dir: Linux /tmp is shared, and another local user could pre-create
|
||||
// the folder and swap the file Orca is about to open.
|
||||
export function runtimeOpenLocalCacheRoot(): string {
|
||||
return join(app.getPath('userData'), 'opened-remote-files')
|
||||
}
|
||||
|
||||
/** A fresh directory per open, so two opens of same-named files never clobber each other. */
|
||||
export async function createRuntimeOpenLocalDirectory(root = runtimeOpenLocalCacheRoot()) {
|
||||
await mkdir(root, { recursive: true, mode: 0o700 })
|
||||
const directory = join(root, randomUUID())
|
||||
// Not recursive: a fresh random name that already exists means something else made it.
|
||||
await mkdir(directory, { mode: 0o700 })
|
||||
return directory
|
||||
}
|
||||
|
||||
/** Best effort: removes cached copies older than a day. Failures never block an open. */
|
||||
export async function pruneRuntimeOpenLocalCache(
|
||||
rootOverride?: string,
|
||||
now = Date.now(),
|
||||
maxAgeMs = OPEN_CACHE_MAX_AGE_MS
|
||||
): Promise<void> {
|
||||
let root: string
|
||||
let names: string[]
|
||||
try {
|
||||
// Inside the try: before app ready (or in tests) userData may not resolve.
|
||||
root = rootOverride ?? runtimeOpenLocalCacheRoot()
|
||||
names = await readdir(root)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const path = join(root, name)
|
||||
try {
|
||||
if (now - (await stat(path)).mtimeMs > maxAgeMs) {
|
||||
await rm(path, { recursive: true, force: true })
|
||||
}
|
||||
} catch {
|
||||
// Another app instance may be pruning the same entry.
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export type RuntimeFileCommandSurface = Pick<
|
||||
| 'readFileExplorerPreview'
|
||||
| 'readChatImagePreview'
|
||||
| 'readFileExplorerChunk'
|
||||
| 'resolveFileExplorerArchivePath'
|
||||
| 'readDocPreviewFile'
|
||||
| 'readDocPreviewChunk'
|
||||
> &
|
||||
@@ -91,6 +92,7 @@ export function installRuntimeFileCommandSurface(
|
||||
readFileExplorerPreview: fileReads.readFileExplorerPreview.bind(fileReads),
|
||||
readChatImagePreview: fileReads.readChatImagePreview.bind(fileReads),
|
||||
readFileExplorerChunk: fileReads.readFileExplorerChunk.bind(fileReads),
|
||||
resolveFileExplorerArchivePath: fileReads.resolveFileExplorerArchivePath.bind(fileReads),
|
||||
readDocPreviewFile: fileReads.readDocPreviewFile.bind(fileReads),
|
||||
readDocPreviewChunk: fileReads.readDocPreviewChunk.bind(fileReads),
|
||||
writeFileExplorerFile: fileMutations.writeFileExplorerFile.bind(fileMutations),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { constants } from 'node:fs'
|
||||
import { open, readlink, realpath, type FileHandle } from 'node:fs/promises'
|
||||
import { sep } from 'node:path'
|
||||
import type { FolderArchiveEntry } from './folder-archive-walk'
|
||||
|
||||
/** Files up to this size are read whole, ahead of the zipper, so per-file open/stat/close overlaps. */
|
||||
const SMALL_FILE_BYTES = 64 * 1024
|
||||
|
||||
export type SmallFileRead =
|
||||
| { kind: 'bytes'; bytes: Uint8Array }
|
||||
| { kind: 'skip'; error: unknown }
|
||||
/** Grew past the small-file limit since the walk; stream it instead. */
|
||||
| { kind: 'stream' }
|
||||
|
||||
export class ChangedSinceWalkError extends Error {}
|
||||
|
||||
export function isSmallFile(entry: FolderArchiveEntry): boolean {
|
||||
return entry.kind === 'file' && entry.size <= SMALL_FILE_BYTES
|
||||
}
|
||||
|
||||
// O_NONBLOCK: a file swapped for a FIFO after the walk must not park a libuv thread in open();
|
||||
// fstat then refuses it. Regular files ignore the flag; Windows has neither flag.
|
||||
const ARCHIVE_OPEN_FLAGS =
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
|
||||
|
||||
/**
|
||||
* Opens a planned file without following a final link, and refuses it if it is no longer the file
|
||||
* the walk saw or if the opened file does not live under `rootRealPath`. A parent directory swapped
|
||||
* for a link after the walk slips past O_NOFOLLOW; the fd's real path (or, off Linux, a re-resolve
|
||||
* plus dev/ino) catches that, so nothing outside the root is ever read.
|
||||
*/
|
||||
export async function openUnchangedFile(
|
||||
entry: FolderArchiveEntry,
|
||||
rootRealPath: string
|
||||
): Promise<FileHandle> {
|
||||
const handle = await open(entry.absolutePath, ARCHIVE_OPEN_FLAGS)
|
||||
try {
|
||||
const opened = await handle.stat()
|
||||
if (!opened.isFile() || opened.dev !== entry.dev || opened.ino !== entry.ino) {
|
||||
throw new ChangedSinceWalkError('archive_entry_changed')
|
||||
}
|
||||
if (!isInsideRoot(await openedRealPath(handle, entry), rootRealPath)) {
|
||||
throw new ChangedSinceWalkError('archive_entry_escaped')
|
||||
}
|
||||
return handle
|
||||
} catch (error) {
|
||||
await handle.close().catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function openedRealPath(handle: FileHandle, entry: FolderArchiveEntry): Promise<string> {
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
return await readlink(`/proc/self/fd/${handle.fd}`)
|
||||
} catch {
|
||||
// No /proc (unusual containers): fall through to the path check.
|
||||
}
|
||||
}
|
||||
return realpath(entry.absolutePath)
|
||||
}
|
||||
|
||||
function isInsideRoot(path: string, rootRealPath: string): boolean {
|
||||
const root = rootRealPath.endsWith(sep) ? rootRealPath : `${rootRealPath}${sep}`
|
||||
return process.platform === 'win32'
|
||||
? path.toLowerCase().startsWith(root.toLowerCase())
|
||||
: path.startsWith(root)
|
||||
}
|
||||
|
||||
/** Never rejects: failures come back as `skip` so a look-ahead read cannot go unhandled. */
|
||||
export async function readSmallFile(
|
||||
entry: FolderArchiveEntry,
|
||||
rootRealPath: string
|
||||
): Promise<SmallFileRead> {
|
||||
let handle: FileHandle
|
||||
try {
|
||||
handle = await openUnchangedFile(entry, rootRealPath)
|
||||
} catch (error) {
|
||||
return { kind: 'skip', error }
|
||||
}
|
||||
try {
|
||||
// One spare byte tells a file that grew since the walk apart from one that matches.
|
||||
const buffer = Buffer.allocUnsafe(Math.min(entry.size, SMALL_FILE_BYTES) + 1)
|
||||
let filled = 0
|
||||
for (;;) {
|
||||
const { bytesRead } = await handle.read(buffer, filled, buffer.byteLength - filled, filled)
|
||||
if (bytesRead === 0) {
|
||||
return { kind: 'bytes', bytes: buffer.subarray(0, filled) }
|
||||
}
|
||||
filled += bytesRead
|
||||
if (filled === buffer.byteLength) {
|
||||
return { kind: 'stream' }
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { kind: 'skip', error }
|
||||
} finally {
|
||||
await handle.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { mkdtemp, mkdir, realpath, rename, rm, symlink, writeFile, chmod } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { unzipSync } from 'fflate'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FolderArchiveSessions } from './folder-archive-sessions'
|
||||
import {
|
||||
FolderArchiveLimitError,
|
||||
planFolderArchive,
|
||||
sanitizeArchiveSegment
|
||||
} from './folder-archive-walk'
|
||||
import { FolderArchiveZipStream, isContainedLinkTarget } from './folder-archive-zip-stream'
|
||||
|
||||
let root: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Canonical, as the RPC hands it over (macOS tmpdir sits behind /var -> /private/var).
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-archive-test-')))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function zipEntries(stream: FolderArchiveZipStream) {
|
||||
const parts: Buffer[] = []
|
||||
for (;;) {
|
||||
const page = await stream.read(1 << 20)
|
||||
parts.push(page.bytes)
|
||||
if (page.eof) {
|
||||
return unzipSync(Buffer.concat(parts))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function drain(sessions: FolderArchiveSessions, archiveId: string, ownerKey = 'device-a') {
|
||||
const parts: Buffer[] = []
|
||||
for (;;) {
|
||||
const page = await sessions.read({ archiveId, ownerKey, maxBytes: 64 * 1024 })
|
||||
parts.push(Buffer.from(page.contentBase64, 'base64'))
|
||||
if (page.eof) {
|
||||
return { bytes: Buffer.concat(parts), last: page }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('FolderArchiveSessions', () => {
|
||||
it('zips a folder with nested files, empty dirs and the default exclusions applied', async () => {
|
||||
const project = join(root, 'project')
|
||||
await mkdir(join(project, 'src', 'empty'), { recursive: true })
|
||||
await mkdir(join(project, 'node_modules', 'pkg'), { recursive: true })
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
await writeFile(join(project, 'src', 'a.txt'), 'hello')
|
||||
await writeFile(join(project, 'run.sh'), '#!/bin/sh\n')
|
||||
await chmod(join(project, 'run.sh'), 0o755)
|
||||
await writeFile(join(project, 'node_modules', 'pkg', 'index.js'), 'x')
|
||||
await writeFile(join(project, '.git', 'HEAD'), 'ref')
|
||||
const sessions = new FolderArchiveSessions()
|
||||
|
||||
const started = await sessions.start({
|
||||
folderPath: project,
|
||||
ownerKey: 'device-a',
|
||||
excludeDefaultDirs: true
|
||||
})
|
||||
expect(started).toMatchObject({
|
||||
suggestedName: 'project.zip',
|
||||
fileCount: 2,
|
||||
totalBytes: 15,
|
||||
excludedDirCount: 2
|
||||
})
|
||||
const { bytes, last } = await drain(sessions, started.archiveId)
|
||||
expect(last.processedBytes).toBe(15)
|
||||
const files = unzipSync(bytes)
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
'project/',
|
||||
'project/run.sh',
|
||||
'project/src/',
|
||||
'project/src/a.txt',
|
||||
'project/src/empty/'
|
||||
])
|
||||
expect(Buffer.from(files['project/src/a.txt']).toString()).toBe('hello')
|
||||
expect(sessions.activeCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('includes dependency folders when exclusion is off', async () => {
|
||||
await mkdir(join(root, 'node_modules'), { recursive: true })
|
||||
await writeFile(join(root, 'node_modules', 'x.js'), 'x')
|
||||
const plan = await planFolderArchive(root, 'r', { excludeDefaultDirs: false })
|
||||
expect(plan.entries.map((entry) => entry.archivePath)).toContain('r/node_modules/x.js')
|
||||
})
|
||||
|
||||
it('keeps links that stay inside the folder and drops ones that point out of it', async () => {
|
||||
const outside = join(root, 'outside-secret.txt')
|
||||
await writeFile(outside, 'secret')
|
||||
const project = join(root, 'p')
|
||||
await mkdir(join(project, 'sub'), { recursive: true })
|
||||
await writeFile(join(project, 'target.txt'), 'in')
|
||||
await symlink(outside, join(project, 'abs-link'))
|
||||
await symlink('../outside-secret.txt', join(project, 'rel-escape'))
|
||||
await symlink(root, join(project, 'dir-link'))
|
||||
await symlink('../target.txt', join(project, 'sub', 'inner-link'))
|
||||
const plan = await planFolderArchive(project, 'p', { excludeDefaultDirs: true })
|
||||
const stream = new FolderArchiveZipStream(plan.entries, plan.rootRealPath)
|
||||
const files = await zipEntries(stream)
|
||||
expect(Buffer.from(files['p/sub/inner-link']).toString()).toBe('../target.txt')
|
||||
for (const dropped of ['p/abs-link', 'p/rel-escape', 'p/dir-link', 'p/dir-link/']) {
|
||||
expect(files[dropped]).toBeUndefined()
|
||||
}
|
||||
expect(Object.keys(files).some((name) => name.includes('outside-secret'))).toBe(false)
|
||||
expect(stream.skippedCount).toBe(3)
|
||||
})
|
||||
|
||||
it('judges link targets against the archive root', () => {
|
||||
expect(isContainedLinkTarget('p/a/l', '../b')).toBe(true)
|
||||
expect(isContainedLinkTarget('p/a/l', '../../x')).toBe(false)
|
||||
expect(isContainedLinkTarget('p/l', '/etc/passwd')).toBe(false)
|
||||
expect(isContainedLinkTarget('p/l', 'C:x')).toBe(false)
|
||||
expect(isContainedLinkTarget('p/a/b/l', 's/../../..')).toBe(false)
|
||||
expect(isContainedLinkTarget('p/l', '..\\x')).toBe(false)
|
||||
})
|
||||
|
||||
it('skips a file moved out of the folder and linked back in after the walk', async () => {
|
||||
const project = join(root, 'p')
|
||||
await mkdir(join(project, 'sub'), { recursive: true })
|
||||
await writeFile(join(project, 'sub', 'a.txt'), 'moved-out')
|
||||
const plan = await planFolderArchive(project, 'p', { excludeDefaultDirs: true })
|
||||
// Same dev/ino as the walk saw, but now outside the root.
|
||||
await rename(join(project, 'sub'), join(root, 'moved'))
|
||||
await symlink(join(root, 'moved'), join(project, 'sub'))
|
||||
const stream = new FolderArchiveZipStream(plan.entries, plan.rootRealPath)
|
||||
const files = await zipEntries(stream)
|
||||
expect(files['p/sub/a.txt']).toBeUndefined()
|
||||
expect(stream.skippedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('never reads past the walked size of a file that grew', async () => {
|
||||
await writeFile(join(root, 'log.bin'), Buffer.alloc(100 * 1024, 1))
|
||||
const plan = await planFolderArchive(root, 'r', { excludeDefaultDirs: true })
|
||||
await writeFile(join(root, 'log.bin'), Buffer.alloc(900 * 1024, 1), { flag: 'a' })
|
||||
const files = await zipEntries(new FolderArchiveZipStream(plan.entries, plan.rootRealPath))
|
||||
expect(files['r/log.bin'].byteLength).toBe(100 * 1024)
|
||||
})
|
||||
|
||||
it('gives case- and Unicode-folded twins distinct names', async () => {
|
||||
await writeFile(join(root, 'Read.md'), 'a')
|
||||
await writeFile(join(root, 'read.md'), 'b')
|
||||
const plan = await planFolderArchive(root, 'r', { excludeDefaultDirs: true })
|
||||
const names = plan.entries.map((entry) => entry.archivePath).filter((p) => p.endsWith('.md'))
|
||||
// macOS temp dirs fold case themselves; then there is only one file to name.
|
||||
expect(new Set(names.map((name) => name.toLowerCase())).size).toBe(names.length)
|
||||
})
|
||||
|
||||
it('skips a file whose parent directory was swapped for a link after the walk', async () => {
|
||||
const outside = join(root, 'outside')
|
||||
await mkdir(outside)
|
||||
await writeFile(join(outside, 'a.txt'), 'secret')
|
||||
const project = join(root, 'p')
|
||||
await mkdir(join(project, 'sub'), { recursive: true })
|
||||
await writeFile(join(project, 'sub', 'a.txt'), 'inside')
|
||||
const plan = await planFolderArchive(project, 'p', { excludeDefaultDirs: true })
|
||||
// Same name, different file: O_NOFOLLOW alone would follow the swapped parent.
|
||||
await rename(join(project, 'sub'), join(root, 'moved'))
|
||||
await symlink(outside, join(project, 'sub'))
|
||||
const stream = new FolderArchiveZipStream(plan.entries, plan.rootRealPath)
|
||||
const parts: Buffer[] = []
|
||||
for (;;) {
|
||||
const page = await stream.read(1 << 20)
|
||||
parts.push(page.bytes)
|
||||
if (page.eof) {
|
||||
break
|
||||
}
|
||||
}
|
||||
const files = unzipSync(Buffer.concat(parts))
|
||||
expect(files['p/sub/a.txt']).toBeUndefined()
|
||||
expect(stream.skippedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('rewrites backslashes and control characters so Windows extractors cannot escape', () => {
|
||||
expect(sanitizeArchiveSegment('..\\..\\evil')).toBe('.._.._evil')
|
||||
expect(sanitizeArchiveSegment('a\u0001b')).toBe('a_b')
|
||||
expect(sanitizeArchiveSegment('..')).toBe('__')
|
||||
expect(sanitizeArchiveSegment('CON')).toBe('_CON')
|
||||
expect(sanitizeArchiveSegment('aux.txt')).toBe('_aux.txt')
|
||||
expect(sanitizeArchiveSegment('a:b*c?.txt')).toBe('a_b_c_.txt')
|
||||
expect(sanitizeArchiveSegment('notes. ')).toBe('notes__')
|
||||
expect(sanitizeArchiveSegment('normal name.txt')).toBe('normal name.txt')
|
||||
expect(sanitizeArchiveSegment('invoice\u202Etxt.exe')).toBe('invoicetxt.exe')
|
||||
expect(sanitizeArchiveSegment('\u200B')).toBe('_')
|
||||
})
|
||||
|
||||
it('refuses folders over the byte or entry limits', async () => {
|
||||
await writeFile(join(root, 'a'), Buffer.alloc(10))
|
||||
await writeFile(join(root, 'b'), Buffer.alloc(10))
|
||||
await expect(
|
||||
planFolderArchive(root, 'r', { excludeDefaultDirs: true, maxTotalBytes: 15 })
|
||||
).rejects.toEqual(new FolderArchiveLimitError('archive_too_large'))
|
||||
await expect(
|
||||
planFolderArchive(root, 'r', { excludeDefaultDirs: true, maxEntries: 2 })
|
||||
).rejects.toEqual(new FolderArchiveLimitError('archive_too_many_entries'))
|
||||
})
|
||||
|
||||
it('refuses a folder that was swapped for a link after it was authorized', async () => {
|
||||
await mkdir(join(root, 'real'))
|
||||
await symlink(join(root, 'real'), join(root, 'swapped'))
|
||||
await expect(
|
||||
planFolderArchive(join(root, 'swapped'), 's', { excludeDefaultDirs: true })
|
||||
).rejects.toThrow('archive_not_directory')
|
||||
})
|
||||
|
||||
it('refuses to archive a file', async () => {
|
||||
await writeFile(join(root, 'f'), 'x')
|
||||
const sessions = new FolderArchiveSessions()
|
||||
await expect(
|
||||
sessions.start({ folderPath: join(root, 'f'), ownerKey: 'a', excludeDefaultDirs: true })
|
||||
).rejects.toThrow('archive_not_directory')
|
||||
})
|
||||
|
||||
it('hides an archive from other devices and frees it on cancel', async () => {
|
||||
await writeFile(join(root, 'f'), 'x')
|
||||
const sessions = new FolderArchiveSessions()
|
||||
const { archiveId } = await sessions.start({
|
||||
folderPath: root,
|
||||
ownerKey: 'device-a',
|
||||
excludeDefaultDirs: true
|
||||
})
|
||||
await expect(sessions.read({ archiveId, ownerKey: 'device-b', maxBytes: 10 })).rejects.toThrow(
|
||||
'archive_not_found'
|
||||
)
|
||||
expect(sessions.cancel({ archiveId, ownerKey: 'device-b' })).toBe(false)
|
||||
expect(sessions.cancel({ archiveId, ownerKey: 'device-a' })).toBe(true)
|
||||
expect(sessions.activeCount()).toBe(0)
|
||||
await expect(sessions.read({ archiveId, ownerKey: 'device-a', maxBytes: 10 })).rejects.toThrow(
|
||||
'archive_not_found'
|
||||
)
|
||||
})
|
||||
|
||||
it('caps concurrent archives and releases idle ones', async () => {
|
||||
const sessions = new FolderArchiveSessions(20, 1)
|
||||
const first = await sessions.start({
|
||||
folderPath: root,
|
||||
ownerKey: 'a',
|
||||
excludeDefaultDirs: true
|
||||
})
|
||||
await expect(
|
||||
sessions.start({ folderPath: root, ownerKey: 'a', excludeDefaultDirs: true })
|
||||
).rejects.toThrow('archive_busy')
|
||||
await vi.waitFor(() => expect(sessions.activeCount()).toBe(0))
|
||||
await expect(
|
||||
sessions.read({ archiveId: first.archiveId, ownerKey: 'a', maxBytes: 10 })
|
||||
).rejects.toThrow('archive_not_found')
|
||||
})
|
||||
|
||||
it('limits archives per client so one client cannot hold every slot', async () => {
|
||||
const sessions = new FolderArchiveSessions(60_000, 8, 2)
|
||||
const start = (ownerKey: string) =>
|
||||
sessions.start({ folderPath: root, ownerKey, excludeDefaultDirs: true })
|
||||
// Concurrent starts reserve their slot before walking.
|
||||
const results = await Promise.allSettled([start('a'), start('a'), start('a')])
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(2)
|
||||
await expect(start('b')).resolves.toMatchObject({ suggestedName: expect.any(String) })
|
||||
})
|
||||
|
||||
it('pauses the producer at the high-water mark until the reader drains', async () => {
|
||||
await writeFile(join(root, 'big.png'), Buffer.alloc(4 * 1024 * 1024, 7))
|
||||
const plan = await planFolderArchive(root, 'r', { excludeDefaultDirs: true })
|
||||
const stream = new FolderArchiveZipStream(plan.entries, plan.rootRealPath, 512 * 1024)
|
||||
// The producer fills to the mark, then must stop until something is read.
|
||||
await vi.waitFor(() => expect(stream.processedBytes).toBeGreaterThanOrEqual(512 * 1024))
|
||||
expect(stream.processedBytes).toBeLessThanOrEqual(1024 * 1024)
|
||||
let total = 0
|
||||
for (;;) {
|
||||
const page = await stream.read(256 * 1024)
|
||||
total += page.bytes.byteLength
|
||||
if (page.eof) {
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(stream.processedBytes).toBe(4 * 1024 * 1024)
|
||||
expect(total).toBeGreaterThan(4 * 1024 * 1024)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
import type {
|
||||
FileArchiveReadResult,
|
||||
FileArchiveStartResult
|
||||
} from '../../../../shared/rpc-contract/files-archive-params'
|
||||
import type { RpcContext } from '../../rpc/core'
|
||||
import { planFolderArchive, sanitizeArchiveSegment } from './folder-archive-walk'
|
||||
import { FolderArchiveZipStream } from './folder-archive-zip-stream'
|
||||
|
||||
// A lost cancel (closed laptop, dropped socket) must not pin host memory or file handles.
|
||||
export const FOLDER_ARCHIVE_IDLE_TIMEOUT_MS = 2 * 60_000
|
||||
/** Per client (paired device or connection), so one client can't hold every slot. */
|
||||
const MAX_ARCHIVES_PER_OWNER = 2
|
||||
/** Host-wide backstop across all clients. */
|
||||
const MAX_ACTIVE_ARCHIVES = 8
|
||||
|
||||
type ArchiveSession = {
|
||||
id: string
|
||||
/** Only the connection that started an archive may read or cancel it. */
|
||||
ownerKey: string
|
||||
stream: FolderArchiveZipStream
|
||||
reading: boolean
|
||||
idleTimer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
export class FolderArchiveSessions {
|
||||
private readonly sessions = new Map<string, ArchiveSession>()
|
||||
// Slots held by walks still running, so parallel starts can't all walk before the limit applies.
|
||||
private readonly reservations = new Map<string, number>()
|
||||
private reservedTotal = 0
|
||||
|
||||
constructor(
|
||||
private readonly idleTimeoutMs = FOLDER_ARCHIVE_IDLE_TIMEOUT_MS,
|
||||
private readonly maxActive = MAX_ACTIVE_ARCHIVES,
|
||||
private readonly maxPerOwner = MAX_ARCHIVES_PER_OWNER
|
||||
) {}
|
||||
|
||||
private ownerCount(ownerKey: string): number {
|
||||
let count = this.reservations.get(ownerKey) ?? 0
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.ownerKey === ownerKey) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private reserve(ownerKey: string): () => void {
|
||||
if (
|
||||
this.sessions.size + this.reservedTotal >= this.maxActive ||
|
||||
this.ownerCount(ownerKey) >= this.maxPerOwner
|
||||
) {
|
||||
throw new Error('archive_busy')
|
||||
}
|
||||
this.reservations.set(ownerKey, (this.reservations.get(ownerKey) ?? 0) + 1)
|
||||
this.reservedTotal += 1
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
this.reservedTotal -= 1
|
||||
const left = (this.reservations.get(ownerKey) ?? 1) - 1
|
||||
if (left > 0) {
|
||||
this.reservations.set(ownerKey, left)
|
||||
} else {
|
||||
this.reservations.delete(ownerKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `folderPath` must already be confined to an allowed workspace root by the caller. */
|
||||
async start(input: {
|
||||
folderPath: string
|
||||
ownerKey: string
|
||||
excludeDefaultDirs: boolean
|
||||
signal?: AbortSignal
|
||||
}): Promise<FileArchiveStartResult> {
|
||||
const releaseReservation = this.reserve(input.ownerKey)
|
||||
let rootName: string
|
||||
let plan: Awaited<ReturnType<typeof planFolderArchive>>
|
||||
try {
|
||||
rootName = sanitizeArchiveRootName(basename(await realpath(input.folderPath)))
|
||||
plan = await planFolderArchive(input.folderPath, rootName, {
|
||||
excludeDefaultDirs: input.excludeDefaultDirs,
|
||||
...(input.signal ? { signal: input.signal } : {})
|
||||
})
|
||||
} finally {
|
||||
// The session below takes over the slot synchronously, so no other start can slip in.
|
||||
releaseReservation()
|
||||
}
|
||||
const session: ArchiveSession = {
|
||||
id: randomUUID(),
|
||||
ownerKey: input.ownerKey,
|
||||
stream: new FolderArchiveZipStream(plan.entries, plan.rootRealPath),
|
||||
reading: false,
|
||||
idleTimer: null
|
||||
}
|
||||
this.sessions.set(session.id, session)
|
||||
this.armIdleTimer(session)
|
||||
return {
|
||||
archiveId: session.id,
|
||||
suggestedName: `${rootName}.zip`,
|
||||
fileCount: plan.fileCount,
|
||||
directoryCount: plan.directoryCount,
|
||||
totalBytes: plan.totalBytes,
|
||||
excludedDirCount: plan.excludedDirCount
|
||||
}
|
||||
}
|
||||
|
||||
async read(input: {
|
||||
archiveId: string
|
||||
ownerKey: string
|
||||
maxBytes: number
|
||||
}): Promise<FileArchiveReadResult> {
|
||||
const session = this.requireSession(input.archiveId, input.ownerKey)
|
||||
if (session.reading) {
|
||||
throw new Error('archive_busy')
|
||||
}
|
||||
session.reading = true
|
||||
this.clearIdleTimer(session)
|
||||
try {
|
||||
const page = await session.stream.read(input.maxBytes)
|
||||
if (page.eof) {
|
||||
this.release(session)
|
||||
}
|
||||
return {
|
||||
contentBase64: page.bytes.toString('base64'),
|
||||
bytesRead: page.bytes.byteLength,
|
||||
eof: page.eof,
|
||||
processedBytes: session.stream.processedBytes,
|
||||
skippedCount: session.stream.skippedCount
|
||||
}
|
||||
} catch (error) {
|
||||
this.release(session)
|
||||
throw error
|
||||
} finally {
|
||||
session.reading = false
|
||||
if (this.sessions.get(session.id) === session) {
|
||||
this.armIdleTimer(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancel(input: { archiveId: string; ownerKey: string }): boolean {
|
||||
const session = this.sessions.get(input.archiveId)
|
||||
if (!session || session.ownerKey !== input.ownerKey) {
|
||||
return false
|
||||
}
|
||||
this.release(session)
|
||||
return true
|
||||
}
|
||||
|
||||
activeCount(): number {
|
||||
return this.sessions.size
|
||||
}
|
||||
|
||||
private requireSession(archiveId: string, ownerKey: string): ArchiveSession {
|
||||
const session = this.sessions.get(archiveId)
|
||||
// Another connection's archive looks the same as a missing one.
|
||||
if (!session || session.ownerKey !== ownerKey) {
|
||||
throw new Error('archive_not_found')
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private release(session: ArchiveSession): void {
|
||||
this.clearIdleTimer(session)
|
||||
if (this.sessions.get(session.id) === session) {
|
||||
this.sessions.delete(session.id)
|
||||
}
|
||||
session.stream.cancel()
|
||||
}
|
||||
|
||||
private armIdleTimer(session: ArchiveSession): void {
|
||||
this.clearIdleTimer(session)
|
||||
session.idleTimer = setTimeout(() => this.release(session), this.idleTimeoutMs)
|
||||
session.idleTimer.unref?.()
|
||||
}
|
||||
|
||||
private clearIdleTimer(session: ArchiveSession): void {
|
||||
if (session.idleTimer) {
|
||||
clearTimeout(session.idleTimer)
|
||||
session.idleTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeArchiveRootName(name: string): string {
|
||||
// A filesystem root has no basename; zip readers choke on separators or control characters here.
|
||||
// oxlint-disable-next-line no-control-regex
|
||||
const cleaned = name.replace(/[\u0000-\u001f/\\]/g, '_').trim()
|
||||
return cleaned === '' || cleaned === '.' || cleaned === '..'
|
||||
? 'folder'
|
||||
: sanitizeArchiveSegment(cleaned)
|
||||
}
|
||||
|
||||
type ArchiveRuntime = RpcContext['runtime']
|
||||
|
||||
const sessionsByRuntime = new WeakMap<ArchiveRuntime, FolderArchiveSessions>()
|
||||
|
||||
/** One session table per runtime, so archives never outlive the runtime that started them. */
|
||||
export function getFolderArchiveSessions(runtime: ArchiveRuntime): FolderArchiveSessions {
|
||||
let sessions = sessionsByRuntime.get(runtime)
|
||||
if (!sessions) {
|
||||
sessions = new FolderArchiveSessions()
|
||||
sessionsByRuntime.set(runtime, sessions)
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { lstat, readdir, realpath } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
FOLDER_ARCHIVE_DEFAULT_EXCLUDED_NAMES,
|
||||
FOLDER_ARCHIVE_MAX_ENTRIES,
|
||||
FOLDER_ARCHIVE_MAX_TOTAL_BYTES
|
||||
} from '../../../../shared/rpc-contract/files-archive-params'
|
||||
|
||||
export type FolderArchiveEntry = {
|
||||
/** Path inside the archive, `/`-separated, prefixed by the archived folder's name. */
|
||||
archivePath: string
|
||||
absolutePath: string
|
||||
kind: 'file' | 'directory' | 'symlink'
|
||||
size: number
|
||||
mode: number
|
||||
mtimeMs: number
|
||||
/** Walk-time identity; the zip stream refuses to read a file whose dev/ino changed since. */
|
||||
dev: number
|
||||
ino: number
|
||||
}
|
||||
|
||||
export type FolderArchivePlan = {
|
||||
/** Canonical path of the archived folder; every read must land under it. */
|
||||
rootRealPath: string
|
||||
entries: FolderArchiveEntry[]
|
||||
fileCount: number
|
||||
directoryCount: number
|
||||
totalBytes: number
|
||||
excludedDirCount: number
|
||||
}
|
||||
|
||||
export class FolderArchiveLimitError extends Error {
|
||||
constructor(readonly code: 'archive_too_large' | 'archive_too_many_entries') {
|
||||
super(code)
|
||||
this.name = 'FolderArchiveLimitError'
|
||||
}
|
||||
}
|
||||
|
||||
export type FolderArchiveWalkOptions = {
|
||||
excludeDefaultDirs: boolean
|
||||
maxTotalBytes?: number
|
||||
maxEntries?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists a folder for archiving without ever following a symlink, so every entry stays under
|
||||
* the root's real path. A directory swapped for a link mid-walk fails its realpath check and is skipped.
|
||||
*/
|
||||
export async function planFolderArchive(
|
||||
rootPath: string,
|
||||
archiveRootName: string,
|
||||
options: FolderArchiveWalkOptions
|
||||
): Promise<FolderArchivePlan> {
|
||||
// The caller hands over the authorized, canonical path. If it no longer resolves to itself (the
|
||||
// folder was swapped for a link after authorization), refuse rather than walk wherever it points.
|
||||
const rootRealPath = await realpath(rootPath)
|
||||
const sameRoot =
|
||||
process.platform === 'win32'
|
||||
? rootRealPath.toLowerCase() === rootPath.toLowerCase()
|
||||
: rootRealPath === rootPath
|
||||
if (!sameRoot) {
|
||||
throw new Error('archive_not_directory')
|
||||
}
|
||||
const maxTotalBytes = options.maxTotalBytes ?? FOLDER_ARCHIVE_MAX_TOTAL_BYTES
|
||||
const maxEntries = options.maxEntries ?? FOLDER_ARCHIVE_MAX_ENTRIES
|
||||
const excluded = new Set<string>(
|
||||
options.excludeDefaultDirs ? FOLDER_ARCHIVE_DEFAULT_EXCLUDED_NAMES : []
|
||||
)
|
||||
const rootStat = await lstat(rootRealPath)
|
||||
if (!rootStat.isDirectory()) {
|
||||
throw new Error('archive_not_directory')
|
||||
}
|
||||
const plan: FolderArchivePlan = {
|
||||
rootRealPath,
|
||||
entries: [
|
||||
{
|
||||
archivePath: `${archiveRootName}/`,
|
||||
absolutePath: rootRealPath,
|
||||
kind: 'directory',
|
||||
size: 0,
|
||||
mode: rootStat.mode,
|
||||
mtimeMs: rootStat.mtimeMs,
|
||||
dev: rootStat.dev,
|
||||
ino: rootStat.ino
|
||||
}
|
||||
],
|
||||
fileCount: 0,
|
||||
directoryCount: 1,
|
||||
totalBytes: 0,
|
||||
excludedDirCount: 0
|
||||
}
|
||||
// Depth-first with an explicit stack: deep trees must not grow the JS call stack.
|
||||
const pending: PendingDirectory[] = [
|
||||
{
|
||||
absolutePath: rootRealPath,
|
||||
archivePath: `${archiveRootName}/`,
|
||||
dev: rootStat.dev,
|
||||
ino: rootStat.ino
|
||||
}
|
||||
]
|
||||
while (pending.length > 0) {
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error('archive_canceled')
|
||||
}
|
||||
const dir = pending.pop()!
|
||||
if (!(await isUnmovedDirectory(dir))) {
|
||||
continue
|
||||
}
|
||||
let names: string[]
|
||||
try {
|
||||
names = await readdir(dir.absolutePath)
|
||||
} catch (error) {
|
||||
if (isVanishedOrDenied(error)) {
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
names.sort()
|
||||
const stats = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
try {
|
||||
return { name, stat: await lstat(join(dir.absolutePath, name)) }
|
||||
} catch (error) {
|
||||
if (isVanishedOrDenied(error)) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
)
|
||||
// A directory swapped for a link while it was listed would have its children lstat'ed outside
|
||||
// the root; check it again and drop the whole listing if it moved.
|
||||
if (!(await isUnmovedDirectory(dir))) {
|
||||
continue
|
||||
}
|
||||
const childDirs: PendingDirectory[] = []
|
||||
// ZIP extractors on macOS and Windows fold case and Unicode form, so `A` and `a` (or NFC and
|
||||
// NFD spellings) would land on one path; later twins get a ` (2)` suffix instead.
|
||||
const usedNames = new Set<string>()
|
||||
for (const item of stats) {
|
||||
if (!item) {
|
||||
continue
|
||||
}
|
||||
const { name, stat } = item
|
||||
const absolutePath = join(dir.absolutePath, name)
|
||||
const archivePath = `${dir.archivePath}${uniqueArchiveSegment(sanitizeArchiveSegment(name), usedNames)}`
|
||||
if (stat.isDirectory()) {
|
||||
if (excluded.has(name)) {
|
||||
plan.excludedDirCount += 1
|
||||
continue
|
||||
}
|
||||
plan.directoryCount += 1
|
||||
plan.entries.push({
|
||||
archivePath: `${archivePath}/`,
|
||||
absolutePath,
|
||||
kind: 'directory',
|
||||
size: 0,
|
||||
mode: stat.mode,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
dev: stat.dev,
|
||||
ino: stat.ino
|
||||
})
|
||||
childDirs.push({
|
||||
absolutePath,
|
||||
archivePath: `${archivePath}/`,
|
||||
dev: stat.dev,
|
||||
ino: stat.ino
|
||||
})
|
||||
} else if (stat.isFile() || stat.isSymbolicLink()) {
|
||||
const kind = stat.isFile() ? 'file' : 'symlink'
|
||||
if (kind === 'file') {
|
||||
plan.fileCount += 1
|
||||
plan.totalBytes += stat.size
|
||||
if (plan.totalBytes > maxTotalBytes) {
|
||||
throw new FolderArchiveLimitError('archive_too_large')
|
||||
}
|
||||
}
|
||||
plan.entries.push({
|
||||
archivePath,
|
||||
absolutePath,
|
||||
kind,
|
||||
size: kind === 'file' ? stat.size : 0,
|
||||
mode: stat.mode,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
dev: stat.dev,
|
||||
ino: stat.ino
|
||||
})
|
||||
}
|
||||
// Sockets, FIFOs and devices have no archivable content.
|
||||
if (plan.entries.length > maxEntries) {
|
||||
throw new FolderArchiveLimitError('archive_too_many_entries')
|
||||
}
|
||||
}
|
||||
// Reverse so the stack pops children in sorted order.
|
||||
for (let index = childDirs.length - 1; index >= 0; index -= 1) {
|
||||
pending.push(childDirs[index])
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
/**
|
||||
* One path segment as stored in the ZIP. Linux allows `\` and control characters in names; Windows
|
||||
* extractors treat `\` as a separator, so a file named `..\..\x` would escape the extract folder.
|
||||
*/
|
||||
export function sanitizeArchiveSegment(name: string): string {
|
||||
// Bidi overrides and zero-width characters let `exe.txt` display as `txt.exe`; drop them.
|
||||
const visible = name.replace(INVISIBLE_FORMAT_CHARS, '')
|
||||
// Windows refuses these characters (`:` can even open an alternate data stream) and drops
|
||||
// trailing dots and spaces, so map them as local download names are mapped. `.`/`..` become `_`/`__`.
|
||||
const cleaned = visible
|
||||
// oxlint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u001f\u007f\\:*?"<>|]/g, '_')
|
||||
.replace(/[. ]+$/, (tail) => '_'.repeat(tail.length))
|
||||
if (cleaned === '') {
|
||||
return '_'
|
||||
}
|
||||
return WINDOWS_RESERVED_NAME.test(cleaned) ? `_${cleaned}` : cleaned
|
||||
}
|
||||
|
||||
// Superscript digits written as escapes: the orcad bundle must stay ASCII.
|
||||
const WINDOWS_RESERVED_NAME =
|
||||
/^(?:con|prn|aux|nul|conin\$|conout\$|com[0-9\u00B9\u00B2\u00B3]|lpt[0-9\u00B9\u00B2\u00B3])(?:\..*)?$/i
|
||||
|
||||
const INVISIBLE_FORMAT_CHARS = /[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g
|
||||
|
||||
function uniqueArchiveSegment(segment: string, used: Set<string>): string {
|
||||
const fold = (value: string) => value.normalize('NFC').toLowerCase()
|
||||
let candidate = segment
|
||||
const dot = segment.lastIndexOf('.')
|
||||
const stem = dot > 0 ? segment.slice(0, dot) : segment
|
||||
const extension = dot > 0 ? segment.slice(dot) : ''
|
||||
for (let copy = 2; used.has(fold(candidate)); copy += 1) {
|
||||
candidate = `${stem} (${copy})${extension}`
|
||||
}
|
||||
used.add(fold(candidate))
|
||||
return candidate
|
||||
}
|
||||
|
||||
type PendingDirectory = { absolutePath: string; archivePath: string; dev: number; ino: number }
|
||||
|
||||
async function isUnmovedDirectory(dir: PendingDirectory): Promise<boolean> {
|
||||
try {
|
||||
const resolved = await realpath(dir.absolutePath)
|
||||
// Windows paths compare case-insensitively; realpath may report a different casing.
|
||||
const samePath =
|
||||
process.platform === 'win32'
|
||||
? resolved.toLowerCase() === dir.absolutePath.toLowerCase()
|
||||
: resolved === dir.absolutePath
|
||||
if (!samePath) {
|
||||
return false
|
||||
}
|
||||
const current = await lstat(dir.absolutePath)
|
||||
return current.isDirectory() && current.dev === dir.dev && current.ino === dir.ino
|
||||
} catch (error) {
|
||||
if (isVanishedOrDenied(error)) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function isVanishedOrDenied(error: unknown): boolean {
|
||||
const code =
|
||||
typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined
|
||||
return (
|
||||
code === 'ENOENT' ||
|
||||
code === 'ENOTDIR' ||
|
||||
code === 'EACCES' ||
|
||||
code === 'EPERM' ||
|
||||
code === 'ELOOP'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { readlink, type FileHandle } from 'node:fs/promises'
|
||||
import { extname, isAbsolute, posix } from 'node:path'
|
||||
import { Zip, ZipDeflate, ZipPassThrough, type ZipInputFile } from 'fflate'
|
||||
import { isVanishedOrDenied, type FolderArchiveEntry } from './folder-archive-walk'
|
||||
import {
|
||||
ChangedSinceWalkError,
|
||||
isSmallFile,
|
||||
openUnchangedFile,
|
||||
readSmallFile,
|
||||
type SmallFileRead
|
||||
} from './folder-archive-file-reads'
|
||||
|
||||
const FILE_READ_BYTES = 256 * 1024
|
||||
/** Small files read ahead at once; bounds look-ahead memory to about 2 MiB. */
|
||||
const PREFETCH_WINDOW = 32
|
||||
/** Encoded bytes buffered ahead of the reader before the producer pauses. */
|
||||
const DEFAULT_HIGH_WATER_BYTES = 8 * 1024 * 1024
|
||||
// Level 4 keeps pure-JS deflate fast enough not to starve a fast link while still shrinking text.
|
||||
const DEFLATE_LEVEL = 4
|
||||
const UNIX_OS = 3
|
||||
const S_IFLNK = 0o120000
|
||||
// Recompressing these costs CPU for no size win.
|
||||
const STORED_EXTENSIONS = new Set([
|
||||
'.7z',
|
||||
'.avif',
|
||||
'.br',
|
||||
'.bz2',
|
||||
'.dmg',
|
||||
'.gif',
|
||||
'.gz',
|
||||
'.heic',
|
||||
'.jar',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.m4a',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.ogg',
|
||||
'.png',
|
||||
'.rar',
|
||||
'.tgz',
|
||||
'.webm',
|
||||
'.webp',
|
||||
'.whl',
|
||||
'.woff',
|
||||
'.woff2',
|
||||
'.xz',
|
||||
'.zip',
|
||||
'.zst'
|
||||
])
|
||||
|
||||
/**
|
||||
* Turns a planned entry list into ZIP bytes on demand. The producer runs ahead of the reader by at
|
||||
* most `highWaterBytes`, so a slow link never makes the host buffer a whole folder.
|
||||
*/
|
||||
export class FolderArchiveZipStream {
|
||||
private readonly chunks: Uint8Array[] = []
|
||||
// Head index instead of shift(): a folder of small files leaves thousands of tiny chunks queued.
|
||||
private chunkHead = 0
|
||||
private bufferedBytes = 0
|
||||
private finished = false
|
||||
private failure: Error | null = null
|
||||
private canceled = false
|
||||
private wakeReader: (() => void) | null = null
|
||||
private wakeProducer: (() => void) | null = null
|
||||
private readonly zip: Zip
|
||||
processedBytes = 0
|
||||
skippedCount = 0
|
||||
|
||||
constructor(
|
||||
private readonly entries: readonly FolderArchiveEntry[],
|
||||
private readonly rootRealPath: string,
|
||||
private readonly highWaterBytes = DEFAULT_HIGH_WATER_BYTES
|
||||
) {
|
||||
this.zip = new Zip((error, data, final) => {
|
||||
if (error) {
|
||||
this.fail(error)
|
||||
return
|
||||
}
|
||||
if (data.byteLength > 0) {
|
||||
this.chunks.push(data)
|
||||
this.bufferedBytes += data.byteLength
|
||||
}
|
||||
if (final) {
|
||||
this.finished = true
|
||||
}
|
||||
this.notifyReader()
|
||||
})
|
||||
void this.produce()
|
||||
}
|
||||
|
||||
/** Resolves with up to `maxBytes` once that much is buffered or the archive ends. */
|
||||
async read(maxBytes: number): Promise<{ bytes: Buffer; eof: boolean }> {
|
||||
while (!this.failure && !this.finished && this.bufferedBytes < maxBytes) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.wakeReader = resolve
|
||||
})
|
||||
}
|
||||
if (this.failure) {
|
||||
throw this.failure
|
||||
}
|
||||
const parts: Uint8Array[] = []
|
||||
let taken = 0
|
||||
while (this.chunkHead < this.chunks.length && taken < maxBytes) {
|
||||
const head = this.chunks[this.chunkHead]
|
||||
const room = maxBytes - taken
|
||||
if (head.byteLength <= room) {
|
||||
parts.push(head)
|
||||
taken += head.byteLength
|
||||
this.chunkHead += 1
|
||||
} else {
|
||||
parts.push(head.subarray(0, room))
|
||||
this.chunks[this.chunkHead] = head.subarray(room)
|
||||
taken += room
|
||||
}
|
||||
}
|
||||
if (this.chunkHead > 1024 || this.chunkHead === this.chunks.length) {
|
||||
this.chunks.splice(0, this.chunkHead)
|
||||
this.chunkHead = 0
|
||||
}
|
||||
this.bufferedBytes -= taken
|
||||
this.notifyProducer()
|
||||
return {
|
||||
bytes: Buffer.concat(parts, taken),
|
||||
eof: this.finished && this.chunkHead >= this.chunks.length
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.canceled = true
|
||||
this.zip.terminate()
|
||||
this.chunks.length = 0
|
||||
this.chunkHead = 0
|
||||
this.bufferedBytes = 0
|
||||
this.fail(new Error('archive_canceled'))
|
||||
this.notifyProducer()
|
||||
}
|
||||
|
||||
private async produce(): Promise<void> {
|
||||
const prefetched = new Map<number, Promise<SmallFileRead>>()
|
||||
try {
|
||||
for (let index = 0; index < this.entries.length; index += 1) {
|
||||
if (this.canceled) {
|
||||
return
|
||||
}
|
||||
// Keep the next few small files' reads in flight while this entry is zipped.
|
||||
for (
|
||||
let ahead = index;
|
||||
ahead < index + PREFETCH_WINDOW && ahead < this.entries.length;
|
||||
ahead += 1
|
||||
) {
|
||||
const next = this.entries[ahead]
|
||||
if (!prefetched.has(ahead) && isSmallFile(next)) {
|
||||
prefetched.set(ahead, readSmallFile(next, this.rootRealPath))
|
||||
}
|
||||
}
|
||||
const small = prefetched.get(index)
|
||||
prefetched.delete(index)
|
||||
await this.addEntry(this.entries[index], small ? await small : undefined)
|
||||
}
|
||||
this.zip.end()
|
||||
} catch (error) {
|
||||
this.fail(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
|
||||
private async addEntry(entry: FolderArchiveEntry, small?: SmallFileRead): Promise<void> {
|
||||
if (entry.kind === 'directory') {
|
||||
const file = withAttributes(new ZipPassThrough(entry.archivePath), entry, entry.mode)
|
||||
this.zip.add(file)
|
||||
file.push(new Uint8Array(0), true)
|
||||
return
|
||||
}
|
||||
if (entry.kind === 'symlink') {
|
||||
let target: string
|
||||
try {
|
||||
target = await readlink(entry.absolutePath)
|
||||
} catch (error) {
|
||||
return this.skip(error)
|
||||
}
|
||||
// Stored as a link, never dereferenced. A link that points outside the archived folder would
|
||||
// let an extractor write through it (zip-slip), so only links that stay inside are kept.
|
||||
if (!isContainedLinkTarget(entry.archivePath, target)) {
|
||||
this.skippedCount += 1
|
||||
return
|
||||
}
|
||||
const file = withAttributes(
|
||||
new ZipPassThrough(entry.archivePath),
|
||||
entry,
|
||||
S_IFLNK | (entry.mode & 0o777)
|
||||
)
|
||||
this.zip.add(file)
|
||||
file.push(new TextEncoder().encode(target), true)
|
||||
return
|
||||
}
|
||||
if (small?.kind === 'skip') {
|
||||
return this.skip(small.error)
|
||||
}
|
||||
if (small?.kind === 'bytes') {
|
||||
await this.waitForRoom()
|
||||
if (this.canceled) {
|
||||
return
|
||||
}
|
||||
const file = this.addFileEntry(entry)
|
||||
this.processedBytes += small.bytes.byteLength
|
||||
file.push(small.bytes, true)
|
||||
return
|
||||
}
|
||||
let handle: FileHandle
|
||||
try {
|
||||
handle = await openUnchangedFile(entry, this.rootRealPath)
|
||||
} catch (error) {
|
||||
return this.skip(error)
|
||||
}
|
||||
try {
|
||||
const file = this.addFileEntry(entry)
|
||||
const buffer = Buffer.allocUnsafe(FILE_READ_BYTES)
|
||||
// Never past the walked size: a file that keeps growing (a log, /dev/zero behind a bind
|
||||
// mount) cannot push the archive past the limits the walk enforced.
|
||||
let remaining = entry.size
|
||||
for (;;) {
|
||||
await this.waitForRoom()
|
||||
if (this.canceled) {
|
||||
return
|
||||
}
|
||||
const { bytesRead } =
|
||||
remaining > 0
|
||||
? await handle.read(buffer, 0, Math.min(FILE_READ_BYTES, remaining), null)
|
||||
: { bytesRead: 0 }
|
||||
if (bytesRead === 0) {
|
||||
file.push(new Uint8Array(0), true)
|
||||
return
|
||||
}
|
||||
remaining -= bytesRead
|
||||
this.processedBytes += bytesRead
|
||||
// fflate may keep a reference until it deflates, so hand it a copy.
|
||||
file.push(new Uint8Array(buffer.subarray(0, bytesRead)), false)
|
||||
}
|
||||
} finally {
|
||||
await handle.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
private addFileEntry(entry: FolderArchiveEntry): ZipPassThrough | ZipDeflate {
|
||||
const file = withAttributes(
|
||||
STORED_EXTENSIONS.has(extname(entry.archivePath).toLowerCase())
|
||||
? new ZipPassThrough(entry.archivePath)
|
||||
: new ZipDeflate(entry.archivePath, { level: DEFLATE_LEVEL }),
|
||||
entry,
|
||||
entry.mode
|
||||
)
|
||||
this.zip.add(file)
|
||||
return file
|
||||
}
|
||||
|
||||
private skip(error: unknown): void {
|
||||
if (!(error instanceof ChangedSinceWalkError) && !isVanishedOrDenied(error)) {
|
||||
throw error
|
||||
}
|
||||
this.skippedCount += 1
|
||||
}
|
||||
|
||||
private async waitForRoom(): Promise<void> {
|
||||
while (!this.canceled && this.bufferedBytes >= this.highWaterBytes) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.wakeProducer = resolve
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fail(error: Error): void {
|
||||
this.failure ??= error
|
||||
this.notifyReader()
|
||||
}
|
||||
|
||||
private notifyReader(): void {
|
||||
const wake = this.wakeReader
|
||||
this.wakeReader = null
|
||||
wake?.()
|
||||
}
|
||||
|
||||
private notifyProducer(): void {
|
||||
const wake = this.wakeProducer
|
||||
this.wakeProducer = null
|
||||
wake?.()
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a link stored at `archivePath` resolves inside the archive's top folder. */
|
||||
export function isContainedLinkTarget(archivePath: string, target: string): boolean {
|
||||
if (target === '' || isAbsolute(target) || target.includes('\\') || /^[a-zA-Z]:/.test(target)) {
|
||||
return false
|
||||
}
|
||||
// A `..` after a normal segment may climb through another stored link (`s/../..`), which the
|
||||
// lexical check below can't see; keep only targets whose `..` steps all come first.
|
||||
const segments = target.split('/')
|
||||
const firstNormal = segments.findIndex((segment) => segment !== '..' && segment !== '.')
|
||||
if (firstNormal !== -1 && segments.slice(firstNormal).includes('..')) {
|
||||
return false
|
||||
}
|
||||
const rootName = archivePath.slice(0, archivePath.indexOf('/'))
|
||||
const resolved = posix.normalize(posix.join(posix.dirname(archivePath), target))
|
||||
return resolved === rootName || resolved.startsWith(`${rootName}/`)
|
||||
}
|
||||
|
||||
function withAttributes<T extends ZipInputFile>(
|
||||
file: T,
|
||||
entry: FolderArchiveEntry,
|
||||
mode: number
|
||||
): T {
|
||||
file.os = UNIX_OS
|
||||
file.attrs = (mode & 0xffff) << 16
|
||||
file.mtime = clampZipMtime(entry.mtimeMs)
|
||||
return file
|
||||
}
|
||||
|
||||
// DOS timestamps cover 1980-2107; fflate throws outside that range.
|
||||
function clampZipMtime(mtimeMs: number): number {
|
||||
const min = Date.UTC(1980, 0, 2)
|
||||
const max = Date.UTC(2107, 11, 30)
|
||||
return Math.min(Math.max(mtimeMs, min), max)
|
||||
}
|
||||
@@ -198,6 +198,16 @@ export class RuntimeFileReads {
|
||||
return readLocalFileRange(filePath, offset, length)
|
||||
}
|
||||
|
||||
/** A workspace folder's real path for archiving, under the same containment as chunk reads. */
|
||||
async resolveFileExplorerArchivePath(
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<string> {
|
||||
const target = await resolveFileExplorerPath(this.host, worktreeSelector, relativePath)
|
||||
requireLocalRuntimeFileRoute(target)
|
||||
return resolveRuntimeAuthorizedPath(target.path, this.host.requireStore())
|
||||
}
|
||||
|
||||
/** A host image a chat names, under desktop's chat-image rule; the path must be on this machine. */
|
||||
async readChatImagePreview(
|
||||
worktreeSelector: string,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineMethod, type RpcContext } from '../../core'
|
||||
import {
|
||||
FileArchiveCancel,
|
||||
FileArchiveRead,
|
||||
FileArchiveStart
|
||||
} from '../../../../../shared/rpc-contract/files-archive-params'
|
||||
import { getFolderArchiveSessions } from '../../../files/archive/folder-archive-sessions'
|
||||
|
||||
// Why device identity: one-shot calls from the same desktop may arrive on different sockets.
|
||||
function archiveOwnerKey(
|
||||
context: Pick<RpcContext, 'pairedDeviceId' | 'clientId' | 'connectionId'>
|
||||
) {
|
||||
return context.pairedDeviceId ?? context.clientId ?? context.connectionId ?? 'in-process'
|
||||
}
|
||||
|
||||
/** Folder downloads: the host zips a workspace folder and the client pages the bytes. */
|
||||
export const FILE_ARCHIVE_METHODS = [
|
||||
defineMethod({
|
||||
name: 'files.archiveStart',
|
||||
permission: 'workspace',
|
||||
params: FileArchiveStart,
|
||||
handler: async (params, context) => {
|
||||
// Same containment as every files.* read: workspace-relative, then authorized-root checked.
|
||||
const folderPath = await context.runtime.resolveFileExplorerArchivePath(
|
||||
params.worktree,
|
||||
params.relativePath
|
||||
)
|
||||
return getFolderArchiveSessions(context.runtime).start({
|
||||
folderPath,
|
||||
ownerKey: archiveOwnerKey(context),
|
||||
excludeDefaultDirs: params.excludeDefaultDirs !== false,
|
||||
...(context.signal ? { signal: context.signal } : {})
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.archiveRead',
|
||||
permission: 'workspace',
|
||||
params: FileArchiveRead,
|
||||
handler: async (params, context) =>
|
||||
getFolderArchiveSessions(context.runtime).read({
|
||||
archiveId: params.archiveId,
|
||||
ownerKey: archiveOwnerKey(context),
|
||||
maxBytes: params.maxBytes
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'files.archiveCancel',
|
||||
permission: 'workspace',
|
||||
params: FileArchiveCancel,
|
||||
handler: async (params, context) => ({
|
||||
released: getFolderArchiveSessions(context.runtime).cancel({
|
||||
archiveId: params.archiveId,
|
||||
ownerKey: archiveOwnerKey(context)
|
||||
})
|
||||
})
|
||||
})
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineMethod, defineStreamingMethod } from '../../core'
|
||||
import { runFileWatchStream } from './file-watch-stream-lifecycle'
|
||||
import { FILE_ARCHIVE_METHODS } from './files-archive-methods'
|
||||
import { FILE_MUTATION_METHODS } from './files-mutation-methods'
|
||||
import { dirListingPageCache } from './files-read-dir-page'
|
||||
import { FILE_STAGED_SAVE_METHODS } from './files-staged-save-methods'
|
||||
@@ -210,6 +211,7 @@ export const FILE_METHODS = [
|
||||
}),
|
||||
...FILE_MUTATION_METHODS,
|
||||
...FILE_STAGED_SAVE_METHODS,
|
||||
...FILE_ARCHIVE_METHODS,
|
||||
defineMethod({
|
||||
name: 'files.search',
|
||||
permission: 'workspace',
|
||||
|
||||
@@ -71,6 +71,7 @@ import type { ShellApi } from './api/shell-api'
|
||||
import type { SpeechApi } from './api/speech-api'
|
||||
import type { SshApi } from './api/ssh-api'
|
||||
import type { PlainSshTerminalApi } from './api/plain-ssh-terminal-bridge'
|
||||
import type { RuntimeDownloadsApi } from './api/runtime-downloads-bridge'
|
||||
import type { LegacyRelayBridgeApi } from './api/legacy-relay-bridge-api'
|
||||
import type { DiagnosticsApi, MemoryApi, StatsApi, TelemetryApi } from './api/telemetry-api'
|
||||
import type { UiCommandEventApi } from './api/ui-command-event-api'
|
||||
@@ -180,6 +181,8 @@ export type PreloadApi = {
|
||||
ssh: SshApi
|
||||
/** Desktop only: an unmanaged ssh login to a host whose move is blocked. */
|
||||
plainSsh?: PlainSshTerminalApi
|
||||
/** Desktop only: streams files and folders from a paired runtime host to this machine. */
|
||||
runtimeDownloads?: RuntimeDownloadsApi
|
||||
/** Desktop only: the API keys Orca hands to agent launches. */
|
||||
agentApiKeys?: AgentApiKeysApi
|
||||
/** Desktop only: Orca Cloud API keys, org settings and routines (hosted builds). */
|
||||
|
||||
@@ -212,6 +212,12 @@ export type BrowserApi = {
|
||||
) => () => void
|
||||
/** Escape pressed in a focused page; the page also receives it unless a grab pick is running. */
|
||||
onGuestEscape: (callback: (args: { browserPageId: string }) => void) => () => void
|
||||
/** A browser page became (or stopped being) a remote desktop, or gained or lost the keyboard. */
|
||||
onRemoteDesktopState: (
|
||||
callback: (args: { browserPageId: string; desktop: boolean; captured: boolean }) => void
|
||||
) => () => void
|
||||
/** The release chord was pressed in a remote desktop: give the keyboard back to Orca. */
|
||||
onRemoteDesktopRelease: (callback: (args: { browserPageId: string }) => void) => () => void
|
||||
sessionListProfiles: () => Promise<BrowserSessionProfile[]>
|
||||
onSessionProfilesChanged: (callback: () => void) => () => void
|
||||
/** Resolves once the SSH workspace's partition is bound and proxy-verified; the webview must wait for it. */
|
||||
|
||||
@@ -168,6 +168,22 @@ export const browserPageInteractionAndSessionsApi = {
|
||||
ipcRenderer.on('browser:guestEscape', listener)
|
||||
return () => ipcRenderer.removeListener('browser:guestEscape', listener)
|
||||
},
|
||||
onRemoteDesktopState: (
|
||||
callback: (args: { browserPageId: string; desktop: boolean; captured: boolean }) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { browserPageId: string; desktop: boolean; captured: boolean }
|
||||
) => callback(data)
|
||||
ipcRenderer.on('browser:remoteDesktopState', listener)
|
||||
return () => ipcRenderer.removeListener('browser:remoteDesktopState', listener)
|
||||
},
|
||||
onRemoteDesktopRelease: (callback: (args: { browserPageId: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { browserPageId: string }) =>
|
||||
callback(data)
|
||||
ipcRenderer.on('browser:remoteDesktopRelease', listener)
|
||||
return () => ipcRenderer.removeListener('browser:remoteDesktopRelease', listener)
|
||||
},
|
||||
sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'),
|
||||
onSessionProfilesChanged: (callback: () => void): (() => void) => {
|
||||
const listener = (): void => callback()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type {
|
||||
RuntimeDownloadProgressEvent,
|
||||
RuntimeDownloadResult,
|
||||
RuntimeDownloadStartArgs
|
||||
} from '../../shared/runtime-downloads/runtime-download-contract'
|
||||
|
||||
export type RuntimeDownloadsApi = {
|
||||
/** Resolves when the download finishes, fails or is canceled. */
|
||||
start: (args: RuntimeDownloadStartArgs) => Promise<RuntimeDownloadResult>
|
||||
cancel: (args: { downloadId: string }) => Promise<{ canceled: boolean }>
|
||||
onProgress: (callback: (event: RuntimeDownloadProgressEvent) => void) => () => void
|
||||
}
|
||||
|
||||
export const runtimeDownloadsApi: RuntimeDownloadsApi = {
|
||||
start: (args) => ipcRenderer.invoke('runtimeDownloads:start', args),
|
||||
cancel: (args) => ipcRenderer.invoke('runtimeDownloads:cancel', args),
|
||||
onProgress: (callback) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: RuntimeDownloadProgressEvent) =>
|
||||
callback(payload)
|
||||
ipcRenderer.on('runtimeDownloads:progress', listener)
|
||||
return () => ipcRenderer.removeListener('runtimeDownloads:progress', listener)
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ import { grokAccountsApi } from './api/grok-accounts-bridge'
|
||||
import { cursorAccountsApi } from './api/cursor-accounts-bridge'
|
||||
import { sshApi } from './api/ssh-bridge'
|
||||
import { plainSshTerminalApi } from './api/plain-ssh-terminal-bridge'
|
||||
import { runtimeDownloadsApi } from './api/runtime-downloads-bridge'
|
||||
import { agentApiKeysApi } from './api/agent-api-keys-bridge'
|
||||
import { cloudApiBridge } from './api/cloud-api-bridge'
|
||||
import { cloudSecretsApi } from './api/cloud-secrets-bridge'
|
||||
@@ -205,6 +206,7 @@ const api = {
|
||||
cursorAccounts: cursorAccountsApi,
|
||||
ssh: sshApi,
|
||||
plainSsh: plainSshTerminalApi,
|
||||
runtimeDownloads: runtimeDownloadsApi,
|
||||
agentApiKeys: agentApiKeysApi,
|
||||
cloudApi: cloudApiBridge,
|
||||
cloudSecrets: cloudSecretsApi,
|
||||
|
||||
@@ -42,29 +42,25 @@ export function TeamPlanCard({
|
||||
const discounted =
|
||||
interval === 'year' && monthly !== null && fullMonthly !== null && monthly < fullMonthly
|
||||
|
||||
// Same bullets as the marketing site's /cloud pricing card; the overage line is in-app only.
|
||||
const features = [
|
||||
t(
|
||||
'auto.components.billing.plans.featureHours4',
|
||||
'{{value0}} sandbox hours per seat each month, pooled across your team',
|
||||
{ value0: String(team.includedHoursPerSeat) }
|
||||
),
|
||||
t('auto.components.billing.plans.featureStandard', 'Standard (4 vCPU · 8 GB)'),
|
||||
t(
|
||||
'auto.components.billing.plans.featureLarge3',
|
||||
'Standard (4 vCPU · 8 GB) sandboxes; Large (8 vCPU · 16 GB) uses sandbox hours at {{value0}}× the rate',
|
||||
{
|
||||
value0: String(team.largeMachineWeight)
|
||||
}
|
||||
'auto.components.billing.plans.featureLarge4',
|
||||
'Large (8 vCPU · 16 GB) uses sandbox hours at {{value0}}× the rate',
|
||||
{ value0: String(team.largeMachineWeight) }
|
||||
),
|
||||
t(
|
||||
'auto.components.billing.plans.featureConcurrency2',
|
||||
'{{value0}} sandboxes running at once per seat',
|
||||
{
|
||||
value0: String(team.concurrentSandboxesPerSeat)
|
||||
}
|
||||
'auto.components.billing.plans.featureSleep3',
|
||||
'Sleeps after {{value0}} minutes idle, so quiet time is free',
|
||||
{ value0: String(team.idleSleepMinutes) }
|
||||
),
|
||||
t('auto.components.billing.plans.featureSleep2', 'Sleeps after {{value0}} minutes idle', {
|
||||
value0: String(team.idleSleepMinutes)
|
||||
}),
|
||||
t('auto.components.billing.plans.featureReady', 'Ready in seconds, nothing to set up or host'),
|
||||
t(
|
||||
'auto.components.billing.plans.featureOverage3',
|
||||
'Optional overage at {{value0}}/hour ({{value1}}/hour on Large), capped at a monthly spend cap you set ({{value2}} by default)',
|
||||
|
||||
+2
@@ -21,6 +21,7 @@ import { BrowserLoadFailureOverlay } from '../navigate/browser-load-failure-over
|
||||
import { useSshWorkspaceBrowserLoadFailureRoute } from '../use-ssh-workspace-browser-route'
|
||||
import type { BrowserLoadFailureAutoRetry } from '../navigate/browser-load-failure-auto-retry'
|
||||
import BrowserFind from './BrowserFind'
|
||||
import { RemoteDesktopKeyboardHint } from '../../cloud-remote-desktop/RemoteDesktopKeyboardHint'
|
||||
import { BrowserNewTabPage } from '../new-tab/BrowserNewTabPage'
|
||||
import { BrowserGuestAnnotateOverlays } from '../annotate/browser-guest-annotate-overlays'
|
||||
import { BrowserPasswordFillDropdown } from '../passwords/BrowserPasswordFillDropdown'
|
||||
@@ -114,6 +115,7 @@ export function BrowserPageViewportOverlays({
|
||||
webviewRef={webviewRef}
|
||||
/>
|
||||
<BrowserLinkStatusBubble browserPageId={browserTab.id} />
|
||||
<RemoteDesktopKeyboardHint browserPageId={browserTab.id} webviewRef={webviewRef} />
|
||||
{showFailureOverlay && browserTab.loadError ? (
|
||||
<BrowserPageLoadFailure
|
||||
browserTab={browserTab}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type CloudRemoteDesktopToastState
|
||||
} from './CloudRemoteDesktopToast'
|
||||
import { RemoteDesktopMenuEntry } from './RemoteDesktopMenuEntry'
|
||||
import { RemoteDesktopKeyboardHintView } from './RemoteDesktopKeyboardHint'
|
||||
|
||||
const noop = (): void => {}
|
||||
|
||||
@@ -53,3 +54,23 @@ export const unavailable = toastFixture({ kind: 'failed', code: 'unavailable' })
|
||||
export const connectFailed = toastFixture({ kind: 'failed', code: 'forward_failed' })
|
||||
export const signedOut = toastFixture({ kind: 'failed', code: 'signed_out' })
|
||||
export const notCloud = toastFixture({ kind: 'failed', code: 'not_cloud' })
|
||||
export const sandboxStarting = toastFixture({ kind: 'failed', code: 'starting' })
|
||||
export const archived = toastFixture({ kind: 'failed', code: 'archived' })
|
||||
|
||||
function keyboardHintFixture(kind: 'captured' | 'released', chord: string, width = 520) {
|
||||
return defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
// The hint floats over the desktop at the top of the tab's page area.
|
||||
<div className="relative h-[120px] overflow-hidden bg-[#1e1f22]">
|
||||
<RemoteDesktopKeyboardHintView kind={kind} visible chord={chord} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const keyboardCaptured = keyboardHintFixture('captured', '⌃⌘')
|
||||
export const keyboardCapturedWindows = keyboardHintFixture('captured', 'Ctrl+Alt')
|
||||
export const keyboardReleased = keyboardHintFixture('released', '⌃⌘')
|
||||
/** A narrow split pane: the hint wraps instead of clipping. */
|
||||
export const keyboardCapturedNarrow = keyboardHintFixture('captured', '⌃⌘', 320)
|
||||
|
||||
@@ -11,7 +11,13 @@ export type CloudRemoteDesktopToastState =
|
||||
| { kind: 'starting'; slow: boolean }
|
||||
| { kind: 'failed'; code: CloudRemoteDesktopFailureCode }
|
||||
|
||||
type FailureCopy = { title: string; detail: string; action: CloudRemoteDesktopNextAction | null }
|
||||
type FailureCopy = {
|
||||
title: string
|
||||
detail: string
|
||||
action: CloudRemoteDesktopNextAction | null
|
||||
/** Asleep and starting are states, not errors. */
|
||||
tone?: 'neutral'
|
||||
}
|
||||
|
||||
function failureCopy(t: Translate, code: CloudRemoteDesktopFailureCode): FailureCopy {
|
||||
switch (code) {
|
||||
@@ -29,12 +35,36 @@ function failureCopy(t: Translate, code: CloudRemoteDesktopFailureCode): Failure
|
||||
}
|
||||
case 'not_running':
|
||||
return {
|
||||
title: t('components.cloudRemoteDesktop.asleepTitle', 'This workspace is asleep.'),
|
||||
title: t('components.cloudRemoteDesktop.asleepTitle', 'This sandbox is asleep.'),
|
||||
detail: t(
|
||||
'components.cloudRemoteDesktop.asleepDetail',
|
||||
'Wake it, then open the remote desktop again. Your files are safe.'
|
||||
),
|
||||
action: 'wake'
|
||||
action: 'wake',
|
||||
tone: 'neutral'
|
||||
}
|
||||
case 'archived':
|
||||
return {
|
||||
title: t('components.cloudRemoteDesktop.archivedTitle', 'This sandbox is archived.'),
|
||||
detail: t(
|
||||
'components.cloudRemoteDesktop.archivedDetail',
|
||||
'Unarchive the workspace to use the remote desktop again. Your files are safe.'
|
||||
),
|
||||
action: null,
|
||||
tone: 'neutral'
|
||||
}
|
||||
case 'starting':
|
||||
return {
|
||||
title: t(
|
||||
'components.cloudRemoteDesktop.startingTitle',
|
||||
'The sandbox is still starting or waking.'
|
||||
),
|
||||
detail: t(
|
||||
'components.cloudRemoteDesktop.startingDetail',
|
||||
'Try again in a moment. Your files are safe.'
|
||||
),
|
||||
action: 'retry',
|
||||
tone: 'neutral'
|
||||
}
|
||||
case 'not_installed':
|
||||
return {
|
||||
@@ -90,7 +120,7 @@ function failureCopy(t: Translate, code: CloudRemoteDesktopFailureCode): Failure
|
||||
function actionLabel(t: Translate, action: CloudRemoteDesktopNextAction): string {
|
||||
switch (action) {
|
||||
case 'wake':
|
||||
return t('components.cloudRemoteDesktop.wake', 'Wake workspace')
|
||||
return t('components.cloudRemoteDesktop.wake', 'Wake sandbox')
|
||||
case 'sign_in':
|
||||
return t('components.cloudRemoteDesktop.signIn', 'Sign in')
|
||||
case 'create_workspace':
|
||||
@@ -146,6 +176,7 @@ export function CloudRemoteDesktopToast({
|
||||
<InlineError
|
||||
title={copy.title}
|
||||
detail={copy.detail}
|
||||
tone={copy.tone}
|
||||
layout="toast"
|
||||
action={
|
||||
copy.action ? (
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState, type MutableRefObject } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { isMacUserAgent } from '@/lib/user-agent-platform'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Main's release chord (remote-desktop-keyboard.ts), as the person presses it. */
|
||||
export function remoteDesktopReleaseChordLabel(mac: boolean = isMacUserAgent()): string {
|
||||
return mac ? '⌃⌘' : 'Ctrl+Alt'
|
||||
}
|
||||
|
||||
const CAPTURED_HINT_MS = 4_000
|
||||
const RELEASED_HINT_MS = 2_500
|
||||
|
||||
type HintKind = 'captured' | 'released'
|
||||
|
||||
export function RemoteDesktopKeyboardHintView({
|
||||
kind,
|
||||
visible,
|
||||
chord
|
||||
}: {
|
||||
kind: HintKind
|
||||
visible: boolean
|
||||
chord: string
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-x-2 top-2 z-20 mx-auto w-fit rounded-md border border-border bg-popover/95 px-2.5 py-1 text-center text-xs text-balance text-popover-foreground shadow-md motion-safe:transition-opacity motion-safe:duration-200',
|
||||
visible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
{kind === 'captured' ? (
|
||||
<Trans
|
||||
i18n={i18n}
|
||||
defaults={t(
|
||||
'components.cloudRemoteDesktop.keyboardCaptured',
|
||||
'Keys go to the remote desktop. Press <kbd>{{chord}}</kbd> to use Orca shortcuts.',
|
||||
{ chord }
|
||||
)}
|
||||
components={{
|
||||
kbd: (
|
||||
<kbd className="mx-0.5 inline-flex h-4 items-center rounded border border-border bg-muted px-1 font-mono text-[10px] font-medium" />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t(
|
||||
'components.cloudRemoteDesktop.keyboardReleased',
|
||||
'Orca shortcuts are back. Click the desktop to type in it.'
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A remote desktop page keeps every key while focused (main stops forwarding Orca's shortcuts).
|
||||
* Shows how to get them back when the desktop takes the keyboard, and moves focus out of the page
|
||||
* when main reports the release chord.
|
||||
*/
|
||||
export function RemoteDesktopKeyboardHint({
|
||||
browserPageId,
|
||||
webviewRef
|
||||
}: {
|
||||
browserPageId: string
|
||||
webviewRef: MutableRefObject<Electron.WebviewTag | null>
|
||||
}): React.JSX.Element | null {
|
||||
const [desktop, setDesktop] = useState(false)
|
||||
// Kept after it hides so the text does not change while it fades out.
|
||||
const [hint, setHint] = useState<{ kind: HintKind; until: number; visible: boolean }>({
|
||||
kind: 'captured',
|
||||
until: 0,
|
||||
visible: false
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let wasCaptured = false
|
||||
// Once per page load and after each release: not on every click back into the desktop.
|
||||
let hintDue = true
|
||||
const unsubscribeState = window.api.browser.onRemoteDesktopState((event) => {
|
||||
if (event.browserPageId !== browserPageId) {
|
||||
return
|
||||
}
|
||||
setDesktop(event.desktop)
|
||||
if (!event.desktop) {
|
||||
hintDue = true
|
||||
}
|
||||
if (event.captured && !wasCaptured && hintDue) {
|
||||
hintDue = false
|
||||
setHint({ kind: 'captured', until: Date.now() + CAPTURED_HINT_MS, visible: true })
|
||||
} else if (!event.captured) {
|
||||
setHint((current) =>
|
||||
current.kind === 'captured' ? { ...current, visible: false } : current
|
||||
)
|
||||
}
|
||||
wasCaptured = event.captured
|
||||
})
|
||||
const unsubscribeRelease = window.api.browser.onRemoteDesktopRelease((event) => {
|
||||
if (event.browserPageId !== browserPageId) {
|
||||
return
|
||||
}
|
||||
// Focus leaves the page, so keys reach Orca's window again.
|
||||
webviewRef.current?.blur()
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
hintDue = true
|
||||
setHint({ kind: 'released', until: Date.now() + RELEASED_HINT_MS, visible: true })
|
||||
})
|
||||
return () => {
|
||||
unsubscribeState()
|
||||
unsubscribeRelease()
|
||||
}
|
||||
}, [browserPageId, webviewRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hint.visible) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => setHint((current) => ({ ...current, visible: false })),
|
||||
Math.max(0, hint.until - Date.now())
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}, [hint])
|
||||
|
||||
if (!desktop) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<RemoteDesktopKeyboardHintView
|
||||
kind={hint.kind}
|
||||
visible={hint.visible}
|
||||
chord={remoteDesktopReleaseChordLabel()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type React from 'react'
|
||||
import { Monitor } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
|
||||
@@ -11,6 +12,9 @@ export function RemoteDesktopMenuEntry({ onSelect }: { onSelect: () => void }):
|
||||
<DropdownMenuItem onSelect={onSelect}>
|
||||
<Monitor className="size-3.5" />
|
||||
{t('components.cloudRemoteDesktop.openMenuItem', 'Open remote desktop')}
|
||||
<Badge variant="tag" className="ml-auto">
|
||||
{t('components.cloudRemoteDesktop.beta', 'Beta')}
|
||||
</Badge>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copy for control-plane error codes from the team workspace registry.
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import { getLastBillingStatus } from '@/components/billing/billing-summary-store'
|
||||
import { sandboxLimitFromBilling } from '@/components/worktree-creation/vm-build-failure-copy'
|
||||
|
||||
export function describeError(error: string, reason?: string, t: Translate = translate): string {
|
||||
if (error === 'not_found') {
|
||||
@@ -10,10 +12,23 @@ export function describeError(error: string, reason?: string, t: Translate = tra
|
||||
)
|
||||
}
|
||||
if (error === 'entitlement_denied') {
|
||||
return reason === 'concurrency_limit'
|
||||
if (reason === 'concurrency_limit') {
|
||||
const limit = sandboxLimitFromBilling(getLastBillingStatus())
|
||||
return limit
|
||||
? t(
|
||||
'auto.components.cloudWorkspaces.errorConcurrencyLimitCount2',
|
||||
'Your team can run {{value0}} sandboxes at once ({{value1}} per seat). Put one to sleep or delete it, then try again.',
|
||||
{ value0: String(limit.team), value1: String(limit.perSeat) }
|
||||
)
|
||||
: t(
|
||||
'auto.components.cloudWorkspaces.errorConcurrencyLimit',
|
||||
"You're at your team's limit of running sandboxes. Put one to sleep or delete it, then try again."
|
||||
)
|
||||
}
|
||||
return reason === 'member_concurrency_limit'
|
||||
? t(
|
||||
'auto.components.cloudWorkspaces.errorConcurrencyLimit',
|
||||
"You're at your team's limit of running sandboxes. Put one to sleep or delete it, then try again."
|
||||
'auto.components.cloudWorkspaces.errorMemberConcurrencyLimit',
|
||||
"You're at your limit of running sandboxes. Put one to sleep or delete it, then try again."
|
||||
)
|
||||
: t(
|
||||
'auto.components.cloudWorkspaces.errorEntitlementDenied',
|
||||
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
import { Copy, Download, File, MonitorDown, Pencil, Search, SquareTerminal } from 'lucide-react'
|
||||
import { FixtureCodeBackdrop } from '../../../../component-fixtures/FixtureCodeBackdrop'
|
||||
import { defineFixture } from '../../../../component-fixtures/component-fixture'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '../../../ui/dropdown-menu'
|
||||
import { RuntimeDownloadToast, type RuntimeDownloadToastState } from './runtime-download-toast'
|
||||
|
||||
const noop = (): void => {}
|
||||
const MB = 1024 * 1024
|
||||
|
||||
function toastFixture(state: RuntimeDownloadToastState) {
|
||||
return defineFixture({
|
||||
width: 380,
|
||||
render: () => (
|
||||
<div className="bg-background p-3">
|
||||
<RuntimeDownloadToast
|
||||
state={state}
|
||||
revealLabel="Reveal in Finder"
|
||||
onAction={noop}
|
||||
onDismiss={noop}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors the file explorer row menu around the new entries (a dropdown, since a context menu
|
||||
// cannot be held open in a fixture).
|
||||
function menuFixture(kind: 'file' | 'folder') {
|
||||
return defineFixture({
|
||||
width: 300,
|
||||
render: () => (
|
||||
<div className="relative h-[250px] overflow-hidden bg-background">
|
||||
<FixtureCodeBackdrop />
|
||||
<DropdownMenu open modal={false}>
|
||||
<DropdownMenuTrigger className="absolute top-2 left-2 size-px" />
|
||||
<DropdownMenuContent align="start" className="min-w-56">
|
||||
<DropdownMenuItem>
|
||||
<SquareTerminal />
|
||||
Open in Terminal
|
||||
</DropdownMenuItem>
|
||||
{kind === 'file' ? (
|
||||
<DropdownMenuItem>
|
||||
<File />
|
||||
View File
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem>
|
||||
<Download />
|
||||
{kind === 'file' ? 'Download…' : 'Download as ZIP…'}
|
||||
</DropdownMenuItem>
|
||||
{kind === 'file' ? (
|
||||
<DropdownMenuItem>
|
||||
<MonitorDown />
|
||||
Open on This Computer
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem>
|
||||
<Search />
|
||||
Find in Folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Copy />
|
||||
Copy Path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Pencil />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const menuFile = menuFixture('file')
|
||||
export const menuFolder = menuFixture('folder')
|
||||
|
||||
export const fileProgress = toastFixture({
|
||||
kind: 'progress',
|
||||
intent: 'save',
|
||||
itemKind: 'file',
|
||||
name: 'random-200mb.bin',
|
||||
snapshot: {
|
||||
downloadId: 'f',
|
||||
phase: 'transferring',
|
||||
receivedBytes: 87 * MB,
|
||||
totalBytes: 200 * MB,
|
||||
processedBytes: 87 * MB,
|
||||
bytesPerSecond: 11.5 * MB,
|
||||
rateWindowMs: 7_500
|
||||
}
|
||||
})
|
||||
export const folderCollecting = toastFixture({
|
||||
kind: 'progress',
|
||||
intent: 'save',
|
||||
itemKind: 'folder',
|
||||
name: 'smallfiles.zip',
|
||||
snapshot: null
|
||||
})
|
||||
export const folderProgress = toastFixture({
|
||||
kind: 'progress',
|
||||
intent: 'save',
|
||||
itemKind: 'folder',
|
||||
name: 'smallfiles.zip',
|
||||
snapshot: {
|
||||
downloadId: 'd',
|
||||
phase: 'transferring',
|
||||
receivedBytes: 1.1 * MB,
|
||||
totalBytes: 2.4 * MB,
|
||||
processedBytes: 1.6 * MB,
|
||||
fileCount: 5000,
|
||||
bytesPerSecond: 1.2 * MB,
|
||||
rateWindowMs: 1_200
|
||||
}
|
||||
})
|
||||
export const openProgress = toastFixture({
|
||||
kind: 'progress',
|
||||
intent: 'open',
|
||||
itemKind: 'file',
|
||||
name: 'build-output.zip',
|
||||
snapshot: {
|
||||
downloadId: 'o',
|
||||
phase: 'transferring',
|
||||
receivedBytes: 0.6 * MB,
|
||||
totalBytes: 1.9 * MB,
|
||||
processedBytes: 0.6 * MB,
|
||||
bytesPerSecond: 5.4 * MB,
|
||||
rateWindowMs: 400
|
||||
}
|
||||
})
|
||||
export const fileDone = toastFixture({
|
||||
kind: 'done',
|
||||
itemKind: 'file',
|
||||
name: 'random-200mb.bin',
|
||||
bytes: 200 * MB,
|
||||
elapsedMs: 17_400
|
||||
})
|
||||
export const folderDone = toastFixture({
|
||||
kind: 'done',
|
||||
itemKind: 'folder',
|
||||
name: 'smallfiles.zip',
|
||||
bytes: 0.86 * MB,
|
||||
elapsedMs: 2_900,
|
||||
fileCount: 5000,
|
||||
excludedDefaultDirs: true
|
||||
})
|
||||
export const folderDoneSkipped = toastFixture({
|
||||
kind: 'done',
|
||||
itemKind: 'folder',
|
||||
name: 'logs.zip',
|
||||
bytes: 3.2 * MB,
|
||||
elapsedMs: 1_800,
|
||||
fileCount: 1,
|
||||
skippedCount: 2
|
||||
})
|
||||
export const errorDisconnected = toastFixture({
|
||||
kind: 'failed',
|
||||
intent: 'save',
|
||||
itemKind: 'file',
|
||||
name: 'random-200mb.bin',
|
||||
code: 'disconnected',
|
||||
place: 'sandbox'
|
||||
})
|
||||
export const errorTooLarge = toastFixture({
|
||||
kind: 'failed',
|
||||
intent: 'save',
|
||||
itemKind: 'folder',
|
||||
name: 'datasets',
|
||||
code: 'too_large',
|
||||
place: 'sandbox'
|
||||
})
|
||||
export const errorTooLargeToOpen = toastFixture({
|
||||
kind: 'failed',
|
||||
intent: 'open',
|
||||
itemKind: 'file',
|
||||
name: 'model.safetensors',
|
||||
code: 'too_large',
|
||||
place: 'sandbox'
|
||||
})
|
||||
export const errorOldSandbox = toastFixture({
|
||||
kind: 'failed',
|
||||
intent: 'save',
|
||||
itemKind: 'folder',
|
||||
name: 'src',
|
||||
code: 'host_update_required',
|
||||
place: 'sandbox'
|
||||
})
|
||||
export const errorNotFound = toastFixture({
|
||||
kind: 'failed',
|
||||
intent: 'save',
|
||||
itemKind: 'file',
|
||||
name: 'report.pdf',
|
||||
code: 'not_found',
|
||||
place: 'sandbox'
|
||||
})
|
||||
export const errorNoApp = toastFixture({ kind: 'no_app', name: 'trace.perfetto' })
|
||||
export const errorOpenRefused = toastFixture({
|
||||
kind: 'failed',
|
||||
intent: 'open',
|
||||
itemKind: 'file',
|
||||
name: 'install.sh',
|
||||
code: 'open_refused',
|
||||
place: 'sandbox'
|
||||
})
|
||||
export const errorFileGrew = toastFixture({
|
||||
kind: 'failed',
|
||||
intent: 'save',
|
||||
itemKind: 'file',
|
||||
name: 'server.log',
|
||||
code: 'too_large',
|
||||
place: 'sandbox'
|
||||
})
|
||||
export const errorTooManyItems = toastFixture({
|
||||
kind: 'failed',
|
||||
intent: 'save',
|
||||
itemKind: 'folder',
|
||||
name: 'node_cache',
|
||||
code: 'too_many_entries',
|
||||
place: 'sandbox'
|
||||
})
|
||||
const LONG_NAME = 'a-very-long-generated-artifact-name-2026-11-09-build-output.tar.gz'
|
||||
export const longNameProgress = toastFixture({
|
||||
kind: 'progress',
|
||||
intent: 'save',
|
||||
itemKind: 'file',
|
||||
name: LONG_NAME,
|
||||
snapshot: {
|
||||
downloadId: 'l',
|
||||
phase: 'transferring',
|
||||
receivedBytes: 412 * MB,
|
||||
totalBytes: 980 * MB,
|
||||
processedBytes: 412 * MB,
|
||||
bytesPerSecond: 12.1 * MB,
|
||||
rateWindowMs: 30_000
|
||||
}
|
||||
})
|
||||
export const longNameDone = toastFixture({
|
||||
kind: 'done',
|
||||
itemKind: 'file',
|
||||
name: LONG_NAME,
|
||||
bytes: 980 * MB,
|
||||
elapsedMs: 83_000
|
||||
})
|
||||
+23
-3
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/files/runtime-file-client-types'
|
||||
import type { TreeNode } from '../tree/file-explorer-types'
|
||||
import { canOpenRuntimeDownloadLocally } from '../../../../../../shared/runtime-downloads/runtime-download-contract'
|
||||
|
||||
export function shouldShowCollapseFolderAction(node: TreeNode, isExpanded: boolean): boolean {
|
||||
return node.isDirectory && isExpanded
|
||||
@@ -22,11 +23,13 @@ export function shouldShowRemoteDownloadAction(
|
||||
connectionId?: string | null,
|
||||
runtimeDownloadContext?: RuntimeFileOperationArgs | null,
|
||||
// Require advertised SFTP recursive transfer; unknown and system-SSH folder downloads stay hidden.
|
||||
supportsFolderDownload = false
|
||||
supportsFolderDownload = false,
|
||||
// Desktop main can stream from the runtime host directly, including host-zipped folders.
|
||||
canStreamFromRuntime = false
|
||||
): boolean {
|
||||
// Native save dialogs require desktop; runtime/system-SSH folders lack recursive transfer contracts.
|
||||
// Native save dialogs require desktop; system-SSH folders lack a recursive transfer contract.
|
||||
const hasDownloadCapability = node.isDirectory
|
||||
? Boolean(connectionId && supportsFolderDownload)
|
||||
? Boolean(connectionId ? supportsFolderDownload : canStreamFromRuntime)
|
||||
: Boolean(connectionId || runtimeDownloadContext)
|
||||
return (
|
||||
hasDownloadCapability &&
|
||||
@@ -34,6 +37,23 @@ export function shouldShowRemoteDownloadAction(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a runtime-hosted file into a temp copy and open it with the local default app. Hidden for
|
||||
* files the OS would run (scripts, installers, apps): those can only be downloaded.
|
||||
*/
|
||||
export function shouldShowOpenLocallyAction(
|
||||
node: TreeNode,
|
||||
connectionId: string | null | undefined,
|
||||
canStreamFromRuntime: boolean
|
||||
): boolean {
|
||||
return (
|
||||
!node.isDirectory &&
|
||||
!connectionId &&
|
||||
canStreamFromRuntime &&
|
||||
canOpenRuntimeDownloadLocally(node.name)
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldShowCopyFileAction(
|
||||
node: TreeNode,
|
||||
connectionId?: string | null,
|
||||
|
||||
+38
-4
@@ -11,6 +11,7 @@ import {
|
||||
FolderPlus,
|
||||
Globe,
|
||||
ListCollapse,
|
||||
MonitorDown,
|
||||
Pencil,
|
||||
Search,
|
||||
SquareTerminal,
|
||||
@@ -43,11 +44,13 @@ import {
|
||||
shouldShowCopyFileAction,
|
||||
shouldShowFindInFolderAction,
|
||||
shouldShowOpenInTerminalAction,
|
||||
shouldShowOpenLocallyAction,
|
||||
shouldShowRemoteDownloadAction,
|
||||
shouldShowViewFileAction
|
||||
} from './file-explorer-row-action-visibility'
|
||||
import { copyFileToOsClipboard, downloadRemoteFile } from './file-explorer-row-file-transfer'
|
||||
import { fileExplorerNodeTabRelativePath } from '../tree/file-explorer-directory-listing'
|
||||
import { canStreamRuntimeDownload, startRuntimeDownload } from './runtime-download-actions'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
||||
@@ -107,11 +110,19 @@ export function FileExplorerRowContextMenu({
|
||||
const copyPathShortcutLabel = useShortcutLabel('fileExplorer.copyPath')
|
||||
const copyRelativePathShortcutLabel = useShortcutLabel('fileExplorer.copyRelativePath')
|
||||
const findInFolderShortcutLabel = useShortcutLabel('sidebar.search.toggle')
|
||||
const canStreamFromRuntime =
|
||||
!connectionId && canStreamRuntimeDownload(node, runtimeDownloadContext)
|
||||
const showRemoteDownloadAction = shouldShowRemoteDownloadAction(
|
||||
node,
|
||||
connectionId,
|
||||
runtimeDownloadContext,
|
||||
supportsFolderDownload
|
||||
supportsFolderDownload,
|
||||
canStreamFromRuntime
|
||||
)
|
||||
const showOpenLocallyAction = shouldShowOpenLocallyAction(
|
||||
node,
|
||||
connectionId,
|
||||
canStreamFromRuntime
|
||||
)
|
||||
const showCopyFileAction = shouldShowCopyFileAction(node, connectionId, selectionSize)
|
||||
const revealOwner = useAppStore((s) =>
|
||||
@@ -133,12 +144,21 @@ export function FileExplorerRowContextMenu({
|
||||
}
|
||||
}, [activeWorktreeId, node.path])
|
||||
const handleDownload = useCallback(() => {
|
||||
if (canStreamFromRuntime && runtimeDownloadContext) {
|
||||
void startRuntimeDownload(node, runtimeDownloadContext, 'save')
|
||||
return
|
||||
}
|
||||
const downloadTarget = connectionId || runtimeDownloadContext
|
||||
if (!downloadTarget) {
|
||||
return
|
||||
}
|
||||
void downloadRemoteFile(node, downloadTarget)
|
||||
}, [connectionId, node, runtimeDownloadContext])
|
||||
}, [canStreamFromRuntime, connectionId, node, runtimeDownloadContext])
|
||||
const handleOpenLocally = useCallback(() => {
|
||||
if (runtimeDownloadContext) {
|
||||
void startRuntimeDownload(node, runtimeDownloadContext, 'open')
|
||||
}
|
||||
}, [node, runtimeDownloadContext])
|
||||
const handleCopyFile = useCallback(() => {
|
||||
void copyFileToOsClipboard(node, connectionId)
|
||||
}, [connectionId, node])
|
||||
@@ -237,8 +257,22 @@ export function FileExplorerRowContextMenu({
|
||||
<ContextMenuItem onSelect={handleDownload}>
|
||||
<Download />
|
||||
{node.isDirectory
|
||||
? t('auto.components.right.sidebar.FileExplorerRow.7ac885bd2f', 'Download Folder')
|
||||
: t('auto.components.right.sidebar.FileExplorerRow.c2112579f6', 'Download')}
|
||||
? canStreamFromRuntime
|
||||
? t(
|
||||
'auto.components.right.sidebar.FileExplorerRow.downloadFolderZip',
|
||||
'Download as ZIP…'
|
||||
)
|
||||
: t('auto.components.right.sidebar.FileExplorerRow.7ac885bd2f', 'Download Folder')
|
||||
: canStreamFromRuntime
|
||||
? // Opens a save dialog, like "Download as ZIP…".
|
||||
t('auto.components.right.sidebar.FileExplorerRow.downloadFileEllipsis', 'Download…')
|
||||
: t('auto.components.right.sidebar.FileExplorerRow.c2112579f6', 'Download')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showOpenLocallyAction && (
|
||||
<ContextMenuItem onSelect={handleOpenLocally}>
|
||||
<MonitorDown />
|
||||
{t('auto.components.right.sidebar.FileExplorerRow.openLocally', 'Open on This Computer')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canCollapseFolderSubtree && shouldShowCollapseFolderAction(node, isExpanded) && (
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeDownloadProgressEvent } from '../../../../../../shared/runtime-downloads/runtime-download-contract'
|
||||
import type { TreeNode } from '../tree/file-explorer-types'
|
||||
import {
|
||||
applyRuntimeDownloadProgress,
|
||||
getRuntimeDownloadProgress,
|
||||
subscribeRuntimeDownloadProgress,
|
||||
trackRuntimeDownload
|
||||
} from '@/runtime/files/downloads/runtime-download-progress-store'
|
||||
import {
|
||||
shouldShowOpenLocallyAction,
|
||||
shouldShowRemoteDownloadAction
|
||||
} from './file-explorer-row-action-visibility'
|
||||
|
||||
vi.mock('@/lib/cloud-remote-desktop/cloud-environment-ids', () => ({
|
||||
isKnownCloudEnvironment: (id: string) => id === 'cloud-env',
|
||||
refreshCloudEnvironmentIds: async () => {}
|
||||
}))
|
||||
|
||||
const { outcomeState } = await import('./runtime-download-actions')
|
||||
|
||||
function node(name: string, isDirectory = false): TreeNode {
|
||||
return { name, path: `/w/${name}`, relativePath: name, isDirectory, depth: 0 }
|
||||
}
|
||||
|
||||
const base = { itemKind: 'file' as const, environmentId: 'cloud-env', elapsedMs: 1_000 }
|
||||
|
||||
describe('outcomeState', () => {
|
||||
it('leaves nothing behind for a canceled download or a file that opened', () => {
|
||||
expect(outcomeState({ status: 'canceled' }, { ...base, node: node('a'), intent: 'save' })).toBe(
|
||||
null
|
||||
)
|
||||
expect(
|
||||
outcomeState(
|
||||
{ status: 'done', destinationPath: '/tmp/a', bytes: 1 },
|
||||
{ ...base, node: node('a'), intent: 'open' }
|
||||
)
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
it('reports a saved copy no app could open as a soft no-app state', () => {
|
||||
expect(
|
||||
outcomeState(
|
||||
{ status: 'done', destinationPath: '/tmp/a.xyz', bytes: 1, openError: 'no app' },
|
||||
{ ...base, node: node('a.xyz'), intent: 'open' }
|
||||
)
|
||||
).toEqual({ kind: 'no_app', name: 'a.xyz' })
|
||||
})
|
||||
|
||||
it('names the sandbox only for cloud workspaces', () => {
|
||||
const error = { status: 'error', code: 'not_found', message: 'x' } as const
|
||||
expect(outcomeState(error, { ...base, node: node('a'), intent: 'save' })).toMatchObject({
|
||||
kind: 'failed',
|
||||
place: 'sandbox'
|
||||
})
|
||||
expect(
|
||||
outcomeState(error, { ...base, environmentId: 'ssh-box', node: node('a'), intent: 'save' })
|
||||
).toMatchObject({ kind: 'failed', place: 'host' })
|
||||
})
|
||||
|
||||
it('never offers Open on the done card for files the OS would run', () => {
|
||||
vi.stubGlobal('api', { platform: { get: () => ({ platform: 'darwin' }) } })
|
||||
const done = { status: 'done', destinationPath: '/d/x', bytes: 3 } as const
|
||||
expect(outcomeState(done, { ...base, node: node('setup.sh'), intent: 'save' })).toMatchObject({
|
||||
kind: 'done',
|
||||
canOpen: false
|
||||
})
|
||||
expect(outcomeState(done, { ...base, node: node('report.pdf'), intent: 'save' })).toMatchObject(
|
||||
{ kind: 'done', canOpen: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('row action visibility', () => {
|
||||
it('offers folder downloads on runtime hosts without an SSH connection', () => {
|
||||
expect(shouldShowRemoteDownloadAction(node('src', true), null, null, false, true)).toBe(true)
|
||||
expect(shouldShowRemoteDownloadAction(node('src', true), null, null, false, false)).toBe(false)
|
||||
})
|
||||
|
||||
it('hides Open on This Computer for folders, SSH and runnable files', () => {
|
||||
expect(shouldShowOpenLocallyAction(node('a.pdf'), null, true)).toBe(true)
|
||||
expect(shouldShowOpenLocallyAction(node('src', true), null, true)).toBe(false)
|
||||
expect(shouldShowOpenLocallyAction(node('a.pdf'), 'ssh-1', true)).toBe(false)
|
||||
for (const name of ['run.sh', 'Setup.EXE', 'x.command', 'evil.bat. ']) {
|
||||
expect(shouldShowOpenLocallyAction(node(name), null, true)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime download progress store', () => {
|
||||
const unsubscribe = vi.fn()
|
||||
afterEach(() => {
|
||||
unsubscribe.mockReset()
|
||||
})
|
||||
|
||||
function event(receivedBytes: number): RuntimeDownloadProgressEvent {
|
||||
return {
|
||||
downloadId: 'd1',
|
||||
phase: 'transferring',
|
||||
receivedBytes,
|
||||
totalBytes: 100,
|
||||
processedBytes: receivedBytes
|
||||
}
|
||||
}
|
||||
|
||||
it('smooths the rate, tracks its window and drops the IPC listener on release', () => {
|
||||
const onProgress = vi.fn(() => unsubscribe)
|
||||
vi.stubGlobal('api', { runtimeDownloads: { onProgress } })
|
||||
const release = trackRuntimeDownload('d1')
|
||||
expect(onProgress).toHaveBeenCalledOnce()
|
||||
applyRuntimeDownloadProgress(event(0), 0)
|
||||
applyRuntimeDownloadProgress(event(10), 1_000)
|
||||
applyRuntimeDownloadProgress(event(30), 2_000)
|
||||
const snapshot = getRuntimeDownloadProgress('d1')
|
||||
// 10 B/s, then 20 B/s smoothed by 0.3.
|
||||
expect(snapshot?.bytesPerSecond).toBeCloseTo(13)
|
||||
expect(snapshot?.rateWindowMs).toBe(2_000)
|
||||
release()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(getRuntimeDownloadProgress('d1')).toBe(null)
|
||||
})
|
||||
|
||||
it('does not bring back a released download when a late toast subscribes', () => {
|
||||
const onProgress = vi.fn(() => unsubscribe)
|
||||
vi.stubGlobal('api', { runtimeDownloads: { onProgress } })
|
||||
const release = trackRuntimeDownload('d2')
|
||||
release()
|
||||
subscribeRuntimeDownloadProgress('d2', () => {})()
|
||||
// A new download must start a fresh IPC listener: the old entry did not linger.
|
||||
trackRuntimeDownload('d3')()
|
||||
expect(onProgress).toHaveBeenCalledTimes(2)
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { createNonSecureContextUuid } from '../../../../../../shared/non-secure-context-uuid'
|
||||
import { getRevealInFileManagerLabel, revealInFileManager } from '@/lib/reveal-in-file-manager'
|
||||
import {
|
||||
isKnownCloudEnvironment,
|
||||
refreshCloudEnvironmentIds
|
||||
} from '@/lib/cloud-remote-desktop/cloud-environment-ids'
|
||||
import { getRemoteFileArgs } from '@/runtime/files/runtime-file-routing'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/files/runtime-file-client-types'
|
||||
import { captureRuntimeEnvironmentRequestRevision } from '@/runtime/rpc/environment-revisions/runtime-environment-revision'
|
||||
import {
|
||||
getRuntimeDownloadProgress,
|
||||
subscribeRuntimeDownloadProgress,
|
||||
trackRuntimeDownload
|
||||
} from '@/runtime/files/downloads/runtime-download-progress-store'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host'
|
||||
import {
|
||||
canOpenRuntimeDownloadLocally,
|
||||
type RuntimeDownloadResult
|
||||
} from '../../../../../../shared/runtime-downloads/runtime-download-contract'
|
||||
import { requestFileExplorerRefresh } from '../watching/file-explorer-refresh-requests'
|
||||
import type { TreeNode } from '../tree/file-explorer-types'
|
||||
import {
|
||||
RuntimeDownloadToast,
|
||||
type RuntimeDownloadToastAction,
|
||||
type RuntimeDownloadToastIntent,
|
||||
type RuntimeDownloadToastState
|
||||
} from './runtime-download-toast'
|
||||
|
||||
export type RuntimeDownloadIntent = RuntimeDownloadToastIntent
|
||||
|
||||
// Long enough to reach "Reveal in Finder" after a long download, short enough not to pile up.
|
||||
// Errors stay until dismissed: their one next action must not time out.
|
||||
const DONE_TOAST_MS = 12_000
|
||||
|
||||
// One transfer per path and intent: "Open on This Computer" has no dialog, so a double click or a
|
||||
// retry while the first copy still runs would otherwise fetch and open it twice.
|
||||
const inFlight = new Set<string>()
|
||||
|
||||
/** Whether this desktop can stream the node straight from its runtime host. */
|
||||
export function canStreamRuntimeDownload(
|
||||
node: TreeNode,
|
||||
context: RuntimeFileOperationArgs | null | undefined
|
||||
): context is RuntimeFileOperationArgs {
|
||||
return Boolean(
|
||||
context && window.api.runtimeDownloads && getRemoteFileArgs(context, node.path) !== null
|
||||
)
|
||||
}
|
||||
|
||||
function localName(destinationPath: string): string {
|
||||
const separator = window.api.platform.get().platform === 'win32' ? /[\\/]/ : /\//
|
||||
return destinationPath.split(separator).pop() || destinationPath
|
||||
}
|
||||
|
||||
/** Progress toast body bound to the live progress store. */
|
||||
function LiveProgressToast({
|
||||
downloadId,
|
||||
state,
|
||||
onAction
|
||||
}: {
|
||||
downloadId: string
|
||||
state: Omit<Extract<RuntimeDownloadToastState, { kind: 'progress' }>, 'snapshot'>
|
||||
onAction: (action: RuntimeDownloadToastAction) => void
|
||||
}): React.JSX.Element {
|
||||
// Stable identities: an inline subscribe would resubscribe on every progress tick.
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => subscribeRuntimeDownloadProgress(downloadId, listener),
|
||||
[downloadId]
|
||||
)
|
||||
const getSnapshot = useCallback(() => getRuntimeDownloadProgress(downloadId), [downloadId])
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot)
|
||||
return (
|
||||
<RuntimeDownloadToast
|
||||
state={{ ...state, snapshot }}
|
||||
revealLabel=""
|
||||
onAction={onAction}
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export async function startRuntimeDownload(
|
||||
node: TreeNode,
|
||||
context: RuntimeFileOperationArgs,
|
||||
intent: RuntimeDownloadIntent,
|
||||
options: { excludeDefaultDirs?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const api = window.api.runtimeDownloads
|
||||
const remote = getRemoteFileArgs(context, node.path)
|
||||
if (!api || !remote) {
|
||||
return
|
||||
}
|
||||
const environmentId = remote.target.environmentId
|
||||
const flightKey = `${environmentId}\u0000${remote.worktreeSelector}\u0000${remote.relativePath}\u0000${intent}`
|
||||
if (inFlight.has(flightKey)) {
|
||||
return
|
||||
}
|
||||
inFlight.add(flightKey)
|
||||
try {
|
||||
await runRuntimeDownload(node, context, intent, options, remote)
|
||||
} finally {
|
||||
inFlight.delete(flightKey)
|
||||
}
|
||||
}
|
||||
|
||||
async function runRuntimeDownload(
|
||||
node: TreeNode,
|
||||
context: RuntimeFileOperationArgs,
|
||||
intent: RuntimeDownloadIntent,
|
||||
options: { excludeDefaultDirs?: boolean },
|
||||
remote: NonNullable<ReturnType<typeof getRemoteFileArgs>>
|
||||
): Promise<void> {
|
||||
const api = window.api.runtimeDownloads
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
const environmentId = remote.target.environmentId
|
||||
// Error copy says "sandbox" for cloud workspaces; the list is usually primed already.
|
||||
void refreshCloudEnvironmentIds()
|
||||
const itemKind = node.isDirectory ? 'folder' : 'file'
|
||||
const downloadId = createNonSecureContextUuid()
|
||||
const toastId = `runtime-download:${downloadId}`
|
||||
const release = trackRuntimeDownload(downloadId)
|
||||
const excludeDefaultDirs = options.excludeDefaultDirs !== false
|
||||
// Timed from the first progress event so the save dialog doesn't count.
|
||||
let startedAt = performance.now()
|
||||
const pending = api.start({
|
||||
downloadId,
|
||||
environmentId,
|
||||
worktree: remote.worktreeSelector,
|
||||
relativePath: remote.relativePath,
|
||||
name: node.name,
|
||||
kind: itemKind,
|
||||
intent,
|
||||
excludeDefaultDirs,
|
||||
expectedEnvironmentPairingRevision: captureRuntimeEnvironmentRequestRevision(environmentId)
|
||||
})
|
||||
const progressName = itemKind === 'folder' ? `${node.name}.zip` : node.name
|
||||
// The save dialog comes first; show progress only after the user has picked a place.
|
||||
const showProgress = (): void => {
|
||||
toast.custom(
|
||||
() => (
|
||||
<LiveProgressToast
|
||||
downloadId={downloadId}
|
||||
state={{ kind: 'progress', intent, itemKind, name: progressName }}
|
||||
onAction={() => void api.cancel({ downloadId })}
|
||||
/>
|
||||
),
|
||||
{ id: toastId, duration: Number.POSITIVE_INFINITY, dismissible: false }
|
||||
)
|
||||
}
|
||||
// `preparing` is always the first event and is sent once the save dialog has closed, so the
|
||||
// elapsed time never includes the dialog, even for a file too small to send any progress.
|
||||
const stopProgressWatch = api.onProgress((event) => {
|
||||
if (event.downloadId === downloadId) {
|
||||
stopProgressWatch()
|
||||
startedAt = performance.now()
|
||||
showProgress()
|
||||
}
|
||||
})
|
||||
let result: RuntimeDownloadResult
|
||||
try {
|
||||
result = await pending
|
||||
} catch (error) {
|
||||
result = {
|
||||
status: 'error',
|
||||
code: 'transfer_failed',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
} finally {
|
||||
stopProgressWatch()
|
||||
release()
|
||||
}
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
const outcome = outcomeState(result, { node, itemKind, intent, environmentId, elapsedMs })
|
||||
if (!outcome) {
|
||||
toast.dismiss(toastId)
|
||||
return
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
// Host details stay out of the toast (plain words only) but remain debuggable.
|
||||
console.warn('[runtime-download] failed', result.code, result.message)
|
||||
}
|
||||
if (result.status === 'done' && result.openError) {
|
||||
console.warn('[runtime-download] open failed', result.openError)
|
||||
}
|
||||
const destinationPath = result.status === 'done' ? result.destinationPath : null
|
||||
const onAction = (action: RuntimeDownloadToastAction): void => {
|
||||
toast.dismiss(toastId)
|
||||
if ((action === 'reveal' || action === 'open') && destinationPath) {
|
||||
void (action === 'reveal'
|
||||
? revealInFileManager(destinationPath, LOCAL_EXECUTION_HOST_ID)
|
||||
: window.api.shell.openPath(destinationPath, LOCAL_EXECUTION_HOST_ID))
|
||||
} else if (action === 'retry') {
|
||||
void startRuntimeDownload(node, context, intent, options)
|
||||
} else if (action === 'download_instead') {
|
||||
void startRuntimeDownload(node, context, 'save', options)
|
||||
} else if (action === 'refresh') {
|
||||
requestFileExplorerRefresh()
|
||||
}
|
||||
}
|
||||
toast.custom(
|
||||
() => (
|
||||
<RuntimeDownloadToast
|
||||
state={outcome}
|
||||
revealLabel={getRevealInFileManagerLabel()}
|
||||
onAction={onAction}
|
||||
onDismiss={() => toast.dismiss(toastId)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
id: toastId,
|
||||
duration: outcome.kind === 'done' ? DONE_TOAST_MS : Number.POSITIVE_INFINITY,
|
||||
dismissible: true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** The toast to leave behind, or null when it should just go away (canceled, opened fine). */
|
||||
export function outcomeState(
|
||||
result: RuntimeDownloadResult,
|
||||
input: {
|
||||
node: Pick<TreeNode, 'name'>
|
||||
itemKind: 'file' | 'folder'
|
||||
intent: RuntimeDownloadIntent
|
||||
environmentId: string
|
||||
elapsedMs: number
|
||||
}
|
||||
): RuntimeDownloadToastState | null {
|
||||
const { node, itemKind, intent } = input
|
||||
if (result.status === 'canceled') {
|
||||
return null
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
return {
|
||||
kind: 'failed',
|
||||
intent,
|
||||
itemKind,
|
||||
name: node.name,
|
||||
code: result.code,
|
||||
place: isKnownCloudEnvironment(input.environmentId) ? 'sandbox' : 'host'
|
||||
}
|
||||
}
|
||||
if (intent === 'open') {
|
||||
return result.openError ? { kind: 'no_app', name: node.name } : null
|
||||
}
|
||||
return {
|
||||
kind: 'done',
|
||||
itemKind,
|
||||
name: localName(result.destinationPath),
|
||||
bytes: result.bytes,
|
||||
elapsedMs: input.elapsedMs,
|
||||
...(result.fileCount !== undefined ? { fileCount: result.fileCount } : {}),
|
||||
excludedDefaultDirs: (result.excludedDirCount ?? 0) > 0,
|
||||
canOpen: canOpenRuntimeDownloadLocally(node.name),
|
||||
...(result.skippedCount ? { skippedCount: result.skippedCount } : {})
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import type { RuntimeDownloadErrorCode } from '../../../../../../shared/runtime-downloads/runtime-download-contract'
|
||||
|
||||
export type RuntimeDownloadFailureAction = 'retry' | 'download_instead' | 'refresh' | 'reveal'
|
||||
|
||||
export type RuntimeDownloadFailureCopy = {
|
||||
title: string
|
||||
detail: string
|
||||
/** 'neutral' for limits and deliberate refusals; 'error' when something actually broke. */
|
||||
tone: 'error' | 'neutral'
|
||||
action: RuntimeDownloadFailureAction | null
|
||||
}
|
||||
|
||||
/** Cloud workspaces live in a sandbox; other runtime hosts are named generically. */
|
||||
export type RuntimeDownloadPlace = 'sandbox' | 'host'
|
||||
|
||||
/** Glossary error tone: what happened, whether anything was lost, at most one next action. */
|
||||
export function runtimeDownloadFailureCopy(
|
||||
t: Translate,
|
||||
state: {
|
||||
intent: 'save' | 'open'
|
||||
itemKind: 'file' | 'folder'
|
||||
name: string
|
||||
code: RuntimeDownloadErrorCode
|
||||
place: RuntimeDownloadPlace
|
||||
}
|
||||
): RuntimeDownloadFailureCopy {
|
||||
const { name, place } = state
|
||||
const title =
|
||||
state.intent === 'open'
|
||||
? t('components.runtimeDownload.openFailedTitle', 'Couldn’t open {{name}}', { name })
|
||||
: t('components.runtimeDownload.failedTitle', 'Couldn’t download {{name}}', { name })
|
||||
const transferFailed: RuntimeDownloadFailureCopy = {
|
||||
title,
|
||||
detail: t(
|
||||
'components.runtimeDownload.transferFailed',
|
||||
'Something went wrong while copying it. Nothing was saved on this computer.'
|
||||
),
|
||||
tone: 'error',
|
||||
action: 'retry'
|
||||
}
|
||||
switch (state.code) {
|
||||
case 'too_large':
|
||||
if (state.itemKind === 'folder') {
|
||||
return {
|
||||
title,
|
||||
detail: t(
|
||||
'components.runtimeDownload.folderTooLarge',
|
||||
'This folder is over 2 GB, the most Orca can download at once. Download the folders inside it one at a time. Nothing was saved on this computer.'
|
||||
),
|
||||
tone: 'neutral',
|
||||
action: null
|
||||
}
|
||||
}
|
||||
return state.intent === 'open'
|
||||
? {
|
||||
title,
|
||||
detail: t(
|
||||
'components.runtimeDownload.tooLargeToOpen',
|
||||
'Files over 1 GB can’t be opened directly. Download it instead.'
|
||||
),
|
||||
tone: 'neutral',
|
||||
action: 'download_instead'
|
||||
}
|
||||
: {
|
||||
title,
|
||||
detail: t(
|
||||
'components.runtimeDownload.fileGrew',
|
||||
'The file grew while Orca was copying it. Nothing was saved on this computer.'
|
||||
),
|
||||
tone: 'error',
|
||||
action: 'retry'
|
||||
}
|
||||
case 'too_many_entries':
|
||||
return {
|
||||
title,
|
||||
detail: t(
|
||||
'components.runtimeDownload.folderTooManyItems',
|
||||
'This folder has more than 65,000 items, the most Orca can download at once. Download the folders inside it one at a time. Nothing was saved on this computer.'
|
||||
),
|
||||
tone: 'neutral',
|
||||
action: null
|
||||
}
|
||||
case 'open_refused':
|
||||
return {
|
||||
title,
|
||||
detail: t(
|
||||
'components.runtimeDownload.openRefused',
|
||||
'Orca doesn’t open files that can run programs, like scripts, installers and apps. Download it to check it first. Nothing was saved on this computer.'
|
||||
),
|
||||
tone: 'neutral',
|
||||
action: 'download_instead'
|
||||
}
|
||||
case 'host_update_required':
|
||||
return {
|
||||
title,
|
||||
detail:
|
||||
place === 'sandbox'
|
||||
? t(
|
||||
'components.runtimeDownload.sandboxUpdateRequired',
|
||||
'This sandbox runs an older version of Orca. New cloud workspaces include downloads. Your files are safe.'
|
||||
)
|
||||
: t(
|
||||
'components.runtimeDownload.hostUpdateRequired',
|
||||
'The remote host runs an older version of Orca. Update Orca there, then try again.'
|
||||
),
|
||||
tone: 'error',
|
||||
action: null
|
||||
}
|
||||
case 'host_busy':
|
||||
return {
|
||||
title,
|
||||
detail: t(
|
||||
'components.runtimeDownload.busy',
|
||||
'Too many downloads are running. Wait for one to finish, then try again.'
|
||||
),
|
||||
tone: 'error',
|
||||
action: 'retry'
|
||||
}
|
||||
case 'not_found':
|
||||
return {
|
||||
title,
|
||||
detail:
|
||||
place === 'sandbox'
|
||||
? t(
|
||||
'components.runtimeDownload.notFoundInSandboxNothingSaved',
|
||||
'It was moved or deleted in the sandbox. Nothing was saved on this computer.'
|
||||
)
|
||||
: t(
|
||||
'components.runtimeDownload.notFoundOnHostNothingSaved',
|
||||
'It was moved or deleted on the remote host. Nothing was saved on this computer.'
|
||||
),
|
||||
tone: 'error',
|
||||
action: 'refresh'
|
||||
}
|
||||
case 'not_a_file':
|
||||
case 'not_a_directory':
|
||||
return {
|
||||
title,
|
||||
detail: t(
|
||||
'components.runtimeDownload.changed',
|
||||
'It changed while Orca was reading it. Try again.'
|
||||
),
|
||||
tone: 'error',
|
||||
action: 'retry'
|
||||
}
|
||||
case 'disconnected':
|
||||
return {
|
||||
title,
|
||||
detail:
|
||||
place === 'sandbox'
|
||||
? t(
|
||||
'components.runtimeDownload.sandboxDisconnected',
|
||||
'The connection to the sandbox dropped. Nothing was saved on this computer.'
|
||||
)
|
||||
: t(
|
||||
'components.runtimeDownload.hostDisconnected',
|
||||
'The connection to the remote host dropped. Nothing was saved on this computer.'
|
||||
),
|
||||
tone: 'error',
|
||||
action: 'retry'
|
||||
}
|
||||
case 'canceled':
|
||||
// Unreachable: outcomeState drops canceled results. Never offer "Try again" for a cancel.
|
||||
return { ...transferFailed, action: null }
|
||||
case 'open_failed':
|
||||
case 'transfer_failed':
|
||||
return transferFailed
|
||||
}
|
||||
}
|
||||
|
||||
/** The file was saved, but nothing on this computer opens its type: a soft state, not a failure. */
|
||||
export function runtimeDownloadNoAppCopy(t: Translate, name: string): RuntimeDownloadFailureCopy {
|
||||
return {
|
||||
title: t('components.runtimeDownload.savedName', 'Saved {{name}}', { name }),
|
||||
detail: t(
|
||||
'components.runtimeDownload.noAppForType',
|
||||
'No app on this computer opens this file type. Reveal it to choose one.'
|
||||
),
|
||||
tone: 'neutral',
|
||||
action: 'reveal'
|
||||
}
|
||||
}
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
import type React from 'react'
|
||||
import { CircleCheck, Download, ExternalLink, FolderOpen, MonitorDown, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { InlineError } from '@/components/ui/inline-error'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useTranslate, type Translate } from '@/i18n/use-translate'
|
||||
import { uiNumberFormat } from '@/i18n/ui-intl-formatters'
|
||||
import { formatElapsedTime } from '@/components/worktree-creation/worktree-creation-clock'
|
||||
import { formatBytes } from '../../../../../../shared/format-bytes'
|
||||
import type { RuntimeDownloadErrorCode } from '../../../../../../shared/runtime-downloads/runtime-download-contract'
|
||||
import type { RuntimeDownloadProgressSnapshot } from '@/runtime/files/downloads/runtime-download-progress-store'
|
||||
import {
|
||||
runtimeDownloadFailureCopy,
|
||||
runtimeDownloadNoAppCopy,
|
||||
type RuntimeDownloadFailureAction,
|
||||
type RuntimeDownloadPlace
|
||||
} from './runtime-download-failure-copy'
|
||||
|
||||
export type { RuntimeDownloadPlace }
|
||||
export type RuntimeDownloadItemKind = 'file' | 'folder'
|
||||
export type RuntimeDownloadToastIntent = 'save' | 'open'
|
||||
|
||||
export type RuntimeDownloadToastState =
|
||||
| {
|
||||
kind: 'progress'
|
||||
intent: RuntimeDownloadToastIntent
|
||||
itemKind: RuntimeDownloadItemKind
|
||||
/** Local name being written, e.g. `src.zip`. */
|
||||
name: string
|
||||
snapshot: RuntimeDownloadProgressSnapshot | null
|
||||
}
|
||||
| {
|
||||
kind: 'done'
|
||||
itemKind: RuntimeDownloadItemKind
|
||||
/** Name of the saved file on this computer. */
|
||||
name: string
|
||||
bytes: number
|
||||
elapsedMs: number
|
||||
fileCount?: number
|
||||
excludedDefaultDirs?: boolean
|
||||
skippedCount?: number
|
||||
/** False for files the OS would run: those are only revealed, never opened from the toast. */
|
||||
canOpen?: boolean
|
||||
}
|
||||
| {
|
||||
kind: 'failed'
|
||||
intent: RuntimeDownloadToastIntent
|
||||
itemKind: RuntimeDownloadItemKind
|
||||
name: string
|
||||
code: RuntimeDownloadErrorCode
|
||||
place: RuntimeDownloadPlace
|
||||
}
|
||||
| { kind: 'no_app'; name: string }
|
||||
|
||||
export type RuntimeDownloadToastAction = 'cancel' | 'open' | RuntimeDownloadFailureAction
|
||||
|
||||
const formatCount = (value: number): string => uiNumberFormat({}).format(value)
|
||||
// A time-left estimate from the first second of a transfer swings wildly; wait for the rate to settle.
|
||||
const ETA_MIN_RATE_WINDOW_MS = 2_000
|
||||
|
||||
// Titles truncate at the end in CSS, which hides the extension; shorten long names in the middle.
|
||||
const MAX_TITLE_NAME_CHARS = 40
|
||||
|
||||
export function middleTruncateName(name: string, max = MAX_TITLE_NAME_CHARS): string {
|
||||
const chars = Array.from(name)
|
||||
if (chars.length <= max) {
|
||||
return name
|
||||
}
|
||||
const tail = Math.floor((max - 1) / 2)
|
||||
const head = max - 1 - tail
|
||||
return `${chars.slice(0, head).join('')}…${chars.slice(chars.length - tail).join('')}`
|
||||
}
|
||||
|
||||
const CARD =
|
||||
'relative w-[356px] rounded-lg border border-border bg-popover px-3 py-2.5 text-sm text-popover-foreground shadow-lg'
|
||||
|
||||
/** Fraction done, or null while the size is unknown (folder walk, empty file). */
|
||||
export function runtimeDownloadFraction(
|
||||
snapshot: RuntimeDownloadProgressSnapshot | null
|
||||
): number | null {
|
||||
if (!snapshot || snapshot.phase !== 'transferring' || !snapshot.totalBytes) {
|
||||
return null
|
||||
}
|
||||
return Math.min(1, snapshot.processedBytes / snapshot.totalBytes)
|
||||
}
|
||||
|
||||
function progressDetail(
|
||||
t: Translate,
|
||||
itemKind: RuntimeDownloadItemKind,
|
||||
snapshot: RuntimeDownloadProgressSnapshot | null
|
||||
): string {
|
||||
if (!snapshot || snapshot.phase === 'preparing') {
|
||||
return itemKind === 'folder'
|
||||
? t('components.runtimeDownload.collectingFiles', 'Collecting files…')
|
||||
: t('components.runtimeDownload.starting', 'Starting…')
|
||||
}
|
||||
if (itemKind === 'folder') {
|
||||
// Counted in files, not bytes: the source size and the ZIP size differ, and the done card
|
||||
// reports the ZIP's size.
|
||||
return snapshot.fileCount === undefined
|
||||
? formatBytes(snapshot.receivedBytes)
|
||||
: t('components.runtimeDownload.folderFilesProgress', '{{files}} files', {
|
||||
count: snapshot.fileCount,
|
||||
files: formatCount(snapshot.fileCount),
|
||||
defaultValue_one: '{{files}} file'
|
||||
})
|
||||
}
|
||||
const amount = snapshot.totalBytes
|
||||
? t('components.runtimeDownload.amountOfTotal', '{{done}} of {{total}}', {
|
||||
done: formatBytes(snapshot.receivedBytes),
|
||||
total: formatBytes(snapshot.totalBytes)
|
||||
})
|
||||
: formatBytes(snapshot.receivedBytes)
|
||||
const rate = snapshot.bytesPerSecond
|
||||
if (!rate || rate <= 0) {
|
||||
return amount
|
||||
}
|
||||
if (snapshot.totalBytes && snapshot.rateWindowMs >= ETA_MIN_RATE_WINDOW_MS) {
|
||||
const leftMs = (Math.max(0, snapshot.totalBytes - snapshot.receivedBytes) / rate) * 1000
|
||||
return t('components.runtimeDownload.amountRateEta', '{{amount}} · {{rate}}/s · {{eta}} left', {
|
||||
amount,
|
||||
rate: formatBytes(rate),
|
||||
eta: formatElapsedTime(leftMs)
|
||||
})
|
||||
}
|
||||
return t('components.runtimeDownload.amountAndRate', '{{amount}} · {{rate}}/s', {
|
||||
amount,
|
||||
rate: formatBytes(rate)
|
||||
})
|
||||
}
|
||||
|
||||
function DismissButton({ onDismiss }: { onDismiss: () => void }): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t('components.runtimeDownload.dismiss', 'Dismiss')}
|
||||
className="absolute top-1.5 right-1.5"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<X aria-hidden />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressCard({
|
||||
state,
|
||||
onAction
|
||||
}: {
|
||||
state: Extract<RuntimeDownloadToastState, { kind: 'progress' }>
|
||||
onAction: (action: RuntimeDownloadToastAction) => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const fraction = runtimeDownloadFraction(state.snapshot)
|
||||
const percent = fraction === null ? null : Math.round(fraction * 100)
|
||||
const detail = progressDetail(t, state.itemKind, state.snapshot)
|
||||
const title =
|
||||
state.intent === 'open'
|
||||
? t('components.runtimeDownload.opening', 'Opening {{name}}', {
|
||||
name: middleTruncateName(state.name)
|
||||
})
|
||||
: t('components.runtimeDownload.downloading', 'Downloading {{name}}', {
|
||||
name: middleTruncateName(state.name)
|
||||
})
|
||||
const progressLabel = t('components.runtimeDownload.progressLabel', 'Download progress')
|
||||
const Icon = state.intent === 'open' ? MonitorDown : Download
|
||||
return (
|
||||
<div className={CARD}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
{/* Only the title is live: the numbers change several times a second and would chatter.
|
||||
The progressbar's value text carries them when asked. */}
|
||||
<p role="status" className="min-w-0 flex-1 truncate font-medium" title={state.name}>
|
||||
{title}
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="xs" onClick={() => onAction('cancel')}>
|
||||
{t('components.runtimeDownload.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5 ps-7">
|
||||
{percent === null ? (
|
||||
// Unknown size: an honest sweep beats a faked percentage.
|
||||
<span
|
||||
role="progressbar"
|
||||
aria-label={progressLabel}
|
||||
aria-valuetext={detail}
|
||||
className="block h-1.5 w-full overflow-hidden rounded-full bg-primary/20"
|
||||
>
|
||||
<span className="block h-full w-2/5 animate-[skill-update-slide_1.35s_ease-in-out_infinite] rounded-full bg-primary motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-40" />
|
||||
</span>
|
||||
) : (
|
||||
<Progress
|
||||
value={percent}
|
||||
aria-label={progressLabel}
|
||||
aria-valuetext={`${percent}%, ${detail}`}
|
||||
className="h-1.5"
|
||||
/>
|
||||
)}
|
||||
<p className="truncate text-xs tabular-nums text-muted-foreground">{detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DoneCard({
|
||||
state,
|
||||
revealLabel,
|
||||
onAction,
|
||||
onDismiss
|
||||
}: {
|
||||
state: Extract<RuntimeDownloadToastState, { kind: 'done' }>
|
||||
revealLabel: string
|
||||
onAction: (action: RuntimeDownloadToastAction) => void
|
||||
onDismiss: () => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const isFolder = state.itemKind === 'folder'
|
||||
const size = isFolder
|
||||
? t('components.runtimeDownload.zipSize', '{{size}} zip', { size: formatBytes(state.bytes) })
|
||||
: formatBytes(state.bytes)
|
||||
const parts =
|
||||
isFolder && state.fileCount !== undefined
|
||||
? [
|
||||
t('components.runtimeDownload.fileCount', '{{files}} files', {
|
||||
count: state.fileCount,
|
||||
files: formatCount(state.fileCount),
|
||||
defaultValue_one: '{{files}} file'
|
||||
}),
|
||||
size
|
||||
]
|
||||
: [size]
|
||||
parts.push(formatElapsedTime(state.elapsedMs))
|
||||
const notes: string[] = []
|
||||
if (state.excludedDefaultDirs) {
|
||||
notes.push(
|
||||
t(
|
||||
'components.runtimeDownload.defaultDirsNotIncluded',
|
||||
'node_modules and .git weren’t included.'
|
||||
)
|
||||
)
|
||||
}
|
||||
if ((state.skippedCount ?? 0) > 0) {
|
||||
notes.push(
|
||||
t(
|
||||
'components.runtimeDownload.skipped',
|
||||
'{{count}} items couldn’t be read and were left out.',
|
||||
{
|
||||
count: state.skippedCount,
|
||||
defaultValue_one: '{{count}} item couldn’t be read and was left out.'
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div role="status" className={`${CARD} pe-9`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<CircleCheck className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="truncate font-medium" title={state.name}>
|
||||
{t('components.runtimeDownload.downloaded', 'Downloaded {{name}}', {
|
||||
name: middleTruncateName(state.name)
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs tabular-nums text-muted-foreground">{parts.join(' · ')}</p>
|
||||
{notes.map((note) => (
|
||||
<p key={note} className="text-xs text-muted-foreground">
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex gap-2 pt-1.5">
|
||||
<Button type="button" variant="outline" size="xs" onClick={() => onAction('reveal')}>
|
||||
<FolderOpen aria-hidden />
|
||||
{revealLabel}
|
||||
</Button>
|
||||
{state.itemKind === 'file' && state.canOpen !== false ? (
|
||||
<Button type="button" variant="outline" size="xs" onClick={() => onAction('open')}>
|
||||
<ExternalLink aria-hidden />
|
||||
{t('components.runtimeDownload.open', 'Open')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DismissButton onDismiss={onDismiss} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function actionLabel(t: Translate, action: RuntimeDownloadFailureAction, revealLabel: string) {
|
||||
switch (action) {
|
||||
case 'retry':
|
||||
return t('components.runtimeDownload.retry', 'Try again')
|
||||
case 'download_instead':
|
||||
return t('components.runtimeDownload.downloadInstead', 'Download')
|
||||
case 'refresh':
|
||||
return t('components.runtimeDownload.refresh', 'Refresh')
|
||||
case 'reveal':
|
||||
return revealLabel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast for a download from a runtime host: live progress with Cancel, a done card whose actions
|
||||
* open the result on this computer, or one error with at most one next action.
|
||||
*/
|
||||
export function RuntimeDownloadToast({
|
||||
state,
|
||||
revealLabel,
|
||||
onAction,
|
||||
onDismiss
|
||||
}: {
|
||||
state: RuntimeDownloadToastState
|
||||
/** Platform name for revealing a path, e.g. "Reveal in Finder". */
|
||||
revealLabel: string
|
||||
onAction: (action: RuntimeDownloadToastAction) => void
|
||||
onDismiss: () => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
if (state.kind === 'progress') {
|
||||
return <ProgressCard state={state} onAction={onAction} />
|
||||
}
|
||||
if (state.kind === 'done') {
|
||||
return (
|
||||
<DoneCard state={state} revealLabel={revealLabel} onAction={onAction} onDismiss={onDismiss} />
|
||||
)
|
||||
}
|
||||
const copy =
|
||||
state.kind === 'no_app'
|
||||
? runtimeDownloadNoAppCopy(t, state.name)
|
||||
: runtimeDownloadFailureCopy(t, state)
|
||||
const action = copy.action
|
||||
return (
|
||||
<div className="relative w-[356px] rounded-lg bg-popover shadow-lg">
|
||||
<InlineError
|
||||
layout="toast"
|
||||
tone={copy.tone}
|
||||
title={copy.title}
|
||||
detail={copy.detail}
|
||||
action={
|
||||
action ? (
|
||||
<Button type="button" variant="outline" size="xs" onClick={() => onAction(action)}>
|
||||
{actionLabel(t, action, revealLabel)}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<DismissButton onDismiss={onDismiss} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+4
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { subscribeFileExplorerRefreshRequests } from '../watching/file-explorer-refresh-requests'
|
||||
|
||||
const REFRESH_SPINNER_DELAY_MS = 200
|
||||
|
||||
@@ -51,5 +52,8 @@ export function useFileExplorerManualRefresh(refreshTree: () => Promise<unknown>
|
||||
})
|
||||
}, [clearSpinnerTimer, refreshTree])
|
||||
|
||||
// A toast outside the tree (e.g. a download whose file was gone) can ask for the same refresh.
|
||||
useEffect(() => subscribeFileExplorerRefreshRequests(handleRefresh), [handleRefresh])
|
||||
|
||||
return { isRefreshing, showRefreshSpinner, handleRefresh }
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// Lets surfaces outside the explorer tree (e.g. a download toast that found its file gone) ask the
|
||||
// mounted explorer to refresh, without threading a callback through every row.
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
export function requestFileExplorerRefresh(): void {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeFileExplorerRefreshRequests(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { cn } from '@/lib/utils'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { VmBuildReveal } from './VmBuildReveal'
|
||||
import { isAccountGate, type VmBuildFailure } from './vm-build-failure'
|
||||
import { vmBuildFailureCopy } from './vm-build-failure-copy'
|
||||
import { getLastBillingStatus } from '@/components/billing/billing-summary-store'
|
||||
import { sandboxLimitFromBilling, vmBuildFailureCopy } from './vm-build-failure-copy'
|
||||
import { FailureActionRow, type VmBuildFailureActions } from './VmBuildFailureActions'
|
||||
import { CheckRow } from './VmBuildCheckRow'
|
||||
|
||||
@@ -54,7 +55,16 @@ export function VmBuildFailureCard({
|
||||
}
|
||||
const visible = failure ?? shown
|
||||
const copy = visible
|
||||
? vmBuildFailureCopy(visible, { cloud, canManageBilling, repoName }, t)
|
||||
? vmBuildFailureCopy(
|
||||
visible,
|
||||
{
|
||||
cloud,
|
||||
canManageBilling,
|
||||
repoName,
|
||||
sandboxLimit: sandboxLimitFromBilling(getLastBillingStatus())
|
||||
},
|
||||
t
|
||||
)
|
||||
: null
|
||||
const headingRef = React.useRef<HTMLHeadingElement>(null)
|
||||
const cardRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing'
|
||||
import type { VmBuildFailure } from './vm-build-failure'
|
||||
|
||||
/** The running-sandbox caps billing enforces: per member, and per team (per seat × seats). */
|
||||
export type SandboxLimit = { perSeat: number; team: number }
|
||||
|
||||
/** Mirrors billing's checkStart limits; null until the plan has loaded. */
|
||||
export function sandboxLimitFromBilling(
|
||||
status: OrcaBillingStatusResult | null
|
||||
): SandboxLimit | null {
|
||||
if (status?.status !== 'ok') {
|
||||
return null
|
||||
}
|
||||
const perSeat = status.entitlement.offers?.team.concurrentSandboxesPerSeat
|
||||
if (!perSeat) {
|
||||
return null
|
||||
}
|
||||
return { perSeat, team: perSeat * Math.max(1, status.entitlement.seats) }
|
||||
}
|
||||
|
||||
export type VmBuildFailureCopyContext = {
|
||||
cloud: boolean
|
||||
/** How the user knows the repository: its GitHub owner/name, else its name in Orca. */
|
||||
repoName: string
|
||||
/** False for team members who can't change billing; they are told to ask an owner or admin. */
|
||||
canManageBilling: boolean
|
||||
/** States the cap on the limit cards; omitted or null falls back to copy without numbers. */
|
||||
sandboxLimit?: SandboxLimit | null
|
||||
}
|
||||
|
||||
/** The card's cause-specific title and one plain-language sentence on what to do next. */
|
||||
export function vmBuildFailureCopy(
|
||||
failure: VmBuildFailure,
|
||||
{ cloud, canManageBilling, repoName }: VmBuildFailureCopyContext,
|
||||
{ cloud, canManageBilling, repoName, sandboxLimit }: VmBuildFailureCopyContext,
|
||||
t: Translate
|
||||
): { title: string; body: string | null } {
|
||||
switch (failure.kind) {
|
||||
@@ -104,10 +124,16 @@ export function vmBuildFailureCopy(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.teamLimitTitle',
|
||||
'You’re at your team’s limit of running sandboxes'
|
||||
),
|
||||
body: t(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.limitBody',
|
||||
'Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.'
|
||||
)
|
||||
body: sandboxLimit
|
||||
? t(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.teamLimitBody',
|
||||
'Your team can run {{value0}} sandboxes at once ({{value1}} per seat). Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.',
|
||||
{ value0: String(sandboxLimit.team), value1: String(sandboxLimit.perSeat) }
|
||||
)
|
||||
: t(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.limitBody',
|
||||
'Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.'
|
||||
)
|
||||
}
|
||||
case 'memberConcurrency':
|
||||
return {
|
||||
@@ -115,10 +141,16 @@ export function vmBuildFailureCopy(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.memberLimitTitle',
|
||||
'You’re at your limit of running sandboxes'
|
||||
),
|
||||
body: t(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.limitBody',
|
||||
'Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.'
|
||||
)
|
||||
body: sandboxLimit
|
||||
? t(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.memberLimitBody2',
|
||||
'You can run {{value0}} sandboxes at once. Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.',
|
||||
{ value0: String(sandboxLimit.perSeat) }
|
||||
)
|
||||
: t(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.limitBody',
|
||||
'Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.'
|
||||
)
|
||||
}
|
||||
case 'githubNotConnected':
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { Translate } from '@/i18n/use-translate'
|
||||
import { openPlansPage } from '@/components/billing/plans/plans-page-store'
|
||||
import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
|
||||
import { classifyVmBuildFailure, type VmBuildFailure } from './vm-build-failure'
|
||||
import { vmBuildFailureCopy } from './vm-build-failure-copy'
|
||||
import { getLastBillingStatus } from '@/components/billing/billing-summary-store'
|
||||
import { sandboxLimitFromBilling, vmBuildFailureCopy } from './vm-build-failure-copy'
|
||||
import { isOrcaCloudBuild } from './vm-build-steps'
|
||||
import { orcaCloudRepoName } from './vm-build-repo-actions'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
@@ -26,7 +27,11 @@ export function vmBuildFailureSummary(
|
||||
recipe ? repos.find((repo) => repo.id === recipe.sourceRepoId) : undefined
|
||||
)
|
||||
// Unknown here; the card itself refines the copy for members once billing status loads.
|
||||
return { failure, ...vmBuildFailureCopy(failure, { cloud, canManageBilling: true, repoName }, t) }
|
||||
const sandboxLimit = sandboxLimitFromBilling(getLastBillingStatus())
|
||||
return {
|
||||
failure,
|
||||
...vmBuildFailureCopy(failure, { cloud, canManageBilling: true, repoName, sandboxLimit }, t)
|
||||
}
|
||||
}
|
||||
|
||||
/** The build sheet's card as a toast, for a VM build that failed while its sheet was off screen. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyVmBuildFailure, isAccountGate, type VmBuildFailure } from './vm-build-failure'
|
||||
import { vmBuildFailureCopy } from './vm-build-failure-copy'
|
||||
import { sandboxLimitFromBilling, vmBuildFailureCopy } from './vm-build-failure-copy'
|
||||
import { orcaCloudRepoName } from './vm-build-repo-actions'
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
|
||||
@@ -104,6 +104,38 @@ describe('vmBuildFailureCopy', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('states the running-sandbox cap on the limit cards once the plan is known', () => {
|
||||
const copy = (kind: VmBuildFailure, sandboxLimit: { perSeat: number; team: number } | null) =>
|
||||
vmBuildFailureCopy(
|
||||
kind,
|
||||
{ cloud: true, canManageBilling: true, repoName: 'acme/web', sandboxLimit },
|
||||
t
|
||||
).body
|
||||
expect(copy({ kind: 'concurrency' }, { perSeat: 3, team: 12 })).toContain(
|
||||
'Your team can run 12 sandboxes at once (3 per seat). Put one to sleep or delete it'
|
||||
)
|
||||
expect(copy({ kind: 'memberConcurrency' }, { perSeat: 3, team: 12 })).toContain(
|
||||
'You can run 3 sandboxes at once. Put one to sleep or delete it'
|
||||
)
|
||||
expect(copy({ kind: 'concurrency' }, null)).toMatch(/^Put one to sleep or delete it/)
|
||||
})
|
||||
|
||||
it('derives the caps the way billing enforces them', () => {
|
||||
const status = (seats: number, perSeat: number | null) =>
|
||||
({
|
||||
status: 'ok',
|
||||
entitlement: {
|
||||
seats,
|
||||
offers: perSeat === null ? null : { team: { concurrentSandboxesPerSeat: perSeat } }
|
||||
}
|
||||
}) as unknown as Parameters<typeof sandboxLimitFromBilling>[0]
|
||||
expect(sandboxLimitFromBilling(status(4, 3))).toEqual({ perSeat: 3, team: 12 })
|
||||
// Billing counts at least one seat.
|
||||
expect(sandboxLimitFromBilling(status(0, 3))).toEqual({ perSeat: 3, team: 3 })
|
||||
expect(sandboxLimitFromBilling(status(4, null))).toBeNull()
|
||||
expect(sandboxLimitFromBilling(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('names the repository GitHub needs to reach', () => {
|
||||
const { title } = vmBuildFailureCopy(
|
||||
{ kind: 'githubNotConnected' },
|
||||
|
||||
+8
@@ -2040,6 +2040,14 @@
|
||||
"workers_one": "{{count}} worker",
|
||||
"workers_other": "{{count}} workers"
|
||||
},
|
||||
"runtimeDownload": {
|
||||
"fileCount_one": "{{files}} file",
|
||||
"fileCount_other": "{{files}} files",
|
||||
"folderFilesProgress_one": "{{files}} file",
|
||||
"folderFilesProgress_other": "{{files}} files",
|
||||
"skipped_one": "{{count}} item couldn’t be read and was left out.",
|
||||
"skipped_other": "{{count}} items couldn’t be read and were left out."
|
||||
},
|
||||
"tab": {
|
||||
"bar": {
|
||||
"bulkClose": {
|
||||
|
||||
@@ -4108,6 +4108,8 @@
|
||||
"paymentFailedBodyMember": "New sandboxes can’t start until the payment goes through. Ask an owner or admin to update the payment method. Nothing was created, and you weren’t charged.",
|
||||
"teamLimitTitle": "You’re at your team’s limit of running sandboxes",
|
||||
"limitBody": "Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.",
|
||||
"teamLimitBody": "Your team can run {{value0}} sandboxes at once ({{value1}} per seat). Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.",
|
||||
"memberLimitBody2": "You can run {{value0}} sandboxes at once. Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.",
|
||||
"memberLimitTitle": "You’re at your limit of running sandboxes",
|
||||
"githubNotConnectedTitle": "GitHub isn’t connected for {{repo}}",
|
||||
"githubNotConnectedBody": "Orca Cloud clones your code from GitHub and needs access to this repository first. Connect GitHub, then try again. Nothing was created, and you weren’t charged.",
|
||||
@@ -14650,7 +14652,10 @@
|
||||
"clipboardStagingUnavailable": "Could not copy the file because Orca's temporary storage is unavailable",
|
||||
"revealInFinder": "Reveal in Finder",
|
||||
"openContainingFolder": "Open Containing Folder",
|
||||
"revealInFileExplorer": "Reveal in File Explorer"
|
||||
"revealInFileExplorer": "Reveal in File Explorer",
|
||||
"downloadFolderZip": "Download as ZIP…",
|
||||
"openLocally": "Open on This Computer",
|
||||
"downloadFileEllipsis": "Download…"
|
||||
},
|
||||
"FileExplorerToolbar": {
|
||||
"d238264654": "Show Git Ignored Files",
|
||||
@@ -20519,7 +20524,8 @@
|
||||
"vpRunningBody2": "Sandboxes keep working while you are away and report back when they finish.",
|
||||
"leftResets": "{{value0}} hours left · resets {{value1}}",
|
||||
"retry": "Try again",
|
||||
"featureSleep2": "Sleeps after {{value0}} minutes idle",
|
||||
"featureSleep3": "Sleeps after {{value0}} minutes idle, so quiet time is free",
|
||||
"featureReady": "Ready in seconds, nothing to set up or host",
|
||||
"billedYearly": "Billed yearly",
|
||||
"waiting": "Waiting for checkout…",
|
||||
"fixPaymentAbove": "Update your payment method above to keep your plan active.",
|
||||
@@ -20529,9 +20535,9 @@
|
||||
"savePercent": "Save {{value0}}%",
|
||||
"seatsPeriod": "{{value0}} · {{value1}}",
|
||||
"was": "Was",
|
||||
"featureConcurrency2": "{{value0}} sandboxes running at once per seat",
|
||||
"featureHours4": "{{value0}} sandbox hours per seat each month, pooled across your team",
|
||||
"featureLarge3": "Standard (4 vCPU · 8 GB) sandboxes; Large (8 vCPU · 16 GB) uses sandbox hours at {{value0}}× the rate",
|
||||
"featureStandard": "Standard (4 vCPU · 8 GB)",
|
||||
"featureLarge4": "Large (8 vCPU · 16 GB) uses sandbox hours at {{value0}}× the rate",
|
||||
"featureOverage3": "Optional overage at {{value0}}/hour ({{value1}}/hour on Large), capped at a monthly spend cap you set ({{value2}} by default)",
|
||||
"footTerms4": "Prices in USD, before tax. Overage is off until an owner or admin turns it on. Cancel anytime; your plan runs to the end of the billing period.",
|
||||
"lockedBody2": "Only an owner or admin of your team can choose a plan. Ask one of them to open Plan & billing in Orca.",
|
||||
@@ -20675,6 +20681,8 @@
|
||||
"emptyTeamTitle": "No team workspaces yet",
|
||||
"environmentName": "{{value0}} (cloud)",
|
||||
"errorConcurrencyLimit": "You're at your team's limit of running sandboxes. Put one to sleep or delete it, then try again.",
|
||||
"errorConcurrencyLimitCount2": "Your team can run {{value0}} sandboxes at once ({{value1}} per seat). Put one to sleep or delete it, then try again.",
|
||||
"errorMemberConcurrencyLimit": "You're at your limit of running sandboxes. Put one to sleep or delete it, then try again.",
|
||||
"errorConflict": "This workspace is still starting or going to sleep. Nothing was changed. Try again in a moment.",
|
||||
"errorConflictInline": "This workspace is still starting or going to sleep. Nothing was changed.",
|
||||
"errorEntitlementDenied": "Your team's plan can't start this workspace right now. Your files are safe. Ask an owner or admin to check the plan.",
|
||||
@@ -22872,7 +22880,7 @@
|
||||
"retry": "Try again",
|
||||
"notCloudTitle": "Remote desktop is only available in cloud workspaces.",
|
||||
"notCloudDetail": "Create a cloud workspace to run apps with a screen.",
|
||||
"asleepTitle": "This workspace is asleep.",
|
||||
"asleepTitle": "This sandbox is asleep.",
|
||||
"asleepDetail": "Wake it, then open the remote desktop again. Your files are safe.",
|
||||
"notInstalledTitle": "This sandbox doesn’t have a remote desktop yet.",
|
||||
"notInstalledDetail": "It was created before remote desktop was available. New cloud workspaces include it. Your files are safe.",
|
||||
@@ -22882,12 +22890,25 @@
|
||||
"forwardFailedDetail": "The desktop is running in the sandbox. Your files are safe.",
|
||||
"unavailableTitle": "Couldn’t start the remote desktop.",
|
||||
"unavailableDetail": "Orca Cloud didn’t respond in time. Your files are safe.",
|
||||
"wake": "Wake workspace",
|
||||
"wake": "Wake sandbox",
|
||||
"signIn": "Sign in",
|
||||
"createWorkspace": "Create workspace",
|
||||
"slowDetail": "The first start in a sandbox can take up to a minute.",
|
||||
"dismiss": "Dismiss",
|
||||
"openMenuItem": "Open remote desktop"
|
||||
"openMenuItem": "Open remote desktop",
|
||||
"startingTitle": "The sandbox is still starting or waking.",
|
||||
"archivedTitle": "This sandbox is archived.",
|
||||
"archivedDetail": "Unarchive the workspace to use the remote desktop again. Your files are safe.",
|
||||
"startingDetail": "Try again in a moment. Your files are safe.",
|
||||
"keyboardCaptured": "Keys go to the remote desktop. Press <kbd>{{chord}}</kbd> to use Orca shortcuts.",
|
||||
"keyboardReleased": "Orca shortcuts are back. Click the desktop to type in it.",
|
||||
"pageAsleep": "This sandbox is asleep. Wake it in Orca, then reconnect.",
|
||||
"pageArchived": "This sandbox is archived. Unarchive the workspace in Orca, then reconnect.",
|
||||
"pageStarting": "The sandbox is starting or waking. Reconnect in a moment.",
|
||||
"pageSignedOut": "You’re signed out of Orca Cloud. Sign in, then reconnect.",
|
||||
"pageDisconnected": "The remote desktop disconnected. Your files are safe.",
|
||||
"reconnect": "Reconnect",
|
||||
"beta": "Beta"
|
||||
},
|
||||
"orcaCloud": {
|
||||
"offline": {
|
||||
@@ -22922,6 +22943,49 @@
|
||||
"terminalOutput": "Saved output of {{title}}",
|
||||
"noGit": "No source control state was saved."
|
||||
}
|
||||
},
|
||||
"runtimeDownload": {
|
||||
"collectingFiles": "Collecting files…",
|
||||
"starting": "Starting…",
|
||||
"amountOfTotal": "{{done}} of {{total}}",
|
||||
"amountAndRate": "{{amount}} · {{rate}}/s",
|
||||
"openFailedTitle": "Couldn’t open {{name}}",
|
||||
"failedTitle": "Couldn’t download {{name}}",
|
||||
"tooLargeToOpen": "Files over 1 GB can’t be opened directly. Download it instead.",
|
||||
"sandboxUpdateRequired": "This sandbox runs an older version of Orca. New cloud workspaces include downloads. Your files are safe.",
|
||||
"hostUpdateRequired": "The remote host runs an older version of Orca. Update Orca there, then try again.",
|
||||
"busy": "Too many downloads are running. Wait for one to finish, then try again.",
|
||||
"changed": "It changed while Orca was reading it. Try again.",
|
||||
"sandboxDisconnected": "The connection to the sandbox dropped. Nothing was saved on this computer.",
|
||||
"hostDisconnected": "The connection to the remote host dropped. Nothing was saved on this computer.",
|
||||
"transferFailed": "Something went wrong while copying it. Nothing was saved on this computer.",
|
||||
"dismiss": "Dismiss",
|
||||
"opening": "Opening {{name}}",
|
||||
"downloading": "Downloading {{name}}",
|
||||
"progressLabel": "Download progress",
|
||||
"cancel": "Cancel",
|
||||
"skipped_one": "{{count}} item couldn’t be read and was left out.",
|
||||
"skipped_other": "{{count}} items couldn’t be read and were left out.",
|
||||
"downloaded": "Downloaded {{name}}",
|
||||
"open": "Open",
|
||||
"retry": "Try again",
|
||||
"downloadInstead": "Download",
|
||||
"fileCount_one": "{{files}} file",
|
||||
"fileCount_other": "{{files}} files",
|
||||
"amountRateEta": "{{amount}} · {{rate}}/s · {{eta}} left",
|
||||
"folderFilesProgress_one": "{{files}} file",
|
||||
"folderFilesProgress_other": "{{files}} files",
|
||||
"zipSize": "{{size}} zip",
|
||||
"defaultDirsNotIncluded": "node_modules and .git weren’t included.",
|
||||
"refresh": "Refresh",
|
||||
"folderTooLarge": "This folder is over 2 GB, the most Orca can download at once. Download the folders inside it one at a time. Nothing was saved on this computer.",
|
||||
"fileGrew": "The file grew while Orca was copying it. Nothing was saved on this computer.",
|
||||
"folderTooManyItems": "This folder has more than 65,000 items, the most Orca can download at once. Download the folders inside it one at a time. Nothing was saved on this computer.",
|
||||
"openRefused": "Orca doesn’t open files that can run programs, like scripts, installers and apps. Download it to check it first. Nothing was saved on this computer.",
|
||||
"notFoundInSandboxNothingSaved": "It was moved or deleted in the sandbox. Nothing was saved on this computer.",
|
||||
"notFoundOnHostNothingSaved": "It was moved or deleted on the remote host. Nothing was saved on this computer.",
|
||||
"savedName": "Saved {{name}}",
|
||||
"noAppForType": "No app on this computer opens this file type. Reveal it to choose one."
|
||||
}
|
||||
},
|
||||
"dashboardPopout": {
|
||||
|
||||
@@ -33,6 +33,11 @@ function subscribe(listener: () => void): () => void {
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
/** Non-React read of the last loaded list; call refreshCloudEnvironmentIds first when it matters. */
|
||||
export function isKnownCloudEnvironment(environmentId: string): boolean {
|
||||
return ids.has(environmentId)
|
||||
}
|
||||
|
||||
export function useIsCloudEnvironment(environmentId: string | null): boolean {
|
||||
return useSyncExternalStore(subscribe, () => (environmentId ? ids.has(environmentId) : false))
|
||||
}
|
||||
|
||||
@@ -13,21 +13,20 @@ import type { CloudRemoteDesktopResult } from '../../../../shared/hosted-sandbox
|
||||
const SLOW_START_MS = 5_000
|
||||
const opening = new Set<string>()
|
||||
|
||||
/** Desktop identity without the per-boot password or the local port a re-dialed forward may change. */
|
||||
/** Desktop identity without the local port a re-dialed forward may change (or an older URL form). */
|
||||
function desktopPage(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.hostname === 'localhost'
|
||||
? `${parsed.pathname}?${parsed.searchParams.toString()}`
|
||||
: null
|
||||
return parsed.hostname === 'localhost' ? parsed.pathname : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This workspace's desktop tab: reused when it is the current session, replaced when it holds a
|
||||
* previous boot's password (it would only retry a dead login), so there is never a second tab.
|
||||
* This workspace's desktop tab: reused when it is this session's page (main hands it a fresh ticket
|
||||
* and resumes it if it stopped retrying), replaced when it points at an older forward or failed to
|
||||
* load, so there is never a second tab.
|
||||
*/
|
||||
function takeDesktopTab(worktreeId: string, url: string): string | null {
|
||||
const state = useAppStore.getState()
|
||||
@@ -69,7 +68,8 @@ export async function openCloudRemoteDesktop(
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{ id, duration: state.kind === 'starting' ? Number.POSITIVE_INFINITY : 12_000 }
|
||||
// Errors stay until dismissed or acted on: each names the one next step.
|
||||
{ id, duration: Number.POSITIVE_INFINITY }
|
||||
)
|
||||
}
|
||||
show({ kind: 'starting', slow: false })
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { RuntimeDownloadProgressEvent } from '../../../../../shared/runtime-downloads/runtime-download-contract'
|
||||
|
||||
export type RuntimeDownloadProgressSnapshot = RuntimeDownloadProgressEvent & {
|
||||
/** Recent receive rate, smoothed so the label does not jitter. */
|
||||
bytesPerSecond: number | null
|
||||
/** How long the rate has been sampled; a time-left estimate waits until it settles. */
|
||||
rateWindowMs: number
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
snapshot: RuntimeDownloadProgressSnapshot | null
|
||||
listeners: Set<() => void>
|
||||
lastSample: { at: number; bytes: number } | null
|
||||
firstSampleAt: number | null
|
||||
}
|
||||
|
||||
const entries = new Map<string, Entry>()
|
||||
let unsubscribeIpc: (() => void) | null = null
|
||||
// Rate is averaged over this window; shorter windows swing with every page.
|
||||
const RATE_SMOOTHING = 0.3
|
||||
|
||||
function entryFor(downloadId: string): Entry {
|
||||
let entry = entries.get(downloadId)
|
||||
if (!entry) {
|
||||
entry = { snapshot: null, listeners: new Set(), lastSample: null, firstSampleAt: null }
|
||||
entries.set(downloadId, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
export function applyRuntimeDownloadProgress(
|
||||
event: RuntimeDownloadProgressEvent,
|
||||
now = performance.now()
|
||||
): void {
|
||||
const entry = entries.get(event.downloadId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
let bytesPerSecond = entry.snapshot?.bytesPerSecond ?? null
|
||||
if (entry.lastSample && now > entry.lastSample.at) {
|
||||
const instant =
|
||||
((event.receivedBytes - entry.lastSample.bytes) * 1000) / (now - entry.lastSample.at)
|
||||
bytesPerSecond =
|
||||
bytesPerSecond === null
|
||||
? instant
|
||||
: bytesPerSecond + RATE_SMOOTHING * (instant - bytesPerSecond)
|
||||
}
|
||||
entry.lastSample = { at: now, bytes: event.receivedBytes }
|
||||
entry.firstSampleAt ??= now
|
||||
entry.snapshot = { ...event, bytesPerSecond, rateWindowMs: now - entry.firstSampleAt }
|
||||
for (const listener of entry.listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts tracking a download; returns a release that forgets it once its toast is done. */
|
||||
export function trackRuntimeDownload(downloadId: string): () => void {
|
||||
entryFor(downloadId)
|
||||
if (!unsubscribeIpc && window.api.runtimeDownloads) {
|
||||
unsubscribeIpc = window.api.runtimeDownloads.onProgress((event) =>
|
||||
applyRuntimeDownloadProgress(event)
|
||||
)
|
||||
}
|
||||
return () => {
|
||||
entries.delete(downloadId)
|
||||
if (entries.size === 0 && unsubscribeIpc) {
|
||||
unsubscribeIpc()
|
||||
unsubscribeIpc = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeRuntimeDownloadProgress(
|
||||
downloadId: string,
|
||||
listener: () => void
|
||||
): () => void {
|
||||
// Never creates an entry: a toast rendering after release() must not resurrect one, or the IPC
|
||||
// listener would never be removed.
|
||||
const entry = entries.get(downloadId)
|
||||
if (!entry) {
|
||||
return () => {}
|
||||
}
|
||||
entry.listeners.add(listener)
|
||||
return () => {
|
||||
entry.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function getRuntimeDownloadProgress(
|
||||
downloadId: string
|
||||
): RuntimeDownloadProgressSnapshot | null {
|
||||
return entries.get(downloadId)?.snapshot ?? null
|
||||
}
|
||||
@@ -77,6 +77,8 @@ export function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']>
|
||||
onGrabModeToggle: () => noopUnsubscribe,
|
||||
onGrabActionShortcut: () => noopUnsubscribe,
|
||||
onGuestEscape: () => noopUnsubscribe,
|
||||
onRemoteDesktopState: () => noopUnsubscribe,
|
||||
onRemoteDesktopRelease: () => noopUnsubscribe,
|
||||
sessionListProfiles: () => Promise.resolve([]),
|
||||
onSessionProfilesChanged: () => noopUnsubscribe,
|
||||
// Web clients render remote workspaces through the server; no local SSH routing exists.
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
export type CloudRemoteDesktopFailureCode =
|
||||
/** Not an Orca Cloud workspace, or this build has no control plane. */
|
||||
| 'not_cloud'
|
||||
/** The workspace is asleep or still starting. */
|
||||
/** The sandbox is asleep: waking it is the next step. */
|
||||
| 'not_running'
|
||||
/** The workspace is archived: unarchive it first. */
|
||||
| 'archived'
|
||||
/** The sandbox is starting or waking: try again in a moment. */
|
||||
| 'starting'
|
||||
/** The workspace's image predates the remote desktop. */
|
||||
| 'not_installed'
|
||||
| 'signed_out'
|
||||
@@ -17,16 +21,12 @@ export type CloudRemoteDesktopResult =
|
||||
| { ok: false; code: CloudRemoteDesktopFailureCode }
|
||||
|
||||
/**
|
||||
* noVNC page on the forwarded port. resize=remote makes the VM's screen follow the tab size;
|
||||
* reconnect rides out a forward re-dial. The password rides in the fragment, which never goes
|
||||
* over HTTP; it differs per VM boot, so a stale tab can never attach to another workspace.
|
||||
* noVNC page on the forwarded port. It holds no secret: Orca hands the page a single-use ticket
|
||||
* for each WebSocket after it loads (and again for a reconnect), so the URL can be shown, kept in
|
||||
* the tab and restored. resize=remote makes the VM's screen follow the tab size; noVNC's own
|
||||
* connect and reconnect are off because each attempt needs a ticket from Orca.
|
||||
*/
|
||||
export function cloudRemoteDesktopUrl(localPort: number, path: string, password: string): string {
|
||||
const params = new URLSearchParams({
|
||||
autoconnect: '1',
|
||||
resize: 'remote',
|
||||
reconnect: '1',
|
||||
reconnect_delay: '1000'
|
||||
})
|
||||
return `http://localhost:${localPort}/${path}?${params.toString()}#password=${encodeURIComponent(password)}`
|
||||
export function cloudRemoteDesktopUrl(localPort: number, path: string): string {
|
||||
const params = new URLSearchParams({ autoconnect: '0', reconnect: '0', resize: 'remote' })
|
||||
return `http://localhost:${localPort}/${path}?${params.toString()}`
|
||||
}
|
||||
|
||||
@@ -152,12 +152,16 @@ export const HostedSandboxListResponseSchema = z
|
||||
.object({ sandboxes: z.array(HostedSandboxViewSchema).max(1000) })
|
||||
.strict()
|
||||
|
||||
/** `POST /v1/sandboxes/:id/desktop`: the remote desktop's port on the VM's loopback, never a public route. */
|
||||
/**
|
||||
* `POST /v1/sandboxes/:id/desktop`: the remote desktop's port on the VM's loopback (never a public
|
||||
* route) and a fresh single-use ticket for one WebSocket to it. Each call mints a new ticket.
|
||||
*/
|
||||
export const HostedSandboxDesktopResponseSchema = z.object({
|
||||
desktop: z.object({
|
||||
port: z.number().int().min(1024).max(65_535),
|
||||
path: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/),
|
||||
password: z.string().regex(/^[A-Za-z0-9]{8}$/)
|
||||
ticket: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
|
||||
expiresInMs: z.number().int().min(1_000).max(300_000)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ export function blockedPluginSaveAsExtension(name: string): string | null {
|
||||
.split(/[\\/]/)
|
||||
.at(-1) ?? ''
|
||||
const dot = base.lastIndexOf('.')
|
||||
const extension = dot > 0 ? base.slice(dot + 1).toLowerCase() : ''
|
||||
// `>= 0`: Windows treats a bare `.hta` or `.bat` as that type and runs it.
|
||||
const extension = dot !== -1 ? base.slice(dot + 1).toLowerCase() : ''
|
||||
return PLUGIN_SAVE_AS_BLOCKED_EXTENSIONS.has(extension) ? extension : null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod'
|
||||
import { FileTreePath } from './files-params'
|
||||
|
||||
/** Uncompressed bytes one folder archive may hold; ZIP without ZIP64 tops out at 4 GiB. */
|
||||
export const FOLDER_ARCHIVE_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
|
||||
/** Entries one folder archive may hold; a ZIP without ZIP64 counts entries in 16 bits. */
|
||||
export const FOLDER_ARCHIVE_MAX_ENTRIES = 65_000
|
||||
/** Largest archive page one read returns; base64 keeps the reply well under the 4 MiB frame budget. */
|
||||
export const FOLDER_ARCHIVE_MAX_READ_BYTES = 2 * 1024 * 1024
|
||||
/** Directory names skipped when the caller asks to leave out dependency and VCS folders. */
|
||||
export const FOLDER_ARCHIVE_DEFAULT_EXCLUDED_NAMES = ['node_modules', '.git'] as const
|
||||
|
||||
export const FileArchiveStart = FileTreePath.extend({
|
||||
/** Skip FOLDER_ARCHIVE_DEFAULT_EXCLUDED_NAMES at any depth. */
|
||||
excludeDefaultDirs: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const FileArchiveRead = z.object({
|
||||
archiveId: z.string().min(1).max(128),
|
||||
maxBytes: z.number().int().positive().max(FOLDER_ARCHIVE_MAX_READ_BYTES)
|
||||
})
|
||||
|
||||
export const FileArchiveCancel = z.object({
|
||||
archiveId: z.string().min(1).max(128)
|
||||
})
|
||||
|
||||
export type FileArchiveStartResult = {
|
||||
archiveId: string
|
||||
/** Archive name the host suggests, e.g. `src.zip`. */
|
||||
suggestedName: string
|
||||
fileCount: number
|
||||
directoryCount: number
|
||||
/** Sum of the regular files' sizes at walk time. */
|
||||
totalBytes: number
|
||||
/** Directories left out by `excludeDefaultDirs`. */
|
||||
excludedDirCount: number
|
||||
}
|
||||
|
||||
export type FileArchiveReadResult = {
|
||||
contentBase64: string
|
||||
bytesRead: number
|
||||
eof: boolean
|
||||
/** Uncompressed file bytes archived so far; compare with `totalBytes` for progress. */
|
||||
processedBytes: number
|
||||
/** Entries skipped because they vanished or became unreadable mid-archive. */
|
||||
skippedCount: number
|
||||
}
|
||||
|
||||
export type FileArchiveErrorCode =
|
||||
| 'archive_not_directory'
|
||||
| 'archive_too_large'
|
||||
| 'archive_too_many_entries'
|
||||
| 'archive_not_found'
|
||||
| 'archive_busy'
|
||||
@@ -238,6 +238,7 @@ import {
|
||||
TypeParams
|
||||
} from './emulator-params'
|
||||
import { EnvironmentDoctorParams } from './environment-doctor-params'
|
||||
import { FileArchiveCancel, FileArchiveRead, FileArchiveStart } from './files-archive-params'
|
||||
import {
|
||||
FileCommitUpload,
|
||||
FileCopy,
|
||||
@@ -1066,6 +1067,9 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'emulator.type': TypeParams,
|
||||
'emulator.unregisterActive': EmulatorUnregisterActiveParams,
|
||||
'environment.doctor': EnvironmentDoctorParams,
|
||||
'files.archiveCancel': FileArchiveCancel,
|
||||
'files.archiveRead': FileArchiveRead,
|
||||
'files.archiveStart': FileArchiveStart,
|
||||
'files.browseServerDir': ServerDirectoryBrowse,
|
||||
'files.commitUpload': FileCommitUpload,
|
||||
'files.copy': FileCopy,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Desktop downloads of files and folders that live on a paired runtime host. */
|
||||
import { blockedPluginSaveAsExtension } from '../plugins/frames/plugin-save-as-extensions'
|
||||
|
||||
export type RuntimeDownloadErrorCode =
|
||||
| 'canceled'
|
||||
| 'not_a_file'
|
||||
| 'not_a_directory'
|
||||
| 'too_large'
|
||||
| 'too_many_entries'
|
||||
| 'host_update_required'
|
||||
| 'host_busy'
|
||||
| 'not_found'
|
||||
| 'disconnected'
|
||||
| 'open_failed'
|
||||
/** "Open on this computer" refuses files the OS would run (scripts, installers, apps). */
|
||||
| 'open_refused'
|
||||
| 'transfer_failed'
|
||||
|
||||
/** Largest file "Open locally" will fetch into the temp cache. */
|
||||
export const RUNTIME_OPEN_LOCALLY_MAX_BYTES = 1024 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Whether Orca may hand a fetched copy straight to the OS opener. The host controls the bytes and the
|
||||
* name, so anything Windows or macOS would run on a double-click is only ever saved, never opened.
|
||||
*/
|
||||
export function canOpenRuntimeDownloadLocally(name: string): boolean {
|
||||
return blockedPluginSaveAsExtension(name) === null
|
||||
}
|
||||
|
||||
export type RuntimeDownloadStartArgs = {
|
||||
/** Caller-chosen id; progress events and cancel use it. */
|
||||
downloadId: string
|
||||
environmentId: string
|
||||
worktree: string
|
||||
relativePath: string
|
||||
/** Display name of the remote entry, used for the suggested local name. */
|
||||
name: string
|
||||
kind: 'file' | 'folder'
|
||||
/** `save` asks where to put it; `open` fetches into a temp cache and opens it. */
|
||||
intent: 'save' | 'open'
|
||||
excludeDefaultDirs?: boolean
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
}
|
||||
|
||||
export type RuntimeDownloadProgressEvent = {
|
||||
downloadId: string
|
||||
phase: 'preparing' | 'transferring'
|
||||
receivedBytes: number
|
||||
totalBytes: number | null
|
||||
processedBytes: number
|
||||
/** Folder downloads: files in the archive, known once the host has listed the folder. */
|
||||
fileCount?: number
|
||||
/** Present once a save location is chosen. */
|
||||
destinationPath?: string
|
||||
}
|
||||
|
||||
export type RuntimeDownloadResult =
|
||||
| { status: 'canceled' }
|
||||
| {
|
||||
status: 'done'
|
||||
destinationPath: string
|
||||
bytes: number
|
||||
/** Folder downloads only. */
|
||||
fileCount?: number
|
||||
skippedCount?: number
|
||||
excludedDirCount?: number
|
||||
/** `open` intent: the OS refused to open the fetched file. */
|
||||
openError?: string
|
||||
}
|
||||
| { status: 'error'; code: RuntimeDownloadErrorCode; message: string }
|
||||
@@ -100,6 +100,44 @@ describe('runtime RPC call queue', () => {
|
||||
await expect(Promise.all(reads)).resolves.toEqual(Array(6).fill('chunk'))
|
||||
})
|
||||
|
||||
it('runs a download in its own four-wide lane beside preview reads', async () => {
|
||||
const queue = new RuntimeRpcCallQueuePool(8, 2)
|
||||
const previewRelease: (() => void)[] = []
|
||||
const downloadRelease: (() => void)[] = []
|
||||
const previews = Array.from({ length: 3 }, () =>
|
||||
queue.enqueue('web-runtime', 'files.readChunk', async () => {
|
||||
await new Promise<void>((resolve) => previewRelease.push(resolve))
|
||||
return 'preview'
|
||||
})
|
||||
)
|
||||
const downloads = Array.from({ length: 6 }, () =>
|
||||
queue.enqueue(
|
||||
'web-runtime',
|
||||
'files.readChunk',
|
||||
async () => {
|
||||
await new Promise<void>((resolve) => downloadRelease.push(resolve))
|
||||
return 'page'
|
||||
},
|
||||
0,
|
||||
undefined,
|
||||
'download'
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() => expect(downloadRelease).toHaveLength(4))
|
||||
expect(previewRelease).toHaveLength(2)
|
||||
|
||||
for (let finished = 0; finished < 6; finished += 1) {
|
||||
await vi.waitFor(() => expect(downloadRelease.length).toBeGreaterThan(0))
|
||||
downloadRelease.shift()?.()
|
||||
}
|
||||
await expect(Promise.all(downloads)).resolves.toEqual(Array(6).fill('page'))
|
||||
for (let finished = 0; finished < 3; finished += 1) {
|
||||
await vi.waitFor(() => expect(previewRelease.length).toBeGreaterThan(0))
|
||||
previewRelease.shift()?.()
|
||||
}
|
||||
await expect(Promise.all(previews)).resolves.toEqual(Array(3).fill('preview'))
|
||||
})
|
||||
|
||||
it('frees the queue slot when a runtime call throws synchronously', async () => {
|
||||
const queue = new RuntimeRpcCallQueuePool(1, 1)
|
||||
const first = queue.enqueue('web-runtime', 'status.get', () => {
|
||||
|
||||
@@ -4,6 +4,9 @@ import { REMOTE_RUNTIME_MAX_PREPARED_RPC_BYTES } from './remote-runtime/remote-r
|
||||
const DEFAULT_REMOTE_RUNTIME_CALL_CONCURRENCY = 8
|
||||
const DEFAULT_REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY = 2
|
||||
const REMOTE_RUNTIME_BULK_READ_CONCURRENCY = 2
|
||||
// Explicit file downloads pipeline more pages than media/preview reads: at ~65 ms RTT two 512 KiB
|
||||
// pages in flight cap a 200 MB download near 9 MB/s, four clear 10 MB/s.
|
||||
const REMOTE_RUNTIME_DOWNLOAD_CONCURRENCY = 4
|
||||
export const RUNTIME_RPC_MAX_QUEUED_CALLS_PER_SELECTOR = 256
|
||||
export const RUNTIME_RPC_MAX_QUEUED_CALLS_TOTAL = 2_048
|
||||
export const RUNTIME_RPC_QUEUE_OVERLOAD_CODE = 'runtime_rpc_queue_overloaded'
|
||||
@@ -63,11 +66,15 @@ function isLongWaitRuntimeMethod(method: string): boolean {
|
||||
function isBulkReadRuntimeMethod(method: string): boolean {
|
||||
return (
|
||||
method === 'files.readChunk' ||
|
||||
method === 'files.archiveRead' ||
|
||||
method === 'files.readDocPreviewChunk' ||
|
||||
method === 'files.readTerminalArtifactChunk'
|
||||
)
|
||||
}
|
||||
|
||||
/** `download`: a user-started transfer that gets its own lane, so it neither starves nor is starved by previews. */
|
||||
export type RuntimeRpcCallLane = 'download'
|
||||
|
||||
export class RuntimeRpcCallQueuePool {
|
||||
private readonly queues = new Map<string, RuntimeCallQueue>()
|
||||
private queuedCallCount = 0
|
||||
@@ -86,18 +93,22 @@ export class RuntimeRpcCallQueuePool {
|
||||
method: string,
|
||||
run: () => Promise<T>,
|
||||
retainedBytes = 0,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
lane?: RuntimeRpcCallLane
|
||||
): Promise<T> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(abortSignalReason(signal))
|
||||
}
|
||||
// Same concurrency bound, counted apart from the selector's other calls; global caps still apply.
|
||||
const bulkRead = isBulkReadRuntimeMethod(method)
|
||||
const queueKey = isLongWaitRuntimeMethod(method)
|
||||
? `${selector}\u0000long-wait`
|
||||
: bulkRead
|
||||
? `${selector}\u0000bulk-read`
|
||||
: selector
|
||||
const download = lane === 'download'
|
||||
const bulkRead = !download && isBulkReadRuntimeMethod(method)
|
||||
const queueKey = download
|
||||
? `${selector}\u0000download`
|
||||
: isLongWaitRuntimeMethod(method)
|
||||
? `${selector}\u0000long-wait`
|
||||
: bulkRead
|
||||
? `${selector}\u0000bulk-read`
|
||||
: selector
|
||||
if (this.queuedCallCount >= this.maxQueuedTotal) {
|
||||
return Promise.reject(new RuntimeRpcCallQueueOverloadError('global'))
|
||||
}
|
||||
@@ -115,7 +126,11 @@ export class RuntimeRpcCallQueuePool {
|
||||
|
||||
const queue = this.getQueue(
|
||||
queueKey,
|
||||
bulkRead ? Math.min(this.concurrency, REMOTE_RUNTIME_BULK_READ_CONCURRENCY) : this.concurrency
|
||||
download
|
||||
? Math.min(this.concurrency, REMOTE_RUNTIME_DOWNLOAD_CONCURRENCY)
|
||||
: bulkRead
|
||||
? Math.min(this.concurrency, REMOTE_RUNTIME_BULK_READ_CONCURRENCY)
|
||||
: this.concurrency
|
||||
)
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const call: QueuedRuntimeCall<T> = {
|
||||
|
||||
Reference in New Issue
Block a user