diff --git a/qt/launcher-gui/.gitignore b/qt/launcher-gui/.gitignore new file mode 100644 index 000000000..6635cf554 --- /dev/null +++ b/qt/launcher-gui/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/qt/launcher-gui/src-tauri/.gitignore b/qt/launcher-gui/src-tauri/.gitignore new file mode 100644 index 000000000..44828a436 --- /dev/null +++ b/qt/launcher-gui/src-tauri/.gitignore @@ -0,0 +1,3 @@ +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/qt/launcher-gui/src-tauri/.taurignore b/qt/launcher-gui/src-tauri/.taurignore new file mode 100644 index 000000000..e69de29bb diff --git a/qt/launcher-gui/src-tauri/Cargo.toml b/qt/launcher-gui/src-tauri/Cargo.toml new file mode 100644 index 000000000..82493085d --- /dev/null +++ b/qt/launcher-gui/src-tauri/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "launcher-gui" +version = "1.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[build-dependencies] +anki_io.workspace = true +anki_proto_gen.workspace = true +anyhow.workspace = true +inflections.workspace = true +prettyplease.workspace = true +prost-build.workspace = true +prost-reflect.workspace = true +tauri-build.workspace = true +syn.workspace = true + +[dependencies] +anki_i18n.workspace = true +anki_io.workspace = true +anki_process.workspace = true +anki_proto.workspace = true +anyhow.workspace = true +data-encoding.workspace = true +dirs.workspace = true +futures.workspace = true +phf.workspace = true +portable-pty.workspace = true +prost.workspace = true +serde.workspace = true +serde_json.workspace = true +snafu.workspace = true +strum.workspace = true +tauri.workspace = true +tauri-plugin-log.workspace = true +tauri-plugin-os.workspace = true +tauri-plugin-single-instance.workspace = true +tokio.workspace = true + +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +libc.workspace = true + +[target.'cfg(windows)'.dependencies] +windows.workspace = true +widestring.workspace = true +libc.workspace = true +libc-stdhandle.workspace = true diff --git a/qt/launcher-gui/src-tauri/build.rs b/qt/launcher-gui/src-tauri/build.rs new file mode 100644 index 000000000..84709a6be --- /dev/null +++ b/qt/launcher-gui/src-tauri/build.rs @@ -0,0 +1,19 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +mod rust_interface; + +use anki_proto_gen::descriptors_path; +use anyhow::Result; +use prost_reflect::DescriptorPool; + +fn main() -> Result<()> { + let descriptors_path = descriptors_path(); + println!("cargo:rerun-if-changed={}", descriptors_path.display()); + let pool = DescriptorPool::decode(std::fs::read(descriptors_path)?.as_ref())?; + rust_interface::write_rust_interface(&pool)?; + + tauri_build::build(); + + Ok(()) +} diff --git a/qt/launcher-gui/src-tauri/capabilities/default.json b/qt/launcher-gui/src-tauri/capabilities/default.json new file mode 100644 index 000000000..43af1fe4d --- /dev/null +++ b/qt/launcher-gui/src-tauri/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "os:default", + "log:default" + ] +} diff --git a/qt/launcher-gui/src-tauri/icons/icon.ico b/qt/launcher-gui/src-tauri/icons/icon.ico new file mode 100644 index 000000000..fd03c333e Binary files /dev/null and b/qt/launcher-gui/src-tauri/icons/icon.ico differ diff --git a/qt/launcher-gui/src-tauri/rust_interface.rs b/qt/launcher-gui/src-tauri/rust_interface.rs new file mode 100644 index 000000000..1326b6b9f --- /dev/null +++ b/qt/launcher-gui/src-tauri/rust_interface.rs @@ -0,0 +1,149 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::borrow::Cow; +use std::env; +use std::fmt::Write; +use std::path::PathBuf; + +use anki_io::write_file_if_changed; +use anki_proto_gen::get_services; +use anki_proto_gen::CollectionService; +use anyhow::Context; +use anyhow::Result; +use inflections::Inflect; +use prost_reflect::DescriptorPool; + +pub fn write_rust_interface(pool: &DescriptorPool) -> Result<()> { + let mut buf = String::new(); + buf.push_str("use prost::Message; use anyhow::Context; use anyhow::anyhow;"); + + let (services, _) = get_services(pool); + if let Some(s) = services + .into_iter() + .find(|s| s.name.starts_with("Launcher")) + { + render_service(&s, &mut buf); + } + + let buf = format_code(buf)?; + let out_dir = env::var("OUT_DIR").unwrap(); + let path = PathBuf::from(out_dir).join("rpc.rs"); + write_file_if_changed(path, buf).context("write file")?; + + Ok(()) +} + +fn render_service(service: &CollectionService, buf: &mut impl Write) { + buf.write_str( + r#" +pub(crate) async fn handle_rpc( + app: ::tauri::AppHandle, + window: ::tauri::WebviewWindow, + req: ::tauri::http::Request>, +) -> ::anyhow::Result> { + let method = &req.uri().path()[1..]; + println!("{}: {method}", window.url().unwrap()); + match method { +"#, + ) + .unwrap(); + + for method in &service.trait_methods { + let method_name = method.name.to_snake_case(); + let handler_method_name = format!("crate::commands::{method_name}"); + let method_name_ts = method_name.to_camel_case(); + + let output_map = if method.output().is_some() { + Cow::from(format!( + ".map(|o: {}| o.encode_to_vec())", + method.output_type().unwrap() + )) + } else { + Cow::from(".map(|()| Vec::new())") + }; + + let handler_call = if method.input().is_some() { + let input_type = method.input_type().unwrap(); + format!( + r##" + let input = ::{input_type}::decode(req.body().as_slice()) + .with_context(|| "failed to decode protobuf for {method_name_ts}")?; + {handler_method_name}(app, window, input) +"## + ) + } else { + format!( + r#" + {handler_method_name}(app, window) +"# + ) + }; + + if let Some(comments) = method.comments.as_deref() { + writeln!( + buf, + r#" + /* + {comments} + */ + "# + ) + .unwrap(); + } + + writeln!( + buf, + r#" + "{method_name_ts}" => {{ + {handler_call} + .await + {output_map} + }} +"# + ) + .unwrap(); + } + buf.write_str( + r#" + _ => Err(anyhow!("{method} not implemented"))?, + } + .with_context(|| format!("{method} rpc call failed")) +} + "#, + ) + .unwrap(); +} + +trait MethodHelpers { + fn input_type(&self) -> Option; + fn output_type(&self) -> Option; +} + +impl MethodHelpers for anki_proto_gen::Method { + fn input_type(&self) -> Option { + self.input().map(|t| rust_type(t.full_name())) + } + + fn output_type(&self) -> Option { + self.output().map(|t| rust_type(t.full_name())) + } +} + +fn rust_type(name: &str) -> String { + let Some((head, tail)) = name.rsplit_once('.') else { + panic!() + }; + format!( + "{}::{}", + head.to_snake_case() + .replace('.', "::") + .replace("anki::", "anki_proto::"), + tail + ) +} + +fn format_code(code: String) -> Result { + let syntax_tree = syn::parse_file(&code)?; + Ok(prettyplease::unparse(&syntax_tree)) +} diff --git a/qt/launcher-gui/src-tauri/src/app.rs b/qt/launcher-gui/src-tauri/src/app.rs new file mode 100644 index 000000000..afe517be7 --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/app.rs @@ -0,0 +1,85 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +// use std::sync::Mutex; + +use tauri::http; +use tauri::App; +use tauri::AppHandle; +use tauri::Manager; +use tauri::Runtime; +use tauri::UriSchemeContext; +use tauri::UriSchemeResponder; +use tauri_plugin_os::locale; + +use crate::generated; +use crate::lang::setup_i18n; +use crate::uv; + +pub const PROTOCOL: &str = "anki"; + +pub fn setup(app: &mut App, state: uv::State) -> anyhow::Result<()> { + setup_i18n(app.app_handle(), &[&locale().unwrap_or_default()]); + + app.manage(state); + + #[cfg(debug_assertions)] + let _ = app + .get_webview_window("main") + .unwrap() + .set_always_on_top(true); + + Ok(()) +} + +pub fn serve( + ctx: UriSchemeContext<'_, R>, + req: http::Request>, + responder: UriSchemeResponder, +) { + let app = ctx.app_handle().to_owned(); + let window = app + .get_webview_window(ctx.webview_label()) + .expect("could not get webview"); + + let builder = http::Response::builder() + .header(http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(http::header::CONTENT_TYPE, "application/binary"); + + tauri::async_runtime::spawn(async move { + match *req.method() { + http::Method::POST => { + let response = match generated::handle_rpc(app, window, req).await { + Ok(res) if !res.is_empty() => builder.body(res), + Ok(res) => builder.status(http::StatusCode::NO_CONTENT).body(res), + Err(e) => { + eprintln!("ERROR: {e:?}"); + builder + .status(http::StatusCode::INTERNAL_SERVER_ERROR) + .header(http::header::CONTENT_TYPE, "text/plain") + .body(format!("{e:?}").as_bytes().to_vec()) + } + }; + responder.respond(response.expect("could not build response")); + } + // handle preflight requests (on windows at least) + http::Method::OPTIONS => { + responder.respond( + builder + .header(http::header::ACCESS_CONTROL_ALLOW_METHODS, "POST") + .header(http::header::ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type") + .body(vec![]) + .unwrap(), + ); + } + _ => unimplemented!("rpc calls must use POST"), + } + }); +} + +pub fn on_second_instance(app: &AppHandle, _args: Vec, _cwd: String) { + let _ = app + .get_webview_window("main") + .expect("no main window") + .set_focus(); +} diff --git a/qt/launcher-gui/src-tauri/src/commands.rs b/qt/launcher-gui/src-tauri/src/commands.rs new file mode 100644 index 000000000..29368cb96 --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/commands.rs @@ -0,0 +1,211 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use anki_proto::generic; +use anki_proto::launcher::get_langs_response; +use anki_proto::launcher::get_mirrors_response; +use anki_proto::launcher::ChooseVersionRequest; +use anki_proto::launcher::ChooseVersionResponse; +use anki_proto::launcher::GetLangsResponse; +use anki_proto::launcher::GetMirrorsResponse; +use anki_proto::launcher::GetVersionsResponse; +use anki_proto::launcher::I18nResourcesRequest; +use anki_proto::launcher::Mirror; +use anki_proto::launcher::Options; +use anki_proto::launcher::ZoomWebviewRequest; +use anyhow::Context; +use anyhow::Result; +use strum::IntoEnumIterator; +use tauri::AppHandle; +use tauri::Manager; +use tauri::Runtime; +use tauri::WebviewWindow; +use tauri_plugin_os::locale; + +use crate::lang::get_tr; +use crate::lang::setup_i18n; +use crate::lang::LANGS; +use crate::lang::LANGS_DEFAULT_REGION; +use crate::lang::LANGS_WITH_REGIONS; +use crate::uv; + +pub async fn i18n_resources( + app: AppHandle, + _window: WebviewWindow, + input: I18nResourcesRequest, +) -> Result { + let tr = get_tr(&app)?; + serde_json::to_vec(&tr.resources_for_js(&input.modules)) + .with_context(|| "failed to serialise i18n resources") + .map(Into::into) +} + +pub async fn get_langs( + _app: AppHandle, + _window: WebviewWindow, +) -> Result { + let langs = LANGS + .into_iter() + .map(|(locale, name)| get_langs_response::Pair { + name: name.to_string(), + locale: locale.to_string(), + }) + .collect(); + + let user_locale = locale() + .and_then(|l| { + if LANGS.contains_key(&l) { + Some(l) + } else { + LANGS_DEFAULT_REGION + .get(l.split('-').next().unwrap()) + .or_else(|| LANGS_DEFAULT_REGION.get("en")) + .map(ToString::to_string) + } + }) + .unwrap(); + + Ok(GetLangsResponse { user_locale, langs }) +} + +pub async fn set_lang( + app: AppHandle, + _window: WebviewWindow, + input: generic::String, +) -> Result<()> { + // python's lang_to_disk_lang + let input = input.val; + let input = if LANGS_WITH_REGIONS.contains(input.as_str()) { + input + } else { + input.split('-').next().unwrap().to_owned() + }; + setup_i18n(&app, &[&*input]); + Ok(()) +} + +pub async fn get_mirrors( + app: AppHandle, + _window: WebviewWindow, +) -> Result { + let tr = get_tr(&app)?; + Ok(GetMirrorsResponse { + mirrors: Mirror::iter() + .map(|mirror| get_mirrors_response::Pair { + mirror: mirror.into(), + name: match mirror { + Mirror::Disabled => tr.launcher_mirror_no_mirror(), + Mirror::China => tr.launcher_mirror_china(), + } + .into(), + }) + .collect(), + }) +} + +pub async fn get_options( + app: AppHandle, + _window: WebviewWindow, +) -> Result { + let state = app.state::(); + let allow_betas = state.prerelease_marker.exists(); + let download_caching = !state.no_cache_marker.exists(); + let mirror = if state.mirror_path.exists() { + Mirror::China + } else { + Mirror::Disabled + } + .into(); + + Ok(Options { + allow_betas, + download_caching, + mirror, + }) +} + +pub async fn get_versions( + app: AppHandle, + _window: WebviewWindow, +) -> Result { + let state = (*app.state::()).clone(); + // TODO: why... + let mut state1 = state.clone(); + + let releases_fut = tauri::async_runtime::spawn_blocking(move || uv::get_releases(&state)); + let check_fut = tauri::async_runtime::spawn_blocking(move || uv::check_versions(&mut state1)); + + let (releases, check) = futures::future::join(releases_fut, check_fut).await; + // TODO: handle errors properly + let uv::Releases { latest, all } = releases.unwrap().unwrap(); + let (current, previous) = check.unwrap().unwrap(); + + Ok(GetVersionsResponse { + latest, + all, + current, + previous, + }) +} + +pub async fn choose_version( + app: AppHandle, + _window: WebviewWindow, + input: ChooseVersionRequest, +) -> Result { + let state = (*app.state::()).clone(); + let version = input.version.clone(); + + tauri::async_runtime::spawn_blocking(move || -> Result<()> { + if let Some(options) = input.options { + uv::set_allow_betas(&state, options.allow_betas)?; + uv::set_cache_enabled(&state, options.download_caching)?; + uv::set_mirror(&state, options.mirror != Mirror::Disabled as i32)?; + } + + if !input.keep_existing || state.pyproject_modified_by_user { + // install or resync + let res = uv::handle_version_install_or_update( + app.clone(), + &state, + &input.version, + input.keep_existing, + ); + println!("handle_version_install_or_update: {res:?}"); + res?; + } + + uv::post_install(&state)?; + + // TODO: show some sort of notification before closing + // if let Some(window) = app.get_webview_window("main") { + // let _ = window.destroy(); + // } + // // app.exit can't be called from the main thread + // app.exit(0); + + Ok(()) + }) + .await??; + + Ok(ChooseVersionResponse { version }) +} + +/// NOTE: [zoomHotkeysEnabled](https://v2.tauri.app/reference/config/#zoomhotkeysenabled) exists +/// but the polyfill it uses on lin doesn't allow regular scrolling +pub async fn zoom_webview( + _app: AppHandle, + window: WebviewWindow, + input: ZoomWebviewRequest, +) -> Result<()> { + let factor = input.scale_factor.into(); + // NOTE: not supported on windows + let _ = window.set_zoom(factor); + Ok(()) +} + +pub async fn window_ready(_app: AppHandle, window: WebviewWindow) -> Result<()> { + window + .show() + .with_context(|| format!("could not show window: {}", window.label())) +} diff --git a/qt/launcher-gui/src-tauri/src/error.rs b/qt/launcher-gui/src-tauri/src/error.rs new file mode 100644 index 000000000..11c5c8690 --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/error.rs @@ -0,0 +1,11 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use snafu::Snafu; + +// TODO: these aren't used yet +#[derive(Debug, PartialEq, Snafu)] +pub enum Error { + OsUnsupported, + InvalidInput, +} diff --git a/qt/launcher-gui/src-tauri/src/generated.rs b/qt/launcher-gui/src-tauri/src/generated.rs new file mode 100644 index 000000000..ffb771b08 --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/generated.rs @@ -0,0 +1,4 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +include!(concat!(env!("OUT_DIR"), "/rpc.rs")); diff --git a/qt/launcher-gui/src-tauri/src/lang.rs b/qt/launcher-gui/src-tauri/src/lang.rs new file mode 100644 index 000000000..9c6d918c0 --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/lang.rs @@ -0,0 +1,143 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::sync::RwLock; + +use anyhow::anyhow; +use anyhow::Result; +use phf::phf_map; +use phf::phf_ordered_map; +use phf::phf_set; +use tauri::AppHandle; +use tauri::Manager; +use tauri::Runtime; + +pub type I18n = anki_i18n::I18n; +pub type Tr = RwLock>; + +pub fn setup_i18n(app: &AppHandle, locales: &[&str]) { + app.manage(Tr::default()); // no-op if it already exists + *app.state::().write().expect("tr lock was poisoned!") = Some(I18n::new(locales)); +} + +pub fn get_tr(app: &AppHandle) -> Result { + let tr_state = app.state::(); + let guard = tr_state.read().expect("tr lock was poisoned!"); + guard + .clone() + .ok_or_else(|| anyhow!("tr was not initialised!")) +} + +pub const LANGS: phf::OrderedMap<&'static str, &'static str> = phf_ordered_map! { + // "af-ZA" => "Afrikaans", + // "ms-MY" => "Bahasa Melayu", + // "ca-ES" => "Català", + // "da-DK" => "Dansk", + // "de-DE" => "Deutsch", + // "et-EE" => "Eesti", + "en-US" => "English (United States)", + // "en-GB" => "English (United Kingdom)", + // "es-ES" => "Español", + // "eo-UY" => "Esperanto", + // "eu-ES" => "Euskara", + "fr-FR" => "Français", + // "gl-ES" => "Galego", + // "hr-HR" => "Hrvatski", + // "it-IT" => "Italiano", + // "jbo-EN" => "lo jbobau", + // "oc-FR" => "Lenga d'òc", + // "kk-KZ" => "Қазақша", + // "hu-HU" => "Magyar", + // "nl-NL" => "Nederlands", + // "nb-NO" => "Norsk", + // "pl-PL" => "Polski", + // "pt-BR" => "Português Brasileiro", + // "pt-PT" => "Português", + // "ro-RO" => "Română", + // "sk-SK" => "Slovenčina", + // "sl-SI" => "Slovenščina", + // "fi-FI" => "Suomi", + // "sv-SE" => "Svenska", + // "vi-VN" => "Tiếng Việt", + // "tr-TR" => "Türkçe", + // "zh-CN" => "简体中文", + "ja-JP" => "日本語", + // "zh-TW" => "繁體中文", + // "ko-KR" => "한국어", + // "cs-CZ" => "Čeština", + // "el-GR" => "Ελληνικά", + // "bg-BG" => "Български", + // "mn-MN" => "Монгол хэл", + // "ru-RU" => "Pусский язык", + // "sr-SP" => "Српски", + // "uk-UA" => "Українська мова", + // "hy-AM" => "Հայերեն", + // "he-IL" => "עִבְרִית", + // "yi" => "ייִדיש", + "ar-SA" => "العربية", + // "fa-IR" => "فارسی", + // "th-TH" => "ภาษาไทย", + // "la-LA" => "Latin", + // "ga-IE" => "Gaeilge", + // "be-BY" => "Беларуская мова", + // "or-OR" => "ଓଡ଼ିଆ", + // "tl" => "Filipino", + // "ug" => "ئۇيغۇر", + // "uz-UZ" => "Oʻzbekcha", +}; + +pub const LANGS_DEFAULT_REGION: phf::Map<&str, &str> = phf_map! { + "af" => "af-ZA", + "ar" => "ar-SA", + "be" => "be-BY", + "bg" => "bg-BG", + "ca" => "ca-ES", + "cs" => "cs-CZ", + "da" => "da-DK", + "de" => "de-DE", + "el" => "el-GR", + "en" => "en-US", + "eo" => "eo-UY", + "es" => "es-ES", + "et" => "et-EE", + "eu" => "eu-ES", + "fa" => "fa-IR", + "fi" => "fi-FI", + "fr" => "fr-FR", + "gl" => "gl-ES", + "he" => "he-IL", + "hr" => "hr-HR", + "hu" => "hu-HU", + "hy" => "hy-AM", + "it" => "it-IT", + "ja" => "ja-JP", + "jbo" => "jbo-EN", + "kk" => "kk-KZ", + "ko" => "ko-KR", + "la" => "la-LA", + "mn" => "mn-MN", + "ms" => "ms-MY", + "nl" => "nl-NL", + "nb" => "nb-NL", + "no" => "nb-NL", + "oc" => "oc-FR", + "or" => "or-OR", + "pl" => "pl-PL", + "pt" => "pt-PT", + "ro" => "ro-RO", + "ru" => "ru-RU", + "sk" => "sk-SK", + "sl" => "sl-SI", + "sr" => "sr-SP", + "sv" => "sv-SE", + "th" => "th-TH", + "tr" => "tr-TR", + "uk" => "uk-UA", + "uz" => "uz-UZ", + "vi" => "vi-VN", + "yi" => "yi", +}; + +pub const LANGS_WITH_REGIONS: phf::Set<&str> = phf_set![ + "en-GB", "ga-IE", "hy-AM", "nb-NO", "nn-NO", "pt-BR", "pt-PT", "sv-SE", "zh-CN", "zh-TW" +]; diff --git a/qt/launcher-gui/src-tauri/src/main.rs b/qt/launcher-gui/src-tauri/src/main.rs new file mode 100644 index 000000000..ad0e86800 --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/main.rs @@ -0,0 +1,38 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod app; +mod commands; +mod error; +mod generated; +mod lang; +mod platform; +mod uv; + +fn main() { + let Some(state) = uv::init_state().unwrap() else { + // either anki was spawned or os not supported (TODO) + return; + }; + + tauri::Builder::default() + .plugin(tauri_plugin_os::init()) + .plugin( + tauri_plugin_log::Builder::new() + .clear_targets() + .target(tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::Stdout, + )) + .level(tauri_plugin_log::log::LevelFilter::Trace) + .build(), + ) + .plugin(tauri_plugin_single_instance::init(app::on_second_instance)) + .setup(|app| Ok(app::setup(app, state)?)) + .register_asynchronous_uri_scheme_protocol(app::PROTOCOL, app::serve) + // .invoke_handler(tauri::generate_handler![]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/qt/launcher-gui/src-tauri/src/platform/mac.rs b/qt/launcher-gui/src-tauri/src/platform/mac.rs new file mode 100644 index 000000000..3f5b0ce2e --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/platform/mac.rs @@ -0,0 +1,99 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::io; +use std::io::Write; +use std::path::Path; +use std::process::Command; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use anki_process::CommandExt as AnkiCommandExt; +use anyhow::Context; +use anyhow::Result; + +pub fn prepare_for_launch_after_update(mut cmd: Command, root: &Path) -> Result<()> { + // Pre-validate by running --version to trigger any Gatekeeper checks + print!("\n\x1B[1mThis may take a few minutes. Please wait\x1B[0m"); + io::stdout().flush().unwrap(); + + // Start progress indicator + let running = Arc::new(AtomicBool::new(true)); + let running_clone = running.clone(); + let progress_thread = thread::spawn(move || { + while running_clone.load(Ordering::Relaxed) { + print!("."); + io::stdout().flush().unwrap(); + thread::sleep(Duration::from_secs(1)); + } + }); + + let _ = cmd + .env("ANKI_FIRST_RUN", "1") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .ensure_success(); + + if cfg!(target_os = "macos") { + // older Anki versions had a short mpv timeout and didn't support + // ANKI_FIRST_RUN, so we need to ensure mpv passes Gatekeeper + // validation prior to launch + let mpv_path = root.join(".venv/lib/python3.9/site-packages/anki_audio/mpv"); + if mpv_path.exists() { + let _ = Command::new(&mpv_path) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .ensure_success(); + } + } + + // Stop progress indicator + running.store(false, Ordering::Relaxed); + progress_thread.join().unwrap(); + println!(); // New line after dots + Ok(()) +} + +pub fn relaunch_in_terminal() -> Result<()> { + let current_exe = std::env::current_exe().context("Failed to get current executable path")?; + Command::new("open") + .args(["-na", "Terminal"]) + .arg(current_exe) + .env_remove("ANKI_LAUNCHER_WANT_TERMINAL") + .ensure_spawn()?; + std::process::exit(0); +} + +pub fn finalize_uninstall() { + if let Ok(exe_path) = std::env::current_exe() { + // Find the .app bundle by walking up the directory tree + let mut app_bundle_path = exe_path.as_path(); + while let Some(parent) = app_bundle_path.parent() { + if let Some(name) = parent.file_name() { + if name.to_string_lossy().ends_with(".app") { + let result = Command::new("trash").arg(parent).output(); + + match result { + Ok(output) if output.status.success() => { + println!("Anki has been uninstalled."); + return; + } + _ => { + // Fall back to manual instructions + println!( + "Please manually drag Anki.app to the trash to complete uninstall." + ); + } + } + return; + } + } + app_bundle_path = parent; + } + } +} diff --git a/qt/launcher-gui/src-tauri/src/platform/mod.rs b/qt/launcher-gui/src-tauri/src/platform/mod.rs new file mode 100644 index 000000000..20fbbc5e5 --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/platform/mod.rs @@ -0,0 +1,71 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +#[cfg(all(unix, not(target_os = "macos")))] +pub mod unix; + +#[cfg(target_os = "macos")] +pub mod mac; + +#[cfg(target_os = "windows")] +pub mod windows; + +use std::path::PathBuf; + +use anki_process::CommandExt; +use anyhow::Context; +use anyhow::Result; + +pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> { + let exe_dir = std::env::current_exe() + .context("Failed to get current executable path")? + .parent() + .context("Failed to get executable directory")? + .to_owned(); + + let resources_dir = if cfg!(target_os = "macos") { + // On macOS, resources are in ../Resources relative to the executable + exe_dir + .parent() + .context("Failed to get parent directory")? + .join("Resources") + } else { + // On other platforms, resources are in the same directory as executable + exe_dir.clone() + }; + + Ok((exe_dir, resources_dir)) +} + +pub fn get_uv_binary_name() -> &'static str { + if cfg!(target_os = "windows") { + "uv.exe" + } else if cfg!(target_os = "macos") { + "uv" + } else if cfg!(target_arch = "x86_64") { + "uv.amd64" + } else { + "uv.arm64" + } +} + +pub fn launch_anki_normally(mut cmd: std::process::Command) -> Result<()> { + #[cfg(windows)] + { + crate::platform::windows::prepare_to_launch_normally(); + cmd.ensure_spawn()?; + } + #[cfg(unix)] + cmd.ensure_spawn()?; + Ok(()) +} + +pub fn ensure_os_supported() -> Result<()> { + #[cfg(all(unix, not(target_os = "macos")))] + unix::ensure_glibc_supported()?; + + #[cfg(target_os = "windows")] + windows::ensure_windows_version_supported()?; + + Ok(()) +} diff --git a/qt/launcher-gui/src-tauri/src/platform/unix.rs b/qt/launcher-gui/src-tauri/src/platform/unix.rs new file mode 100644 index 000000000..29e860033 --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/platform/unix.rs @@ -0,0 +1,53 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use anyhow::Result; + +pub fn finalize_uninstall() { + use std::io::stdin; + use std::io::stdout; + use std::io::Write; + + let uninstall_script = std::path::Path::new("/usr/local/share/anki/uninstall.sh"); + + if uninstall_script.exists() { + println!("To finish uninstalling, run 'sudo /usr/local/share/anki/uninstall.sh'"); + } else { + println!("Anki has been uninstalled."); + } + println!("Press enter to quit."); + let _ = stdout().flush(); + let mut input = String::new(); + let _ = stdin().read_line(&mut input); +} + +pub fn ensure_glibc_supported() -> Result<()> { + use std::ffi::CStr; + let get_glibc_version = || -> Option<(u32, u32)> { + let version_ptr = unsafe { libc::gnu_get_libc_version() }; + if version_ptr.is_null() { + return None; + } + + let version_cstr = unsafe { CStr::from_ptr(version_ptr) }; + let version_str = version_cstr.to_str().ok()?; + + // Parse version string (format: "2.36" or "2.36.1") + let version_parts: Vec<&str> = version_str.split('.').collect(); + if version_parts.len() < 2 { + return None; + } + + let major: u32 = version_parts[0].parse().ok()?; + let minor: u32 = version_parts[1].parse().ok()?; + + Some((major, minor)) + }; + + let (major, minor) = get_glibc_version().unwrap_or_default(); + if major < 2 || (major == 2 && minor < 36) { + anyhow::bail!("Anki requires a modern Linux distro with glibc 2.36 or later."); + } + + Ok(()) +} diff --git a/qt/launcher-gui/src-tauri/src/platform/windows.rs b/qt/launcher-gui/src-tauri/src/platform/windows.rs new file mode 100644 index 000000000..72725058f --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/platform/windows.rs @@ -0,0 +1,180 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::io::stdin; +use std::process::Command; + +use anyhow::Context; +use anyhow::Result; +use widestring::u16cstr; +use windows::core::PCWSTR; +use windows::Wdk::System::SystemServices::RtlGetVersion; +use windows::Win32::System::Console::AttachConsole; +use windows::Win32::System::Console::GetConsoleWindow; +use windows::Win32::System::Console::ATTACH_PARENT_PROCESS; +use windows::Win32::System::Registry::RegCloseKey; +use windows::Win32::System::Registry::RegOpenKeyExW; +use windows::Win32::System::Registry::RegQueryValueExW; +use windows::Win32::System::Registry::HKEY; +use windows::Win32::System::Registry::HKEY_CURRENT_USER; +use windows::Win32::System::Registry::KEY_READ; +use windows::Win32::System::Registry::REG_SZ; +use windows::Win32::System::SystemInformation::OSVERSIONINFOW; +use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; + +/// Returns true if running on Windows 10 (not Windows 11) +fn is_windows_10() -> bool { + unsafe { + let mut info = OSVERSIONINFOW { + dwOSVersionInfoSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if RtlGetVersion(&mut info).is_ok() { + // Windows 10 has build numbers < 22000, Windows 11 >= 22000 + info.dwBuildNumber < 22000 && info.dwMajorVersion == 10 + } else { + false + } + } +} + +/// Ensures Windows 10 version 1809 or later +pub fn ensure_windows_version_supported() -> Result<()> { + unsafe { + let mut info = OSVERSIONINFOW { + dwOSVersionInfoSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + if RtlGetVersion(&mut info).is_err() { + anyhow::bail!("Failed to get Windows version information"); + } + + if info.dwBuildNumber >= 17763 { + return Ok(()); + } + + anyhow::bail!("Windows 10 version 1809 or later is required.") + } +} + +pub fn finalize_uninstall() { + let uninstaller_path = get_uninstaller_path(); + + match uninstaller_path { + Some(path) => { + println!("Launching Windows uninstaller..."); + let result = Command::new(&path).env("ANKI_LAUNCHER", "1").spawn(); + + match result { + Ok(_) => { + println!("Uninstaller launched successfully."); + return; + } + Err(e) => { + println!("Failed to launch uninstaller: {e}"); + println!("You can manually run: {}", path.display()); + } + } + } + None => { + println!("Windows uninstaller not found."); + println!("You may need to uninstall via Windows Settings > Apps."); + } + } + println!("Press enter to close..."); + let mut input = String::new(); + let _ = stdin().read_line(&mut input); +} + +fn get_uninstaller_path() -> Option { + // Try to read install directory from registry + if let Some(install_dir) = read_registry_install_dir() { + let uninstaller = install_dir.join("uninstall.exe"); + if uninstaller.exists() { + return Some(uninstaller); + } + } + + // Fall back to default location + let default_dir = dirs::data_local_dir()?.join("Programs").join("Anki"); + let uninstaller = default_dir.join("uninstall.exe"); + if uninstaller.exists() { + return Some(uninstaller); + } + + None +} + +fn read_registry_install_dir() -> Option { + unsafe { + let mut hkey = HKEY::default(); + + // Convert the registry path to wide string + let subkey = u16cstr!("SOFTWARE\\Anki"); + + // Open the registry key + let result = RegOpenKeyExW( + HKEY_CURRENT_USER, + PCWSTR(subkey.as_ptr()), + Some(0), + KEY_READ, + &mut hkey, + ); + + if result.is_err() { + return None; + } + + // Query the Install_Dir64 value + let value_name = u16cstr!("Install_Dir64"); + + let mut value_type = REG_SZ; + let mut data_size = 0u32; + + // First call to get the size + let result = RegQueryValueExW( + hkey, + PCWSTR(value_name.as_ptr()), + None, + Some(&mut value_type), + None, + Some(&mut data_size), + ); + + if result.is_err() || data_size == 0 { + let _ = RegCloseKey(hkey); + return None; + } + + // Allocate buffer and read the value + let mut buffer: Vec = vec![0; (data_size / 2) as usize]; + let result = RegQueryValueExW( + hkey, + PCWSTR(value_name.as_ptr()), + None, + Some(&mut value_type), + Some(buffer.as_mut_ptr() as *mut u8), + Some(&mut data_size), + ); + + let _ = RegCloseKey(hkey); + + if result.is_ok() { + // Convert wide string back to PathBuf + let len = buffer.iter().position(|&x| x == 0).unwrap_or(buffer.len()); + let path_str = String::from_utf16_lossy(&buffer[..len]); + Some(std::path::PathBuf::from(path_str)) + } else { + None + } + } +} + +pub fn prepare_to_launch_normally() { + // Set the App User Model ID for Windows taskbar grouping + unsafe { + let _ = + SetCurrentProcessExplicitAppUserModelID(PCWSTR(u16cstr!("Ankitects.Anki").as_ptr())); + } +} diff --git a/qt/launcher-gui/src-tauri/src/uv.rs b/qt/launcher-gui/src-tauri/src/uv.rs new file mode 100644 index 000000000..05212dcfd --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/uv.rs @@ -0,0 +1,930 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::io::stdin; +use std::io::stdout; +use std::io::Write; +use std::process::Command; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anki_io::copy_file; +use anki_io::create_dir_all; +use anki_io::modified_time; +use anki_io::read_file; +use anki_io::remove_file; +use anki_io::write_file; +use anki_io::ToUtf8Path; +use anki_process::CommandExt as AnkiCommandExt; +use anyhow::anyhow; +use anyhow::Context; +use anyhow::Result; +use tauri::AppHandle; +use tauri::Emitter; +use tauri::Runtime; + +use crate::platform; +use crate::platform::ensure_os_supported; +use crate::platform::get_exe_and_resources_dirs; +use crate::platform::get_uv_binary_name; +pub use crate::platform::launch_anki_normally; + +#[derive(Debug, Clone)] +pub struct State { + pub current_version: Option, + pub prerelease_marker: std::path::PathBuf, + uv_install_root: std::path::PathBuf, + uv_cache_dir: std::path::PathBuf, + pub no_cache_marker: std::path::PathBuf, + anki_base_folder: std::path::PathBuf, + uv_path: std::path::PathBuf, + uv_python_install_dir: std::path::PathBuf, + user_pyproject_path: std::path::PathBuf, + user_python_version_path: std::path::PathBuf, + dist_pyproject_path: std::path::PathBuf, + dist_python_version_path: std::path::PathBuf, + uv_lock_path: std::path::PathBuf, + sync_complete_marker: std::path::PathBuf, + launcher_trigger_file: std::path::PathBuf, + pub mirror_path: std::path::PathBuf, + pub pyproject_modified_by_user: bool, + previous_version: Option, + resources_dir: std::path::PathBuf, + venv_folder: std::path::PathBuf, + /// system Python + PyQt6 library mode + system_qt: bool, +} + +#[derive(Debug, Clone)] +pub enum VersionKind { + PyOxidizer(String), + Uv(String), +} + +#[derive(Debug)] +pub struct Releases { + pub latest: Vec, + pub all: Vec, +} + +pub fn init_state() -> Result> { + let uv_install_root = if let Ok(custom_root) = std::env::var("ANKI_LAUNCHER_VENV_ROOT") { + std::path::PathBuf::from(custom_root) + } else { + dirs::data_local_dir() + .context("Unable to determine data_dir")? + .join("AnkiProgramFiles") + }; + + let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?; + + let mut state = State { + // TODO: return error instead of relying on member field here if os unsupported + current_version: None, + prerelease_marker: uv_install_root.join("prerelease"), + uv_install_root: uv_install_root.clone(), + uv_cache_dir: uv_install_root.join("cache"), + no_cache_marker: uv_install_root.join("nocache"), + anki_base_folder: get_anki_base_path()?, + uv_path: exe_dir.join(get_uv_binary_name()), + uv_python_install_dir: uv_install_root.join("python"), + user_pyproject_path: uv_install_root.join("pyproject.toml"), + user_python_version_path: uv_install_root.join(".python-version"), + dist_pyproject_path: resources_dir.join("pyproject.toml"), + dist_python_version_path: resources_dir.join(".python-version"), + uv_lock_path: uv_install_root.join("uv.lock"), + sync_complete_marker: uv_install_root.join(".sync_complete"), + launcher_trigger_file: uv_install_root.join(".want-launcher"), + mirror_path: uv_install_root.join("mirror"), + pyproject_modified_by_user: false, // calculated later + previous_version: None, + system_qt: (cfg!(unix) && !cfg!(target_os = "macos")) + && resources_dir.join("system_qt").exists(), + resources_dir, + venv_folder: uv_install_root.join(".venv"), + }; + + // Check for uninstall request from Windows uninstaller + if std::env::var("ANKI_LAUNCHER_UNINSTALL").is_ok() { + // handle_uninstall(&state)?; + println!("TODO: UNINSTALL"); + return Ok(None); + } + + // Create install directory + create_dir_all(&state.uv_install_root)?; + + let launcher_requested = + state.launcher_trigger_file.exists() || !state.user_pyproject_path.exists(); + + // Calculate whether user has custom edits that need syncing + let pyproject_time = file_timestamp_secs(&state.user_pyproject_path); + let sync_time = file_timestamp_secs(&state.sync_complete_marker); + state.pyproject_modified_by_user = pyproject_time > sync_time; + let pyproject_has_changed = state.pyproject_modified_by_user; + + let debug = cfg!(debug_assertions); + + if !launcher_requested && !pyproject_has_changed && !debug { + let args: Vec = std::env::args().skip(1).collect(); + let cmd = build_python_command(&state, &args)?; + launch_anki_normally(cmd)?; + return Ok(None); + } + + if launcher_requested { + // Remove the trigger file to make request ephemeral + let _ = remove_file(&state.launcher_trigger_file); + } + + // TODO: + let _ = ensure_os_supported(); + + // TODO: we should call this here instead of via getVersions + // check_versions(&mut state); + + Ok(Some(state)) +} + +pub fn post_install(state: &State) -> Result<()> { + // Write marker file to indicate we've completed the sync process + write_sync_marker(state)?; + + #[cfg(target_os = "macos")] + { + let cmd = build_python_command(&state, &[])?; + platform::mac::prepare_for_launch_after_update(cmd, &uv_install_root)?; + } + + // respawn the launcher as a disconnected subprocess for normal startup + // respawn_launcher()?; + let args: Vec = std::env::args().skip(1).collect(); + let cmd = build_python_command(state, &args)?; + launch_anki_normally(cmd)?; + + Ok(()) +} + +fn extract_aqt_version(state: &State) -> Option { + // Check if .venv exists first + if !state.venv_folder.exists() { + return None; + } + + let output = uv_command(state) + .ok()? + .env("VIRTUAL_ENV", &state.venv_folder) + .args(["pip", "show", "aqt"]) + .output(); + + let output = output.ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + for line in stdout.lines() { + if let Some(version) = line.strip_prefix("Version: ") { + return Some(version.trim().to_string()); + } + } + None +} + +pub fn check_versions(state: &mut State) -> Result<(Option, Option)> { + // If sync_complete_marker is missing, do nothing + if !state.sync_complete_marker.exists() { + return Ok((None, None)); + } + + // Determine current version by invoking uv pip show aqt + match extract_aqt_version(state) { + Some(version) => { + state.current_version = Some(normalize_version(&version)); + } + None => { + Err(anyhow::anyhow!( + "Warning: Could not determine current Anki version" + ))?; + } + } + + // Read previous version from "previous-version" file + let previous_version_path = state.uv_install_root.join("previous-version"); + if let Ok(content) = read_file(&previous_version_path) { + if let Ok(version_str) = String::from_utf8(content) { + let version = version_str.trim().to_string(); + if !version.is_empty() { + state.previous_version = Some(normalize_version(&version)); + } + } + } + + Ok(( + state.current_version.clone(), + state.previous_version.clone(), + )) +} + +pub fn handle_version_install_or_update( + app: AppHandle, + state: &State, + version: &str, + keep_existing: bool, +) -> Result<()> { + let version_kind = + parse_version_kind(version).ok_or_else(|| anyhow!("{version} is not a valid version!"))?; + if !keep_existing { + apply_version_kind(&version_kind, state)?; + } + + // Extract current version before syncing (but don't write to file yet) + let previous_version_to_save = state.current_version.clone(); + + // Remove sync marker before attempting sync + let _ = remove_file(&state.sync_complete_marker); + + let python_version_trimmed = if state.user_python_version_path.exists() { + let python_version = read_file(&state.user_python_version_path)?; + let python_version_str = + String::from_utf8(python_version).context("Invalid UTF-8 in .python-version")?; + Some(python_version_str.trim().to_string()) + } else { + None + }; + + // Prepare to sync the venv + let mut command = uv_pty_command(state)?; + + if cfg!(target_os = "macos") { + // remove CONDA_PREFIX/bin from PATH to avoid conda interference + if let Ok(conda_prefix) = std::env::var("CONDA_PREFIX") { + if let Ok(current_path) = std::env::var("PATH") { + let conda_bin = format!("{conda_prefix}/bin"); + let filtered_paths: Vec<&str> = current_path + .split(':') + .filter(|&path| path != conda_bin) + .collect(); + let new_path = filtered_paths.join(":"); + command.env("PATH", new_path); + } + } + // put our fake install_name_tool at the top of the path to override + // potential conflicts + if let Ok(current_path) = std::env::var("PATH") { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|p| p.to_path_buf())); + if let Some(exe_dir) = exe_dir { + let new_path = format!("{}:{}", exe_dir.display(), current_path); + command.env("PATH", new_path); + } + } + } + + // Create venv with system site packages if system Qt is enabled + if state.system_qt { + let mut venv_command = uv_command(state)?; + venv_command.args([ + "venv", + "--no-managed-python", + "--system-site-packages", + "--no-config", + ]); + venv_command.ensure_success()?; + } + + command.env("UV_CACHE_DIR", &state.uv_cache_dir); + command.env("UV_PYTHON_INSTALL_DIR", &state.uv_python_install_dir); + command.env( + "UV_HTTP_TIMEOUT", + std::env::var("UV_HTTP_TIMEOUT").unwrap_or_else(|_| "180".to_string()), + ); + + command.args(["sync", "--upgrade", "--no-config"]); + if !state.system_qt { + command.arg("--managed-python"); + } + + // Add python version if .python-version file exists (but not for system Qt) + if let Some(version) = &python_version_trimmed { + if !state.system_qt { + command.args(["--python", version]); + } + } + + if state.no_cache_marker.exists() { + command.env("UV_NO_CACHE", "1"); + } + + // NOTE: pty and child must live in the same thread + let pty_system = portable_pty::NativePtySystem::default(); + + use portable_pty::PtySystem; + let pair = pty_system + .openpty(portable_pty::PtySize { + // NOTE: must be the same as xterm.js', otherwise text won't wrap + // TODO: maybe don't hardcode? + rows: 12, + cols: 60, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + + let mut reader = pair.master.try_clone_reader().unwrap(); + let mut writer = pair.master.take_writer().unwrap(); + + tauri::async_runtime::spawn_blocking(move || { + let mut buf = [0u8; 1024]; + loop { + let res = reader.read(&mut buf); + match res { + // EOF + Ok(0) => break, + Ok(n) => { + let output = String::from_utf8_lossy(&buf[..n]).to_string(); + // NOTE: windows requests curspr position before actually running child + if output == "\x1b[6n" { + writeln!(&mut writer, "\x1b[0;0R").unwrap(); + } + // cheaper to base64ise a string than jsonify an [u8] + let data = data_encoding::BASE64.encode(&buf[..n]); + let _ = app.emit("pty-data", data); + } + Err(e) => { + eprintln!("Error reading from PTY: {}", e); + break; + } + } + } + }); + + let mut child = pair.slave.spawn_command(command).unwrap(); + drop(pair.slave); + println!("waiting on uv..."); + let status = child.wait(); + println!("uv exited with status: {:?}", status); + + match status { + Ok(_) => { + // Sync succeeded + if !keep_existing && matches!(version_kind, VersionKind::PyOxidizer(_)) { + inject_helper_addon()?; + } + + // Now that sync succeeded, save the previous version + if let Some(current_version) = previous_version_to_save { + let previous_version_path = state.uv_install_root.join("previous-version"); + if let Err(e) = write_file(&previous_version_path, ¤t_version) { + // TODO: + println!("Warning: Could not save previous version: {e}"); + } + } + + Ok(()) + } + Err(e) => { + // TODO: + // If sync fails due to things like a missing wheel on pypi, + // we need to remove the lockfile or uv will cache the bad result. + let _ = remove_file(&state.uv_lock_path); + println!("Install failed: {e:#}"); + println!(); + Err(e.into()) + } + } +} + +pub fn set_allow_betas(state: &State, allow_betas: bool) -> Result<()> { + if allow_betas { + write_file(&state.prerelease_marker, "")?; + } else { + let _ = remove_file(&state.prerelease_marker); + } + Ok(()) +} + +pub fn set_cache_enabled(state: &State, cache_enabled: bool) -> Result<()> { + if cache_enabled { + let _ = remove_file(&state.no_cache_marker); + } else { + write_file(&state.no_cache_marker, "")?; + // Delete the cache directory and everything in it + if state.uv_cache_dir.exists() { + let _ = anki_io::remove_dir_all(&state.uv_cache_dir); + } + } + Ok(()) +} + +fn write_sync_marker(state: &State) -> Result<()> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("Failed to get system time")? + .as_secs(); + write_file(&state.sync_complete_marker, timestamp.to_string())?; + Ok(()) +} + +/// Get mtime of provided file, or 0 if unavailable +fn file_timestamp_secs(path: &std::path::Path) -> i64 { + modified_time(path) + .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64) + .unwrap_or_default() +} + +fn with_only_latest_patch(versions: &[String]) -> Vec { + // Only show the latest patch release for a given (major, minor) + let mut seen_major_minor = std::collections::HashSet::new(); + versions + .iter() + .filter(|v| { + let (major, minor, _, _) = parse_version_for_filtering(v); + if major == 2 { + return true; + } + let major_minor = (major, minor); + if seen_major_minor.contains(&major_minor) { + false + } else { + seen_major_minor.insert(major_minor); + true + } + }) + .cloned() + .collect() +} + +fn parse_version_for_filtering(version_str: &str) -> (u32, u32, u32, bool) { + // Remove any build metadata after + + let version_str = version_str.split('+').next().unwrap_or(version_str); + + // Check for prerelease markers + let is_prerelease = ["a", "b", "rc", "alpha", "beta"] + .iter() + .any(|marker| version_str.to_lowercase().contains(marker)); + + // Extract numeric parts (stop at first non-digit/non-dot character) + let numeric_end = version_str + .find(|c: char| !c.is_ascii_digit() && c != '.') + .unwrap_or(version_str.len()); + let numeric_part = &version_str[..numeric_end]; + + let parts: Vec<&str> = numeric_part.split('.').collect(); + + let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); + let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); + let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); + + (major, minor, patch, is_prerelease) +} + +fn normalize_version(version: &str) -> String { + let (major, minor, patch, _is_prerelease) = parse_version_for_filtering(version); + + if major <= 2 { + // Don't transform versions <= 2.x + return version.to_string(); + } + + // For versions > 2, pad the minor version with leading zero if < 10 + let normalized_minor = if minor < 10 { + format!("0{minor}") + } else { + minor.to_string() + }; + + // Find any prerelease suffix + let mut prerelease_suffix = ""; + + // Look for prerelease markers after the numeric part + let numeric_end = version + .find(|c: char| !c.is_ascii_digit() && c != '.') + .unwrap_or(version.len()); + if numeric_end < version.len() { + let suffix_part = &version[numeric_end..]; + let suffix_lower = suffix_part.to_lowercase(); + + for marker in ["alpha", "beta", "rc", "a", "b"] { + if suffix_lower.starts_with(marker) { + prerelease_suffix = &version[numeric_end..]; + break; + } + } + } + + // Reconstruct the version + if version.matches('.').count() >= 2 { + format!("{major}.{normalized_minor}.{patch}{prerelease_suffix}") + } else { + format!("{major}.{normalized_minor}{prerelease_suffix}") + } +} + +fn filter_and_normalize_versions( + all_versions: Vec, + include_prereleases: bool, +) -> Vec { + let mut valid_versions: Vec = all_versions + .into_iter() + .map(|v| normalize_version(&v)) + .collect(); + + // Reverse to get chronological order (newest first) + valid_versions.reverse(); + + if !include_prereleases { + valid_versions.retain(|v| { + let (_, _, _, is_prerelease) = parse_version_for_filtering(v); + !is_prerelease + }); + } + + valid_versions +} + +fn fetch_versions(state: &State) -> Result> { + let versions_script = state.resources_dir.join("versions.py"); + + let mut cmd = uv_command(state)?; + cmd.args(["run", "--no-project", "--no-config", "--managed-python"]) + .args(["--with", "pip-system-certs,requests[socks]"]); + + let python_version = read_file(&state.dist_python_version_path)?; + let python_version_str = + String::from_utf8(python_version).context("Invalid UTF-8 in .python-version")?; + let version_trimmed = python_version_str.trim(); + if !version_trimmed.is_empty() { + cmd.args(["--python", version_trimmed]); + } + + cmd.arg(&versions_script); + + let output = match cmd.utf8_output() { + Ok(output) => output, + Err(e) => { + return Err(e.into()); + } + }; + let versions = serde_json::from_str(&output.stdout).context("Failed to parse versions JSON")?; + Ok(versions) +} + +pub fn get_releases(state: &State) -> Result { + let include_prereleases = state.prerelease_marker.exists(); + let all_versions = fetch_versions(state)?; + let all_versions = filter_and_normalize_versions(all_versions, include_prereleases); + + let latest_patches = with_only_latest_patch(&all_versions); + let latest_releases: Vec = latest_patches.into_iter().take(5).collect(); + Ok(Releases { + latest: latest_releases, + all: all_versions, + }) +} + +pub fn apply_version_kind(version_kind: &VersionKind, state: &State) -> Result<()> { + let content = read_file(&state.dist_pyproject_path)?; + let content_str = String::from_utf8(content).context("Invalid UTF-8 in pyproject.toml")?; + let updated_content = match version_kind { + VersionKind::PyOxidizer(version) => { + // Replace package name and add PyQt6 dependencies + content_str.replace( + "anki-release", + &format!( + concat!( + "aqt[qt6]=={}\",\n", + " \"anki-audio==0.1.0; sys.platform == 'win32' or sys.platform == 'darwin'\",\n", + " \"pyqt6==6.6.1\",\n", + " \"pyqt6-qt6==6.6.2\",\n", + " \"pyqt6-webengine==6.6.0\",\n", + " \"pyqt6-webengine-qt6==6.6.2\",\n", + " \"pyqt6_sip==13.6.0" + ), + version + ), + ) + } + VersionKind::Uv(version) => content_str.replace( + "anki-release", + &format!("anki-release=={version}\",\n \"anki=={version}\",\n \"aqt=={version}"), + ), + }; + + let final_content = if state.system_qt { + format!( + concat!( + "{}\n\n[tool.uv]\n", + "override-dependencies = [\n", + " \"pyqt6; sys_platform=='never'\",\n", + " \"pyqt6-qt6; sys_platform=='never'\",\n", + " \"pyqt6-webengine; sys_platform=='never'\",\n", + " \"pyqt6-webengine-qt6; sys_platform=='never'\",\n", + " \"pyqt6_sip; sys_platform=='never'\"\n", + "]\n" + ), + updated_content + ) + } else { + updated_content + }; + + write_file(&state.user_pyproject_path, &final_content)?; + + // Update .python-version based on version kind + match version_kind { + VersionKind::PyOxidizer(_) => { + write_file(&state.user_python_version_path, "3.9")?; + } + VersionKind::Uv(_) => { + copy_file( + &state.dist_python_version_path, + &state.user_python_version_path, + )?; + } + } + Ok(()) +} + +pub fn parse_version_kind(version: &str) -> Option { + let numeric_chars: String = version + .chars() + .filter(|c| c.is_ascii_digit() || *c == '.') + .collect(); + + let parts: Vec<&str> = numeric_chars.split('.').collect(); + + if parts.len() < 2 { + return None; + } + + let major: u32 = match parts[0].parse() { + Ok(val) => val, + Err(_) => return None, + }; + + let minor: u32 = match parts[1].parse() { + Ok(val) => val, + Err(_) => return None, + }; + + let patch: u32 = if parts.len() >= 3 { + match parts[2].parse() { + Ok(val) => val, + Err(_) => return None, + } + } else { + 0 // Default patch to 0 if not provided + }; + + // Reject versions < 2.1.50 + if major == 2 && (minor != 1 || patch < 50) { + return None; + } + + if major < 25 || (major == 25 && minor < 6) { + Some(VersionKind::PyOxidizer(version.to_string())) + } else { + Some(VersionKind::Uv(version.to_string())) + } +} + +fn inject_helper_addon() -> Result<()> { + let addons21_path = get_anki_addons21_path()?; + + if !addons21_path.exists() { + return Ok(()); + } + + let addon_folder = addons21_path.join("anki-launcher"); + + // Remove existing anki-launcher folder if it exists + if addon_folder.exists() { + anki_io::remove_dir_all(&addon_folder)?; + } + + // Create the anki-launcher folder + create_dir_all(&addon_folder)?; + + // Write the embedded files + let init_py_content = include_str!("../../../launcher/addon/__init__.py"); + let manifest_json_content = include_str!("../../../launcher/addon/manifest.json"); + + write_file(addon_folder.join("__init__.py"), init_py_content)?; + write_file(addon_folder.join("manifest.json"), manifest_json_content)?; + + Ok(()) +} + +fn get_anki_base_path() -> Result { + let anki_base_path = if cfg!(target_os = "windows") { + // Windows: %APPDATA%\Anki2 + dirs::config_dir() + .context("Unable to determine config directory")? + .join("Anki2") + } else if cfg!(target_os = "macos") { + // macOS: ~/Library/Application Support/Anki2 + dirs::data_dir() + .context("Unable to determine data directory")? + .join("Anki2") + } else { + // Linux: ~/.local/share/Anki2 + dirs::data_dir() + .context("Unable to determine data directory")? + .join("Anki2") + }; + + Ok(anki_base_path) +} + +fn get_anki_addons21_path() -> Result { + Ok(get_anki_base_path()?.join("addons21")) +} + +// TODO: revert +#[allow(unused)] +fn handle_uninstall(state: &State) -> Result { + // println!("{}", state.tr.launcher_uninstall_confirm()); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim().to_lowercase(); + + if input != "y" { + // println!("{}", state.tr.launcher_uninstall_cancelled()); + println!(); + return Ok(false); + } + + // Remove program files + if state.uv_install_root.exists() { + anki_io::remove_dir_all(&state.uv_install_root)?; + // println!("{}", state.tr.launcher_program_files_removed()); + } + + println!(); + // println!("{}", state.tr.launcher_remove_all_profiles_confirm()); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim().to_lowercase(); + + if input == "y" && state.anki_base_folder.exists() { + anki_io::remove_dir_all(&state.anki_base_folder)?; + // println!("{}", state.tr.launcher_user_data_removed()); + } + + println!(); + + // Platform-specific messages + #[cfg(target_os = "macos")] + platform::mac::finalize_uninstall(); + + #[cfg(target_os = "windows")] + platform::windows::finalize_uninstall(); + + #[cfg(all(unix, not(target_os = "macos")))] + platform::unix::finalize_uninstall(); + + Ok(true) +} + +fn uv_command(state: &State) -> Result { + let mut command = Command::new(&state.uv_path); + command.current_dir(&state.uv_install_root); + + // remove UV_* environment variables to avoid interference + for (key, _) in std::env::vars() { + if key.starts_with("UV_") { + command.env_remove(key); + } + } + command + .env_remove("VIRTUAL_ENV") + .env_remove("SSLKEYLOGFILE"); + + // Add mirror environment variable if enabled + if let Some((python_mirror, pypi_mirror)) = get_mirror_urls(state)? { + command + .env("UV_PYTHON_INSTALL_MIRROR", &python_mirror) + .env("UV_DEFAULT_INDEX", &pypi_mirror); + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + + command.creation_flags(windows::Win32::System::Threading::CREATE_NO_WINDOW.0); + } + Ok(command) +} + +fn uv_pty_command(state: &State) -> Result { + let mut command = portable_pty::CommandBuilder::new(&state.uv_path); + command.cwd(&state.uv_install_root); + + // remove UV_* environment variables to avoid interference + for (key, _) in std::env::vars() { + if key.starts_with("UV_") { + command.env_remove(key); + } + } + command.env_remove("VIRTUAL_ENV"); + command.env_remove("SSLKEYLOGFILE"); + + // Add mirror environment variable if enabled + if let Some((python_mirror, pypi_mirror)) = get_mirror_urls(state)? { + command.env("UV_PYTHON_INSTALL_MIRROR", &python_mirror); + command.env("UV_DEFAULT_INDEX", &pypi_mirror); + } + + Ok(command) +} + +pub fn build_python_command(state: &State, args: &[String]) -> Result { + let python_exe = if cfg!(target_os = "windows") { + let show_console = std::env::var("ANKI_CONSOLE").is_ok(); + if show_console { + state.venv_folder.join("Scripts/python.exe") + } else { + state.venv_folder.join("Scripts/pythonw.exe") + } + } else { + state.venv_folder.join("bin/python") + }; + + let mut cmd = Command::new(&python_exe); + cmd.args(["-c", "import aqt, sys; sys.argv[0] = 'Anki'; aqt.run()"]); + cmd.args(args); + // tell the Python code it was invoked by the launcher, and updating is + // available + cmd.env("ANKI_LAUNCHER", std::env::current_exe()?.utf8()?.as_str()); + + // Set UV and Python paths for the Python code + cmd.env("ANKI_LAUNCHER_UV", state.uv_path.utf8()?.as_str()); + cmd.env("UV_PROJECT", state.uv_install_root.utf8()?.as_str()); + cmd.env_remove("SSLKEYLOGFILE"); + + Ok(cmd) +} + +fn get_mirror_urls(state: &State) -> Result> { + if !state.mirror_path.exists() { + return Ok(None); + } + + let content = read_file(&state.mirror_path)?; + let content_str = String::from_utf8(content).context("Invalid UTF-8 in mirror file")?; + + let lines: Vec<&str> = content_str.lines().collect(); + if lines.len() >= 2 { + Ok(Some(( + lines[0].trim().to_string(), + lines[1].trim().to_string(), + ))) + } else { + Ok(None) + } +} + +pub fn set_mirror(state: &State, enabled: bool) -> Result<()> { + if enabled { + let china_mirrors = "https://registry.npmmirror.com/-/binary/python-build-standalone/\nhttps://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/"; + write_file(&state.mirror_path, china_mirrors)?; + } else if state.mirror_path.exists() { + let _ = remove_file(&state.mirror_path); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_version() { + // Test versions <= 2.x (should not be transformed) + assert_eq!(normalize_version("2.1.50"), "2.1.50"); + + // Test basic versions > 2 with zero-padding + assert_eq!(normalize_version("25.7"), "25.07"); + assert_eq!(normalize_version("25.07"), "25.07"); + assert_eq!(normalize_version("25.10"), "25.10"); + assert_eq!(normalize_version("24.6.1"), "24.06.1"); + assert_eq!(normalize_version("24.06.1"), "24.06.1"); + + // Test prerelease versions + assert_eq!(normalize_version("25.7a1"), "25.07a1"); + assert_eq!(normalize_version("25.7.1a1"), "25.07.1a1"); + + // Test versions with patch = 0 + assert_eq!(normalize_version("25.7.0"), "25.07.0"); + assert_eq!(normalize_version("25.7.0a1"), "25.07.0a1"); + } +} diff --git a/qt/launcher-gui/src-tauri/tauri.conf.json b/qt/launcher-gui/src-tauri/tauri.conf.json new file mode 100644 index 000000000..bfc46a44f --- /dev/null +++ b/qt/launcher-gui/src-tauri/tauri.conf.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "anki-launcher", + "version": "0.1.0", + "identifier": "com.ichi2.anki-launcher", + "build": { + "beforeDevCommand": "./launcher", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "./yarn lb", + "frontendDist": "../build" + }, + "app": { + "windows": [ + { + "title": "Anki Launcher", + "width": 600, + "height": 600, + "visible": true + } + ], + "security": { + "csp": { + "default-src": "'self'", + "connect-src": "anki: http://anki.localhost ipc: http://ipc.localhost tauri: http://tauri.localhost", + "img-src": "data: 'self'", + "style-src": "'self' 'unsafe-inline'" + } + } + }, + "bundle": { + "active": false, + "targets": [], + "icon": [ + "../../launcher/lin/anki.png", + "icons/icon.ico" + ] + } +} diff --git a/qt/launcher-gui/src/app.html b/qt/launcher-gui/src/app.html new file mode 100644 index 000000000..b338b1f81 --- /dev/null +++ b/qt/launcher-gui/src/app.html @@ -0,0 +1,13 @@ + + + + + + + Anki Launcher + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/qt/launcher-gui/src/routes/+layout.svelte b/qt/launcher-gui/src/routes/+layout.svelte new file mode 100644 index 000000000..cd943cbe7 --- /dev/null +++ b/qt/launcher-gui/src/routes/+layout.svelte @@ -0,0 +1,26 @@ + + + + diff --git a/qt/launcher-gui/src/routes/+layout.ts b/qt/launcher-gui/src/routes/+layout.ts new file mode 100644 index 000000000..d9484bbfe --- /dev/null +++ b/qt/launcher-gui/src/routes/+layout.ts @@ -0,0 +1,18 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +// import { checkNightMode } from "@tslib/nightmode"; +import type { LayoutLoad } from "./$types"; + +// Tauri doesn't have a Node.js server to do proper SSR +// so we use adapter-static with a fallback to index.html to put the site in SPA mode +// See: https://svelte.dev/docs/kit/single-page-apps +// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info +export const ssr = false; + +export const load: LayoutLoad = async () => { + // checkNightMode(); + // TODO: don't force nightmode + document.documentElement.className = "night-mode"; + document.documentElement.dataset.bsTheme = "dark"; +}; diff --git a/qt/launcher-gui/src/routes/+page.svelte b/qt/launcher-gui/src/routes/+page.svelte new file mode 100644 index 000000000..600662bcd --- /dev/null +++ b/qt/launcher-gui/src/routes/+page.svelte @@ -0,0 +1,47 @@ + + + + + + diff --git a/qt/launcher-gui/src/routes/+page.ts b/qt/launcher-gui/src/routes/+page.ts new file mode 100644 index 000000000..7d2ac7a88 --- /dev/null +++ b/qt/launcher-gui/src/routes/+page.ts @@ -0,0 +1,27 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { getLangs, getMirrors, getOptions, getVersions } from "@generated/backend-launcher"; +import { ModuleName, setupI18n } from "@tslib/i18n"; +import type { PageLoad } from "./$types"; +import { versionsStore } from "./stores"; + +export const load = (async () => { + const i18nPromise = setupI18n({ modules: [ModuleName.LAUNCHER] }, true); + const langsPromise = getLangs({}); + const optionsPromise = getOptions({}); + const mirrorsPromise = getMirrors({}); + + getVersions({}).then((res) => { + versionsStore.set(res); + }); + + const [_, { userLocale, langs }, options, { mirrors }] = await Promise.all([ + i18nPromise, + langsPromise, + optionsPromise, + mirrorsPromise, + ]); + + return { langs, userLocale, options, mirrors }; +}) satisfies PageLoad; diff --git a/qt/launcher-gui/src/routes/Start.svelte b/qt/launcher-gui/src/routes/Start.svelte new file mode 100644 index 000000000..cd7dfb95e --- /dev/null +++ b/qt/launcher-gui/src/routes/Start.svelte @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + {#key $currentLang} + + + + +

{tr.launcherTitle()}

+
+ + + {tr.launcherLanguage()} + + +
+ {#if latestVersion != null && latestVersion != currentVersion} + + + + {/if} + {#if currentVersion != null} + + + + {/if} + + +
+ {"->"} +
+
+ {#if availableVersions.length !== 0} + + {:else} + {"loading"} + {/if} +
+
+
+
+
+ {#await choosePromise} + + {:then res} + {#if res != null} + + {/if} + {/await} + {/key} + +
+ {#key $currentLang} + {tr.launcherOutput()} + {/key} +
+
+
+ {#key $currentLang} + + +
+ + + {tr.launcherAllowBetasToggle()} + + +
+
+ + + {tr.launcherDownloadCaching()} + + +
+
+ + + {tr.launcherUseMirror()} + + +
+
+
+ {/key} +
+ + diff --git a/qt/launcher-gui/src/routes/Warning.svelte b/qt/launcher-gui/src/routes/Warning.svelte new file mode 100644 index 000000000..f7549e6ca --- /dev/null +++ b/qt/launcher-gui/src/routes/Warning.svelte @@ -0,0 +1,21 @@ + + + +{#if warning} + +
+ {withoutUnicodeIsolation(warning)} +
+
+{/if} diff --git a/qt/launcher-gui/src/routes/base.scss b/qt/launcher-gui/src/routes/base.scss new file mode 100644 index 000000000..c65f458a8 --- /dev/null +++ b/qt/launcher-gui/src/routes/base.scss @@ -0,0 +1,19 @@ +@import "$lib/sass/base"; + +// override Bootstrap transition duration +$carousel-transition: var(--transition); + +@import "bootstrap/scss/buttons"; +@import "bootstrap/scss/button-group"; +@import "bootstrap/scss/transitions"; +@import "bootstrap/scss/modal"; +@import "bootstrap/scss/carousel"; +@import "bootstrap/scss/close"; +@import "bootstrap/scss/alert"; +@import "bootstrap/scss/badge"; +@import "$lib/sass/bootstrap-forms"; +@import "$lib/sass/bootstrap-tooltip"; + +input { + color: var(--fg); +} diff --git a/qt/launcher-gui/src/routes/stores.ts b/qt/launcher-gui/src/routes/stores.ts new file mode 100644 index 000000000..864dfb31c --- /dev/null +++ b/qt/launcher-gui/src/routes/stores.ts @@ -0,0 +1,12 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import type { GetLangsResponse_Pair, GetMirrorsResponse_Pair, GetVersionsResponse } from "@generated/anki/launcher_pb"; +import { writable } from "svelte/store"; + +export const zoomFactor = writable(1.2); +export const langsStore = writable([]); +export const mirrorsStore = writable([]); +export const currentLang = writable(""); +export const initialLang = writable(""); +export const versionsStore = writable(undefined); diff --git a/qt/launcher-gui/src/routes/svg.d.ts b/qt/launcher-gui/src/routes/svg.d.ts new file mode 100644 index 000000000..e96a7886e --- /dev/null +++ b/qt/launcher-gui/src/routes/svg.d.ts @@ -0,0 +1,13 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +// TODO: this is purely to make svelte-check happy, as it complains about the icon components not being found otherwise + +declare module "*.svg?component" { + import type { Component } from "svelte"; + import type { SVGAttributes } from "svelte/elements"; + + const content: Component>; + + export default content; +} diff --git a/qt/launcher-gui/static/anki.png b/qt/launcher-gui/static/anki.png new file mode 100644 index 000000000..5700121d6 Binary files /dev/null and b/qt/launcher-gui/static/anki.png differ diff --git a/qt/launcher-gui/svelte.config.js b/qt/launcher-gui/svelte.config.js new file mode 100644 index 000000000..2398e9474 --- /dev/null +++ b/qt/launcher-gui/svelte.config.js @@ -0,0 +1,32 @@ +// Tauri doesn't have a Node.js server to do proper SSR +// so we use adapter-static with a fallback to index.html to put the site in SPA mode +// See: https://svelte.dev/docs/kit/single-page-apps +// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info +import adapter from "@sveltejs/adapter-static"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +import { dirname, join } from "path"; +import { sveltePreprocess } from "svelte-preprocess"; +import { fileURLToPath } from "url"; + +// This prevents errors being shown when opening VSCode on the root of the +// project, instead of the ts folder. +const tsFolder = dirname(fileURLToPath(import.meta.url)); + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: [vitePreprocess(), sveltePreprocess()], + kit: { + adapter: adapter({ + fallback: "index.html", + }), + alias: { + "@tslib": join(tsFolder, "../../ts/lib/tslib"), + "@generated": join(tsFolder, "../../out/ts/lib/generated"), + }, + files: { + lib: join(tsFolder, "../../ts/lib"), + }, + }, +}; + +export default config; diff --git a/qt/launcher-gui/tsconfig.json b/qt/launcher-gui/tsconfig.json new file mode 100644 index 000000000..fdc216c0d --- /dev/null +++ b/qt/launcher-gui/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": ["./.svelte-kit/tsconfig.json"], + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "noImplicitAny": false + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/qt/launcher-gui/vite.config.js b/qt/launcher-gui/vite.config.js new file mode 100644 index 000000000..efc824a41 --- /dev/null +++ b/qt/launcher-gui/vite.config.js @@ -0,0 +1,39 @@ +import svg from "@poppanator/sveltekit-svg"; +import { sveltekit } from "@sveltejs/kit/vite"; +import { realpathSync } from "fs"; +import { defineConfig } from "vite"; + +const host = process.env.TAURI_DEV_HOST; + +// https://vite.dev/config/ +export default defineConfig(async () => ({ + plugins: [sveltekit(), svg({})], + + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent Vite from obscuring rust errors + clearScreen: false, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + // 3. tell Vite to ignore watching `src-tauri` + ignored: ["**/src-tauri/**"], + }, + fs: { + allow: [ + realpathSync("../../out"), + realpathSync("../../ts"), + ], + }, + }, +}));