Skip to content

Commit 40a805b

Browse files
Jonathan KingstonJonathan Kingston
authored andcommitted
Take the product's name out of the shell, and make the origin a parameter
The shell claimed one binary could serve any application while naming Copse in eighteen places — the env prefix, the protocol marker, the scheme it serves under, the default window title. None of it was structural: the protocol itself is three window-lifecycle messages with geometry fields, which is as generic as it needs to be. Only the labels were wrong, so only the labels changed. COPSE_FRONTEND_DIR -> TAURI_SHELL_FRONTEND_DIR COPSE_SIDECAR_ENTRY -> TAURI_SHELL_SIDECAR_ENTRY COPSE_SIDECAR_NODE -> TAURI_SHELL_SIDECAR_NODE COPSE_TAURI_SHELL=1 -> TAURI_SHELL=1 "@copse-tauri " -> "@tauri-shell " The scheme is the exception, and is now a runtime parameter rather than a rename: it is the page's origin, so it decides what CSP 'self' resolves to and what any origin-scoped storage is keyed by. An application that wants its own should have it. `TAURI_SHELL_SCHEME` defaults to `app`, and anything that is not a legal scheme is refused with a log line instead of quietly producing a URL that resolves somewhere else. Confirmed on three distinct schemes now, including two invented for the test — CSP 'self' matches under all of them on the patched engine, because servo 0008 fixes embedder-registered schemes as a class rather than `tauri://` specifically. Renaming both halves of a two-sided contract is exactly where a silent failure comes from: the sidecar decides a shell is attached by looking for the environment flag, and finds protocol lines by their prefix, so an old sidecar against a new shell simply never asks for a window. Nothing errors and there is no application. The shell now says so if no `create-window` has arrived after fifteen seconds, naming both things it depends on. Not renamed: `dev.copse.tauri-shell`. That is the org's reverse-DNS namespace on an org repository, not product branding, and it holds nothing — the application's secrets are the sidecar's business, keyed by a service name in its own code rather than by this process's bundle identity.
1 parent 278bc9a commit 40a805b

2 files changed

Lines changed: 110 additions & 32 deletions

File tree

‎README.md‎

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,31 @@ else as a download rather than a 20-minute compile.
1414
## Using a release
1515

1616
```bash
17-
COPSE_FRONTEND_DIR=path/to/dist/renderer \
18-
COPSE_SIDECAR_ENTRY=path/to/dist/sidecar/index.js \
17+
TAURI_SHELL_FRONTEND_DIR=path/to/dist/renderer \
18+
TAURI_SHELL_SIDECAR_ENTRY=path/to/dist/sidecar/index.js \
1919
./tauri-shell
2020
```
2121

2222
| variable | default | meaning |
2323
| -------- | ------- | ------- |
24-
| `COPSE_FRONTEND_DIR` | `dist/renderer` | directory served at `copse://localhost/` |
25-
| `COPSE_SIDECAR_ENTRY` | `../dist/sidecar/index.js`, then exe-relative | the sidecar's entry point |
26-
| `COPSE_SIDECAR_NODE` | `node` | the node binary to spawn it with |
24+
| `TAURI_SHELL_FRONTEND_DIR` | `dist/renderer` | directory served at `<scheme>://localhost/` |
25+
| `TAURI_SHELL_SIDECAR_ENTRY` | `../dist/sidecar/index.js`, then exe-relative | the sidecar's entry point |
26+
| `TAURI_SHELL_SIDECAR_NODE` | `node` | the node binary to spawn it with |
27+
| `TAURI_SHELL_SCHEME` | `app` | the scheme the frontend is served under |
2728

28-
The sidecar is spawned with `COPSE_TAURI_SHELL=1` in its environment, so it
29-
can tell it is running under this shell rather than under Electron.
29+
The sidecar is spawned with `TAURI_SHELL=1` in its environment, so it can tell
30+
it is running under this shell rather than under Electron.
31+
32+
The scheme is configurable because it *is* the page's origin: it decides what
33+
CSP `'self'` resolves to and what origin-scoped storage is keyed by, so an
34+
application that wants its own should have it. Anything that is not a legal
35+
scheme is refused with a log line rather than quietly producing a URL that
36+
resolves somewhere unexpected.
3037

3138
## The protocol
3239

3340
Line-oriented JSON over the sidecar's stdio. Every protocol line is prefixed
34-
`@copse-tauri `; anything else on stdout is passed through as sidecar logging,
41+
`@tauri-shell `; anything else on stdout is passed through as sidecar logging,
3542
so a sidecar can print freely without corrupting the channel.
3643

