1use std::path::{Path, PathBuf};
37
38pub const OPEN_DIR: &str = "/run/qcode-open";
40
41pub const OPEN_PROGRAM: &str = "/usr/local/bin/qcode-open";
43
44const SUFFIX: &str = ".url";
46
47pub const SCRIPT: &str = "#!/bin/sh\n\
54 # Hands the address to QCode, which shows it in a window of this container.\n\
55 set -eu\n\
56 [ -n \"${1:-}\" ] || exit 1\n\
57 [ -d \"$0_DIR\" ] || exit 1\n\
58 name=\"$0_DIR/$(date +%s%N)\"\n\
59 printf '%s\\n' \"$1\" > \"$name.part\"\n\
60 mv \"$name.part\" \"$name.url\"\n";
61
62#[must_use]
64pub fn script() -> String {
65 SCRIPT.replace("$0_DIR", OPEN_DIR)
66}
67
68#[must_use]
74pub fn opener_step() -> String {
75 format!(
76 "RUN {write} \\\n && chmod 0755 '{OPEN_PROGRAM}' \\\n && mkdir -p '{OPEN_DIR}'",
77 write = written(&script(), OPEN_PROGRAM),
78 )
79}
80
81pub const BROWSER_DIR: &str = "/opt/qcode-browser";
84
85pub const BROWSER_PROGRAM: &str = "/opt/qcode-browser/qcode-browser";
87
88pub const BROWSER_PACKAGE: &str = r#"{ "name": "qcode-browser", "version": "1.0.0", "main": "main.js" }
91"#;
92
93pub const BROWSER_MAIN: &str = r#"'use strict';
103const { app, BrowserWindow } = require('electron');
104const page = (words) => words.slice(1).reverse().find((word) => word.startsWith('https://') || word.startsWith('http://'));
105app.userAgentFallback = app.userAgentFallback.split(' ').filter((word) => !word.startsWith('Electron/') && !word.startsWith('qcode-browser/')).join(' ');
106if (!app.requestSingleInstanceLock()) {
107 app.quit();
108} else {
109 let shown = null;
110 app.on('second-instance', (event, words) => {
111 const next = page(words);
112 if (shown && next) {
113 shown.loadURL(next);
114 shown.show();
115 shown.focus();
116 }
117 });
118 app.whenReady().then(() => {
119 shown = new BrowserWindow({ width: 560, height: 760, autoHideMenuBar: true });
120 shown.setMenu(null);
121 const first = page(process.argv);
122 if (first) {
123 shown.loadURL(first);
124 }
125 });
126 app.on('window-all-closed', () => app.quit());
127}
128"#;
129
130#[must_use]
135pub fn written(contents: &str, path: &str) -> String {
136 let quoted = contents.replace('\\', "\\\\").replace('\'', "'\\''").replace('\n', "\\n");
137 format!("printf '%b' '{quoted}' > '{path}'")
138}
139
140#[must_use]
148pub fn browser_install(install_dir: &str, program: &str) -> Vec<String> {
149 vec![
150 format!("mkdir -p '{BROWSER_DIR}/resources/app'"),
151 format!(
152 "for part in '{install_dir}'/*; do [ \"${{part##*/}}\" = resources ] || cp -al \"$part\" '{BROWSER_DIR}/'; done"
153 ),
154 format!("mv '{BROWSER_DIR}/{program}' '{BROWSER_PROGRAM}'"),
155 written(BROWSER_PACKAGE, &format!("{BROWSER_DIR}/resources/app/package.json")),
156 written(BROWSER_MAIN, &format!("{BROWSER_DIR}/resources/app/main.js")),
157 format!("test -x '{BROWSER_PROGRAM}'"),
158 ]
159}
160
161pub const NO_BROWSER: &str = "qcode-no-browser";
163
164#[must_use]
173pub fn page_command(flags: &[&str], address: &str) -> Vec<String> {
174 let mut command = vec![
175 "sh".to_owned(),
176 "-c".to_owned(),
177 format!("[ -x \"$1\" ] || {{ echo {NO_BROWSER}; exit 0; }}; nohup \"$@\" </dev/null >/dev/null 2>&1 &"),
178 "sh".to_owned(),
179 BROWSER_PROGRAM.to_owned(),
180 ];
181 command.extend(flags.iter().map(|flag| (*flag).to_owned()));
182 command.push(address.to_owned());
183 command
184}
185
186#[must_use]
191pub fn taken(folder: &Path) -> Vec<String> {
192 let Ok(entries) = std::fs::read_dir(folder) else { return Vec::new() };
193 let mut files: Vec<PathBuf> = entries
194 .flatten()
195 .map(|entry| entry.path())
196 .filter(|path| path.to_str().is_some_and(|name| name.ends_with(SUFFIX)))
197 .collect();
198 files.sort();
200 let mut addresses = Vec::new();
201 for file in files {
202 let read = std::fs::read_to_string(&file);
203 let _ = std::fs::remove_file(&file);
204 if let Ok(text) = read {
205 let address = text.trim().to_owned();
206 if !address.is_empty() {
207 addresses.push(address);
208 }
209 }
210 }
211 addresses
212}
213
214#[must_use]
220pub fn is_web(address: &str) -> bool {
221 let lower = address.to_ascii_lowercase();
222 (lower.starts_with("http://") || lower.starts_with("https://")) && !address.contains(['\n', '\r', '\0'])
223}
224
225#[cfg(test)]
226mod tests {
227 use super::{BROWSER_DIR, BROWSER_MAIN, BROWSER_PROGRAM, browser_install, is_web, page_command, script, taken};
228 use std::path::PathBuf;
229
230 fn folder(name: &str) -> PathBuf {
231 let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
232 let path = std::env::temp_dir().join(format!("qcode-signin-{name}-{stamp}"));
233 std::fs::create_dir_all(&path).expect("a folder of this test's own");
234 path
235 }
236
237 #[test]
238 fn the_script_names_the_folder_and_moves_the_address_into_place() {
239 let text = script();
240 assert!(text.starts_with("#!/bin/sh\n"), "{text}");
241 assert!(text.contains(super::OPEN_DIR), "the folder is filled in: {text}");
242 assert!(!text.contains("$0_DIR"), "nothing is left to fill in: {text}");
243 assert!(text.contains(".part\"\nmv "), "it moves the finished file into place: {text}");
244 }
245
246 #[test]
247 fn addresses_are_taken_oldest_first_and_never_twice() {
248 let dir = folder("taken");
249 std::fs::write(dir.join("200.url"), "https://example.com/second\n").expect("an address");
250 std::fs::write(dir.join("100.url"), "https://example.com/first\n").expect("an address");
251 std::fs::write(dir.join("300.url.part"), "https://example.com/half").expect("a half-written file");
253 assert_eq!(taken(&dir), ["https://example.com/first", "https://example.com/second"]);
254 assert_eq!(taken(&dir), Vec::<String>::new(), "each one is taken once");
255 assert!(dir.join("300.url.part").exists(), "a half-written file is left alone");
256 let _ = std::fs::remove_dir_all(&dir);
257 }
258
259 #[test]
260 fn an_empty_or_unreadable_file_leaves_nothing_behind() {
261 let dir = folder("empty");
262 std::fs::write(dir.join("100.url"), " \n").expect("an empty address");
263 assert_eq!(taken(&dir), Vec::<String>::new());
264 assert!(!dir.join("100.url").exists(), "it is not tried again forever");
265 assert_eq!(taken(&folder("none").join("gone")), Vec::<String>::new(), "a folder that is not there is quiet");
266 let _ = std::fs::remove_dir_all(&dir);
267 }
268
269 #[test]
270 fn only_web_addresses_are_opened() {
271 assert!(is_web("https://accounts.google.com/o/oauth2/auth?client_id=x"));
272 assert!(is_web("http://localhost:45049/oauth-callback"));
273 for refused in [
274 "file:///etc/passwd",
275 "javascript:alert(1)",
276 "antigravity-ide://open",
277 "ssh://host",
278 "https://example.com/\nfile:///etc/passwd",
279 "",
280 ] {
281 assert!(!is_web(refused), "{refused}");
282 }
283 }
284
285 #[test]
286 fn the_browser_is_the_applications_own_executable_linked_not_copied_beside_a_program_of_ours() {
287 let root = folder("browser");
290 let app = root.join("app");
291 std::fs::create_dir_all(app.join("resources/app")).expect("the application's resources");
292 std::fs::create_dir_all(app.join("locales")).expect("a folder of data files");
293 std::fs::write(app.join("antigravity-ide"), "#!/bin/sh\n").expect("an executable");
294 std::fs::set_permissions(app.join("antigravity-ide"), std::os::unix::fs::PermissionsExt::from_mode(0o755))
295 .expect("it can be run");
296 std::fs::write(app.join("chrome-sandbox"), "helper").expect("the sandbox helper");
297 std::fs::write(app.join("locales/en-US.pak"), "words").expect("a data file");
298 std::fs::write(app.join("resources/app/package.json"), "{\"name\": \"the editor\"}").expect("the editor");
299
300 let browser = root.join("browser");
302 let here = |command: &String| {
303 command
304 .replace(BROWSER_DIR, &browser.display().to_string())
305 .replace("/opt/antigravity-ide", &app.display().to_string())
306 };
307 let commands: Vec<String> =
308 browser_install("/opt/antigravity-ide", "antigravity-ide").iter().map(here).collect();
309 let ran = std::process::Command::new("sh").arg("-c").arg(commands.join(" && ")).status().expect("a shell");
310 assert!(ran.success(), "{commands:?}");
311
312 use std::os::unix::fs::MetadataExt;
313 let inode = |path: &std::path::Path| std::fs::metadata(path).expect("the file is there").ino();
314 let program = browser.join(BROWSER_PROGRAM.rsplit('/').next().expect("a file name"));
315 assert_eq!(inode(&program), inode(&app.join("antigravity-ide")), "the executable is linked");
318 assert!(!std::fs::symlink_metadata(&program).expect("it is there").file_type().is_symlink());
319 assert_eq!(inode(&browser.join("chrome-sandbox")), inode(&app.join("chrome-sandbox")));
320 assert_eq!(inode(&browser.join("locales/en-US.pak")), inode(&app.join("locales/en-US.pak")));
321 let main = std::fs::read_to_string(browser.join("resources/app/main.js")).expect("our program");
323 assert_eq!(main, BROWSER_MAIN, "written into the image exactly as it is here");
324 let package = std::fs::read_to_string(browser.join("resources/app/package.json")).expect("its package");
325 assert!(package.contains("\"qcode-browser\"") && package.contains("\"main.js\""), "{package}");
326 assert!(!package.contains("editor"), "{package}");
327 let _ = std::fs::remove_dir_all(&root);
328 }
329
330 #[test]
331 fn the_browsers_program_survives_the_one_layer_of_escaping_unchanged() {
332 assert!(!BROWSER_MAIN.contains('\\'), "a backslash would be read by printf");
333 assert!(super::BROWSER_PACKAGE.contains("\"name\": \"qcode-browser\""));
335 assert!(BROWSER_MAIN.contains("requestSingleInstanceLock"));
337 assert!(BROWSER_MAIN.contains("'second-instance'"));
338 }
339
340 #[test]
341 fn a_page_is_shown_by_the_sign_in_window_left_running_on_its_own() {
342 let address = "https://accounts.google.com/o/oauth2/auth?a=1&redirect_uri=http%3A%2F%2Flocalhost%3A1";
343 let command = page_command(&["--ozone-platform=wayland"], address);
344 assert_eq!(command[..2], ["sh", "-c"]);
345 assert_eq!(command[3..], ["sh", BROWSER_PROGRAM, "--ozone-platform=wayland", address]);
346
347 let dir = folder("page");
353 let stand_in = dir.join("browser");
354 let (started, go, seen, gave_up) = (dir.join("started"), dir.join("go"), dir.join("seen"), dir.join("gave-up"));
355 let script = format!(
356 "#!/bin/sh\n: > '{started}'\n\
357 i=0; while [ ! -e '{go}' ] && [ $i -lt 600 ]; do sleep 0.1; i=$((i + 1)); done\n\
358 [ -e '{go}' ] || {{ : > '{gave_up}'; exit 0; }}\n\
359 printf '%s\\n' \"$@\" > '{seen}.part' && mv '{seen}.part' '{seen}'\n",
360 started = started.display(),
361 go = go.display(),
362 gave_up = gave_up.display(),
363 seen = seen.display(),
364 );
365 std::fs::write(&stand_in, script).expect("a stand-in browser");
366 std::fs::set_permissions(&stand_in, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("runnable");
367 let mut here = command.clone();
368 here[4] = stand_in.display().to_string();
369 let out = std::process::Command::new(&here[0]).args(&here[1..]).output().expect("a shell");
370 assert!(out.status.success());
371 assert!(!gave_up.exists(), "the shell waited for the browser until it gave up");
372 assert!(!seen.exists(), "the browser cannot have finished before it was let go");
373 assert_eq!(String::from_utf8_lossy(&out.stdout), "", "a browser that is there says nothing");
374 let within = |path: &std::path::Path| {
375 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
376 while !path.exists() && std::time::Instant::now() < deadline {
377 std::thread::sleep(std::time::Duration::from_millis(50));
378 }
379 path.exists()
380 };
381 assert!(within(&started), "the stand-in browser was started");
382 std::fs::write(&go, "").expect("the stand-in is let go");
383 assert!(within(&seen), "the stand-in browser wrote what it was started with");
384 let words = std::fs::read_to_string(&seen).expect("what it was started with");
385 assert_eq!(words, format!("--ozone-platform=wayland\n{address}\n"));
386
387 here[4] = dir.join("nothing-here").display().to_string();
389 let out = std::process::Command::new(&here[0]).args(&here[1..]).output().expect("a shell");
390 assert!(out.status.success());
391 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), super::NO_BROWSER);
392 let _ = std::fs::remove_dir_all(&dir);
393 }
394}