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
33 changes: 20 additions & 13 deletions crates/ssh-browser/assets/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,24 @@
* file rather than a `<style>` block — the root of the suffix and the dashboard are the same
* page in two places, and a second copy of these rules is how they stop being.
*
* Not themed. The theme decides what a *listing* looks like — the pages that show what is on
* a host — and this is the chrome around them, which stays put while they change.
* Themed, by the same palette the daemon renders a listing with. It was not, and the seam
* showed: choosing a dark theme turned the daemon's pages dark and left this one white,
* because its four colours were hex values written here that no scheme had a say in.
*
* So there is no palette in this file. It arrives first — inline from the daemon, and in the
* extension from `/_control/theme` through the service worker — and every colour below is a
* `var()` reading it. Before any daemon has answered there is nothing to inject and these
* fall back to the browser's own black on white, which is the honest look for a page that
* cannot reach anything.
*/

:root {
--line: #e3e3e3;
--dim: #666;
--bad: #b00020;
--good: #0a7d33;
/* On `html`, the way the listing's stylesheet does it. Put on `body` the colour stops at the
content box, so a short page leaves the browser's own white below the fold on one of these
two pages and not the other. */
html {
background: var(--bg);
}
body {
color: var(--fg);
font:
14px/1.6 system-ui,
sans-serif;
Expand Down Expand Up @@ -73,8 +80,8 @@ li > a {
}
li > button:hover,
li > a:hover {
background: #fafafa;
border-color: #c9c9c9;
background: var(--hover);
border-color: var(--dim);
}
li > button:disabled {
cursor: progress;
Expand Down Expand Up @@ -168,10 +175,10 @@ dd {
}
.act button:hover,
.open-site:hover {
background: #fafafa;
background: var(--hover);
}
.danger {
border-color: #e8c4c9 !important;
border-color: var(--bad) !important;
color: var(--bad);
}
.note {
Expand All @@ -194,7 +201,7 @@ footer input {
width: 6em;
}
.cmd {
background: #f6f6f6;
background: var(--hover);
border: 1px solid var(--line);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, monospace;
Expand Down
57 changes: 52 additions & 5 deletions crates/ssh-browser/src/origin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,14 @@ impl Origin {
format!("{}://{alias}.{}/", self.scheme, self.suffix)
}

/// The root of the suffix: the list of sites, and the way back to it from inside one.
///
/// Beside `site_url` and built the same way, because the two differ by a label and getting
/// that difference wrong means linking out of the daemon entirely.
fn home_url(&self) -> String {
format!("{}://{}/", self.scheme, self.suffix)
}

async fn alias_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.sessions.read().await.keys().cloned().collect();
names.sort();
Expand Down Expand Up @@ -2088,12 +2096,21 @@ impl Origin {
#[derive(serde::Serialize)]
struct Themes<'a> {
current: &'a str,
/// The current palette, as the `:root` block that carries it.
///
/// Sent rather than named, because the dashboard cannot read a `.yaml` compiled
/// into this binary and a second copy of sixteen hex values in TypeScript is the
/// thing this whole arrangement exists to avoid. The dashboard drops it into the
/// page and the one stylesheet both halves share reads it.
css: String,
themes: Vec<Choice<'a>>,
}
// The list comes from the daemon rather than being written out again in the
// dashboard. Two copies of it is how a theme gets added and stays invisible.
let current = self.theme.read().await;
control::json(&Themes {
current: &self.theme.read().await,
css: theme::css_for(&current),
current: &current,
themes: theme::all()
.iter()
.map(|t| Choice {
Expand Down Expand Up @@ -2201,7 +2218,13 @@ impl Origin {

plain_ok(
"text/html; charset=utf-8",
Bytes::from(autoindex(alias, &rel, &levels, &self.theme.read().await)),
Bytes::from(autoindex(
alias,
&rel,
&levels,
&self.theme.read().await,
&self.home_url(),
)),
)
}

Expand Down Expand Up @@ -2633,6 +2656,10 @@ impl Origin {
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
<title>ssh-browser</title><style>",
);
// The palette first, then the layout that reads it -- the same order a listing uses,
// and now the same palette. This page and a directory on a host used to be two
// different-looking products sharing a suffix.
s.push_str(&theme::css_for(&self.theme.read().await));
s.push_str(DASHBOARD_CSS);
s.push_str("</style>");

Expand Down Expand Up @@ -3252,6 +3279,9 @@ body{color:var(--fg);font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;m
header{align-items:baseline;background:var(--bg);border-bottom:1px solid var(--line);\
display:flex;gap:6px;padding:7px 12px;position:sticky;top:0;z-index:1}\
header b{font-size:12px;font-weight:600;letter-spacing:.04em}\
header .home{border-right:1px solid var(--line);color:var(--dim);font-size:11px;\
margin-right:6px;padding-right:8px;text-decoration:none;white-space:nowrap}\
header .home:hover{color:var(--accent)}\
header span{color:var(--dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
font-size:11px;overflow-wrap:anywhere}\
#tree{padding:4px 0 40px}\
Expand Down Expand Up @@ -3394,7 +3424,19 @@ fn render_level(out: &mut String, path: &str, rows: &[Row], open: &[(String, Vec
/// `levels` runs from the alias base down to where the reader is, each already sorted, so
/// the page opens with the whole path expanded and the rest of every level beside it. They
/// come out of the cache the path walk already filled, so the depth costs no round trips.
fn autoindex(alias: &str, rel: &str, levels: &[(String, Vec<Row>)], theme: &str) -> String {
///
/// `home` is the root of the suffix, and the header links to it. A reader who has walked into
/// `panza.ssh-browser` has no way back to the list of sites: it is a different origin, so the
/// back button is the only route and only if they arrived by it. This is the daemon's own page
/// so a link on it costs nobody anything — and it stays off the pages that are somebody's
/// file, which get nothing added to them, ever.
fn autoindex(
alias: &str,
rel: &str,
levels: &[(String, Vec<Row>)],
theme: &str,
home: &str,
) -> String {
let shown = if rel.is_empty() { "/" } else { rel };
let mut s = String::from("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">");
s.push_str("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>");
Expand All @@ -3403,7 +3445,12 @@ fn autoindex(alias: &str, rel: &str, levels: &[(String, Vec<Row>)], theme: &str)
// The palette first, then the layout that reads it.
s.push_str(&theme::css_for(theme));
s.push_str(LISTING_CSS);
s.push_str("</style></head><body><header><b>");
s.push_str("</style></head><body><header><a class=\"home\" href=\"");
s.push_str(&escape(home));
// A word rather than a glyph. This header already carries an alias and a path in small
// type; a house drawn in it would be one more thing to decode, and "all sites" says both
// where it goes and what is there.
s.push_str("\" title=\"every site this daemon serves\">all sites</a><b>");
s.push_str(&escape(alias));
s.push_str("</b><span>");
s.push_str(&escape(shown));
Expand Down Expand Up @@ -4227,7 +4274,7 @@ mod tests {
/// the ancestors or the site scan. Both of those need a remote; these do not.
fn listing(alias: &str, rel: &str, entries: &[Entry]) -> String {
let levels = vec![(rel.to_string(), rows_of(entries, &HashSet::new()))];
autoindex(alias, rel, &levels, theme::DEFAULT)
autoindex(alias, rel, &levels, theme::DEFAULT, "http://ssh-browser/")
}

#[test]
Expand Down
8 changes: 8 additions & 0 deletions crates/ssh-browser/src/theme/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,12 @@ impl Scheme {
format!("--k-code:{}", c(0xE)),
format!("--k-media:{}", c(0xB)),
format!("--k-plain:{}", c(0x3)),
// base08 is what every scheme paints an error in, base0B what it paints a string
// in. The dashboard had its own red and green, written as two hex values no
// palette had a say in — which is why choosing a dark theme turned the daemon's
// pages dark and left the dashboard white.
format!("--bad:{}", c(0x8)),
format!("--good:{}", c(0xB)),
]
.join(";")
}
Expand Down Expand Up @@ -371,6 +377,8 @@ mod tests {
"--k-code",
"--k-media",
"--k-plain",
"--bad",
"--good",
];
for theme in all() {
let css = css_for(&theme.name);
Expand Down
32 changes: 32 additions & 0 deletions e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,38 @@ async function main() {
]);
await spare.close();

// One look, not two. The dashboard and a directory on a host are both ssh-browser, and
// until the palette was shared they were a light card UI and a dark file explorer that
// happened to live under one suffix. Compared as resolved colours rather than as CSS
// text, because what matters is what the two pages became.
const aTree = await browser.newPage();
// `assets/`, not the alias root: the root holds an `index.html`, so it is served as that
// page and there is no tree on it to compare.
await aTree.goto(`http://${ALIAS}.${SUFFIX}/assets/`, { waitUntil: "domcontentloaded" });
const onTree = await aTree.evaluate(() => {
return {
// On `html`, which is where both stylesheets put it -- a colour on `body` stops at the
// content box and leaves the browser's white below a short page.
background: getComputedStyle(document.documentElement).backgroundColor,
colour: getComputedStyle(document.body).color,
};
});
// And the way back out, which an origin of its own otherwise has none of.
const home = await aTree.getAttribute("header .home", "href");
await aTree.close();

const dashboardBody = await dashboard.evaluate(() => ({
background: getComputedStyle(document.documentElement).backgroundColor,
colour: getComputedStyle(document.body).color,
}));

check("a directory and the dashboard are painted the same", () =>
assert.deepEqual(onTree, dashboardBody),
);
check("and a directory says how to get back to the list of sites", () =>
assert.equal(home, `http://${SUFFIX}/`),
);

check("the fallback is the dashboard's own page", () => {
assert.notEqual(onSpare, null, "no site card on the loopback listener");
assert.deepEqual(onSpare?.look, onDashboard?.look);
Expand Down
18 changes: 16 additions & 2 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export interface Reply {
suffix?: string;
open?: OpenAlias[];
current?: string;
/// The palette the daemon renders a listing with, as the `:root` block carrying it.
///
/// Relayed rather than reconstructed. Sixteen hex values copied into TypeScript would be a
/// second place for a theme to be wrong, and only one of them would be the one anybody had
/// looked at.
css?: string;
themes?: { name: string; label: string }[];
hosts?: KnownHost[];
unusable?: { host: string; why: string }[];
Expand Down Expand Up @@ -435,8 +441,16 @@ async function getTheme(): Promise<Reply> {
if (!res.ok) {
return { ok: false, detail: `${res.status}: ${await res.text()}` };
}
const body = (await res.json()) as { current: string; themes: { name: string; label: string }[] };
return { ok: true, detail: "", current: body.current, themes: body.themes };
const body = (await res.json()) as {
current: string;
css: string;
themes: { name: string; label: string }[];
};
// `css` too. Naming the fields one at a time is what keeps a page from being handed
// whatever the daemon happens to add — and it is also why the palette reached this function
// and stopped here, leaving the dashboard black on white beside the pages it links to.
// Adding a field is one line; noticing the missing one took a screenshot.
return { ok: true, detail: "", current: body.current, css: body.css, themes: body.themes };
}

async function setTheme(name: string): Promise<Reply> {
Expand Down
49 changes: 49 additions & 0 deletions extension/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ interface Reply {
unusable?: { host: string; why: string }[];
url?: string;
current?: string;
/// The current palette, as the `:root` block that carries it.
css?: string;
themes?: { name: string; label: string; variant: string }[];
/// Under https: completed and failed TLS handshakes.
///
Expand Down Expand Up @@ -439,6 +441,14 @@ async function renderConfig(): Promise<void> {
void (async () => {
const chose = await send({ kind: "setTheme", name: select.value });
say(chose.detail, !chose.ok);
// This page repaints with it too. Choosing a dark theme and watching everything but the
// page you chose it on go dark is how the two looks drifted apart in the first place.
if (chose.ok) {
const now = await send({ kind: "theme" });
if (now.ok && now.css !== undefined) {
await applyTheme(now.css);
}
}
})();
});
picker.append(select);
Expand Down Expand Up @@ -744,11 +754,48 @@ async function start(): Promise<void> {
el("daemon").textContent = `${reply.detail} on 127.0.0.1:${port}`;
say("");

// Here rather than only on the settings view, which is where this lived and where almost
// nobody goes. A dashboard themed only after you had been to settings is a dashboard that
// does not match the pages it links to.
const themed = await send({ kind: "theme" });
if (themed.ok && themed.css !== undefined) {
await applyTheme(themed.css);
}

if (await refresh()) {
route();
}
}

/// Paint this page with the palette the daemon renders listings with.
///
/// The stylesheet both halves share carries no colours of its own, so without this the
/// dashboard is the browser's black on white while a directory on a host is whatever theme
/// was chosen — two products sharing a suffix, which is what souta was looking at.
///
/// Remembered between runs, and applied before the daemon is asked. A dashboard that flashed
/// white and then went dark on every open would be worse than one that never changed.
async function applyTheme(css?: string): Promise<void> {
if (css === undefined) {
const seen = (await chrome.storage.local.get("themeCss")) as { themeCss?: string };
css = seen.themeCss;
} else {
await chrome.storage.local.set({ themeCss: css });
}
if (css === undefined) {
return;
}
// After the linked stylesheet, so the palette wins wherever both have something to say.
// One element reused, or picking a theme twice would leave two.
let held = document.getElementById("palette");
if (held === null) {
held = document.createElement("style");
held.id = "palette";
document.head.append(held);
}
held.textContent = css;
}

window.addEventListener("hashchange", route);

// The port is read back first because it says *which* daemon to look for; checking before
Expand All @@ -758,5 +805,7 @@ void (async () => {
if (typeof port === "number") {
currentPort = port;
}
// Before `start`, so the page is already the right colour when it first paints.
await applyTheme();
await start();
})();