3744
**Sidecar → shell**
@@ -45,7 +52,7 @@ so a sidecar can print freely without corrupting the channel.
4552
// show | hide | focus | close | maximize | minimize
4653
```
4754

48-
`url` is resolved against `copse://localhost/`, so it is whatever path the
55+
`url` is resolved against `<scheme>://localhost/`, so it is whatever path the
4956
frontend directory holds, plus any query the sidecar wants the page to see.
5057

5158
**Shell → sidecar**
@@ -62,19 +69,27 @@ business, arranged through the query string it puts in `url`.
6269
The shell exits when the sidecar's stdout closes. Windows closing does not end
6370
it: an app may legitimately have none for a while.
6471

72+
Both ends of this are easy to get half-right, and the failure is silent — a
73+
sidecar that does not see `TAURI_SHELL=1`, or does not use the prefix above,
74+
simply never asks for a window, and you get a process that starts and shows
75+
nothing. So the shell says so if no `create-window` arrives within fifteen
76+
seconds.
77+
6578
## Why the frontend is served, not embedded
6679

6780
`tauri_build` normally bakes `frontendDist` into the executable, which would
68-
tie every build to one application. Instead the shell registers a `copse://`
69-
URI scheme and serves the directory `COPSE_FRONTEND_DIR` names.
81+
tie every build to one application. Instead the shell registers a URI scheme
82+
at startup and serves the directory `TAURI_SHELL_FRONTEND_DIR` names.
7083

7184
That is not merely equivalent to the embedded path, it is equivalent *in the
7285
ways that matter*: on the patched engine a registered custom scheme gets a
7386
tuple origin and counts as potentially trustworthy, so the page keeps CSP
7487
`'self'` and the secure-context APIs it would have had on `tauri://localhost`.
75-
Verified rather than assumed — a page served this way under an enforced
88+
Verified rather than assumed, and on schemes invented for the test rather than
89+
on `tauri://` itself: a page served this way under an enforced
7690
`default-src 'self'` loads its own subresources on the patched engine, and is
77-
blocked on a stock one.
91+
blocked on a stock one. The fix is a property of embedder-registered schemes
92+
as a class, which is what makes an arbitrary `TAURI_SHELL_SCHEME` safe.
7893

7994
Path traversal is refused rather than normalised: a request naming `..` is a
8095
403 in the log, not a read of an arbitrary file.

‎src/main.rs‎

Lines changed: 82 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// for the protocol.
88
//
99
// Nothing about the hosted app is compiled in. The frontend is read off disk
10-
// at runtime through a registered `copse://` scheme rather than baked into the
10+
// at runtime through a registered URI scheme rather than baked into the
1111
// binary by `tauri_build`, which is what lets one published binary serve any
1212
// app — and what lets this repository build it, on free public-runner minutes,
1313
// for a private repository to consume.
@@ -29,7 +29,7 @@ type ServoRuntime = tauri_runtime_servo::Servo<tauri::EventLoopMessage>;
2929

3030
/// Prefix marking protocol lines on the sidecar's stdout; everything else is
3131
/// passed through as sidecar logging.
32-
const PREFIX: &str = "@copse-tauri ";
32+
const PREFIX: &str = "@tauri-shell ";
3333

3434
#[derive(Deserialize)]
3535
#[serde(tag = "op")]
@@ -71,7 +71,7 @@ fn parse_hex_color(value: &str) -> Option<Color> {
7171
}
7272

7373
fn window_label(win_id: u64) -> String {
74-
format!("copse-{win_id}")
74+
format!("win-{win_id}")
7575
}
7676

