From d4565742d8c34f8970c6aa4d0249fd1028ccee01 Mon Sep 17 00:00:00 2001 From: llama Date: Tue, 14 Oct 2025 09:22:33 +0800 Subject: [PATCH] qt/launcher-gui --- qt/launcher-gui/.gitignore | 10 + qt/launcher-gui/src-tauri/.gitignore | 3 + qt/launcher-gui/src-tauri/.taurignore | 0 qt/launcher-gui/src-tauri/Cargo.toml | 50 + qt/launcher-gui/src-tauri/build.rs | 19 + .../src-tauri/capabilities/default.json | 11 + qt/launcher-gui/src-tauri/icons/icon.ico | Bin 0 -> 103420 bytes qt/launcher-gui/src-tauri/rust_interface.rs | 149 +++ qt/launcher-gui/src-tauri/src/app.rs | 85 ++ qt/launcher-gui/src-tauri/src/commands.rs | 211 ++++ qt/launcher-gui/src-tauri/src/error.rs | 11 + qt/launcher-gui/src-tauri/src/generated.rs | 4 + qt/launcher-gui/src-tauri/src/lang.rs | 143 +++ qt/launcher-gui/src-tauri/src/main.rs | 38 + qt/launcher-gui/src-tauri/src/platform/mac.rs | 99 ++ qt/launcher-gui/src-tauri/src/platform/mod.rs | 71 ++ .../src-tauri/src/platform/unix.rs | 53 + .../src-tauri/src/platform/windows.rs | 180 ++++ qt/launcher-gui/src-tauri/src/uv.rs | 930 ++++++++++++++++++ qt/launcher-gui/src-tauri/tauri.conf.json | 38 + qt/launcher-gui/src/app.html | 13 + qt/launcher-gui/src/routes/+layout.svelte | 26 + qt/launcher-gui/src/routes/+layout.ts | 18 + qt/launcher-gui/src/routes/+page.svelte | 47 + qt/launcher-gui/src/routes/+page.ts | 27 + qt/launcher-gui/src/routes/Start.svelte | 315 ++++++ qt/launcher-gui/src/routes/Warning.svelte | 21 + qt/launcher-gui/src/routes/base.scss | 19 + qt/launcher-gui/src/routes/stores.ts | 12 + qt/launcher-gui/src/routes/svg.d.ts | 13 + qt/launcher-gui/static/anki.png | Bin 0 -> 34595 bytes qt/launcher-gui/svelte.config.js | 32 + qt/launcher-gui/tsconfig.json | 19 + qt/launcher-gui/vite.config.js | 39 + 34 files changed, 2706 insertions(+) create mode 100644 qt/launcher-gui/.gitignore create mode 100644 qt/launcher-gui/src-tauri/.gitignore create mode 100644 qt/launcher-gui/src-tauri/.taurignore create mode 100644 qt/launcher-gui/src-tauri/Cargo.toml create mode 100644 qt/launcher-gui/src-tauri/build.rs create mode 100644 qt/launcher-gui/src-tauri/capabilities/default.json create mode 100644 qt/launcher-gui/src-tauri/icons/icon.ico create mode 100644 qt/launcher-gui/src-tauri/rust_interface.rs create mode 100644 qt/launcher-gui/src-tauri/src/app.rs create mode 100644 qt/launcher-gui/src-tauri/src/commands.rs create mode 100644 qt/launcher-gui/src-tauri/src/error.rs create mode 100644 qt/launcher-gui/src-tauri/src/generated.rs create mode 100644 qt/launcher-gui/src-tauri/src/lang.rs create mode 100644 qt/launcher-gui/src-tauri/src/main.rs create mode 100644 qt/launcher-gui/src-tauri/src/platform/mac.rs create mode 100644 qt/launcher-gui/src-tauri/src/platform/mod.rs create mode 100644 qt/launcher-gui/src-tauri/src/platform/unix.rs create mode 100644 qt/launcher-gui/src-tauri/src/platform/windows.rs create mode 100644 qt/launcher-gui/src-tauri/src/uv.rs create mode 100644 qt/launcher-gui/src-tauri/tauri.conf.json create mode 100644 qt/launcher-gui/src/app.html create mode 100644 qt/launcher-gui/src/routes/+layout.svelte create mode 100644 qt/launcher-gui/src/routes/+layout.ts create mode 100644 qt/launcher-gui/src/routes/+page.svelte create mode 100644 qt/launcher-gui/src/routes/+page.ts create mode 100644 qt/launcher-gui/src/routes/Start.svelte create mode 100644 qt/launcher-gui/src/routes/Warning.svelte create mode 100644 qt/launcher-gui/src/routes/base.scss create mode 100644 qt/launcher-gui/src/routes/stores.ts create mode 100644 qt/launcher-gui/src/routes/svg.d.ts create mode 100644 qt/launcher-gui/static/anki.png create mode 100644 qt/launcher-gui/svelte.config.js create mode 100644 qt/launcher-gui/tsconfig.json create mode 100644 qt/launcher-gui/vite.config.js 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 0000000000000000000000000000000000000000..fd03c333ea8f5f8b0d057e3541dad7e4d7ef66dc GIT binary patch literal 103420 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 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 0000000000000000000000000000000000000000..5700121d60358ed2b6278a806835b9dcd92cdb48 GIT binary patch literal 34595 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 QC`R<2yRQcV}? zbUKG-433^6=sASv9^TZwy}eq2=g}XQNGYT&>f5A*NJtR2UmVbAs+vz0)S%Xy$mR5;;m#91&a5be%faPEi-1Z?c{uaAbmH#d3esiznW26VgKsu$T&lMTmB(;apZGzC7#?STtB z5LJ>vmNgXxHujw*?Zj{^c;L!H${=0bn7rBc)9mJ6%o?{9WHF`TuXQ3WZ1lKe8Io!$ zVT6jJ0}v z(eL*;b?Q_FGmJ4hyRe3m6c;s-dVQB zlJ8yFXK16+gKD%f5T!VqP5BvT#f&30`;^tE3bkDWBNWORq%~L>krNY>bACy3&*=_* znPaUgnzBfN!h$V&9-}7JB9|^*VrOTksvms*`RDoWcfZT#<|a!^OKfaxaNBLS@lXEA zKM_Ba1{^;A-M{;H?$V`8Y;JC{x3|Y|IIJ|ao{oDpKKK9sczqte{XiVYRfCvrx69Jf r5+_ca;I`Xt<5z$6S6>y4?f-886j&)73?t#}00000NkvXXu0mjf^0KgP literal 0 HcmV?d00001 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"), + ], + }, + }, +}));