use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Duration;
use noctiluca::actions::{self, ACTIONS};
use noctiluca::args::{Args, Run, USAGE, parse_args};
use noctiluca::bookmarks::{self, Bookmarks};
use noctiluca::cdp::{ChromeBackend, ChromeOptions};
use noctiluca::config::{Config, View, data_dir};
use noctiluca::engine::Engine;
use noctiluca::file_url;
const STOP: Duration = Duration::from_secs(5);
static CONTROL: std::sync::Mutex<Option<noctiluca::control::ControlLink>> =
std::sync::Mutex::new(None);
fn main() -> ExitCode {
let panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
panic(info);
stop_browsers();
}));
rich_intuituive::interact::session::on_terminate(stop_browsers);
let args: Vec<String> = std::env::args().skip(1).collect();
let result = run(&args);
stop_browsers();
match result {
Ok(code) => code,
Err(error) => {
eprintln!("noctiluca: {error}");
ExitCode::FAILURE
}
}
}
fn stop_browsers() {
if let Some(link) = CONTROL.lock().unwrap_or_else(|e| e.into_inner()).take() {
link.close();
}
noctiluca::tab::stop_all(STOP);
rich_embed::ChromeEngine::end_all();
}
fn run(args: &[String]) -> std::io::Result<ExitCode> {
let result = match parse_args(args) {
Ok(Run::Help) => {
println!("{USAGE}");
Ok(())
}
Ok(Run::Version) => {
println!("noctiluca {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Ok(Run::Keys { site, config }) => keys(site.as_deref(), config.as_deref()),
Ok(Run::ControlHelp) => {
println!("{}", noctiluca::control::ACTION_USAGE);
Ok(())
}
Ok(Run::Ls { json }) => ls(json),
Ok(Run::Action(action)) => return Ok(control_action(&action)),
Ok(Run::Browse(parsed)) if parsed.split.is_some() => split(&parsed, args),
Ok(Run::Browse(args)) => browse(args),
Err(error) => {
eprintln!("noctiluca: {error}\n\n{USAGE}");
return Ok(ExitCode::from(2));
}
};
result.map(|()| ExitCode::SUCCESS)
}
#[cfg(unix)]
fn ls(json: bool) -> std::io::Result<()> {
println!("{}", noctiluca::control::ls(json)?);
Ok(())
}
#[cfg(not(unix))]
fn ls(_json: bool) -> std::io::Result<()> {
Err(noctiluca::control::unix_only())
}
#[cfg(unix)]
fn control_action(action: &noctiluca::control::ActionArgs) -> ExitCode {
match noctiluca::control::action(action) {
Ok(out) => {
println!("{out}");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("noctiluca: {error}");
ExitCode::FAILURE
}
}
}
#[cfg(not(unix))]
fn control_action(_action: &noctiluca::control::ActionArgs) -> ExitCode {
eprintln!("noctiluca: {}", noctiluca::control::unix_only());
ExitCode::FAILURE
}
fn listen(
args: &Args,
config: &Config,
) -> std::io::Result<Option<noctiluca::control::ControlLink>> {
if !(args.control || args.read_only || config.agent.control) {
return Ok(None);
}
let read_only = args.read_only || config.agent.read_only;
if !read_only && (args.profile.is_some() || config.profile.is_some()) {
eprintln!(
"noctiluca: note: --control with a kept profile lets scripts act on your signed-in \
sites; --read-only lets them read only"
);
}
let link = noctiluca::control::ControlLink::bind(read_only)
.map_err(|e| std::io::Error::new(e.kind(), format!("--control: {e}")))?;
*CONTROL.lock().unwrap_or_else(|e| e.into_inner()) = Some(link.clone());
Ok(Some(link))
}
fn split(args: &Args, line: &[String]) -> std::io::Result<()> {
use rich_interact::host::{Split, detect};
let direction = args.split.expect("--split");
let host = detect().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::Unsupported,
"--split needs a terminal that can split: tmux, WezTerm, kitty (with remote \
control on), zellij, or one with terminal-browser's socket",
)
})?;
let mut command = vec![std::env::current_exe()?.display().to_string()];
command.extend(noctiluca::args::without_split(line));
let pane = host
.split(&Split::new(direction, command))
.map_err(|e| std::io::Error::other(format!("--split: {e} ({})", host.name())))?;
if pane.id.is_empty() {
eprintln!("noctiluca: opened {} in {}", direction.name(), host.name());
} else {
eprintln!(
"noctiluca: opened {} in {} (pane {})",
direction.name(),
host.name(),
pane.id
);
}
Ok(())
}
fn keys(site: Option<&str>, path: Option<&Path>) -> std::io::Result<()> {
let config = Config::load(path)?;
for warning in &config.warnings {
eprintln!("noctiluca: config: {warning}");
}
let (keymap, warnings) = actions::keymap(config.preset, &config.keys);
for warning in warnings {
eprintln!("noctiluca: config: {warning}");
}
match site {
None => {
for action in ACTIONS {
println!(
"{:>16} {:<16} {}",
actions::keys_label(&keymap, action.id),
action.id,
action.label
);
}
for site in &config.sites {
for key in &site.keys {
println!(
"{:>16} {} [site {}]",
key.keys,
key.target.describe(),
site.pattern
);
}
}
}
Some(host) => {
let url = if host.contains("://") {
host.to_string()
} else {
format!("https://{host}/")
};
let matching: Vec<_> = config
.sites
.iter()
.filter(|s| noctiluca::config::site_match(&s.pattern, &url).is_some())
.collect();
if matching.is_empty() {
println!("No site keys for {host}.");
}
for site in matching {
for key in &site.keys {
println!(
"{:>16} {} [site {}]",
key.keys,
key.target.describe(),
site.pattern
);
}
}
}
}
Ok(())
}
fn start_url(args: &Args, config: &Config, marks: &Bookmarks) -> Option<String> {
if args.target.is_empty() {
return None;
}
if let [one] = args.target.as_slice() {
let path = Path::new(one);
if path.exists() && !one.contains("://") {
return file_url(path).ok();
}
}
let typed = args.target.join(" ");
Some(
config
.destination(&typed, "about:blank", &marks.items)
.url()
.to_string(),
)
}
fn engine(args: &Args, config: &Config, switches: &[String]) -> std::io::Result<Box<dyn Engine>> {
#[cfg(feature = "fake-engine")]
if let Some(dir) = std::env::var_os("NOCTILUCA_FAKE_ENGINE") {
let frames = Path::new(&dir).join("frames");
let engine = noctiluca::testing::FakeEngine::from_dir(Path::new(&dir))?;
return Ok(Box::new(if frames.is_dir() {
engine.replaying(&frames)
} else {
engine
}));
}
let profile = args
.profile
.clone()
.or_else(|| config.profile.clone())
.map(|name| {
data_dir()
.unwrap_or_else(std::env::temp_dir)
.join("profiles")
.join(name)
});
let binary = args
.chrome
.clone()
.or_else(|| std::env::var_os("RICH_EMBED_CHROME").map(PathBuf::from));
let (columns, rows) = terminal_size();
let backend = ChromeBackend::start(ChromeOptions {
binary,
audio: args.audio || config.audio,
profile,
quarantine: None,
no_js: args.no_js,
transparent: args.transparent || config.transparent,
switches: switches.to_vec(),
columns,
rows,
})
.map_err(|e| {
let root = std::env::var("USER").is_ok_and(|u| u == "root");
if root {
std::io::Error::new(
e.kind(),
format!(
"{e} (Chrome cannot run its sandbox as root: run Noctiluca as a normal user)"
),
)
} else {
e
}
})?;
Ok(Box::new(backend))
}
fn open_tunnel(host: &str, args: &Args) -> std::io::Result<noctiluca::ssh::TunnelLink> {
let mut options = noctiluca::ssh::SshOptions::new(host);
options.args = args.ssh_args.clone();
if args.profile.is_some() {
eprintln!(
"noctiluca: note: with --ssh, the kept profile's cookies for localhost are \
shared with the far side's localhost"
);
}
eprintln!("noctiluca: connecting to {host} over SSH (Ctrl+C stops)…");
let tunnel = noctiluca::ssh::Tunnel::open(options, Duration::from_secs(120))
.map_err(|e| std::io::Error::new(e.kind(), format!("--ssh: {e}")))?;
Ok(noctiluca::ssh::TunnelLink::new(tunnel))
}
fn terminal_size() -> (u16, u16) {
let var = |name: &str| std::env::var(name).ok().and_then(|v| v.parse().ok());
(var("COLUMNS").unwrap_or(100), var("LINES").unwrap_or(40))
}
fn browse(args: Args) -> std::io::Result<()> {
let mut config = Config::load(args.config.as_deref())?;
if let Some(fps) = args.fps {
config.fps = fps;
}
config.audio |= args.audio;
config.transparent |= args.transparent;
config.adapt = noctiluca::args::adapt_of(&args, config.adapt)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
let marks = bookmarks::default_path()
.map(|p| Bookmarks::load(&p))
.transpose()?
.unwrap_or_default();
let url = start_url(&args, &config, &marks);
let tunnel = args
.ssh
.as_ref()
.map(|host| open_tunnel(host, &args))
.transpose()?;
let switches = tunnel
.as_ref()
.map(|t| {
t.0.lock()
.unwrap_or_else(|e| e.into_inner())
.chrome_switches()
})
.unwrap_or_default();
if let Some(how) = args.dump {
for warning in &config.warnings {
eprintln!("noctiluca: config: {warning}");
}
let url = url.unwrap_or_default();
let page = noctiluca::dump::read_page(
engine(&args, &config, &switches)?,
&url,
Duration::from_secs(60),
)?;
println!("{}", noctiluca::dump::dump(&page, how));
return Ok(());
}
if args.view.unwrap_or(config.view) == View::Raster {
return raster(&args, url.unwrap_or_else(|| "about:blank".into()), switches);
}
let engine = engine(&args, &config, &switches)?;
let control = listen(&args, &config)?;
noctiluca::app::app(
engine,
noctiluca::app::Options {
config,
bookmarks: marks,
url,
view: args.view,
images: args.images,
tunnel,
control,
},
)
.mouse(args.no_mouse.then_some(false))
.run()
}
fn raster(args: &Args, url: String, switches: Vec<String>) -> std::io::Result<()> {
use rich_intuituive::prelude::*;
let binary = args.chrome.clone().or_else(noctiluca::cdp::find_chrome);
let audio = args.audio;
App::new(move || {
let mut engine = rich_embed::ChromeEngine::new()
.audio(audio)
.args(switches.clone());
if let Some(binary) = binary {
engine = engine.binary(binary);
}
rich_embed::web_view_with(engine, url)
.address_bar(true)
.node()
.on_key("ctrl+q", |cx| cx.quit())
})
.run()
}