7777
fn send_window_event(stdin: &SharedStdin, win_id: u64, event: &str) {
@@ -96,12 +96,12 @@ fn create_window(
9696
show: Option<bool>,
9797
background_color: Option<String>,
9898
) {
99-
// `copse://localhost/<url>` rather than WebviewUrl::App: App resolves
99+
// `<scheme>://localhost/<url>` rather than WebviewUrl::App: App resolves
100100
// against the store `tauri_build` embeds at compile time, which is exactly
101101
// the coupling this binary exists to avoid. The sidecar still sends the
102102
// same relative URL it always did — `index.html?winId=1&...` — so the
103103
// protocol is unchanged.
104-
let app_url: tauri::Url = match format!("copse://localhost/{url}").parse() {
104+
let app_url: tauri::Url = match format!("{}://localhost/{url}", scheme()).parse() {
105105
Ok(parsed) => parsed,
106106
Err(error) => {
107107
eprintln!("[shell] window {win_id} has an unusable url '{url}': {error}");
@@ -113,7 +113,7 @@ fn create_window(
113113
window_label(win_id),
114114
WebviewUrl::CustomProtocol(app_url),
115115
)
116-
.title(title.unwrap_or_else(|| "Copse".to_string()))
116+
.title(title.unwrap_or_else(|| "App".to_string()))
117117
.inner_size(width.unwrap_or(1200.0), height.unwrap_or(800.0))
118118
// The sidecar mirrors Electron's hidden-then-show pattern, but an
119119
// unmapped GTK window has no X11 handle yet and Servo needs one to
@@ -219,15 +219,44 @@ fn handle_sidecar_message(
219219
}
220220
}
221221

222-
/// The directory the `copse://` scheme serves.
222+
/// The URI scheme the frontend is served under, and therefore the page's
223+
/// origin.
223224
///
224-
/// `COPSE_FRONTEND_DIR` when set; otherwise `dist/renderer` relative to the
225+
/// A runtime parameter because the origin is the host application's business:
226+
/// it is what CSP `'self'` resolves to and what any origin-scoped storage is
227+
/// keyed by, so an app that wants its own should have it. Validated rather
228+
/// than trusted — a scheme with a `:` or a slash in it would silently produce
229+
/// a URL that resolves somewhere else entirely.
230+
fn scheme() -> String {
231+
let configured = std::env::var("TAURI_SHELL_SCHEME").unwrap_or_default();
232+
let configured = configured.trim();
233+
if configured.is_empty() {
234+
return "app".to_string();
235+
}
236+
let valid = configured
237+
.chars()
238+
.next()
239+
.is_some_and(|c| c.is_ascii_alphabetic())
240+
&& configured
241+
.chars()
242+
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.');
243+
if valid {
244+
configured.to_string()
245+
} else {
246+
eprintln!("[shell] ignoring unusable TAURI_SHELL_SCHEME '{configured}'; using 'app'");
247+
"app".to_string()
248+
}
249+
}
250+
251+
/// The directory the configured scheme serves.
252+
///
253+
/// `TAURI_SHELL_FRONTEND_DIR` when set; otherwise `dist/renderer` relative to the
225254
/// working directory, which is the layout the sidecar's own build produces.
226255
/// Deliberately not fatal when missing: the shell still starts, every request
227256
/// 404s, and the log says which directory it looked in — which is a far
228257
/// clearer failure than a blank window.
229258
fn frontend_dir() -> PathBuf {
230-
if let Ok(explicit) = std::env::var("COPSE_FRONTEND_DIR") {
259+
if let Ok(explicit) = std::env::var("TAURI_SHELL_FRONTEND_DIR") {
231260
return PathBuf::from(explicit);
232261
}
233262
PathBuf::from("dist/renderer")
@@ -251,7 +280,7 @@ fn content_type_for(path: &str) -> &'static str {
251280
}
252281
}
253282

254-
/// The path a `copse://localhost/<path>?<query>` request asks for, rejected if
283+
/// The path a `<scheme>://localhost/<path>?<query>` request asks for, rejected if
255284
/// it tries to climb out of the frontend directory.
256285
///
257286
/// The scheme is same-origin to the page, so anything the page can reach can
@@ -273,18 +302,18 @@ fn request_path(uri: &str) -> Option<String> {
273302

274303
/// Locate `dist/sidecar/index.js`.
275304
///
276-
/// `COPSE_SIDECAR_ENTRY` wins when set. Otherwise: the historical default is
305+
/// `TAURI_SHELL_SIDECAR_ENTRY` wins when set. Otherwise: the historical default is
277306
/// `../dist/sidecar/index.js`, which is *cwd*-relative and so only resolves
278307
/// when the shell is started from inside `tauri-shell/` — which `cargo run`
279308
/// does and a perf harness invoking the release binary by path does not. The
280309
/// failure is a bare Node MODULE_NOT_FOUND naming a path nobody wrote, so try
281310
/// the exe-relative location too and, when neither exists, say what was tried.
282311
fn sidecar_entry() -> std::io::Result<PathBuf> {
283-
if let Ok(explicit) = std::env::var("COPSE_SIDECAR_ENTRY") {
312+
if let Ok(explicit) = std::env::var("TAURI_SHELL_SIDECAR_ENTRY") {
284313
return Ok(PathBuf::from(explicit));
285314
}
286315
let mut candidates = vec![PathBuf::from("../dist/sidecar/index.js")];
287-
// target/release/copse-tauri-shell → up three to the repo root.
316+
// target/release/tauri-shell → up three to the repo root.
288317
if let Ok(exe) = std::env::current_exe() {
289318
if let Some(root) = exe
290319
.parent()
@@ -303,7 +332,7 @@ fn sidecar_entry() -> std::io::Result<PathBuf> {
303332
std::io::ErrorKind::NotFound,
304333
format!(
305334
"no dist/sidecar/index.js (tried {}) — run `pnpm build && pnpm build:tauri`, \
306-
or point COPSE_SIDECAR_ENTRY at it",
335+
or point TAURI_SHELL_SIDECAR_ENTRY at it",
307336
candidates
308337
.iter()
309338
.map(|path| path.display().to_string())
@@ -313,12 +342,23 @@ fn sidecar_entry() -> std::io::Result<PathBuf> {
313342
))
314343
}
315344

345+
/// How long to wait for the sidecar's first `create-window` before assuming
346+
/// the two ends disagree.
347+
///
348+
/// The failure this catches is silent by construction: the sidecar decides
349+
/// whether a shell is attached by looking for `TAURI_SHELL=1`, and reads
350+
/// protocol lines by their prefix. Get either wrong — an old sidecar, a
351+
/// renamed variable — and it simply never asks for a window. Nothing errors;
352+
/// there is just no application. Worth a line in the log rather than a
353+
/// mystery.
354+
const FIRST_WINDOW_GRACE: std::time::Duration = std::time::Duration::from_secs(15);
355+
316356
fn spawn_sidecar(handle: AppHandle<ServoRuntime>, alive: Arc<AtomicBool>) -> std::io::Result<()> {
317-
let node = std::env::var("COPSE_SIDECAR_NODE").unwrap_or_else(|_| "node".to_string());
357+
let node = std::env::var("TAURI_SHELL_SIDECAR_NODE").unwrap_or_else(|_| "node".to_string());
318358
let entry = sidecar_entry()?;
319359
let mut child = Command::new(node)
320360
.arg(entry)
321-
.env("COPSE_TAURI_SHELL", "1")
361+
.env("TAURI_SHELL", "1")
322362
.stdin(Stdio::piped())
323363
.stdout(Stdio::piped())
324364
.spawn()?;
@@ -328,6 +368,20 @@ fn spawn_sidecar(handle: AppHandle<ServoRuntime>, alive: Arc<AtomicBool>) -> std
328368
));
329369
let stdout = child.stdout.take().expect("sidecar stdout is piped");
330370

371+
let asked_for_a_window = Arc::new(AtomicBool::new(false));
372+
let watchdog = asked_for_a_window.clone();
373+
std::thread::spawn(move || {
374+
std::thread::sleep(FIRST_WINDOW_GRACE);
375+
if !watchdog.load(Ordering::SeqCst) {
376+
eprintln!(
377+
"[shell] the sidecar has not asked for a window in {}s. It may not \
378+
have recognised this shell: it looks for TAURI_SHELL=1 in its \
379+
environment and for protocol lines prefixed '{PREFIX}'.",
380+
FIRST_WINDOW_GRACE.as_secs()
381+
);
382+
}
383+
});
384+
331385
std::thread::spawn(move || {
332386
let reader = BufReader::new(stdout);
333387
for line in reader.lines() {
@@ -337,7 +391,12 @@ fn spawn_sidecar(handle: AppHandle<ServoRuntime>, alive: Arc<AtomicBool>) -> std
337391
continue;
338392
};
339393
match serde_json::from_str::<SidecarMessage>(payload) {
340-
Ok(message) => handle_sidecar_message(&handle, &stdin, message),
394+
Ok(message) => {
395+
if matches!(message, SidecarMessage::CreateWindow { .. }) {
396+
asked_for_a_window.store(true, Ordering::SeqCst);
397+
}
398+
handle_sidecar_message(&handle, &stdin, message)
399+
}
341400
Err(error) => eprintln!("[shell] bad sidecar message: {error}: {payload}"),
342401
}
343402
}
@@ -356,7 +415,11 @@ fn main() {
356415
let alive_for_setup = sidecar_alive.clone();
357416

358417
let root = frontend_dir();
359-
println!("[shell] serving copse://localhost/ from {}", root.display());
418+
let scheme = scheme();
419+
println!(
420+
"[shell] serving {scheme}://localhost/ from {}",
421+
root.display()
422+
);
360423

361424
let app = tauri::Builder::<ServoRuntime>::new()
362425
// Servo cannot read custom protocol request bodies; route Tauri's own
@@ -368,7 +431,7 @@ fn main() {
368431
// and counts as potentially trustworthy on the patched engine, so the
369432
// page keeps CSP 'self' and the secure-context APIs it would have had
370433
// on tauri://localhost.
371-
.register_uri_scheme_protocol("copse", move |_ctx, request| {
434+
.register_uri_scheme_protocol(scheme, move |_ctx, request| {
372435
let uri = request.uri().to_string();
373436
let Some(path) = request_path(&uri) else {
374437
eprintln!("[shell] refused traversal in {uri}");

0 commit comments

Comments
 (0)