Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ npm test
home folder and a free localhost port, and closes its own server and browser.
Node and npm are test tools only; the release build embeds its assets.

For Console CSS and JavaScript development, serve the assets from this checkout:

```sh
THIRDSHIFT_CONSOLE_ASSETS=src/console/assets cargo run -- console --no-sign-in
```

In a debug build, `/assets/*` reads that directory with `no-store`; missing files
return 404. Open Console pages use their existing `/events` connection to swap
changed stylesheets in place after loading them, and reload for changed scripts,
usually within a second. No rebuild or manual reload is needed for asset edits.
Paths outside the directory and symlinks are refused. Custom Theme stylesheets
and fonts retain their usual handling.

Without the variable, and in release builds even when it is set, assets come
from the binary and disk edits have no effect. Askama templates and Rust code
stay compiled in and still require a build.

Tests load Playwright through `tests/playwright.cjs`, which gives every page a
5 s default timeout (#1391): a broken locator fails fast instead of waiting
30 s. A step that needs longer passes its own `timeout`.
Expand Down
104 changes: 104 additions & 0 deletions console/tests/assets.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { once } = require('node:events');
const { mkdtemp, mkdir, cp, readFile, writeFile, rename, rm } = require('node:fs/promises');
const { join, resolve } = require('node:path');
const { tmpdir } = require('node:os');
const { createInterface } = require('node:readline');
const { chromium } = require('./playwright.cjs');
const { test } = require('node:test');

test('development assets restyle open pages and reload changed scripts over one stream', async t => {
const home = await mkdtemp(join(tmpdir(), 'thirdshift-assets-'));
let server, exited, browser;
try {
const assets = join(home, 'assets');
await cp(resolve(__dirname, '../../src/console/assets'), assets, { recursive: true });
const cssPath = join(assets, 'console.css');
const css = await readFile(cssPath, 'utf8');
await writeFile(cssPath, `${css}\nbody { --asset-test: before; }\n`);
await mkdir(join(home, '.thirdshift'));
await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n');
server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'), ['console', '--no-sign-in'], {
env: { ...process.env, HOME: home, THIRDSHIFT_CONSOLE_ASSETS: assets }, stdio: ['ignore', 'pipe', 'inherit'],
});
exited = once(server, 'exit');
const lines = createInterface({ input: server.stdout });
const [origin] = await once(lines, 'line', { signal: AbortSignal.timeout(10000) });
lines.close();
if (!(await (await fetch(`${origin}/assets/console.css`)).text()).includes('--asset-test: before')) {
t.skip('binary does not enable debug development assets');
return;
}
browser = await chromium.launch({ headless: true });
const paths = ['/', '/logs', '/account', '/console-log'];
const pages = await Promise.all(paths.map(() => browser.newPage()));
const violations = [], streams = paths.map(() => 0), errors = [];
for (const [index, page] of pages.entries()) {
await page.exposeFunction('cspViolation', value => violations.push(value));
page.on('pageerror', error => errors.push(error.message));
await page.addInitScript(() => document.addEventListener('securitypolicyviolation', event => window.cspViolation(event.violatedDirective)));
page.on('request', request => { if (new URL(request.url()).pathname === '/events') streams[index]++; });
const look = page.waitForResponse(response => response.url() === `${origin}/look`);
await page.goto(`${origin}${paths[index]}`);
await look;
await page.waitForFunction(() => getComputedStyle(document.body).getPropertyValue('--asset-test').trim() === 'before');
await page.evaluate(() => { window.preservedThroughStyleChange = true; });
}
const began = Date.now();
// Atomic replacement is how editors commonly save a stylesheet.
await writeFile(`${cssPath}.tmp`, `${css}\nbody { --asset-test: changed; }\n`);
await rename(`${cssPath}.tmp`, cssPath);
for (const page of pages) {
await page.waitForFunction(() => getComputedStyle(document.body).getPropertyValue('--asset-test').trim() === 'changed', null, { timeout: 2000 }).catch(async error => {
t.diagnostic(JSON.stringify({ errors, streams }));
throw error;
});
assert.equal(await page.evaluate(() => window.preservedThroughStyleChange), true);
}
assert.ok(Date.now() - began < 2000, 'stylesheet update should arrive in about a second');
assert.deepEqual(streams, paths.map(() => 1));
// Imported Theme styles are refreshed through their containing stylesheet.
const themesPath = join(assets, 'themes.css');
await writeFile(themesPath, `${await readFile(themesPath, 'utf8')}\nbody { --import-test: changed; }\n`);
for (const page of pages) await page.waitForFunction(() => getComputedStyle(document.body).getPropertyValue('--import-test').trim() === 'changed');
// Delay one real response across a newer edit. It must never win later.
const page = pages[0];
let captured, release;
const held = new Promise(resolve => { captured = resolve; });
const released = new Promise(resolve => { release = resolve; });
let first = true;
await page.route('**/assets/console.css?revision=*', async route => {
if (!first) return route.continue();
first = false;
const response = await route.fetch();
captured();
await released;
await route.fulfill({ response });
});
try {
await writeFile(cssPath, `${css}\nbody { --asset-test: stale; }\n`);
await Promise.race([held, new Promise((_, reject) => setTimeout(() => reject(new Error('stylesheet request missing')), 5000))]);
await writeFile(cssPath, `${css}\nbody { --asset-test: newest; }\n`);
await page.waitForFunction(() => getComputedStyle(document.body).getPropertyValue('--asset-test').trim() === 'newest');
} finally { release(); }
await page.waitForFunction(() => !document.querySelector('link[media="not all"]'));
assert.equal(await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--asset-test').trim()), 'newest');
await page.unroute('**/assets/console.css?revision=*');
const scriptPath = join(assets, 'changes.js');
const navigations = pages.map(page => page.waitForEvent('load'));
await writeFile(scriptPath, `${await readFile(scriptPath, 'utf8')}\nwindow.changedScriptRan = true;\n`);
await Promise.all(navigations);
for (const page of pages) {
await page.waitForFunction(() => window.changedScriptRan === true);
assert.equal(await page.evaluate(() => window.preservedThroughStyleChange), undefined);
}
assert.deepEqual(violations, []);
assert.deepEqual(errors, []);
} finally {
await browser?.close();
if (server && server.exitCode === null) server.kill();
if (exited) await exited;
await rm(home, { recursive: true, force: true });
}
});
29 changes: 23 additions & 6 deletions src/console.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! The machine's Console. Async execution is confined to this module.
mod account_commands;
mod accounts;
mod assets;
mod auth;
mod bug_origins;
pub(crate) mod conversation;
Expand Down Expand Up @@ -401,6 +402,7 @@ pub fn serve(no_sign_in: bool) -> Result<()> {
accounts::Accounts::load()?;
}
let executable = restart::Executable::current()?;
let assets = assets::Development::from_environment()?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
Expand All @@ -409,14 +411,16 @@ pub fn serve(no_sign_in: bool) -> Result<()> {
.await
.context("can't listen for the Console on localhost")?;
let watched = executable.clone();
let (changes, notifier, observer) = changes::observe(&config, &executable)?;
let (changes, notifier, observer) = changes::observe(&config, &executable, &assets)?;
let github = github::follow();
let mut control = changes.clone();
println!("http://{}", listener.local_addr()?);
std::io::stdout().flush()?;
axum::serve(
listener,
router(no_sign_in, changes).layer(axum::Extension(notifier)),
router(no_sign_in, changes)
.layer(axum::Extension(notifier))
.layer(axum::Extension(assets)),
)
.with_graceful_shutdown(async move {
let mut accounts_revision = 0;
Expand Down Expand Up @@ -744,15 +748,28 @@ fn page(result: std::result::Result<Result<String>, tokio::task::JoinError>) ->
}
}

async fn asset(Path(path): Path<String>) -> Response {
async fn asset(
axum::Extension(assets): axum::Extension<assets::Development>,
Path(path): Path<String>,
) -> Response {
if path == "user-themes.css" || path.starts_with("user-fonts/") {
return match tokio::task::spawn_blocking(move || user_asset(&path)).await {
Ok(response) => response,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
}
let Some(file) = ASSETS.get_file(&path) else {
return StatusCode::NOT_FOUND.into_response();
let body = if assets.directory().is_some() {
let requested = path.clone();
match tokio::task::spawn_blocking(move || assets.read(&requested)).await {
Ok(Ok(bytes)) => axum::body::Body::from(bytes),
Ok(Err(_)) => return StatusCode::NOT_FOUND.into_response(),
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
} else {
let Some(file) = ASSETS.get_file(&path) else {
return StatusCode::NOT_FOUND.into_response();
};
axum::body::Body::from(file.contents())
};
let content_type = match std::path::Path::new(&path)
.extension()
Expand All @@ -769,7 +786,7 @@ async fn asset(Path(path): Path<String>) -> Response {
(header::CONTENT_TYPE, content_type),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
],
file.contents(),
body,
)
.into_response()
}
Expand Down
71 changes: 71 additions & 0 deletions src/console/assets.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! Debug-only asset selection and confined disk reads.
use std::ffi::CString;
use std::fs::{File, OpenOptions};
use std::io::{self, Read};
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Component, Path, PathBuf};

#[derive(Clone, Default)]
pub(super) struct Development {
directory: Option<PathBuf>,
}

impl Development {
pub fn from_environment() -> anyhow::Result<Self> {
#[cfg(debug_assertions)]
if let Some(directory) = std::env::var_os("THIRDSHIFT_CONSOLE_ASSETS") {
let directory = std::env::current_dir()?.join(directory);
return Ok(Self {
directory: Some(super::changes::observed_path(&directory)),
});
}
Ok(Self::default())
}

pub fn directory(&self) -> Option<&Path> {
self.directory.as_deref()
}

pub fn read(&self, path: &str) -> io::Result<Vec<u8>> {
// Check the raw spelling too: Path::components normalizes interior `.`.
if path.is_empty()
|| path.contains('\\')
|| path
.split('/')
.any(|part| part.is_empty() || part == "." || part == "..")
{
return Err(io::ErrorKind::InvalidInput.into());
}
let directory = self.directory.as_ref().ok_or(io::ErrorKind::NotFound)?;
let mut file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
.open(directory)?;
let mut parts = Path::new(path).components().peekable();
while let Some(part) = parts.next() {
let Component::Normal(name) = part else {
return Err(io::ErrorKind::InvalidInput.into());
};
let name = CString::new(name.as_encoded_bytes())?;
let mut flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC;
if parts.peek().is_some() {
flags |= libc::O_DIRECTORY;
}
// SAFETY: the directory fd and NUL-terminated component are live.
// Each open pins the next component and refuses symlink races.
let fd = unsafe { libc::openat(file.as_raw_fd(), name.as_ptr(), flags) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: openat returned a new, uniquely owned descriptor.
file = unsafe { File::from_raw_fd(fd) };
}
if !file.metadata()?.is_file() {
return Err(io::ErrorKind::InvalidInput.into());
}
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Ok(bytes)
}
}
15 changes: 15 additions & 0 deletions src/console/assets/changes.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
// One stream per tab, shared by the page, peek and notification consumers.
import { refreshAssetStyles } from './look.js';

const subscribers = new Set();
let polling, stream;
let assets;
// Pages without live Command content still need the shared stream in development.
document.addEventListener('console-development-assets', () => watchCommands(() => {}), { once: true });
function refreshAssets(event) {
const current = JSON.parse(event.data);
if (assets && current.scripts !== assets.scripts) {
location.reload();
} else if (current.styles !== (assets?.styles ?? 0)) {
refreshAssetStyles();
}
assets = current;
}
function refreshAll() {
for (const refresh of subscribers) refresh();
}
Expand All @@ -11,6 +25,7 @@ function startUpdates() {
if (typeof EventSource === 'function') {
stream = new EventSource('/events');
stream.addEventListener('commands', refreshAll);
stream.addEventListener('assets', refreshAssets);
stream.addEventListener('error', startPolling);
stream.addEventListener('open', () => {
clearInterval(polling);
Expand Down
Loading
Loading