mirror of
https://github.com/ankitects/anki.git
synced 2025-11-06 12:47:11 -05:00
qt/launcher-gui
This commit is contained in:
parent
ef149840ce
commit
d4565742d8
34 changed files with 2706 additions and 0 deletions
10
qt/launcher-gui/.gitignore
vendored
Normal file
10
qt/launcher-gui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
3
qt/launcher-gui/src-tauri/.gitignore
vendored
Normal file
3
qt/launcher-gui/src-tauri/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
0
qt/launcher-gui/src-tauri/.taurignore
Normal file
0
qt/launcher-gui/src-tauri/.taurignore
Normal file
50
qt/launcher-gui/src-tauri/Cargo.toml
Normal file
50
qt/launcher-gui/src-tauri/Cargo.toml
Normal file
|
|
@ -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
|
||||
19
qt/launcher-gui/src-tauri/build.rs
Normal file
19
qt/launcher-gui/src-tauri/build.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
11
qt/launcher-gui/src-tauri/capabilities/default.json
Normal file
11
qt/launcher-gui/src-tauri/capabilities/default.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
BIN
qt/launcher-gui/src-tauri/icons/icon.ico
Normal file
BIN
qt/launcher-gui/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
149
qt/launcher-gui/src-tauri/rust_interface.rs
Normal file
149
qt/launcher-gui/src-tauri/rust_interface.rs
Normal file
|
|
@ -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<R: ::tauri::Runtime>(
|
||||
app: ::tauri::AppHandle<R>,
|
||||
window: ::tauri::WebviewWindow<R>,
|
||||
req: ::tauri::http::Request<Vec<u8>>,
|
||||
) -> ::anyhow::Result<Vec<u8>> {
|
||||
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<String>;
|
||||
fn output_type(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
impl MethodHelpers for anki_proto_gen::Method {
|
||||
fn input_type(&self) -> Option<String> {
|
||||
self.input().map(|t| rust_type(t.full_name()))
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<String> {
|
||||
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<String> {
|
||||
let syntax_tree = syn::parse_file(&code)?;
|
||||
Ok(prettyplease::unparse(&syntax_tree))
|
||||
}
|
||||
85
qt/launcher-gui/src-tauri/src/app.rs
Normal file
85
qt/launcher-gui/src-tauri/src/app.rs
Normal file
|
|
@ -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<R: Runtime>(
|
||||
ctx: UriSchemeContext<'_, R>,
|
||||
req: http::Request<Vec<u8>>,
|
||||
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<String>, _cwd: String) {
|
||||
let _ = app
|
||||
.get_webview_window("main")
|
||||
.expect("no main window")
|
||||
.set_focus();
|
||||
}
|
||||
211
qt/launcher-gui/src-tauri/src/commands.rs
Normal file
211
qt/launcher-gui/src-tauri/src/commands.rs
Normal file
|
|
@ -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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
_window: WebviewWindow<R>,
|
||||
input: I18nResourcesRequest,
|
||||
) -> Result<generic::Json> {
|
||||
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<R: Runtime>(
|
||||
_app: AppHandle<R>,
|
||||
_window: WebviewWindow<R>,
|
||||
) -> Result<GetLangsResponse> {
|
||||
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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
_window: WebviewWindow<R>,
|
||||
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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
_window: WebviewWindow<R>,
|
||||
) -> Result<GetMirrorsResponse> {
|
||||
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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
_window: WebviewWindow<R>,
|
||||
) -> Result<Options> {
|
||||
let state = app.state::<uv::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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
_window: WebviewWindow<R>,
|
||||
) -> Result<GetVersionsResponse> {
|
||||
let state = (*app.state::<uv::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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
_window: WebviewWindow<R>,
|
||||
input: ChooseVersionRequest,
|
||||
) -> Result<ChooseVersionResponse> {
|
||||
let state = (*app.state::<uv::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<R: Runtime>(
|
||||
_app: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
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<R: Runtime>(_app: AppHandle<R>, window: WebviewWindow<R>) -> Result<()> {
|
||||
window
|
||||
.show()
|
||||
.with_context(|| format!("could not show window: {}", window.label()))
|
||||
}
|
||||
11
qt/launcher-gui/src-tauri/src/error.rs
Normal file
11
qt/launcher-gui/src-tauri/src/error.rs
Normal file
|
|
@ -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,
|
||||
}
|
||||
4
qt/launcher-gui/src-tauri/src/generated.rs
Normal file
4
qt/launcher-gui/src-tauri/src/generated.rs
Normal file
|
|
@ -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"));
|
||||
143
qt/launcher-gui/src-tauri/src/lang.rs
Normal file
143
qt/launcher-gui/src-tauri/src/lang.rs
Normal file
|
|
@ -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<anki_i18n::Launcher>;
|
||||
pub type Tr = RwLock<Option<I18n>>;
|
||||
|
||||
pub fn setup_i18n<R: Runtime>(app: &AppHandle<R>, locales: &[&str]) {
|
||||
app.manage(Tr::default()); // no-op if it already exists
|
||||
*app.state::<Tr>().write().expect("tr lock was poisoned!") = Some(I18n::new(locales));
|
||||
}
|
||||
|
||||
pub fn get_tr<R: Runtime>(app: &AppHandle<R>) -> Result<I18n> {
|
||||
let tr_state = app.state::<Tr>();
|
||||
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"
|
||||
];
|
||||
38
qt/launcher-gui/src-tauri/src/main.rs
Normal file
38
qt/launcher-gui/src-tauri/src/main.rs
Normal file
|
|
@ -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");
|
||||
}
|
||||
99
qt/launcher-gui/src-tauri/src/platform/mac.rs
Normal file
99
qt/launcher-gui/src-tauri/src/platform/mac.rs
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
qt/launcher-gui/src-tauri/src/platform/mod.rs
Normal file
71
qt/launcher-gui/src-tauri/src/platform/mod.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
53
qt/launcher-gui/src-tauri/src/platform/unix.rs
Normal file
53
qt/launcher-gui/src-tauri/src/platform/unix.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
180
qt/launcher-gui/src-tauri/src/platform/windows.rs
Normal file
180
qt/launcher-gui/src-tauri/src/platform/windows.rs
Normal file
|
|
@ -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::<OSVERSIONINFOW>() 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::<OSVERSIONINFOW>() 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<std::path::PathBuf> {
|
||||
// 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<std::path::PathBuf> {
|
||||
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<u16> = 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()));
|
||||
}
|
||||
}
|
||||
930
qt/launcher-gui/src-tauri/src/uv.rs
Normal file
930
qt/launcher-gui/src-tauri/src/uv.rs
Normal file
|
|
@ -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<String>,
|
||||
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<String>,
|
||||
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<String>,
|
||||
pub all: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn init_state() -> Result<Option<State>> {
|
||||
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<String> = 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<String> = 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<String> {
|
||||
// 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<String>, Option<String>)> {
|
||||
// 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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
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<String> {
|
||||
// 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<String>,
|
||||
include_prereleases: bool,
|
||||
) -> Vec<String> {
|
||||
let mut valid_versions: Vec<String> = 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<Vec<String>> {
|
||||
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<Releases> {
|
||||
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<String> = 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<VersionKind> {
|
||||
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<std::path::PathBuf> {
|
||||
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<std::path::PathBuf> {
|
||||
Ok(get_anki_base_path()?.join("addons21"))
|
||||
}
|
||||
|
||||
// TODO: revert
|
||||
#[allow(unused)]
|
||||
fn handle_uninstall(state: &State) -> Result<bool> {
|
||||
// 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<Command> {
|
||||
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<portable_pty::CommandBuilder> {
|
||||
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<Command> {
|
||||
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<Option<(String, String)>> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
38
qt/launcher-gui/src-tauri/tauri.conf.json
Normal file
38
qt/launcher-gui/src-tauri/tauri.conf.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
qt/launcher-gui/src/app.html
Normal file
13
qt/launcher-gui/src/app.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/anki.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Anki Launcher</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
26
qt/launcher-gui/src/routes/+layout.svelte
Normal file
26
qt/launcher-gui/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import "./base.scss";
|
||||
import { warn, debug, info, error } from "@tauri-apps/plugin-log";
|
||||
|
||||
function forwardConsole(
|
||||
fnName: "log" | "debug" | "info" | "warn" | "error",
|
||||
logger: (message: string) => Promise<void>,
|
||||
) {
|
||||
const original = console[fnName];
|
||||
console[fnName] = (message: any) => {
|
||||
original(message);
|
||||
logger(message);
|
||||
};
|
||||
}
|
||||
|
||||
forwardConsole("debug", debug);
|
||||
forwardConsole("info", info);
|
||||
forwardConsole("warn", warn);
|
||||
forwardConsole("error", error);
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
18
qt/launcher-gui/src/routes/+layout.ts
Normal file
18
qt/launcher-gui/src/routes/+layout.ts
Normal file
|
|
@ -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";
|
||||
};
|
||||
47
qt/launcher-gui/src/routes/+page.svelte
Normal file
47
qt/launcher-gui/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts" module>
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PageProps } from "./$types";
|
||||
import { setLang, windowReady, zoomWebview } from "@generated/backend-launcher";
|
||||
import { getMirrors } from "@generated/backend-launcher";
|
||||
import { ModuleName, setupI18n } from "@tslib/i18n";
|
||||
import { onMount } from "svelte";
|
||||
import { currentLang, zoomFactor } from "./stores";
|
||||
import Start from "./Start.svelte";
|
||||
|
||||
const { data }: PageProps = $props();
|
||||
|
||||
const langs = data.langs;
|
||||
const options = $state(data.options);
|
||||
let mirrors = $state(data.mirrors);
|
||||
let selectedLang = $state(data.userLocale);
|
||||
|
||||
async function onLangChange(lang: string) {
|
||||
// TODO: setLang could call setupI18n?
|
||||
await setLang({ val: lang });
|
||||
await setupI18n({ modules: [ModuleName.LAUNCHER] }, true);
|
||||
$currentLang = lang;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
onLangChange(selectedLang);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$currentLang; // rerun on i18n reinit
|
||||
getMirrors({}).then(({ mirrors: _mirrors }) => (mirrors = _mirrors));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
zoomWebview({ scaleFactor: $zoomFactor });
|
||||
});
|
||||
|
||||
onMount(() => windowReady({}));
|
||||
</script>
|
||||
|
||||
<Start bind:selectedLang {langs} {options} {mirrors} />
|
||||
27
qt/launcher-gui/src/routes/+page.ts
Normal file
27
qt/launcher-gui/src/routes/+page.ts
Normal file
|
|
@ -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;
|
||||
315
qt/launcher-gui/src/routes/Start.svelte
Normal file
315
qt/launcher-gui/src/routes/Start.svelte
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import * as tr from "@generated/ftl-launcher";
|
||||
import {
|
||||
Mirror,
|
||||
type Options,
|
||||
type ChooseVersionResponse,
|
||||
type GetLangsResponse_Pair,
|
||||
type GetMirrorsResponse_Pair,
|
||||
} from "@generated/anki/launcher_pb";
|
||||
import { chooseVersion } from "@generated/backend-launcher";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte";
|
||||
import SettingTitle from "$lib/components/SettingTitle.svelte";
|
||||
import TitledContainer from "$lib/components/TitledContainer.svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
import SwitchRow from "$lib/components/SwitchRow.svelte";
|
||||
import EnumSelector from "$lib/components/EnumSelector.svelte";
|
||||
import Warning from "./Warning.svelte";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { protoBase64 } from "@bufbuild/protobuf";
|
||||
import { currentLang, versionsStore } from "./stores";
|
||||
import { onMount } from "svelte";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
// TODO: why
|
||||
/* eslint-disable prefer-const */
|
||||
let {
|
||||
langs,
|
||||
selectedLang = $bindable(),
|
||||
options,
|
||||
mirrors,
|
||||
}: {
|
||||
langs: GetLangsResponse_Pair[];
|
||||
selectedLang: string;
|
||||
options: Options;
|
||||
mirrors: GetMirrorsResponse_Pair[];
|
||||
} = $props();
|
||||
/* eslint-enable prefer-const */
|
||||
|
||||
const availableLangs = $derived(
|
||||
langs.map((p) => ({ label: p.name, value: p.locale })),
|
||||
);
|
||||
|
||||
const availableMirrors = $derived(
|
||||
mirrors.map(({ mirror, name }) => ({
|
||||
label: name,
|
||||
value: mirror,
|
||||
})),
|
||||
);
|
||||
// only the labels are expected to change
|
||||
// svelte-ignore state_referenced_locally
|
||||
let selectedMirror = $state(availableMirrors[0].value ?? Mirror.DISABLED);
|
||||
|
||||
let allowBetas: boolean = $state(options.allowBetas);
|
||||
let downloadCaching: boolean = $state(options.downloadCaching);
|
||||
|
||||
const availableVersions = $derived(
|
||||
$versionsStore?.all.map((v) => ({ label: v, value: v })) ?? [],
|
||||
);
|
||||
// const availableLatestVersions = $derived($versionsStore?.latest ?? []);
|
||||
let selectedVersion = $derived(availableVersions[0]?.value);
|
||||
/* eslint-disable prefer-const */
|
||||
let currentVersion = $derived($versionsStore?.current);
|
||||
let latestVersion = $derived($versionsStore?.latest[0]);
|
||||
/* eslint-enable prefer-const */
|
||||
|
||||
let choosePromise: Promise<ChooseVersionResponse | null> = $state(
|
||||
Promise.resolve(null),
|
||||
);
|
||||
|
||||
const choose = (version: string, keepExisting: boolean) => {
|
||||
choosePromise = chooseVersion({
|
||||
version,
|
||||
keepExisting,
|
||||
options: { allowBetas, downloadCaching, mirror: selectedMirror },
|
||||
});
|
||||
};
|
||||
|
||||
let termRef: HTMLDivElement;
|
||||
let termTabRef: HTMLDetailsElement;
|
||||
|
||||
onMount(() => {
|
||||
const term = new Terminal({
|
||||
disableStdin: true,
|
||||
rows: 12,
|
||||
cols: 60,
|
||||
cursorStyle: "underline",
|
||||
cursorInactiveStyle: "none",
|
||||
// TODO: saw this in the docs, but do we need it?
|
||||
windowsMode: navigator.platform.indexOf("Win") != -1,
|
||||
});
|
||||
|
||||
term.open(termRef);
|
||||
|
||||
termRef.oncontextmenu = (e) => {
|
||||
e.preventDefault();
|
||||
term.selectAll();
|
||||
const lines = term.getSelection().trim();
|
||||
term.clearSelection();
|
||||
navigator.clipboard.writeText(lines);
|
||||
};
|
||||
|
||||
const unlisten = listen<string>("pty-data", (e) => {
|
||||
const data = protoBase64.dec(e.payload);
|
||||
if (!termTabRef.open) {
|
||||
termTabRef.open = true;
|
||||
}
|
||||
term.write(data);
|
||||
});
|
||||
|
||||
return () => {
|
||||
term.dispose();
|
||||
unlisten.then((cb) => cb());
|
||||
};
|
||||
});
|
||||
|
||||
// const zoomIn = () => ($zoomFactor = Math.min($zoomFactor + 0.1, 3));
|
||||
// const zoomOut = () => ($zoomFactor = Math.max($zoomFactor - 0.1, 0.5));
|
||||
</script>
|
||||
|
||||
<!-- TODO: this breaks scrolling on wsl, fine on win -->
|
||||
<!-- <svelte:window -->
|
||||
<!-- onwheel={(e) => { -->
|
||||
<!-- if (!e.ctrlKey) { -->
|
||||
<!-- return true; -->
|
||||
<!-- } -->
|
||||
<!-- e.preventDefault(); -->
|
||||
<!-- e.deltaY < 0 ? zoomIn() : zoomOut(); -->
|
||||
<!-- }} -->
|
||||
<!-- /> -->
|
||||
|
||||
<Container
|
||||
breakpoint="sm"
|
||||
--gutter-inline="0.25rem"
|
||||
--gutter-block="0.75rem"
|
||||
class="container-columns"
|
||||
>
|
||||
{#key $currentLang}
|
||||
<Row class="row-columns">
|
||||
<TitledContainer title={""}>
|
||||
<Row --cols={2} slot="title" class="title">
|
||||
<img src="/anki.png" alt="logo" class="logo" />
|
||||
<h1 class="title">{tr.launcherTitle()}</h1>
|
||||
</Row>
|
||||
<EnumSelectorRow
|
||||
breakpoint="sm"
|
||||
bind:value={selectedLang}
|
||||
choices={availableLangs}
|
||||
defaultValue={selectedLang}
|
||||
hideRevert
|
||||
>
|
||||
<SettingTitle>
|
||||
{tr.launcherLanguage()}
|
||||
</SettingTitle>
|
||||
</EnumSelectorRow>
|
||||
<div class="group">
|
||||
{#if latestVersion != null && latestVersion != currentVersion}
|
||||
<Row class="centre m-3">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={() => choose(latestVersion, false)}
|
||||
>
|
||||
{#if latestVersion == null}
|
||||
{tr.launcherLatestAnki()}
|
||||
{:else}
|
||||
{tr.launcherLatestAnkiVersion({
|
||||
version: latestVersion!,
|
||||
})}
|
||||
{/if}
|
||||
</button>
|
||||
</Row>
|
||||
{/if}
|
||||
{#if currentVersion != null}
|
||||
<Row class="centre m-3">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={() => choose(currentVersion, true)}
|
||||
>
|
||||
{tr.launcherKeepExistingVersion({
|
||||
current: currentVersion ?? "N/A",
|
||||
})}
|
||||
</button>
|
||||
</Row>
|
||||
{/if}
|
||||
<Row class="centre m-3">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={() => choose(selectedVersion!, false)}
|
||||
disabled={selectedVersion == null}
|
||||
>
|
||||
{tr.launcherChooseAVersion()}
|
||||
</button>
|
||||
<div class="m-2">
|
||||
{"->"}
|
||||
</div>
|
||||
<div style="width: 100px">
|
||||
{#if availableVersions.length !== 0}
|
||||
<EnumSelector
|
||||
bind:value={selectedVersion}
|
||||
choices={availableVersions}
|
||||
/>
|
||||
{:else}
|
||||
{"loading"}
|
||||
{/if}
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
</TitledContainer>
|
||||
</Row>
|
||||
{#await choosePromise}
|
||||
<Warning warning={tr.launcherSyncing()} className="alert-info" />
|
||||
{:then res}
|
||||
{#if res != null}
|
||||
<Warning
|
||||
warning={tr.launcherAnkiWillStartShortly()}
|
||||
className="alert-success"
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
{/key}
|
||||
<Row class="row-columns">
|
||||
<details bind:this={termTabRef}>
|
||||
{#key $currentLang}
|
||||
<summary>{tr.launcherOutput()}</summary>
|
||||
{/key}
|
||||
<div id="terminal" bind:this={termRef}></div>
|
||||
</details>
|
||||
</Row>
|
||||
{#key $currentLang}
|
||||
<Row class="row-columns">
|
||||
<TitledContainer title={tr.launcherAdvanced()}>
|
||||
<div class="m-2">
|
||||
<SwitchRow
|
||||
bind:value={allowBetas}
|
||||
defaultValue={allowBetas}
|
||||
hideRevert
|
||||
>
|
||||
<SettingTitle>
|
||||
{tr.launcherAllowBetasToggle()}
|
||||
</SettingTitle>
|
||||
</SwitchRow>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<SwitchRow
|
||||
bind:value={downloadCaching}
|
||||
defaultValue={downloadCaching}
|
||||
hideRevert
|
||||
>
|
||||
<SettingTitle>
|
||||
{tr.launcherDownloadCaching()}
|
||||
</SettingTitle>
|
||||
</SwitchRow>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<EnumSelectorRow
|
||||
breakpoint="sm"
|
||||
bind:value={selectedMirror}
|
||||
choices={availableMirrors}
|
||||
defaultValue={selectedMirror}
|
||||
hideRevert
|
||||
>
|
||||
<SettingTitle>
|
||||
{tr.launcherUseMirror()}
|
||||
</SettingTitle>
|
||||
</EnumSelectorRow>
|
||||
</div>
|
||||
</TitledContainer>
|
||||
</Row>
|
||||
{/key}
|
||||
</Container>
|
||||
|
||||
<style lang="scss">
|
||||
:root {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
|
||||
text-rendering: optimizeLegibility;
|
||||
font-synthesis: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
max-width: 50px;
|
||||
margin-inline-end: 1em;
|
||||
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.centre) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
#terminal {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
21
qt/launcher-gui/src/routes/Warning.svelte
Normal file
21
qt/launcher-gui/src/routes/Warning.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withoutUnicodeIsolation } from "@tslib/i18n";
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
|
||||
export let warning: string;
|
||||
export let className = "alert-warning";
|
||||
</script>
|
||||
|
||||
{#if warning}
|
||||
<Row>
|
||||
<div class="col-12 alert {className} mb-0" in:slide out:slide>
|
||||
{withoutUnicodeIsolation(warning)}
|
||||
</div>
|
||||
</Row>
|
||||
{/if}
|
||||
19
qt/launcher-gui/src/routes/base.scss
Normal file
19
qt/launcher-gui/src/routes/base.scss
Normal file
|
|
@ -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);
|
||||
}
|
||||
12
qt/launcher-gui/src/routes/stores.ts
Normal file
12
qt/launcher-gui/src/routes/stores.ts
Normal file
|
|
@ -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<GetLangsResponse_Pair[]>([]);
|
||||
export const mirrorsStore = writable<GetMirrorsResponse_Pair[]>([]);
|
||||
export const currentLang = writable("");
|
||||
export const initialLang = writable("");
|
||||
export const versionsStore = writable<GetVersionsResponse | undefined>(undefined);
|
||||
13
qt/launcher-gui/src/routes/svg.d.ts
vendored
Normal file
13
qt/launcher-gui/src/routes/svg.d.ts
vendored
Normal file
|
|
@ -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<SVGAttributes<SVGSVGElement>>;
|
||||
|
||||
export default content;
|
||||
}
|
||||
BIN
qt/launcher-gui/static/anki.png
Normal file
BIN
qt/launcher-gui/static/anki.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
32
qt/launcher-gui/svelte.config.js
Normal file
32
qt/launcher-gui/svelte.config.js
Normal file
|
|
@ -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;
|
||||
19
qt/launcher-gui/tsconfig.json
Normal file
19
qt/launcher-gui/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
39
qt/launcher-gui/vite.config.js
Normal file
39
qt/launcher-gui/vite.config.js
Normal file
|
|
@ -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"),
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Loading…
Reference in a new issue