#![cfg_attr(windows, windows_subsystem = "windows")]
use std::sync::Once;
use std::time::Duration;
use tauri::{
image::Image,
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::TrayIconBuilder,
Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, WindowEvent,
};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
const WINDOW_ICON: &[u8] = include_bytes!("../../icons/256.png");
const TRAY_ICON: &[u8] = include_bytes!("../../icons/tray.png");
#[cfg(target_os = "macos")]
const SHORTCUT: &str = "Cmd+Shift+Space";
#[cfg(not(target_os = "macos"))]
const SHORTCUT: &str = "Ctrl+Shift+Space";
const SCHEME: &str = "snyvi";
const DEFAULT_PORT: u16 = 7777;
const WINDOW_MARK: &str = "window=1";
fn main() {
let url = match std::env::args().nth(1) {
Some(u) => u,
None => hand_to_snyvi(None),
};
#[cfg(target_os = "linux")]
if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() {
eprintln!("snyvi-app: no display server");
std::process::exit(3);
}
let parsed: tauri::Url = match url.parse() {
Ok(u) => u,
Err(e) => {
eprintln!("snyvi-app: {url}: {e}");
std::process::exit(2);
}
};
let parsed = if parsed.scheme() == SCHEME {
if !daemon_up() {
hand_to_snyvi(Some(&url));
}
resolve(&parsed, &base_url(None))
} else {
parsed
};
let home = parsed.origin().ascii_serialization();
let shortcut = shortcut_wanted();
let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
let Some(w) = app.get_webview_window("main") else {
return;
};
if let Some(u) = argv.get(1).and_then(|u| u.parse::<tauri::Url>().ok()) {
open_in(&w, u);
}
reveal(&w);
}))
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_deep_link::init());
let builder = match shortcut.is_some() {
true => builder.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, _key, event| {
if event.state() != ShortcutState::Pressed {
return;
}
if let Some(w) = app.get_webview_window("main") {
toggle(&w);
}
})
.build(),
),
false => builder,
};
let run = builder
.setup(move |app| {
use tauri_plugin_window_state::{AppHandleExt, StateFlags, WindowExt};
let at_home = home.clone();
let w = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(parsed))
.title("snyvi")
.inner_size(1280.0, 860.0)
.min_inner_size(480.0, 320.0)
.on_navigation(move |url| {
if stays_home(url, &at_home) {
return true;
}
hand_to_desktop(url.as_str());
false
})
.on_new_window(|url, _features| {
hand_to_desktop(url.as_str());
tauri::webview::NewWindowResponse::Deny
})
.icon(Image::from_bytes(WINDOW_ICON)?)?
.build()?;
let _ = w.restore_state(StateFlags::all());
let tray = match tray(app.handle(), &w) {
Ok(()) => true,
Err(e) => {
eprintln!("snyvi-app: no tray icon ({e})");
false
}
};
if let Some(key) = &shortcut {
match app.global_shortcut().register(key.as_str()) {
Ok(()) => eprintln!("snyvi-app: {key} shows or hides the window from anywhere"),
Err(e) => eprintln!("snyvi-app: no global shortcut ({key}: {e})"),
}
}
#[cfg(any(target_os = "linux", windows))]
{
let handle = app.handle().clone();
std::thread::spawn(move || claim_scheme(&handle));
}
let window = w.clone();
let handle = app.handle().clone();
w.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
if !tray {
return;
}
api.prevent_close();
let _ = handle.save_window_state(StateFlags::all());
let _ = window.hide();
said_where_it_went();
}
});
Ok(())
})
.build(tauri::generate_context!());
let app = match run {
Ok(app) => app,
Err(e) => {
eprintln!("snyvi-app: {e}");
std::process::exit(1);
}
};
app.run(|_app, _event| {
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = _event {
let Some(w) = _app.get_webview_window("main") else {
return;
};
if let Some(u) = urls.into_iter().find(|u| u.scheme() == SCHEME) {
open_in(&w, u);
}
reveal(&w);
}
});
}
fn open_in(w: &WebviewWindow, url: tauri::Url) {
let url = if url.scheme() == SCHEME {
resolve(&url, &base_url(Some(w)))
} else {
url
};
let _ = w.navigate(url);
}
fn resolve(link: &tauri::Url, base: &str) -> tauri::Url {
let mut path = String::new();
if let Some(host) = link.host_str().filter(|h| !h.is_empty()) {
path.push('/');
path.push_str(host);
}
path.push_str(link.path());
if path.is_empty() {
path.push('/');
}
let url = match link.query() {
Some(q) => format!("{base}{path}?{q}&{WINDOW_MARK}"),
None => format!("{base}{path}?{WINDOW_MARK}"),
};
url.parse()
.unwrap_or_else(|_| format!("{base}/").parse().expect("base url"))
}
fn base_url(w: Option<&WebviewWindow>) -> String {
if let Some(u) = w.and_then(|w| w.url().ok()) {
if u.scheme().starts_with("http") {
return u.origin().ascii_serialization();
}
}
format!("http://127.0.0.1:{}", port())
}
fn port() -> u16 {
std::env::var("SNYVI_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT)
}
fn daemon_up() -> bool {
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port()));
std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok()
}
#[cfg(any(target_os = "linux", windows))]
fn claim_scheme(app: &tauri::AppHandle) {
use tauri_plugin_deep_link::DeepLinkExt;
#[cfg(target_os = "linux")]
{
let owner = std::process::Command::new("xdg-mime")
.args(["query", "default", &format!("x-scheme-handler/{SCHEME}")])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
if !owner.is_empty() && !owner.contains("snyvi-app-handler") {
return;
}
}
#[cfg(windows)]
if app.deep_link().is_registered(SCHEME).unwrap_or(false) {
return;
}
if let Err(e) = app.deep_link().register(SCHEME) {
eprintln!("snyvi-app: {SCHEME}:// links will not open here ({e})");
}
}
fn stays_home(url: &tauri::Url, home: &str) -> bool {
url.scheme() == "about" || url.origin().ascii_serialization() == home
}
fn hand_to_desktop(url: &str) {
use std::process::{Command, Stdio};
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let safe: String = url
.chars()
.filter(|c| *c != '"' && *c != '\n' && *c != '\r')
.collect();
if Command::new("cmd")
.arg("/C")
.raw_arg(format!("start \"\" \"{safe}\""))
.creation_flags(CREATE_NO_WINDOW)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok()
{
return;
}
}
#[cfg(not(windows))]
for opener in ["xdg-open", "open"] {
if Command::new(opener)
.arg(url)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.is_ok()
{
return;
}
}
eprintln!("snyvi-app: nothing on this desktop opens {url}");
}
fn tray(app: &tauri::AppHandle, window: &WebviewWindow) -> tauri::Result<()> {
let show = MenuItem::with_id(app, "show", "Show snyvi", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit snyvi", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &PredefinedMenuItem::separator(app)?, &quit])?;
let _ = window;
let builder = TrayIconBuilder::with_id("snyvi")
.icon(Image::from_bytes(TRAY_ICON)?)
.tooltip("snyvi")
.menu(&menu)
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => {
if let Some(w) = app.get_webview_window("main") {
reveal(&w);
}
}
"quit" => app.exit(0),
_ => {}
});
#[cfg(target_os = "windows")]
let builder = {
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
builder
.show_menu_on_left_click(false)
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
if let Some(w) = tray.app_handle().get_webview_window("main") {
if w.is_visible().unwrap_or(false) {
let _ = w.hide();
} else {
reveal(&w);
}
}
}
})
};
builder.build(app)?;
Ok(())
}
fn reveal(w: &WebviewWindow) {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
fn toggle(w: &WebviewWindow) {
if w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false) {
let _ = w.hide();
} else {
reveal(w);
}
}
fn shortcut_wanted() -> Option<String> {
let key = match std::env::var("SNYVI_SHORTCUT") {
Ok(v) => match v.trim() {
"" | "0" | "off" | "none" | "no" | "false" => return None,
k => k.to_string(),
},
Err(_) => SHORTCUT.to_string(),
};
#[cfg(target_os = "linux")]
if std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_none() {
eprintln!(
"snyvi-app: no global shortcut on Wayland; bind a key to `snyvi app` in the desktop's settings"
);
return None;
}
Some(key)
}
fn hand_to_snyvi(link: Option<&str>) -> ! {
let sibling = std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
.and_then(|p| {
p.parent()
.map(|d| d.join(if cfg!(windows) { "snyvi.exe" } else { "snyvi" }))
})
.filter(|p| p.is_file());
if let Some(snyvi) = sibling {
let mut cmd = std::process::Command::new(&snyvi);
cmd.arg("app");
if let Some(l) = link {
cmd.arg(l);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let e = cmd.exec();
eprintln!("snyvi-app: {}: {e}", snyvi.display());
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(unix))]
match cmd.status() {
Ok(s) => std::process::exit(s.code().unwrap_or(1)),
Err(e) => eprintln!("snyvi-app: {}: {e}", snyvi.display()),
}
}
match link {
Some(l) => eprintln!(
"snyvi-app: {l}: no daemon on port {} and no snyvi beside this executable to start one; run `snyvi app` first",
port()
),
None => eprintln!("usage: snyvi-app <url> (normally run for you by `snyvi app`)"),
}
std::process::exit(2);
}
fn said_where_it_went() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
eprintln!(
"snyvi-app: hidden to the tray. Click the tray icon to show it, or Quit from its menu."
);
});
}
#[cfg(test)]
mod tests {
use super::*;
fn r(link: &str) -> String {
resolve(&link.parse().unwrap(), "http://127.0.0.1:7777").to_string()
}
fn goes(url: &str) -> bool {
stays_home(&url.parse().unwrap(), "http://127.0.0.1:7777")
}
#[test]
fn the_window_keeps_the_daemon_and_gives_the_web_away() {
assert!(goes("http://127.0.0.1:7777/"));
assert!(goes("http://127.0.0.1:7777/d/abc?window=1"));
assert!(goes("http://127.0.0.1:7777/api/docs/abc/raw"));
assert!(goes("about:blank"));
assert!(!goes("https://github.com/snymrova/snyvi"));
assert!(!goes("http://127.0.0.1:7778/d/abc"));
assert!(!goes("https://127.0.0.1:7777/d/abc"));
assert!(!goes("mailto:a@b.c"));
assert!(!goes("file:///etc/hosts"));
}
#[test]
fn a_link_is_the_daemon_address_with_the_mark() {
assert_eq!(r("snyvi://d/abc"), "http://127.0.0.1:7777/d/abc?window=1");
assert_eq!(r("snyvi:///d/abc"), "http://127.0.0.1:7777/d/abc?window=1");
assert_eq!(r("snyvi://"), "http://127.0.0.1:7777/?window=1");
assert_eq!(
r("snyvi://d/abc?v=2"),
"http://127.0.0.1:7777/d/abc?v=2&window=1"
);
}
}