mirror of
https://github.com/ankitects/anki.git
synced 2025-11-06 12:47:11 -05:00
Merge 934d11aebb into dac26ce671
This commit is contained in:
commit
026a407b39
72 changed files with 7146 additions and 102 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
[env]
|
[env]
|
||||||
STRINGS_PY = { value = "out/pylib/anki/_fluent.py", relative = true }
|
STRINGS_PY = { value = "out/pylib/anki/_fluent.py", relative = true }
|
||||||
STRINGS_TS = { value = "out/ts/lib/generated/ftl.ts", relative = true }
|
STRINGS_TS = { value = "out/ts/lib/generated/ftl.ts", relative = true }
|
||||||
|
STRINGS_LAUNCHER_TS = { value = "out/ts/lib/generated/ftl-launcher.ts", relative = true }
|
||||||
DESCRIPTORS_BIN = { value = "out/rslib/proto/descriptors.bin", relative = true }
|
DESCRIPTORS_BIN = { value = "out/rslib/proto/descriptors.bin", relative = true }
|
||||||
# build script will append .exe if necessary
|
# build script will append .exe if necessary
|
||||||
PROTOC = { value = "out/extracted/protoc/bin/protoc", relative = true }
|
PROTOC = { value = "out/extracted/protoc/bin/protoc", relative = true }
|
||||||
|
|
|
||||||
17
.deny.toml
17
.deny.toml
|
|
@ -7,6 +7,23 @@ db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||||
ignore = [
|
ignore = [
|
||||||
# burn depends on an unmaintained package 'paste'
|
# burn depends on an unmaintained package 'paste'
|
||||||
"RUSTSEC-2024-0436",
|
"RUSTSEC-2024-0436",
|
||||||
|
# proc-macro-error unmaintained (tauri)
|
||||||
|
"RUSTSEC-2024-0370",
|
||||||
|
# gtk-rs' gtk3 bindings unmaintained (tauri)
|
||||||
|
"RUSTSEC-2024-0411",
|
||||||
|
"RUSTSEC-2024-0412",
|
||||||
|
"RUSTSEC-2024-0413",
|
||||||
|
"RUSTSEC-2024-0414",
|
||||||
|
"RUSTSEC-2024-0415",
|
||||||
|
"RUSTSEC-2024-0416",
|
||||||
|
"RUSTSEC-2024-0417",
|
||||||
|
"RUSTSEC-2024-0418",
|
||||||
|
"RUSTSEC-2024-0419",
|
||||||
|
"RUSTSEC-2024-0420",
|
||||||
|
# UB in glib crate (tauri)
|
||||||
|
"RUSTSEC-2024-0429",
|
||||||
|
# fxhash unmaintained (tauri)
|
||||||
|
"RUSTSEC-2025-0057",
|
||||||
]
|
]
|
||||||
|
|
||||||
[licenses]
|
[licenses]
|
||||||
|
|
|
||||||
3352
Cargo.lock
generated
3352
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ members = [
|
||||||
"ftl",
|
"ftl",
|
||||||
"pylib/rsbridge",
|
"pylib/rsbridge",
|
||||||
"qt/launcher",
|
"qt/launcher",
|
||||||
|
"qt/launcher-gui/src-tauri",
|
||||||
"rslib",
|
"rslib",
|
||||||
"rslib/i18n",
|
"rslib/i18n",
|
||||||
"rslib/io",
|
"rslib/io",
|
||||||
|
|
@ -102,6 +103,7 @@ once_cell = "1.21.3"
|
||||||
pbkdf2 = { version = "0.12", features = ["simple"] }
|
pbkdf2 = { version = "0.12", features = ["simple"] }
|
||||||
phf = { version = "0.11.3", features = ["macros"] }
|
phf = { version = "0.11.3", features = ["macros"] }
|
||||||
pin-project = "1.1.10"
|
pin-project = "1.1.10"
|
||||||
|
portable-pty = "0.9"
|
||||||
prettyplease = "0.2.34"
|
prettyplease = "0.2.34"
|
||||||
prost = "0.13"
|
prost = "0.13"
|
||||||
prost-build = "0.13"
|
prost-build = "0.13"
|
||||||
|
|
@ -127,6 +129,12 @@ snafu = { version = "0.8.6", features = ["rust_1_61"] }
|
||||||
strum = { version = "0.27.1", features = ["derive"] }
|
strum = { version = "0.27.1", features = ["derive"] }
|
||||||
syn = { version = "2.0.103", features = ["parsing", "printing"] }
|
syn = { version = "2.0.103", features = ["parsing", "printing"] }
|
||||||
tar = "0.4.44"
|
tar = "0.4.44"
|
||||||
|
tauri = { version = "2", features = ["devtools"] }
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-log = "2"
|
||||||
|
tauri-plugin-os = "2"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
tempfile = "3.20.0"
|
tempfile = "3.20.0"
|
||||||
termcolor = "1.4.1"
|
termcolor = "1.4.1"
|
||||||
tokio = { version = "1.45", features = ["fs", "rt-multi-thread", "macros", "signal"] }
|
tokio = { version = "1.45", features = ["fs", "rt-multi-thread", "macros", "signal"] }
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ fn prepare_translations(build: &mut Build) -> Result<()> {
|
||||||
outputs: &[
|
outputs: &[
|
||||||
RustOutput::Data("py", "pylib/anki/_fluent.py"),
|
RustOutput::Data("py", "pylib/anki/_fluent.py"),
|
||||||
RustOutput::Data("ts", "ts/lib/generated/ftl.ts"),
|
RustOutput::Data("ts", "ts/lib/generated/ftl.ts"),
|
||||||
|
RustOutput::Data("launcher_ts", "ts/lib/generated/ftl_launcher.ts"),
|
||||||
],
|
],
|
||||||
target: None,
|
target: None,
|
||||||
extra_args: "-p anki_i18n",
|
extra_args: "-p anki_i18n",
|
||||||
|
|
@ -247,7 +248,7 @@ pub fn check_minilints(build: &mut Build) -> Result<()> {
|
||||||
let files = inputs![
|
let files = inputs![
|
||||||
glob![
|
glob![
|
||||||
"**/*.{py,rs,ts,svelte,mjs,md}",
|
"**/*.{py,rs,ts,svelte,mjs,md}",
|
||||||
"{node_modules,ts/.svelte-kit}/**"
|
"{node_modules,ts/.svelte-kit,qt/launcher-gui/.svelte-kit}/**"
|
||||||
],
|
],
|
||||||
"Cargo.lock"
|
"Cargo.lock"
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,7 @@ fn build_and_check_reviewer(build: &mut Build) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_web(build: &mut Build) -> Result<()> {
|
fn check_web(build: &mut Build) -> Result<()> {
|
||||||
let fmt_excluded = "{target,ts/.svelte-kit,node_modules}/**";
|
let fmt_excluded = "{target,ts/.svelte-kit,node_modules,qt/launcher-gui/.svelte-kit}/**";
|
||||||
let dprint_files = inputs![glob!["**/*.{ts,mjs,js,md,json,toml,scss}", fmt_excluded]];
|
let dprint_files = inputs![glob!["**/*.{ts,mjs,js,md,json,toml,scss}", fmt_excluded]];
|
||||||
let prettier_files = inputs![glob!["**/*.svelte", fmt_excluded]];
|
let prettier_files = inputs![glob!["**/*.svelte", fmt_excluded]];
|
||||||
|
|
||||||
|
|
@ -363,8 +363,11 @@ fn check_web(build: &mut Build) -> Result<()> {
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
let eslint_rc = inputs![".eslintrc.cjs"];
|
let eslint_rc = inputs![".eslintrc.cjs"];
|
||||||
for folder in ["ts", "qt/aqt/data/web/js"] {
|
for folder in ["ts", "qt/aqt/data/web/js", "qt/launcher-gui/src"] {
|
||||||
let inputs = inputs![glob![format!("{folder}/**"), "ts/.svelte-kit/**"]];
|
let inputs = inputs![glob![
|
||||||
|
format!("{folder}/**"),
|
||||||
|
"{ts,qt/launcher-gui}/.svelte-kit/**"
|
||||||
|
]];
|
||||||
build.add_action(
|
build.add_action(
|
||||||
"check:eslint",
|
"check:eslint",
|
||||||
Eslint {
|
Eslint {
|
||||||
|
|
|
||||||
|
|
@ -2503,6 +2503,14 @@
|
||||||
"name": "num_enum_derive",
|
"name": "num_enum_derive",
|
||||||
"repository": "https://github.com/illicitonion/num_enum"
|
"repository": "https://github.com/illicitonion/num_enum"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"authors": "Jacob Pratt <open-source@jhpratt.dev>",
|
||||||
|
"description": "A minimal library that determines the number of running threads for the current process.",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"license_file": null,
|
||||||
|
"name": "num_threads",
|
||||||
|
"repository": "https://github.com/jhpratt/num_threads"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"authors": "Cldfire",
|
"authors": "Cldfire",
|
||||||
"description": "A safe and ergonomic Rust wrapper for the NVIDIA Management Library",
|
"description": "A safe and ergonomic Rust wrapper for the NVIDIA Management Library",
|
||||||
|
|
@ -3463,6 +3471,14 @@
|
||||||
"name": "serde_repr",
|
"name": "serde_repr",
|
||||||
"repository": "https://github.com/dtolnay/serde-repr"
|
"repository": "https://github.com/dtolnay/serde-repr"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"authors": null,
|
||||||
|
"description": "Serde-compatible spanned Value",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"license_file": null,
|
||||||
|
"name": "serde_spanned",
|
||||||
|
"repository": "https://github.com/toml-rs/toml"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"authors": "Jacob Brown <kardeiz@gmail.com>",
|
"authors": "Jacob Brown <kardeiz@gmail.com>",
|
||||||
"description": "De/serialize structs with named fields as array of values",
|
"description": "De/serialize structs with named fields as array of values",
|
||||||
|
|
@ -4247,6 +4263,14 @@
|
||||||
"name": "valuable",
|
"name": "valuable",
|
||||||
"repository": "https://github.com/tokio-rs/valuable"
|
"repository": "https://github.com/tokio-rs/valuable"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"authors": "Ashley Mannix <ashleymannix@live.com.au>",
|
||||||
|
"description": "Anonymous structured values",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"license_file": null,
|
||||||
|
"name": "value-bag",
|
||||||
|
"repository": "https://github.com/sval-rs/value-bag"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"authors": null,
|
"authors": null,
|
||||||
"description": "Implement things as if rust had variadics",
|
"description": "Implement things as if rust had variadics",
|
||||||
|
|
@ -4607,6 +4631,14 @@
|
||||||
"name": "windows-link",
|
"name": "windows-link",
|
||||||
"repository": "https://github.com/microsoft/windows-rs"
|
"repository": "https://github.com/microsoft/windows-rs"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"authors": null,
|
||||||
|
"description": "Linking for Windows",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"license_file": null,
|
||||||
|
"name": "windows-link",
|
||||||
|
"repository": "https://github.com/microsoft/windows-rs"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"authors": null,
|
"authors": null,
|
||||||
"description": "Windows numeric types",
|
"description": "Windows numeric types",
|
||||||
|
|
@ -4687,6 +4719,14 @@
|
||||||
"name": "windows-sys",
|
"name": "windows-sys",
|
||||||
"repository": "https://github.com/microsoft/windows-rs"
|
"repository": "https://github.com/microsoft/windows-rs"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"authors": null,
|
||||||
|
"description": "Rust for Windows",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"license_file": null,
|
||||||
|
"name": "windows-sys",
|
||||||
|
"repository": "https://github.com/microsoft/windows-rs"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"authors": "Microsoft",
|
"authors": "Microsoft",
|
||||||
"description": "Import libs for Windows",
|
"description": "Import libs for Windows",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ launcher-updating-anki = Updating Anki...
|
||||||
launcher-latest-anki = Install Latest Anki (default)
|
launcher-latest-anki = Install Latest Anki (default)
|
||||||
launcher-choose-a-version = Choose a version
|
launcher-choose-a-version = Choose a version
|
||||||
launcher-sync-project-changes = Sync project changes
|
launcher-sync-project-changes = Sync project changes
|
||||||
launcher-keep-existing-version = Keep existing version ({ $current })
|
launcher-keep-existing-version = Launch existing version ({ $current })
|
||||||
launcher-revert-to-previous = Revert to previous version ({ $prev })
|
launcher-revert-to-previous = Revert to previous version ({ $prev })
|
||||||
launcher-allow-betas = Allow betas: { $state }
|
launcher-allow-betas = Allow betas: { $state }
|
||||||
launcher-on = on
|
launcher-on = on
|
||||||
|
|
@ -22,10 +22,10 @@ launcher-versions-before-cant-be-installed = Versions before 2.1.50 can't be ins
|
||||||
launcher-invalid-version = Invalid version.
|
launcher-invalid-version = Invalid version.
|
||||||
launcher-unable-to-check-for-versions = Unable to check for Anki versions. Please check your internet connection.
|
launcher-unable-to-check-for-versions = Unable to check for Anki versions. Please check your internet connection.
|
||||||
launcher-checking-for-updates = Checking for updates...
|
launcher-checking-for-updates = Checking for updates...
|
||||||
launcher-uninstall-confirm = Uninstall Anki's program files? (y/n)
|
launcher-uninstall-confirm = Uninstall Anki's program files
|
||||||
launcher-uninstall-cancelled = Uninstall cancelled.
|
launcher-uninstall-cancelled = Uninstall cancelled.
|
||||||
launcher-program-files-removed = Program files removed.
|
launcher-program-files-removed = Program files removed.
|
||||||
launcher-remove-all-profiles-confirm = Remove all profiles/cards? (y/n)
|
launcher-remove-all-profiles-confirm = Remove all profiles/cards?
|
||||||
launcher-user-data-removed = User data removed.
|
launcher-user-data-removed = User data removed.
|
||||||
launcher-download-mirror-options = Download mirror options:
|
launcher-download-mirror-options = Download mirror options:
|
||||||
launcher-mirror-no-mirror = No mirror
|
launcher-mirror-no-mirror = No mirror
|
||||||
|
|
@ -36,3 +36,37 @@ launcher-beta-releases-enabled = Beta releases enabled.
|
||||||
launcher-beta-releases-disabled = Beta releases disabled.
|
launcher-beta-releases-disabled = Beta releases disabled.
|
||||||
launcher-download-caching-enabled = Download caching enabled.
|
launcher-download-caching-enabled = Download caching enabled.
|
||||||
launcher-download-caching-disabled = Download caching disabled and cache cleared.
|
launcher-download-caching-disabled = Download caching disabled and cache cleared.
|
||||||
|
|
||||||
|
# TODO: dedup
|
||||||
|
launcher-language = Language
|
||||||
|
launcher-latest-anki-version = Install Latest Anki ({ $version })
|
||||||
|
launcher-allow-betas-toggle = Allow betas
|
||||||
|
launcher-use-mirror = Download mirror
|
||||||
|
launcher-download-caching = Download caching
|
||||||
|
launcher-advanced = Advanced
|
||||||
|
launcher-output = Output
|
||||||
|
launcher-syncing = Syncing
|
||||||
|
launcher-failed-to-sync = Failed to sync!
|
||||||
|
launcher-loading = Loading
|
||||||
|
launcher-loading-versions = Loading versions
|
||||||
|
launcher-checking-existing = Checking existing version
|
||||||
|
launcher-failed-to-get-existing-and-available = Failed to get existing and available versions!
|
||||||
|
launcher-failed-to-get-releases = Failed to get available versions!
|
||||||
|
launcher-error-details = Error details
|
||||||
|
launcher-will-close-in = Launcher will close in { $count }
|
||||||
|
launcher-anki-is-warming-up = Anki is preparing to run
|
||||||
|
launcher-this-may-take = This may take a few minutes. Please wait
|
||||||
|
launcher-os-unsupported = Unsupported OS
|
||||||
|
launcher-unknown-error = Unknown error
|
||||||
|
launcher-remove-profiles-warning = This will irreversibly delete all of your Anki flashcards and media!
|
||||||
|
launcher-uninstall-confirm-and-remove-profiles = Uninstall Anki's program files and remove profiles
|
||||||
|
launcher-uninstalling = Uninstalling
|
||||||
|
launcher-failed-to-uninstall = Failed to uninstall!
|
||||||
|
launcher-uninstall-complete = Anki has been uninstalled
|
||||||
|
launcher-uninstall-action-needed = Action needed to finish uninstalling!
|
||||||
|
launcher-uninstall-unix = To finish uninstalling, run '{ $path }'
|
||||||
|
launcher-uninstall-mac = Please manually drag Anki.app to the trash to complete uninstall.
|
||||||
|
launcher-uninstall-win-not-found = Windows uninstaller not found
|
||||||
|
launcher-uninstall-win-not-found-extra = You may need to uninstall via Windows Settings > Apps.
|
||||||
|
launcher-uninstall-win-failed = Failed to launch uninstaller
|
||||||
|
launcher-uninstall-win-failed-extra = You can manually run: { $path }
|
||||||
|
|
|
||||||
19
launcher
Executable file
19
launcher
Executable file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
./ninja node_modules extract:uv ts:generated
|
||||||
|
|
||||||
|
mkdir -p target/debug target/release
|
||||||
|
|
||||||
|
cp .python-version target/debug/
|
||||||
|
cp qt/launcher/versions.py target/debug/
|
||||||
|
cp qt/launcher/pyproject.toml target/debug/
|
||||||
|
cp out/extracted/uv/uv target/debug/uv.amd64
|
||||||
|
cp out/extracted/uv/uv target/debug/uv.arm64
|
||||||
|
|
||||||
|
cp .python-version target/release/
|
||||||
|
cp qt/launcher/versions.py target/release/
|
||||||
|
cp qt/launcher/pyproject.toml target/release/
|
||||||
|
cp out/extracted/uv/uv target/release/uv.amd64
|
||||||
|
cp out/extracted/uv/uv target/release/uv.arm64
|
||||||
16
launcher.bat
Executable file
16
launcher.bat
Executable file
|
|
@ -0,0 +1,16 @@
|
||||||
|
@echo off
|
||||||
|
|
||||||
|
.\tools\ninja.bat node_modules extract:uv ts:generated
|
||||||
|
|
||||||
|
mkdir ,\target\debug
|
||||||
|
mkdir .\target\release
|
||||||
|
|
||||||
|
copy .\.python-version .\target\debug\
|
||||||
|
copy .\qt\launcher\versions.py .\target\debug\
|
||||||
|
copy .\qt\launcher\pyproject.toml .\target\debug\
|
||||||
|
copy .\out\extracted\uv\uv.exe .\target\debug\
|
||||||
|
|
||||||
|
copy .\.python-version .\target\release\
|
||||||
|
copy .\qt\launcher\versions.py .\target\release\
|
||||||
|
copy .\qt\launcher\pyproject.toml .\target\release\
|
||||||
|
copy .\out\extracted\uv\uv.exe .\target\release\
|
||||||
|
|
@ -12,7 +12,10 @@
|
||||||
"svelte-check:once": "cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings --threshold warning",
|
"svelte-check:once": "cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings --threshold warning",
|
||||||
"svelte-check": "cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"svelte-check": "cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"vitest:once": "cd ts && vitest run",
|
"vitest:once": "cd ts && vitest run",
|
||||||
"vitest": "cd ts && vitest"
|
"vitest": "cd ts && vitest",
|
||||||
|
"ld": "cd qt/launcher-gui && vite dev",
|
||||||
|
"lb": "cd qt/launcher-gui && vite build",
|
||||||
|
"lsc": "cd qt/launcher-gui && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings --threshold warning"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@bufbuild/protoc-gen-es": "^1.8.0",
|
"@bufbuild/protoc-gen-es": "^1.8.0",
|
||||||
|
|
@ -65,6 +68,10 @@
|
||||||
"@fluent/bundle": "^0.18.0",
|
"@fluent/bundle": "^0.18.0",
|
||||||
"@mdi/svg": "^7.0.96",
|
"@mdi/svg": "^7.0.96",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-log": "^2.7.0",
|
||||||
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"bootstrap": "^5.3.0",
|
"bootstrap": "^5.3.0",
|
||||||
"bootstrap-icons": "^1.10.5",
|
"bootstrap-icons": "^1.10.5",
|
||||||
"codemirror": "^5.63.1",
|
"codemirror": "^5.63.1",
|
||||||
|
|
|
||||||
147
proto/anki/launcher.proto
Normal file
147
proto/anki/launcher.proto
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package anki.launcher;
|
||||||
|
|
||||||
|
import "anki/generic.proto";
|
||||||
|
import "google/protobuf/empty.proto";
|
||||||
|
|
||||||
|
service LauncherService {
|
||||||
|
rpc I18nResources(I18nResourcesRequest) returns (generic.Json);
|
||||||
|
rpc GetLangs(generic.Empty) returns (GetLangsResponse);
|
||||||
|
rpc SetLang(generic.String) returns (generic.Empty);
|
||||||
|
rpc ChooseVersion(ChooseVersionRequest) returns (ChooseVersionResponse);
|
||||||
|
rpc GetOptions(generic.Empty) returns (Options);
|
||||||
|
rpc GetMirrors(generic.Empty) returns (GetMirrorsResponse);
|
||||||
|
rpc WindowReady(generic.Empty) returns (generic.Empty);
|
||||||
|
rpc ZoomWebview(ZoomWebviewRequest) returns (generic.Empty);
|
||||||
|
|
||||||
|
rpc GetState(generic.Empty) returns (State);
|
||||||
|
|
||||||
|
rpc GetAvailableVersions(generic.Empty) returns (Versions);
|
||||||
|
rpc GetExistingVersions(generic.Empty) returns (ExistingVersions);
|
||||||
|
|
||||||
|
rpc GetUninstallInfo(generic.Empty) returns (Uninstall);
|
||||||
|
rpc UninstallAnki(UninstallRequest) returns (UninstallResponse);
|
||||||
|
|
||||||
|
rpc LaunchAnki(generic.Empty) returns (generic.Empty);
|
||||||
|
rpc Exit(generic.Empty) returns (generic.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: this should not be necessary
|
||||||
|
service BackendLauncherService {}
|
||||||
|
|
||||||
|
enum Event { PTY_DATA = 0; }
|
||||||
|
|
||||||
|
message I18nResourcesRequest {
|
||||||
|
repeated string modules = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message I18nResourcesResponse {
|
||||||
|
repeated string langs = 1;
|
||||||
|
repeated string resources = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetLangsResponse {
|
||||||
|
message Pair {
|
||||||
|
string name = 1;
|
||||||
|
string locale = 2;
|
||||||
|
}
|
||||||
|
string user_locale = 1;
|
||||||
|
repeated Pair langs = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Mirror {
|
||||||
|
DISABLED = 0;
|
||||||
|
CHINA = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetMirrorsResponse {
|
||||||
|
message Pair {
|
||||||
|
Mirror mirror = 1;
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
repeated Pair mirrors = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Options {
|
||||||
|
bool allow_betas = 1;
|
||||||
|
bool download_caching = 2;
|
||||||
|
Mirror mirror = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetVersionsResponse {
|
||||||
|
repeated string all = 1;
|
||||||
|
repeated string latest = 2;
|
||||||
|
optional string current = 3;
|
||||||
|
optional string previous = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChooseVersionRequest {
|
||||||
|
string version = 1;
|
||||||
|
optional string current = 2;
|
||||||
|
bool keep_existing = 3;
|
||||||
|
Options options = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChooseVersionResponse {
|
||||||
|
string version = 1;
|
||||||
|
bool warming_up = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UninstallRequest {
|
||||||
|
bool delete_base_folder = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UninstallResponse {
|
||||||
|
message WindowsInstallerError {
|
||||||
|
string error = 1;
|
||||||
|
string path = 2;
|
||||||
|
}
|
||||||
|
oneof action_needed {
|
||||||
|
string unix_script = 1;
|
||||||
|
google.protobuf.Empty mac_manual = 2;
|
||||||
|
WindowsInstallerError windows_installer_failed = 3;
|
||||||
|
google.protobuf.Empty windows_installer_not_found = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message ZoomWebviewRequest {
|
||||||
|
float scale_factor = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Version {
|
||||||
|
string version = 1;
|
||||||
|
bool is_prerelease = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Versions {
|
||||||
|
repeated Version all = 1;
|
||||||
|
repeated Version latest = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ExistingVersions {
|
||||||
|
optional Version current = 1;
|
||||||
|
optional Version previous = 2;
|
||||||
|
bool pyproject_modified_by_user = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Uninstall {
|
||||||
|
bool anki_program_files_exists = 1;
|
||||||
|
bool anki_base_folder_exists = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NormalState {
|
||||||
|
Options options = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message State {
|
||||||
|
oneof kind {
|
||||||
|
string os_unsupported = 1;
|
||||||
|
string unknown_error = 2;
|
||||||
|
NormalState normal = 3;
|
||||||
|
google.protobuf.Empty uninstall = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
strum.workspace = true
|
||||||
|
tauri.workspace = true
|
||||||
|
tauri-plugin-dialog.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
|
||||||
23
qt/launcher-gui/src-tauri/build.rs
Normal file
23
qt/launcher-gui/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// 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)?;
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed=../../../out/buildhash");
|
||||||
|
let buildhash = std::fs::read_to_string("../../../out/buildhash").unwrap_or_default();
|
||||||
|
println!("cargo:rustc-env=BUILDHASH={buildhash}");
|
||||||
|
|
||||||
|
tauri_build::build();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
12
qt/launcher-gui/src-tauri/capabilities/default.json
Normal file
12
qt/launcher-gui/src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"os:default",
|
||||||
|
"log:default",
|
||||||
|
"dialog: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;");
|
||||||
|
|
||||||
|
// TODO: render as trait for better compiler errors
|
||||||
|
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"))?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.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))
|
||||||
|
}
|
||||||
114
qt/launcher-gui/src-tauri/src/app.rs
Normal file
114
qt/launcher-gui/src-tauri/src/app.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// 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::I18n;
|
||||||
|
use crate::state::State;
|
||||||
|
use crate::uv;
|
||||||
|
|
||||||
|
pub const PROTOCOL: &str = "anki";
|
||||||
|
|
||||||
|
pub trait StateExt<R: Runtime> {
|
||||||
|
fn flow(&self) -> &State;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Runtime, T: Manager<R>> StateExt<R> for T {
|
||||||
|
fn flow(&self) -> &State {
|
||||||
|
self.state::<State>().inner()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init() -> Option<State> {
|
||||||
|
let mut state = State::init().unwrap_or_else(State::UnknownError);
|
||||||
|
|
||||||
|
match state {
|
||||||
|
State::Normal(ref mut state) => state.check_versions(),
|
||||||
|
State::LaunchAnki(ref paths) => {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let cmd = uv::build_python_command(paths, &args).unwrap();
|
||||||
|
uv::launch_anki_normally(cmd).unwrap();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup(app: &mut App, state: State) -> anyhow::Result<()> {
|
||||||
|
let tr = I18n::new(&[&locale().unwrap_or_default()]);
|
||||||
|
app.manage(crate::lang::Tr::new(Some(tr)));
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
260
qt/launcher-gui/src-tauri/src/commands.rs
Normal file
260
qt/launcher-gui/src-tauri/src/commands.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
// 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::state::Kind as StateProtoKind;
|
||||||
|
use anki_proto::launcher::ChooseVersionRequest;
|
||||||
|
use anki_proto::launcher::ChooseVersionResponse;
|
||||||
|
use anki_proto::launcher::Event;
|
||||||
|
use anki_proto::launcher::GetLangsResponse;
|
||||||
|
use anki_proto::launcher::GetMirrorsResponse;
|
||||||
|
use anki_proto::launcher::I18nResourcesRequest;
|
||||||
|
use anki_proto::launcher::Mirror;
|
||||||
|
use anki_proto::launcher::NormalState as NormalStateProto;
|
||||||
|
use anki_proto::launcher::Options;
|
||||||
|
use anki_proto::launcher::State as StateProto;
|
||||||
|
use anki_proto::launcher::Uninstall as UninstallProto;
|
||||||
|
use anki_proto::launcher::UninstallRequest;
|
||||||
|
use anki_proto::launcher::UninstallResponse;
|
||||||
|
use anki_proto::launcher::ZoomWebviewRequest;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use anyhow::Context;
|
||||||
|
use anyhow::Result;
|
||||||
|
use strum::IntoEnumIterator;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use tauri::Runtime;
|
||||||
|
use tauri::WebviewWindow;
|
||||||
|
use tauri_plugin_os::locale;
|
||||||
|
|
||||||
|
use crate::app::StateExt;
|
||||||
|
use crate::lang::I18nExt;
|
||||||
|
use crate::lang::LANGS;
|
||||||
|
use crate::lang::LANGS_DEFAULT_REGION;
|
||||||
|
use crate::lang::LANGS_WITH_REGIONS;
|
||||||
|
use crate::state::ExistingVersions;
|
||||||
|
use crate::state::State;
|
||||||
|
use crate::state::Uninstall;
|
||||||
|
use crate::state::Versions;
|
||||||
|
use crate::uv;
|
||||||
|
|
||||||
|
pub async fn i18n_resources<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
_window: WebviewWindow<R>,
|
||||||
|
input: I18nResourcesRequest,
|
||||||
|
) -> Result<generic::Json> {
|
||||||
|
let tr = app.tr()?;
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
app.setup_tr(&[&*input]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_state<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
_window: WebviewWindow<R>,
|
||||||
|
) -> Result<StateProto> {
|
||||||
|
let kind = match app.flow() {
|
||||||
|
State::LaunchAnki(_) => unreachable!(),
|
||||||
|
State::OsUnsupported(e) => StateProtoKind::OsUnsupported(format!("{e:?}")),
|
||||||
|
State::UnknownError(e) => StateProtoKind::UnknownError(format!("{e:?}")),
|
||||||
|
State::Uninstall(_) => StateProtoKind::Uninstall(()),
|
||||||
|
State::Normal(normal) => StateProtoKind::Normal(NormalStateProto {
|
||||||
|
options: Some((&normal.initial_options).into()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
Ok(StateProto { kind: Some(kind) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_mirrors<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
_window: WebviewWindow<R>,
|
||||||
|
) -> Result<GetMirrorsResponse> {
|
||||||
|
let tr = app.tr()?;
|
||||||
|
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.flow();
|
||||||
|
let options = (&state.normal()?.initial_options).into();
|
||||||
|
Ok(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_available_versions<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
_window: WebviewWindow<R>,
|
||||||
|
) -> Result<Versions> {
|
||||||
|
let state = app.flow().normal()?;
|
||||||
|
let mut rx = state.available_versions.clone().unwrap();
|
||||||
|
rx.changed().await?;
|
||||||
|
let x = rx.borrow();
|
||||||
|
match x.as_ref().unwrap() {
|
||||||
|
Ok(versions) => Ok(versions.clone()),
|
||||||
|
// TODO: errors are passed as strings to the web
|
||||||
|
Err(e) => Err(anyhow!("{e:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_existing_versions<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
_window: WebviewWindow<R>,
|
||||||
|
) -> Result<ExistingVersions> {
|
||||||
|
let state = app.flow().normal()?;
|
||||||
|
let mut rx = state.current_versions.clone().unwrap();
|
||||||
|
rx.changed().await?;
|
||||||
|
let x = rx.borrow();
|
||||||
|
match x.as_ref().unwrap() {
|
||||||
|
Ok(versions) => Ok(versions.clone()),
|
||||||
|
Err(e) => Err(anyhow!("{e:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn choose_version<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
_window: WebviewWindow<R>,
|
||||||
|
input: ChooseVersionRequest,
|
||||||
|
) -> Result<ChooseVersionResponse> {
|
||||||
|
let state = app.flow().normal()?;
|
||||||
|
let paths = state.paths.clone();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if let Some(options) = input.options {
|
||||||
|
uv::set_allow_betas(&paths, options.allow_betas)?;
|
||||||
|
uv::set_cache_enabled(&paths, options.download_caching)?;
|
||||||
|
uv::set_mirror(&paths, options.mirror != Mirror::Disabled as i32)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = input.version;
|
||||||
|
let on_pty_data = move |data| {
|
||||||
|
let _ = app.emit(Event::PtyData.as_str_name(), data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if !input.keep_existing || paths.pyproject_modified_by_user {
|
||||||
|
// install or resync
|
||||||
|
uv::handle_version_install_or_update(
|
||||||
|
&paths,
|
||||||
|
&version,
|
||||||
|
input.keep_existing,
|
||||||
|
input.current.as_deref(),
|
||||||
|
on_pty_data,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let warming_up = uv::post_install(&paths)?;
|
||||||
|
|
||||||
|
Ok(ChooseVersionResponse {
|
||||||
|
version,
|
||||||
|
warming_up,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn launch_anki<R: Runtime>(app: AppHandle<R>, _window: WebviewWindow<R>) -> Result<()> {
|
||||||
|
app.flow().paths().and_then(uv::launch_anki)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exit<R: Runtime>(app: AppHandle<R>, window: WebviewWindow<R>) -> Result<()> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let _ = window.destroy();
|
||||||
|
// can't be called from the main thread
|
||||||
|
app.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_uninstall_info<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
_window: WebviewWindow<R>,
|
||||||
|
) -> Result<UninstallProto> {
|
||||||
|
app.flow().paths().map(Uninstall::from).map(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn uninstall_anki<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
_window: WebviewWindow<R>,
|
||||||
|
input: UninstallRequest,
|
||||||
|
) -> Result<UninstallResponse> {
|
||||||
|
let paths = app.flow().paths()?;
|
||||||
|
let action_needed = uv::handle_uninstall(paths, input.delete_base_folder)?;
|
||||||
|
Ok(UninstallResponse { action_needed })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NOTE: [zoomHotkeysEnabled](https://v2.tauri.app/reference/config/#zoomhotkeysenabled) exists
|
||||||
|
/// but the polyfill it uses on linux 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()))
|
||||||
|
}
|
||||||
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"));
|
||||||
148
qt/launcher-gui/src-tauri/src/lang.rs
Normal file
148
qt/launcher-gui/src-tauri/src/lang.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
// 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::Manager;
|
||||||
|
use tauri::Runtime;
|
||||||
|
|
||||||
|
pub type I18n = anki_i18n::I18n<anki_i18n::Launcher>;
|
||||||
|
pub type Tr = RwLock<Option<I18n>>;
|
||||||
|
|
||||||
|
pub trait I18nExt<R: Runtime> {
|
||||||
|
fn setup_tr(&self, locales: &[&str]);
|
||||||
|
fn tr(&self) -> Result<I18n>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Runtime, T: Manager<R>> I18nExt<R> for T {
|
||||||
|
fn setup_tr(&self, locales: &[&str]) {
|
||||||
|
*self.state::<Tr>().write().expect("tr lock was poisoned!") = Some(I18n::new(locales));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tr(&self) -> Result<I18n> {
|
||||||
|
let tr_state = self.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"
|
||||||
|
];
|
||||||
36
qt/launcher-gui/src-tauri/src/main.rs
Normal file
36
qt/launcher-gui/src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// 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 generated;
|
||||||
|
mod lang;
|
||||||
|
mod platform;
|
||||||
|
mod state;
|
||||||
|
mod uv;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let Some(state) = app::init() else { return };
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.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_os::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.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");
|
||||||
|
}
|
||||||
60
qt/launcher-gui/src-tauri/src/platform/mac.rs
Normal file
60
qt/launcher-gui/src-tauri/src/platform/mac.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use anki_process::CommandExt as AnkiCommandExt;
|
||||||
|
use anki_proto::launcher::uninstall_response::ActionNeeded;
|
||||||
|
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
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finalize_uninstall() -> Result<Option<ActionNeeded>> {
|
||||||
|
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();
|
||||||
|
|
||||||
|
return Ok(match result {
|
||||||
|
Ok(output) if output.status.success() => None,
|
||||||
|
_ => {
|
||||||
|
// Fall back to manual instructions
|
||||||
|
Some(ActionNeeded::MacManual(()))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app_bundle_path = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
123
qt/launcher-gui/src-tauri/src/platform/mod.rs
Normal file
123
qt/launcher-gui/src-tauri/src/platform/mod.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// 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 respawn_launcher() -> Result<()> {
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
|
let mut launcher_cmd = if cfg!(target_os = "macos") {
|
||||||
|
// On macOS, we need to launch the .app bundle, not the executable directly
|
||||||
|
let current_exe =
|
||||||
|
std::env::current_exe().context("Failed to get current executable path")?;
|
||||||
|
|
||||||
|
// Navigate from Contents/MacOS/launcher to the .app bundle
|
||||||
|
let app_bundle = current_exe
|
||||||
|
.parent() // MacOS
|
||||||
|
.and_then(|p| p.parent()) // Contents
|
||||||
|
.and_then(|p| p.parent()) // .app
|
||||||
|
.context("Failed to find .app bundle")?;
|
||||||
|
|
||||||
|
let mut cmd = std::process::Command::new("open");
|
||||||
|
cmd.arg(app_bundle);
|
||||||
|
cmd
|
||||||
|
} else {
|
||||||
|
let current_exe =
|
||||||
|
std::env::current_exe().context("Failed to get current executable path")?;
|
||||||
|
std::process::Command::new(current_exe)
|
||||||
|
};
|
||||||
|
|
||||||
|
launcher_cmd
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
|
||||||
|
// TODO: remove
|
||||||
|
launcher_cmd.env("ANKI_LAUNCHER_SKIP", "1");
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||||
|
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||||
|
launcher_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
launcher_cmd.process_group(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let child = launcher_cmd.ensure_spawn()?;
|
||||||
|
std::mem::forget(child);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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_exec()?;
|
||||||
|
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(())
|
||||||
|
}
|
||||||
46
qt/launcher-gui/src-tauri/src/platform/unix.rs
Normal file
46
qt/launcher-gui/src-tauri/src/platform/unix.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use anki_proto::launcher::uninstall_response::ActionNeeded;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn finalize_uninstall() -> Result<Option<ActionNeeded>> {
|
||||||
|
let uninstall_script = std::path::Path::new("/usr/local/share/anki/uninstall.sh");
|
||||||
|
|
||||||
|
Ok(uninstall_script
|
||||||
|
.exists()
|
||||||
|
.then_some(ActionNeeded::UnixScript(
|
||||||
|
uninstall_script.display().to_string(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
182
qt/launcher-gui/src-tauri/src/platform/windows.rs
Normal file
182
qt/launcher-gui/src-tauri/src/platform/windows.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
// 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 anki_proto::launcher::uninstall_response::ActionNeeded;
|
||||||
|
use anki_proto::launcher::uninstall_response::WindowsInstallerError;
|
||||||
|
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() -> Result<Option<ActionNeeded>> {
|
||||||
|
let uninstaller_path = get_uninstaller_path();
|
||||||
|
|
||||||
|
Ok(match uninstaller_path {
|
||||||
|
Some(path) => {
|
||||||
|
println!("Launching Windows uninstaller...");
|
||||||
|
let result = Command::new(&path).env("ANKI_LAUNCHER", "1").spawn();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => None,
|
||||||
|
Err(e) => {
|
||||||
|
println!("Failed to launch uninstaller: {e}");
|
||||||
|
println!("You can manually run: {}", path.display());
|
||||||
|
Some(ActionNeeded::WindowsInstallerFailed(
|
||||||
|
WindowsInstallerError {
|
||||||
|
error: format!("{e:?}"),
|
||||||
|
path: path.display().to_string(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
println!("Windows uninstaller not found.");
|
||||||
|
println!("You may need to uninstall via Windows Settings > Apps.");
|
||||||
|
Some(ActionNeeded::WindowsInstallerNotFound(()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
144
qt/launcher-gui/src-tauri/src/state.rs
Normal file
144
qt/launcher-gui/src-tauri/src/state.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub use anki_proto::launcher::ExistingVersions;
|
||||||
|
use anki_proto::launcher::Mirror;
|
||||||
|
use anki_proto::launcher::Options as OptionsProto;
|
||||||
|
use anki_proto::launcher::Uninstall as UninstallProto;
|
||||||
|
pub use anki_proto::launcher::Version;
|
||||||
|
pub use anki_proto::launcher::Versions;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
use crate::uv;
|
||||||
|
|
||||||
|
pub struct Options {
|
||||||
|
allow_betas: bool,
|
||||||
|
download_caching: bool,
|
||||||
|
mirror: Mirror,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Options> for OptionsProto {
|
||||||
|
fn from(o: &Options) -> Self {
|
||||||
|
Self {
|
||||||
|
allow_betas: o.allow_betas,
|
||||||
|
download_caching: o.download_caching,
|
||||||
|
mirror: o.mirror.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&uv::Paths> for Options {
|
||||||
|
fn from(state: &uv::Paths) -> Self {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
allow_betas,
|
||||||
|
download_caching,
|
||||||
|
mirror,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NormalState {
|
||||||
|
pub paths: Arc<uv::Paths>,
|
||||||
|
pub initial_options: Options,
|
||||||
|
pub current_versions: Option<watch::Receiver<Option<Result<ExistingVersions>>>>,
|
||||||
|
pub available_versions: Option<watch::Receiver<Option<Result<Versions>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<uv::Paths> for NormalState {
|
||||||
|
fn from(paths: uv::Paths) -> Self {
|
||||||
|
Self {
|
||||||
|
initial_options: Options::from(&paths),
|
||||||
|
current_versions: None,
|
||||||
|
available_versions: None,
|
||||||
|
paths: paths.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NormalState> for State {
|
||||||
|
fn from(state: NormalState) -> Self {
|
||||||
|
Self::Normal(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Uninstall {
|
||||||
|
anki_program_files_exists: bool,
|
||||||
|
anki_base_folder_exists: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: AsRef<uv::Paths>> From<T> for Uninstall {
|
||||||
|
fn from(paths: T) -> Self {
|
||||||
|
let paths = paths.as_ref();
|
||||||
|
Self {
|
||||||
|
anki_program_files_exists: paths.uv_install_root.exists(),
|
||||||
|
anki_base_folder_exists: paths.anki_base_folder.exists(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Uninstall> for UninstallProto {
|
||||||
|
fn from(u: Uninstall) -> Self {
|
||||||
|
Self {
|
||||||
|
anki_program_files_exists: u.anki_program_files_exists,
|
||||||
|
anki_base_folder_exists: u.anki_base_folder_exists,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum State {
|
||||||
|
LaunchAnki(Arc<uv::Paths>),
|
||||||
|
OsUnsupported(anyhow::Error),
|
||||||
|
UnknownError(anyhow::Error),
|
||||||
|
Uninstall(Arc<uv::Paths>),
|
||||||
|
Normal(NormalState),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub fn normal(&self) -> Result<&NormalState> {
|
||||||
|
match self {
|
||||||
|
State::Normal(state) => Ok(state),
|
||||||
|
_ => Err(anyhow!("unexpected state")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn paths(&self) -> Result<&uv::Paths> {
|
||||||
|
match self {
|
||||||
|
State::LaunchAnki(paths) => Ok(paths),
|
||||||
|
State::Uninstall(paths) => Ok(paths),
|
||||||
|
State::Normal(state) => Ok(&state.paths),
|
||||||
|
_ => Err(anyhow!("unexpected state")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NormalState {
|
||||||
|
pub fn check_versions(&mut self) {
|
||||||
|
let (av_tx, av_rx) = tokio::sync::watch::channel(None);
|
||||||
|
let paths = self.paths.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let res = uv::get_releases(&paths);
|
||||||
|
let _ = av_tx.send(Some(res));
|
||||||
|
});
|
||||||
|
|
||||||
|
let (cv_tx, cv_rx) = tokio::sync::watch::channel(None);
|
||||||
|
let paths = self.paths.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let res = uv::check_versions(&paths);
|
||||||
|
let _ = cv_tx.send(Some(res));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.current_versions = Some(cv_rx);
|
||||||
|
self.available_versions = Some(av_rx);
|
||||||
|
}
|
||||||
|
}
|
||||||
920
qt/launcher-gui/src-tauri/src/uv.rs
Normal file
920
qt/launcher-gui/src-tauri/src/uv.rs
Normal file
|
|
@ -0,0 +1,920 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
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 anki_proto::launcher::uninstall_response::ActionNeeded;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use anyhow::Context;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
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;
|
||||||
|
use crate::platform::respawn_launcher;
|
||||||
|
use crate::state::ExistingVersions;
|
||||||
|
use crate::state::State;
|
||||||
|
use crate::state::Version;
|
||||||
|
use crate::state::Versions;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Paths {
|
||||||
|
pub prerelease_marker: std::path::PathBuf,
|
||||||
|
pub uv_install_root: std::path::PathBuf,
|
||||||
|
uv_cache_dir: std::path::PathBuf,
|
||||||
|
pub no_cache_marker: std::path::PathBuf,
|
||||||
|
pub 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,
|
||||||
|
resources_dir: std::path::PathBuf,
|
||||||
|
venv_folder: std::path::PathBuf,
|
||||||
|
/// system Python + PyQt6 library mode
|
||||||
|
system_qt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<Paths> for Paths {
|
||||||
|
fn as_ref(&self) -> &Paths {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum VersionKind {
|
||||||
|
PyOxidizer(String),
|
||||||
|
Uv(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_aqt_version(state: &Paths) -> 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: &Paths) -> Result<ExistingVersions> {
|
||||||
|
let mut res = ExistingVersions {
|
||||||
|
pyproject_modified_by_user: state.pyproject_modified_by_user,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// If sync_complete_marker is missing, do nothing
|
||||||
|
if !state.sync_complete_marker.exists() {
|
||||||
|
return Ok(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine current version by invoking uv pip show aqt
|
||||||
|
match extract_aqt_version(state) {
|
||||||
|
Some(version) => {
|
||||||
|
res.current = 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() {
|
||||||
|
res.previous = Some(normalize_version(&version));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_allow_betas(state: &Paths, 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: &Paths, 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_version_install_or_update<F>(
|
||||||
|
state: &Paths,
|
||||||
|
version: &str,
|
||||||
|
keep_existing: bool,
|
||||||
|
previous_version_to_save: Option<&str>,
|
||||||
|
on_pty_data: F,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
F: Fn(String) + Send + 'static,
|
||||||
|
{
|
||||||
|
let version_kind = parse_version_kind(version)
|
||||||
|
.ok_or_else(|| anyhow!(r#""{version}" is not a valid version!"#))?;
|
||||||
|
if !keep_existing {
|
||||||
|
apply_version_kind(&version_kind, state)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: support this
|
||||||
|
// 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_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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: 10,
|
||||||
|
cols: 50,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
|
.with_context(|| "failed to open pty")?;
|
||||||
|
|
||||||
|
let mut reader = pair.master.try_clone_reader()?;
|
||||||
|
let mut writer = pair.master.take_writer()?;
|
||||||
|
|
||||||
|
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 cursor 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]);
|
||||||
|
on_pty_data(data);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error reading from PTY: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let cmdline = command.as_unix_command_line()?;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Sync succeeded
|
||||||
|
Ok(exit_status) if exit_status.success() => {
|
||||||
|
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, current_version) {
|
||||||
|
// TODO:
|
||||||
|
println!("Warning: Could not save previous version: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
Ok(exit_status) => {
|
||||||
|
let _ = remove_file(&state.uv_lock_path);
|
||||||
|
let code = exit_status.exit_code();
|
||||||
|
Err(anyhow!("Failed to run ({code}): {cmdline}"))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = remove_file(&state.uv_lock_path);
|
||||||
|
Err(e.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_sync_marker(state: &Paths) -> 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: &[Version]) -> Vec<Version> {
|
||||||
|
// 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.version);
|
||||||
|
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) -> Version {
|
||||||
|
let (major, minor, patch, is_prerelease) = parse_version_for_filtering(version);
|
||||||
|
|
||||||
|
if major <= 2 {
|
||||||
|
// Don't transform versions <= 2.x
|
||||||
|
return Version {
|
||||||
|
version: version.to_string(),
|
||||||
|
is_prerelease,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
let version = if version.matches('.').count() >= 2 {
|
||||||
|
format!("{major}.{normalized_minor}.{patch}{prerelease_suffix}")
|
||||||
|
} else {
|
||||||
|
format!("{major}.{normalized_minor}{prerelease_suffix}")
|
||||||
|
};
|
||||||
|
|
||||||
|
Version {
|
||||||
|
version,
|
||||||
|
is_prerelease,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_and_normalize_versions1(all_versions: Vec<String>) -> Vec<Version> {
|
||||||
|
let mut valid_versions: Vec<Version> = all_versions
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| normalize_version(&v))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Reverse to get chronological order (newest first)
|
||||||
|
valid_versions.reverse();
|
||||||
|
|
||||||
|
valid_versions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_versions(state: &Paths) -> 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: &Paths) -> Result<Versions> {
|
||||||
|
let all_versions = fetch_versions(state)?;
|
||||||
|
let all_versions = filter_and_normalize_versions1(all_versions);
|
||||||
|
|
||||||
|
let latest_patches = with_only_latest_patch(&all_versions);
|
||||||
|
let latest_releases: Vec<Version> = latest_patches.into_iter().take(5).collect();
|
||||||
|
|
||||||
|
Ok(Versions {
|
||||||
|
latest: latest_releases,
|
||||||
|
all: all_versions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_version_kind(version_kind: &VersionKind, state: &Paths) -> 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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_uninstall(state: &Paths, delete_base_folder: bool) -> Result<Option<ActionNeeded>> {
|
||||||
|
// Remove program files
|
||||||
|
anki_io::remove_dir_all(&state.uv_install_root)
|
||||||
|
.with_context(|| anyhow!("Failed to delete AnkiProgramFiles"))?;
|
||||||
|
|
||||||
|
if delete_base_folder {
|
||||||
|
anki_io::remove_dir_all(&state.anki_base_folder)
|
||||||
|
.with_context(|| anyhow!("Failed to delete anki base folder"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform-specific messages
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
return platform::mac::finalize_uninstall();
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return platform::windows::finalize_uninstall();
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
return platform::unix::finalize_uninstall();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uv_command(state: &Paths) -> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.no_cache_marker.exists() {
|
||||||
|
command.env("UV_NO_CACHE", "1");
|
||||||
|
} else {
|
||||||
|
command.env("UV_CACHE_DIR", &state.uv_cache_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// have uv use the system certstore instead of webpki-roots'
|
||||||
|
command.env("UV_NATIVE_TLS", "1");
|
||||||
|
|
||||||
|
#[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: &Paths) -> 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_mirror_urls(state: &Paths) -> 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: &Paths, 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(())
|
||||||
|
}
|
||||||
|
pub fn post_install(state: &Paths) -> Result<bool> {
|
||||||
|
// Write marker file to indicate we've completed the sync process
|
||||||
|
write_sync_marker(state)?;
|
||||||
|
|
||||||
|
// whether or not anki needs to warm up
|
||||||
|
Ok(cfg!(target_os = "macos"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn launch_anki(_state: &Paths) -> Result<()> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let cmd = self.build_python_command(&[])?;
|
||||||
|
platform::mac::prepare_for_launch_after_update(cmd, &_state.uv_install_root)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// respawn the launcher as a disconnected subprocess for normal startup
|
||||||
|
respawn_launcher()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_python_command(state: &Paths, 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 diff_launcher_was_installed(state: &Paths) -> Result<bool> {
|
||||||
|
let launcher_version = option_env!("BUILDHASH").unwrap_or("dev").trim();
|
||||||
|
let launcher_version_path = state.uv_install_root.join("launcher-version");
|
||||||
|
if let Ok(content) = read_file(&launcher_version_path) {
|
||||||
|
if let Ok(version_str) = String::from_utf8(content) {
|
||||||
|
if version_str.trim() == launcher_version {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_file(launcher_version_path, launcher_version)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub fn init() -> Result<Self> {
|
||||||
|
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 paths = Paths {
|
||||||
|
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
|
||||||
|
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() {
|
||||||
|
return Ok(Self::Uninstall(paths.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create install directory
|
||||||
|
create_dir_all(&paths.uv_install_root)?;
|
||||||
|
|
||||||
|
let launcher_requested =
|
||||||
|
paths.launcher_trigger_file.exists() || !paths.user_pyproject_path.exists();
|
||||||
|
|
||||||
|
// Calculate whether user has custom edits that need syncing
|
||||||
|
let pyproject_time = file_timestamp_secs(&paths.user_pyproject_path);
|
||||||
|
let sync_time = file_timestamp_secs(&paths.sync_complete_marker);
|
||||||
|
paths.pyproject_modified_by_user = pyproject_time > sync_time;
|
||||||
|
let pyproject_has_changed = paths.pyproject_modified_by_user;
|
||||||
|
let different_launcher = diff_launcher_was_installed(&paths)?;
|
||||||
|
|
||||||
|
// TODO: remove
|
||||||
|
let debug = cfg!(debug_assertions) && std::env::var("ANKI_LAUNCHER_SKIP").is_err();
|
||||||
|
|
||||||
|
if !launcher_requested && !pyproject_has_changed && !different_launcher && !debug {
|
||||||
|
return Ok(Self::LaunchAnki(paths.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if launcher_requested {
|
||||||
|
// Remove the trigger file to make request ephemeral
|
||||||
|
let _ = remove_file(&paths.launcher_trigger_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = ensure_os_supported() {
|
||||||
|
return Ok(Self::OsUnsupported(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self::Normal(paths.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_version() {
|
||||||
|
let normalize_version = |v| normalize_version(v).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": "1.0.0",
|
||||||
|
"identifier": "com.ichi2.anki-launcher",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "./launcher",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "./yarn lb",
|
||||||
|
"frontendDist": "../build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Anki Launcher",
|
||||||
|
"width": 800,
|
||||||
|
"height": 780,
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
12
qt/launcher-gui/src/app.html
Normal file
12
qt/launcher-gui/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%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";
|
||||||
|
};
|
||||||
74
qt/launcher-gui/src/routes/+page.svelte
Normal file
74
qt/launcher-gui/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<!--
|
||||||
|
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 * as _tr from "@generated/ftl-launcher";
|
||||||
|
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 { tr, zoomFactor } from "./stores";
|
||||||
|
import Start from "./Start.svelte";
|
||||||
|
import ErrorState from "./ErrorState.svelte";
|
||||||
|
import Normal from "./Normal.svelte";
|
||||||
|
import Uninstall from "./Uninstall.svelte";
|
||||||
|
|
||||||
|
const { data }: PageProps = $props();
|
||||||
|
|
||||||
|
let langs = $state(data.langs);
|
||||||
|
let selectedLang = $state(data.userLocale);
|
||||||
|
let flow = $state(data.state);
|
||||||
|
let mirrors = $state(data.mirrors);
|
||||||
|
let uninstallInfo = data.uninstallInfo;
|
||||||
|
|
||||||
|
async function onLangChange(lang: string) {
|
||||||
|
await setLang({ val: lang });
|
||||||
|
await setupI18n({ modules: [ModuleName.LAUNCHER] }, true);
|
||||||
|
|
||||||
|
$tr = _tr;
|
||||||
|
mirrors = (await getMirrors({})).mirrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
onLangChange(selectedLang);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
zoomWebview({ scaleFactor: $zoomFactor });
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => windowReady({}));
|
||||||
|
|
||||||
|
let footer: any = $state(null);
|
||||||
|
|
||||||
|
const uninstall = uninstallInfo.ankiProgramFilesExists
|
||||||
|
? () => {
|
||||||
|
flow.case = "uninstall";
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Start bind:selectedLang {langs} {footer}>
|
||||||
|
{#if flow.case === "normal"}
|
||||||
|
<Normal {mirrors} options={flow.value.options!} {uninstall} bind:footer />
|
||||||
|
{:else if flow.case === "uninstall"}
|
||||||
|
<Uninstall {uninstallInfo} bind:footer />
|
||||||
|
{:else if flow.case === "osUnsupported"}
|
||||||
|
<ErrorState
|
||||||
|
title={$tr.launcherOsUnsupported()}
|
||||||
|
detail={flow.value}
|
||||||
|
bind:footer
|
||||||
|
/>
|
||||||
|
{:else if flow.case === "unknownError"}
|
||||||
|
<ErrorState
|
||||||
|
title={$tr.launcherUnknownError()}
|
||||||
|
detail={flow.value}
|
||||||
|
bind:footer
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</Start>
|
||||||
24
qt/launcher-gui/src/routes/+page.ts
Normal file
24
qt/launcher-gui/src/routes/+page.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import { getLangs, getMirrors, getState, getUninstallInfo } from "@generated/backend-launcher";
|
||||||
|
import { ModuleName, setupI18n } from "@tslib/i18n";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
const i18nPromise = setupI18n({ modules: [ModuleName.LAUNCHER] }, true);
|
||||||
|
const langsPromise = getLangs({});
|
||||||
|
const statePromise = getState({});
|
||||||
|
const mirrorsPromise = getMirrors({});
|
||||||
|
const uninstallInfoPromise = getUninstallInfo({});
|
||||||
|
|
||||||
|
const [_, { userLocale, langs }, { kind: state }, { mirrors }, uninstallInfo] = await Promise.all([
|
||||||
|
i18nPromise,
|
||||||
|
langsPromise,
|
||||||
|
statePromise,
|
||||||
|
mirrorsPromise,
|
||||||
|
uninstallInfoPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { langs, userLocale, state, mirrors, uninstallInfo };
|
||||||
|
}) satisfies PageLoad;
|
||||||
118
qt/launcher-gui/src/routes/Action.svelte
Normal file
118
qt/launcher-gui/src/routes/Action.svelte
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { tr } from "./stores";
|
||||||
|
import type { ExistingVersions, Versions } from "@generated/anki/launcher_pb";
|
||||||
|
import Row from "$lib/components/Row.svelte";
|
||||||
|
import EnumSelector from "$lib/components/EnumSelector.svelte";
|
||||||
|
import Warning from "./Warning.svelte";
|
||||||
|
import Spinner from "./Spinner.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
releasesPromise,
|
||||||
|
existingPromise,
|
||||||
|
allowBetas,
|
||||||
|
choose,
|
||||||
|
uninstall,
|
||||||
|
}: {
|
||||||
|
releasesPromise: Promise<Versions>;
|
||||||
|
existingPromise: Promise<ExistingVersions>;
|
||||||
|
allowBetas: boolean;
|
||||||
|
choose: (version: string, existing: boolean, current?: string) => void;
|
||||||
|
uninstall: (() => void) | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// TODO: replace once svelte's experimental async mode is on
|
||||||
|
let releases = $state(undefined as Versions | undefined);
|
||||||
|
let existing = $state(undefined as ExistingVersions | undefined);
|
||||||
|
releasesPromise.then((r) => (releases = r));
|
||||||
|
existingPromise.then((r) => (existing = r));
|
||||||
|
|
||||||
|
let availableVersions = $derived(
|
||||||
|
releases?.all
|
||||||
|
.filter((v) => allowBetas || !v.isPrerelease)
|
||||||
|
.map((v) => ({ label: v.version, value: v.version })),
|
||||||
|
);
|
||||||
|
|
||||||
|
let latest = $derived(availableVersions?.[0]?.value);
|
||||||
|
let selected = $derived(availableVersions?.[0]?.value);
|
||||||
|
let current = $derived(existing!?.current?.version);
|
||||||
|
let pyprojectModified = $derived(existing?.pyprojectModifiedByUser);
|
||||||
|
|
||||||
|
function _choose(version: string, keepExisting: boolean = false) {
|
||||||
|
choose(version, keepExisting, current);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
{#await releasesPromise}
|
||||||
|
<Spinner label={$tr.launcherLoadingVersions()} />
|
||||||
|
{:then}
|
||||||
|
{#if latest != null && latest != current}
|
||||||
|
<Row class="centre m-3">
|
||||||
|
<button class="btn btn-primary" onclick={() => _choose(latest)}>
|
||||||
|
{#if latest == null}
|
||||||
|
{$tr.launcherLatestAnki()}
|
||||||
|
{:else}
|
||||||
|
{$tr.launcherLatestAnkiVersion({
|
||||||
|
version: latest!,
|
||||||
|
})}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
{/if}
|
||||||
|
{:catch}
|
||||||
|
<Warning warning={$tr.launcherFailedToGetReleases()} className="alert-danger" />
|
||||||
|
{/await}
|
||||||
|
{#await existingPromise}
|
||||||
|
<Spinner label={$tr.launcherCheckingExisting()} />
|
||||||
|
{:then}
|
||||||
|
{#if current != null}
|
||||||
|
<Row class="centre m-3">
|
||||||
|
<button class="btn btn-primary" onclick={() => _choose(current, true)}>
|
||||||
|
{#if pyprojectModified}
|
||||||
|
{$tr.launcherSyncProjectChanges()}
|
||||||
|
{:else}
|
||||||
|
{$tr.launcherKeepExistingVersion({ current })}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
{#if availableVersions}
|
||||||
|
<Row class="centre m-3">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={() => _choose(selected!)}
|
||||||
|
disabled={selected == null}
|
||||||
|
>
|
||||||
|
{$tr.launcherChooseAVersion()}
|
||||||
|
</button>
|
||||||
|
<div class="m-2">
|
||||||
|
{"->"}
|
||||||
|
</div>
|
||||||
|
<div style="width: 100px">
|
||||||
|
<EnumSelector bind:value={selected} choices={availableVersions} />
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
{/if}
|
||||||
|
{#if uninstall != null}
|
||||||
|
<Row class="centre m-3">
|
||||||
|
<button class="btn btn-primary" onclick={uninstall}>
|
||||||
|
{$tr.launcherUninstall()}
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global(.centre) {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
65
qt/launcher-gui/src/routes/AnkiWillStart.svelte
Normal file
65
qt/launcher-gui/src/routes/AnkiWillStart.svelte
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts" module>
|
||||||
|
let count = $state(3);
|
||||||
|
let timeout: any = $state(undefined);
|
||||||
|
let firstRun = $state(true);
|
||||||
|
let launch = $state(Promise.resolve({}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { tr } from "./stores";
|
||||||
|
import Icon from "$lib/components/Icon.svelte";
|
||||||
|
import { checkDecagramOutline } from "$lib/components/icons";
|
||||||
|
import Warning from "./Warning.svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { exit, launchAnki } from "@generated/backend-launcher";
|
||||||
|
import type { ChooseVersionResponse } from "@generated/anki/launcher_pb";
|
||||||
|
import IconConstrain from "$lib/components/IconConstrain.svelte";
|
||||||
|
import Spinner from "./Spinner.svelte";
|
||||||
|
|
||||||
|
const { res }: { res: ChooseVersionResponse } = $props();
|
||||||
|
const { warmingUp } = res;
|
||||||
|
|
||||||
|
if (firstRun) {
|
||||||
|
firstRun = false;
|
||||||
|
launch = launchAnki({});
|
||||||
|
|
||||||
|
const countdown = () => {
|
||||||
|
count -= 1;
|
||||||
|
if (count <= 0) {
|
||||||
|
exit({});
|
||||||
|
} else {
|
||||||
|
timeout = setTimeout(countdown, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!warmingUp) {
|
||||||
|
timeout = setTimeout(countdown, 1000);
|
||||||
|
onMount(() => {
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// wait for warm-up to end
|
||||||
|
launch.then(countdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await launch}
|
||||||
|
<Spinner>
|
||||||
|
<div>{$tr.launcherAnkiIsWarmingUp()}</div>
|
||||||
|
{#if warmingUp}
|
||||||
|
<div class="m-1">{$tr.launcherThisMayTake()}</div>
|
||||||
|
{/if}
|
||||||
|
</Spinner>
|
||||||
|
{:then}
|
||||||
|
<!-- TODO: replace with Spinner showing checkmark/cross -->
|
||||||
|
<Warning warning={$tr.launcherWillCloseIn({ count })} className="alert-success">
|
||||||
|
<IconConstrain iconSize={100} slot="icon">
|
||||||
|
<Icon icon={checkDecagramOutline} />
|
||||||
|
</IconConstrain>
|
||||||
|
</Warning>
|
||||||
|
{/await}
|
||||||
55
qt/launcher-gui/src/routes/ErrorState.svelte
Normal file
55
qt/launcher-gui/src/routes/ErrorState.svelte
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Icon from "$lib/components/Icon.svelte";
|
||||||
|
import IconConstrain from "$lib/components/IconConstrain.svelte";
|
||||||
|
import { alertIcon } from "$lib/components/icons";
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
footer = $bindable(),
|
||||||
|
}: { title: string; detail: string; footer: any } = $props();
|
||||||
|
|
||||||
|
footer = null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="outer">
|
||||||
|
<div class="inner">
|
||||||
|
<IconConstrain iconSize={300}>
|
||||||
|
<Icon icon={alertIcon} />
|
||||||
|
</IconConstrain>
|
||||||
|
</div>
|
||||||
|
<div class="title">{title}</div>
|
||||||
|
<div class="detail">{detail}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.outer {
|
||||||
|
margin-top: 50px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
& :global(svg) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: x-large;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
font-size: medium;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
148
qt/launcher-gui/src/routes/Normal.svelte
Normal file
148
qt/launcher-gui/src/routes/Normal.svelte
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Mirror,
|
||||||
|
type Options as OptionsProto,
|
||||||
|
type ChooseVersionResponse,
|
||||||
|
type GetMirrorsResponse_Pair,
|
||||||
|
} from "@generated/anki/launcher_pb";
|
||||||
|
import {
|
||||||
|
chooseVersion,
|
||||||
|
getAvailableVersions,
|
||||||
|
getExistingVersions,
|
||||||
|
} from "@generated/backend-launcher";
|
||||||
|
import Row from "$lib/components/Row.svelte";
|
||||||
|
import { tr } from "./stores";
|
||||||
|
import Warning from "./Warning.svelte";
|
||||||
|
import Action from "./Action.svelte";
|
||||||
|
import Spinner from "./Spinner.svelte";
|
||||||
|
import Options from "./Options.svelte";
|
||||||
|
import Term from "./Term.svelte";
|
||||||
|
import AnkiWillStart from "./AnkiWillStart.svelte";
|
||||||
|
import type { Terminal } from "@xterm/xterm";
|
||||||
|
|
||||||
|
let {
|
||||||
|
options,
|
||||||
|
mirrors,
|
||||||
|
uninstall,
|
||||||
|
footer = $bindable(),
|
||||||
|
}: {
|
||||||
|
options: OptionsProto;
|
||||||
|
mirrors: GetMirrorsResponse_Pair[];
|
||||||
|
uninstall: (() => void) | null;
|
||||||
|
footer: any; // https://github.com/sveltejs/svelte/issues/15182
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
footer = _footer;
|
||||||
|
|
||||||
|
let releasesPromise = $state(getAvailableVersions({}));
|
||||||
|
let existingPromise = $state(getExistingVersions({}));
|
||||||
|
let loadPromise = $derived(Promise.any([releasesPromise, existingPromise]));
|
||||||
|
|
||||||
|
let allowBetas = $state(options.allowBetas);
|
||||||
|
let downloadCaching = $state(options.downloadCaching);
|
||||||
|
let selectedMirror = $state(Mirror.DISABLED);
|
||||||
|
|
||||||
|
let choosePromise: Promise<ChooseVersionResponse | null> = $state(
|
||||||
|
Promise.resolve(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
let term: Terminal | undefined = $state(undefined);
|
||||||
|
let termOpen = $state(false);
|
||||||
|
let chosen = $state(false);
|
||||||
|
|
||||||
|
const choose = (version: string, keepExisting: boolean, current?: string) => {
|
||||||
|
chosen = true;
|
||||||
|
term?.reset();
|
||||||
|
choosePromise = chooseVersion({
|
||||||
|
version,
|
||||||
|
keepExisting,
|
||||||
|
options: { allowBetas, downloadCaching, mirror: selectedMirror },
|
||||||
|
...(current ? { current } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await choosePromise}
|
||||||
|
<Row class="centre m-3">
|
||||||
|
<Spinner label={$tr.launcherSyncing()} />
|
||||||
|
</Row>
|
||||||
|
{:then res}
|
||||||
|
{#if res === null}
|
||||||
|
{#await loadPromise}
|
||||||
|
<Row class="centre m-3">
|
||||||
|
<Spinner label={$tr.launcherLoadingVersions()} />
|
||||||
|
</Row>
|
||||||
|
{:then}
|
||||||
|
<Action
|
||||||
|
{releasesPromise}
|
||||||
|
{existingPromise}
|
||||||
|
{allowBetas}
|
||||||
|
{choose}
|
||||||
|
{uninstall}
|
||||||
|
/>
|
||||||
|
{#await releasesPromise catch e}
|
||||||
|
<Row>
|
||||||
|
<pre>{e.message}</pre>
|
||||||
|
</Row>
|
||||||
|
{/await}
|
||||||
|
{:catch e: AggregateError}
|
||||||
|
<Warning
|
||||||
|
warning={$tr.launcherFailedToGetExistingAndAvailable()}
|
||||||
|
className="alert-danger"
|
||||||
|
/>
|
||||||
|
{#each e.errors as err}
|
||||||
|
<Row>
|
||||||
|
<pre>{err.message}</pre>
|
||||||
|
</Row>
|
||||||
|
{/each}
|
||||||
|
{/await}
|
||||||
|
{:else}
|
||||||
|
<Row class="centre m-3">
|
||||||
|
<AnkiWillStart {res} />
|
||||||
|
</Row>
|
||||||
|
{/if}
|
||||||
|
{:catch e}
|
||||||
|
<Warning warning={$tr.launcherFailedToSync()} className="alert-danger" />
|
||||||
|
<Row>
|
||||||
|
<pre>{e.message}</pre>
|
||||||
|
</Row>
|
||||||
|
{/await}
|
||||||
|
|
||||||
|
{#snippet _footer()}
|
||||||
|
<Term bind:term bind:open={termOpen} />
|
||||||
|
{#if !chosen}
|
||||||
|
<Row class="row-columns">
|
||||||
|
<Options
|
||||||
|
{mirrors}
|
||||||
|
bind:allowBetas
|
||||||
|
bind:downloadCaching
|
||||||
|
bind:selectedMirror
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.centre) {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
qt/launcher-gui/src/routes/Options.svelte
Normal file
64
qt/launcher-gui/src/routes/Options.svelte
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { tr } from "./stores";
|
||||||
|
import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte";
|
||||||
|
import SettingTitle from "$lib/components/SettingTitle.svelte";
|
||||||
|
import SwitchRow from "$lib/components/SwitchRow.svelte";
|
||||||
|
import TitledContainer from "$lib/components/TitledContainer.svelte";
|
||||||
|
import { Mirror } from "@generated/anki/launcher_pb";
|
||||||
|
|
||||||
|
let {
|
||||||
|
allowBetas = $bindable(),
|
||||||
|
downloadCaching = $bindable(),
|
||||||
|
mirrors,
|
||||||
|
selectedMirror = $bindable(),
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const availableMirrors = $derived(
|
||||||
|
mirrors.map(({ mirror, name }) => ({
|
||||||
|
label: name,
|
||||||
|
value: mirror,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// only the labels are expected to change
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
selectedMirror = availableMirrors[0].value ?? Mirror.DISABLED;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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>
|
||||||
71
qt/launcher-gui/src/routes/Spinner.svelte
Normal file
71
qt/launcher-gui/src/routes/Spinner.svelte
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
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { pageTheme } from "$lib/sveltelib/theme";
|
||||||
|
import { type Snippet } from "svelte";
|
||||||
|
|
||||||
|
let { label = "", children }: { label?: string; children?: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- spinner taken from https://loading.io/css/; CC0 -->
|
||||||
|
<div class="progress">
|
||||||
|
<div class="spinner" class:nightMode={$pageTheme.isDark}>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div id="label">
|
||||||
|
{label}
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.spinner {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||||
|
border-color: #000 transparent transparent transparent;
|
||||||
|
}
|
||||||
|
&.nightMode div {
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
div:nth-child(1) {
|
||||||
|
animation-delay: -0.45s;
|
||||||
|
}
|
||||||
|
div:nth-child(2) {
|
||||||
|
animation-delay: -0.3s;
|
||||||
|
}
|
||||||
|
div:nth-child(3) {
|
||||||
|
animation-delay: -0.15s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#label {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
92
qt/launcher-gui/src/routes/Start.svelte
Normal file
92
qt/launcher-gui/src/routes/Start.svelte
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { type GetLangsResponse_Pair } from "@generated/anki/launcher_pb";
|
||||||
|
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 { tr } from "./stores";
|
||||||
|
|
||||||
|
let {
|
||||||
|
langs,
|
||||||
|
selectedLang = $bindable(),
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
langs: GetLangsResponse_Pair[];
|
||||||
|
selectedLang: string;
|
||||||
|
children: any;
|
||||||
|
footer: any;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const availableLangs = $derived(
|
||||||
|
langs.map((p) => ({ label: p.name, value: p.locale })),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Container
|
||||||
|
breakpoint="sm"
|
||||||
|
--gutter-inline="0.25rem"
|
||||||
|
--gutter-block="0.75rem"
|
||||||
|
class="container-columns"
|
||||||
|
>
|
||||||
|
<Row class="row-columns">
|
||||||
|
<TitledContainer>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
|
</TitledContainer>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{@render footer?.()}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
85
qt/launcher-gui/src/routes/Term.svelte
Normal file
85
qt/launcher-gui/src/routes/Term.svelte
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
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { on } from "@tslib/events";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { protoBase64 } from "@bufbuild/protobuf";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import { Event as BackendEvent } from "@generated/anki/launcher_pb";
|
||||||
|
|
||||||
|
let {
|
||||||
|
term = $bindable(),
|
||||||
|
open = $bindable(false),
|
||||||
|
}: { term: Terminal | undefined; open: boolean } = $props();
|
||||||
|
|
||||||
|
let termRef: HTMLDivElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
term = new Terminal({
|
||||||
|
fontFamily: '"Cascadia Code", Menlo, monospace',
|
||||||
|
disableStdin: true,
|
||||||
|
rows: 10,
|
||||||
|
cols: 50,
|
||||||
|
cursorStyle: "underline",
|
||||||
|
cursorInactiveStyle: "none",
|
||||||
|
altClickMovesCursor: false,
|
||||||
|
// TODO: saw this in the docs, but do we need it?
|
||||||
|
// windowsMode: navigator.platform.indexOf("Win") != -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
term.open(termRef);
|
||||||
|
|
||||||
|
// dom renderer has viewport issues, try webgl
|
||||||
|
try {
|
||||||
|
const webgl = new WebglAddon();
|
||||||
|
term.loadAddon(webgl);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("WebGL addon threw an exception during load", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlisten = listen<string>(BackendEvent[BackendEvent.PTY_DATA], (e) => {
|
||||||
|
const data = protoBase64.dec(e.payload);
|
||||||
|
open = true;
|
||||||
|
term!.write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// prevent wheel events from scrolling page if terminal has scrollback
|
||||||
|
const unsub = on(
|
||||||
|
document.querySelector(".xterm")! as HTMLElement,
|
||||||
|
"wheel",
|
||||||
|
(e) => {
|
||||||
|
if (term && term.buffer.active.baseY > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then((cb) => cb());
|
||||||
|
unsub();
|
||||||
|
term!.dispose();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="term-container centre" style:display={open ? "block" : "none"}>
|
||||||
|
<div class="term" bind:this={termRef}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.term-container {
|
||||||
|
display: flex;
|
||||||
|
margin: 20px 0 50px;
|
||||||
|
background-color: black;
|
||||||
|
border-radius: var(--border-radius-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.term {
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
124
qt/launcher-gui/src/routes/Uninstall.svelte
Normal file
124
qt/launcher-gui/src/routes/Uninstall.svelte
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { uninstallAnki } from "@generated/backend-launcher";
|
||||||
|
import type { Uninstall, UninstallResponse } from "@generated/anki/launcher_pb";
|
||||||
|
import SettingTitle from "$lib/components/SettingTitle.svelte";
|
||||||
|
import SwitchRow from "$lib/components/SwitchRow.svelte";
|
||||||
|
import Row from "$lib/components/Row.svelte";
|
||||||
|
import Warning from "./Warning.svelte";
|
||||||
|
import Spinner from "./Spinner.svelte";
|
||||||
|
import { tr } from "./stores";
|
||||||
|
|
||||||
|
let {
|
||||||
|
uninstallInfo,
|
||||||
|
footer = $bindable(),
|
||||||
|
}: { uninstallInfo: Uninstall; footer: any } = $props();
|
||||||
|
|
||||||
|
footer = null;
|
||||||
|
|
||||||
|
let uninstallPromise: Promise<UninstallResponse | void> = $state(Promise.resolve());
|
||||||
|
|
||||||
|
let deleteBaseFolder = $state(false);
|
||||||
|
|
||||||
|
function confirmUninstall() {
|
||||||
|
uninstallPromise = uninstallAnki({ deleteBaseFolder });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await uninstallPromise}
|
||||||
|
<Row class="centre m-3">
|
||||||
|
<Spinner label={$tr.launcherUninstalling()} />
|
||||||
|
</Row>
|
||||||
|
{:then res}
|
||||||
|
{#if !res}
|
||||||
|
<div class="group">
|
||||||
|
<SwitchRow
|
||||||
|
bind:value={deleteBaseFolder}
|
||||||
|
defaultValue={false}
|
||||||
|
disabled={!uninstallInfo.ankiBaseFolderExists}
|
||||||
|
hideRevert
|
||||||
|
>
|
||||||
|
<SettingTitle>
|
||||||
|
{$tr.launcherRemoveAllProfilesConfirm()}
|
||||||
|
</SettingTitle>
|
||||||
|
</SwitchRow>
|
||||||
|
{#if deleteBaseFolder}
|
||||||
|
<Warning
|
||||||
|
warning={$tr.launcherRemoveProfilesWarning()}
|
||||||
|
className="alert-danger"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<Row class="centre m-3">
|
||||||
|
<button class="btn btn-primary" onclick={confirmUninstall}>
|
||||||
|
{deleteBaseFolder
|
||||||
|
? $tr.launcherUninstallConfirmAndRemoveProfiles()
|
||||||
|
: $tr.launcherUninstallConfirm()}
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@const kind = res.actionNeeded?.case}
|
||||||
|
{#if !kind}
|
||||||
|
<Row class="centre">
|
||||||
|
<Warning
|
||||||
|
warning={$tr.launcherUninstallComplete()}
|
||||||
|
className="alert-success"
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
{:else}
|
||||||
|
<Row class="centre">
|
||||||
|
<Warning
|
||||||
|
warning={$tr.launcherUninstallActionNeeded()}
|
||||||
|
className="alert-warning"
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
{#if kind === "unixScript"}
|
||||||
|
<Row class="centre mb-3">
|
||||||
|
{$tr.launcherUninstallUnix({ path: res.actionNeeded.value })}
|
||||||
|
</Row>
|
||||||
|
{:else if kind === "macManual"}
|
||||||
|
<Row class="centre mb-3">
|
||||||
|
{$tr.launcherUninstallMac()}
|
||||||
|
</Row>
|
||||||
|
{:else if kind === "windowsInstallerNotFound"}
|
||||||
|
<Row class="centre mb-3">
|
||||||
|
{$tr.launcherUninstallWinNotFound()}
|
||||||
|
</Row>
|
||||||
|
<Row class="centre mb-3">
|
||||||
|
{$tr.launcherUninstallWinNotFoundExtra()}
|
||||||
|
</Row>
|
||||||
|
{:else}
|
||||||
|
{@const { error, path } = res.actionNeeded.value}
|
||||||
|
<Row class="centre mb-3">
|
||||||
|
{$tr.launcherUninstallWinFailed()}
|
||||||
|
</Row>
|
||||||
|
<Row class="centre mb-3">
|
||||||
|
{$tr.launcherUninstallWinFailedExtra({ path })}
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<pre>{error}</pre>
|
||||||
|
</Row>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{:catch e}
|
||||||
|
<Warning warning={$tr.launcherFailedToUninstall()} className="alert-danger" />
|
||||||
|
<Row>
|
||||||
|
<pre>{e.message}</pre>
|
||||||
|
</Row>
|
||||||
|
{/await}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.group {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.centre) {
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
qt/launcher-gui/src/routes/Warning.svelte
Normal file
28
qt/launcher-gui/src/routes/Warning.svelte
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<!--
|
||||||
|
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 class="centre m-3">
|
||||||
|
<div class="col-12 alert {className} mb-0" in:slide out:slide>
|
||||||
|
<slot name="icon" />
|
||||||
|
{withoutUnicodeIsolation(warning)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.centre {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
24
qt/launcher-gui/src/routes/base.scss
Normal file
24
qt/launcher-gui/src/routes/base.scss
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// follows AnkiWebView.standard_css
|
||||||
|
html {
|
||||||
|
font-family: "Segoe UI", "Helvetica", sans-serif;
|
||||||
|
}
|
||||||
8
qt/launcher-gui/src/routes/stores.ts
Normal file
8
qt/launcher-gui/src/routes/stores.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import * as _tr from "@generated/ftl-launcher";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
export const zoomFactor = writable(1.2);
|
||||||
|
export const tr = writable(_tr);
|
||||||
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 |
BIN
qt/launcher-gui/static/favicon.ico
Normal file
BIN
qt/launcher-gui/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 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"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
@ -28,7 +28,7 @@ fn main() -> Result<()> {
|
||||||
let mut modules = get_modules(&map);
|
let mut modules = get_modules(&map);
|
||||||
write_strings(&map, &modules, "strings.rs", "All");
|
write_strings(&map, &modules, "strings.rs", "All");
|
||||||
|
|
||||||
typescript::write_ts_interface(&modules)?;
|
typescript::write_ts_interface(&modules, "STRINGS_TS")?;
|
||||||
python::write_py_interface(&modules)?;
|
python::write_py_interface(&modules)?;
|
||||||
|
|
||||||
// write strings.json file to requested path
|
// write strings.json file to requested path
|
||||||
|
|
@ -48,5 +48,7 @@ fn main() -> Result<()> {
|
||||||
modules.retain(|module| module.name == "launcher");
|
modules.retain(|module| module.name == "launcher");
|
||||||
write_strings(&map, &modules, "strings_launcher.rs", "Launcher");
|
write_strings(&map, &modules, "strings_launcher.rs", "Launcher");
|
||||||
|
|
||||||
|
typescript::write_ts_interface(&modules, "STRINGS_LAUNCHER_TS")?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -467,10 +467,10 @@ fn format_number_values(val: &FluentValue, alt_separator: Option<&'static str>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ResourcesForJavascript {
|
pub struct ResourcesForJavascript {
|
||||||
langs: Vec<String>,
|
pub langs: Vec<String>,
|
||||||
resources: Vec<String>,
|
pub resources: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn without_unicode_isolation(s: &str) -> String {
|
pub fn without_unicode_isolation(s: &str) -> String {
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,14 @@ use crate::extract::Module;
|
||||||
use crate::extract::Variable;
|
use crate::extract::Variable;
|
||||||
use crate::extract::VariableKind;
|
use crate::extract::VariableKind;
|
||||||
|
|
||||||
pub fn write_ts_interface(modules: &[Module]) -> Result<()> {
|
pub fn write_ts_interface(modules: &[Module], path_key: &str) -> Result<()> {
|
||||||
let mut ts_out = header();
|
let mut ts_out = header();
|
||||||
write_imports(&mut ts_out);
|
write_imports(&mut ts_out);
|
||||||
|
|
||||||
render_module_map(modules, &mut ts_out);
|
render_module_map(modules, &mut ts_out);
|
||||||
render_methods(modules, &mut ts_out);
|
render_methods(modules, &mut ts_out);
|
||||||
|
|
||||||
if let Ok(path) = env::var("STRINGS_TS") {
|
if let Ok(path) = env::var(path_key) {
|
||||||
let path = PathBuf::from(path);
|
let path = PathBuf::from(path);
|
||||||
create_dir_all(path.parent().unwrap())?;
|
create_dir_all(path.parent().unwrap())?;
|
||||||
write_file_if_changed(path, ts_out)?;
|
write_file_if_changed(path, ts_out)?;
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,16 @@ fn main() -> Result<()> {
|
||||||
|
|
||||||
let pool = rust::write_rust_protos(descriptors_path)?;
|
let pool = rust::write_rust_protos(descriptors_path)?;
|
||||||
let (_, services) = get_services(&pool);
|
let (_, services) = get_services(&pool);
|
||||||
|
|
||||||
|
let (services, launcher_services): (Vec<_>, Vec<_>) = services
|
||||||
|
.into_iter()
|
||||||
|
.partition(|s| !s.name.trim_start_matches("Backend").starts_with("Launcher"));
|
||||||
|
|
||||||
python::write_python_interface(&services)?;
|
python::write_python_interface(&services)?;
|
||||||
typescript::write_ts_interface(&services)?;
|
typescript::write_ts_interface(&services, false)?;
|
||||||
|
|
||||||
|
// for launcher-gui
|
||||||
|
typescript::write_ts_interface(&launcher_services, true)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ pub fn write_rust_protos(descriptors_path: PathBuf) -> Result<DescriptorPool> {
|
||||||
"ImportAnkiPackageUpdateCondition",
|
"ImportAnkiPackageUpdateCondition",
|
||||||
"#[derive(serde::Deserialize, serde::Serialize)]",
|
"#[derive(serde::Deserialize, serde::Serialize)]",
|
||||||
)
|
)
|
||||||
|
.type_attribute(".anki.launcher.Mirror", "#[derive(strum::EnumIter)]")
|
||||||
.compile_protos(paths.as_slice(), &[proto_dir])
|
.compile_protos(paths.as_slice(), &[proto_dir])
|
||||||
.context("prost build")?;
|
.context("prost build")?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,3 +37,4 @@ protobuf!(stats, "stats");
|
||||||
protobuf!(sync, "sync");
|
protobuf!(sync, "sync");
|
||||||
protobuf!(tags, "tags");
|
protobuf!(tags, "tags");
|
||||||
protobuf!(ankihub, "ankihub");
|
protobuf!(ankihub, "ankihub");
|
||||||
|
protobuf!(launcher, "launcher");
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ use anyhow::Result;
|
||||||
use inflections::Inflect;
|
use inflections::Inflect;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
|
||||||
pub(crate) fn write_ts_interface(services: &[BackendService]) -> Result<()> {
|
pub(crate) fn write_ts_interface(services: &[BackendService], is_launcher: bool) -> Result<()> {
|
||||||
let root = Path::new("../../out/ts/lib/generated");
|
let root = Path::new("../../out/ts/lib/generated");
|
||||||
create_dir_all(root)?;
|
create_dir_all(root)?;
|
||||||
|
|
||||||
|
|
@ -29,13 +29,17 @@ pub(crate) fn write_ts_interface(services: &[BackendService]) -> Result<()> {
|
||||||
let method = MethodDetails::from_method(method);
|
let method = MethodDetails::from_method(method);
|
||||||
record_referenced_type(&mut referenced_packages, &method.input_type);
|
record_referenced_type(&mut referenced_packages, &method.input_type);
|
||||||
record_referenced_type(&mut referenced_packages, &method.output_type);
|
record_referenced_type(&mut referenced_packages, &method.output_type);
|
||||||
write_ts_method(&method, &mut ts_out);
|
write_ts_method(&method, &mut ts_out, is_launcher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let imports = imports(referenced_packages);
|
let imports = imports(referenced_packages);
|
||||||
write_file_if_changed(
|
write_file_if_changed(
|
||||||
root.join("backend.ts"),
|
root.join(if is_launcher {
|
||||||
|
"backend-launcher.ts"
|
||||||
|
} else {
|
||||||
|
"backend.ts"
|
||||||
|
}),
|
||||||
format!("{}{}{}", ts_header(), imports, ts_out),
|
format!("{}{}{}", ts_header(), imports, ts_out),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|
@ -75,12 +79,19 @@ fn write_ts_method(
|
||||||
comments,
|
comments,
|
||||||
}: &MethodDetails,
|
}: &MethodDetails,
|
||||||
out: &mut String,
|
out: &mut String,
|
||||||
|
is_launcher: bool,
|
||||||
) {
|
) {
|
||||||
let comments = format_comments(comments);
|
let comments = format_comments(comments);
|
||||||
|
let proto_method_name = method_name;
|
||||||
|
let options = if is_launcher {
|
||||||
|
"{ ...options, customProtocol: true, alertOnError: false }"
|
||||||
|
} else {
|
||||||
|
"options"
|
||||||
|
};
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
r#"{comments}export async function {method_name}(input: PlainMessage<{input_type}>, options?: PostProtoOptions): Promise<{output_type}> {{
|
r#"{comments}export async function {method_name}(input: PlainMessage<{input_type}>, options?: PostProtoOptions): Promise<{output_type}> {{
|
||||||
return await postProto("{method_name}", new {input_type}(input), {output_type}, options);
|
return await postProto("{proto_method_name}", new {input_type}(input), {output_type}, {options});
|
||||||
}}"#
|
}}"#
|
||||||
).unwrap()
|
).unwrap()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,20 @@ pub fn write_rust_interface(pool: &DescriptorPool) -> Result<()> {
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
buf.push_str("use crate::error::Result; use prost::Message;");
|
buf.push_str("use crate::error::Result; use prost::Message;");
|
||||||
|
|
||||||
|
// TODO: we're misusing this for the launcher
|
||||||
let (col_services, backend_services) = get_services(pool);
|
let (col_services, backend_services) = get_services(pool);
|
||||||
let col_services = col_services
|
let col_services = col_services
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|s| s.name != "FrontendService")
|
.filter(|s| !matches!(&*s.name, "FrontendService" | "LauncherService"))
|
||||||
.collect_vec();
|
.collect_vec();
|
||||||
let backend_services = backend_services
|
let backend_services = backend_services
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|s| s.name != "BackendFrontendService")
|
.filter(|s| {
|
||||||
|
!matches!(
|
||||||
|
&*s.name,
|
||||||
|
"BackendFrontendService" | "BackendLauncherService"
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect_vec();
|
.collect_vec();
|
||||||
|
|
||||||
render_collection_services(&col_services, &mut buf)?;
|
render_collection_services(&col_services, &mut buf)?;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const IGNORED_FOLDERS: &[&str] = &[
|
||||||
".mypy_cache",
|
".mypy_cache",
|
||||||
"./extra",
|
"./extra",
|
||||||
"./ts/.svelte-kit",
|
"./ts/.svelte-kit",
|
||||||
|
"./qt/launcher-gui/.svelte-kit",
|
||||||
];
|
];
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
export let choices: Choice<T>[];
|
export let choices: Choice<T>[];
|
||||||
export let disabled: boolean = false;
|
export let disabled: boolean = false;
|
||||||
export let disabledChoices: T[] = [];
|
export let disabledChoices: T[] = [];
|
||||||
|
export let hideRevert: boolean = false;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Row --cols={13}>
|
<Row --cols={13}>
|
||||||
|
|
@ -27,7 +28,9 @@
|
||||||
<Col --col-size={6} {breakpoint}>
|
<Col --col-size={6} {breakpoint}>
|
||||||
<ConfigInput>
|
<ConfigInput>
|
||||||
<EnumSelector bind:value {choices} {disabled} {disabledChoices} />
|
<EnumSelector bind:value {choices} {disabled} {disabledChoices} />
|
||||||
<RevertButton slot="revert" bind:value {defaultValue} />
|
{#if !hideRevert}
|
||||||
|
<RevertButton slot="revert" bind:value {defaultValue} />
|
||||||
|
{/if}
|
||||||
</ConfigInput>
|
</ConfigInput>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
padding: var(--popover-padding-block, 6px) var(--popover-padding-inline, 6px);
|
padding: var(--popover-padding-block, 6px) var(--popover-padding-inline, 6px);
|
||||||
|
|
||||||
&.scrollable {
|
&.scrollable {
|
||||||
max-height: 400px;
|
max-height: 200px;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
export let value: boolean;
|
export let value: boolean;
|
||||||
export let defaultValue: boolean;
|
export let defaultValue: boolean;
|
||||||
export let disabled: boolean = false;
|
export let disabled: boolean = false;
|
||||||
|
export let hideRevert: boolean = false;
|
||||||
|
|
||||||
const id = Math.random().toString(36).substring(2);
|
const id = Math.random().toString(36).substring(2);
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -22,7 +23,9 @@
|
||||||
<Col --col-justify="flex-end">
|
<Col --col-justify="flex-end">
|
||||||
<ConfigInput grow={false}>
|
<ConfigInput grow={false}>
|
||||||
<Switch {id} bind:value {disabled} />
|
<Switch {id} bind:value {disabled} />
|
||||||
<RevertButton slot="revert" bind:value {defaultValue} />
|
{#if !hideRevert}
|
||||||
|
<RevertButton slot="revert" bind:value {defaultValue} />
|
||||||
|
{/if}
|
||||||
</ConfigInput>
|
</ConfigInput>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
let className: string = "";
|
let className: string = "";
|
||||||
export { className as class };
|
export { className as class };
|
||||||
|
|
||||||
export let title: string;
|
export let title: string = "";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -24,6 +24,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
style:--container-margin="0"
|
style:--container-margin="0"
|
||||||
>
|
>
|
||||||
<div class="position-relative">
|
<div class="position-relative">
|
||||||
|
<slot name="title" />
|
||||||
<h1>
|
<h1>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import AlignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?component";
|
||||||
import alignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?url";
|
import alignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?url";
|
||||||
import CheckCircle_ from "@mdi/svg/svg/check-circle.svg?component";
|
import CheckCircle_ from "@mdi/svg/svg/check-circle.svg?component";
|
||||||
import checkCircle_ from "@mdi/svg/svg/check-circle.svg?url";
|
import checkCircle_ from "@mdi/svg/svg/check-circle.svg?url";
|
||||||
|
import CheckDecagramOutline_ from "@mdi/svg/svg/check-decagram-outline.svg?component";
|
||||||
|
import checkDecagramOutline_ from "@mdi/svg/svg/check-decagram-outline.svg?url";
|
||||||
import ChevronDown_ from "@mdi/svg/svg/chevron-down.svg?component";
|
import ChevronDown_ from "@mdi/svg/svg/chevron-down.svg?component";
|
||||||
import chevronDown_ from "@mdi/svg/svg/chevron-down.svg?url";
|
import chevronDown_ from "@mdi/svg/svg/chevron-down.svg?url";
|
||||||
import ChevronUp_ from "@mdi/svg/svg/chevron-up.svg?component";
|
import ChevronUp_ from "@mdi/svg/svg/chevron-up.svg?component";
|
||||||
|
|
@ -192,6 +194,7 @@ import StickySolid_ from "../../icons/sticky-pin-solid.svg?component";
|
||||||
import stickySolid_ from "../../icons/sticky-pin-solid.svg?url";
|
import stickySolid_ from "../../icons/sticky-pin-solid.svg?url";
|
||||||
|
|
||||||
export const checkCircle = { url: checkCircle_, component: CheckCircle_ };
|
export const checkCircle = { url: checkCircle_, component: CheckCircle_ };
|
||||||
|
export const checkDecagramOutline = { url: checkDecagramOutline_, component: CheckDecagramOutline_ };
|
||||||
export const chevronDown = { url: chevronDown_, component: ChevronDown_ };
|
export const chevronDown = { url: chevronDown_, component: ChevronDown_ };
|
||||||
export const chevronUp = { url: chevronUp_, component: ChevronUp_ };
|
export const chevronUp = { url: chevronUp_, component: ChevronUp_ };
|
||||||
export const closeBox = { url: closeBox_, component: CloseBox_ };
|
export const closeBox = { url: closeBox_, component: CloseBox_ };
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,27 @@
|
||||||
export interface PostProtoOptions {
|
export interface PostProtoOptions {
|
||||||
/** True by default. Shows a dialog with the error message, then rethrows. */
|
/** True by default. Shows a dialog with the error message, then rethrows. */
|
||||||
alertOnError?: boolean;
|
alertOnError?: boolean;
|
||||||
|
// whether to use the "anki:" custom protocol or not
|
||||||
|
customProtocol?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IS_WINDOWS = navigator.platform.startsWith("Win");
|
||||||
|
const CUSTOM_PROTOCOL_URI = IS_WINDOWS ? "http://anki.localhost" : "anki://localhost";
|
||||||
|
|
||||||
export async function postProto<T>(
|
export async function postProto<T>(
|
||||||
method: string,
|
method: string,
|
||||||
input: { toBinary(): Uint8Array; getType(): { typeName: string } },
|
input: { toBinary(): Uint8Array; getType(): { typeName: string } },
|
||||||
outputType: { fromBinary(arr: Uint8Array): T },
|
outputType: { fromBinary(arr: Uint8Array): T },
|
||||||
options: PostProtoOptions = {},
|
options: PostProtoOptions = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
const { alertOnError = true, customProtocol = false } = options;
|
||||||
try {
|
try {
|
||||||
const inputBytes = input.toBinary();
|
const inputBytes = input.toBinary();
|
||||||
const path = `/_anki/${method}`;
|
const backendUrl = customProtocol ? CUSTOM_PROTOCOL_URI : "/_anki";
|
||||||
|
const path = `${backendUrl}/${method}`;
|
||||||
const outputBytes = await postProtoInner(path, inputBytes);
|
const outputBytes = await postProtoInner(path, inputBytes);
|
||||||
return outputType.fromBinary(outputBytes);
|
return outputType.fromBinary(outputBytes);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { alertOnError = true } = options;
|
|
||||||
if (alertOnError && !(err instanceof Error && err.message === "500: Interrupted")) {
|
if (alertOnError && !(err instanceof Error && err.message === "500: Interrupted")) {
|
||||||
alert(err);
|
alert(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import "intl-pluralrules";
|
import "intl-pluralrules";
|
||||||
|
|
||||||
import { i18nResources } from "@generated/backend";
|
import { i18nResources } from "@generated/backend";
|
||||||
|
import { i18nResources as launcherI18nResources } from "@generated/backend-launcher";
|
||||||
import type { ModuleName } from "@generated/ftl";
|
import type { ModuleName } from "@generated/ftl";
|
||||||
import { FluentBundle, FluentResource } from "@generated/ftl";
|
import { FluentBundle, FluentResource } from "@generated/ftl";
|
||||||
import { firstLanguage, setBundles } from "@generated/ftl";
|
import { firstLanguage, setBundles } from "@generated/ftl";
|
||||||
|
|
@ -77,8 +78,9 @@ export function withoutUnicodeIsolation(s: string): string {
|
||||||
return s.replace(/[\u2068-\u2069]+/g, "");
|
return s.replace(/[\u2068-\u2069]+/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupI18n(args: { modules: ModuleName[] }): Promise<void> {
|
export async function setupI18n(args: { modules: ModuleName[] }, launcher = false): Promise<void> {
|
||||||
const resources = await i18nResources(args);
|
const fn = launcher ? launcherI18nResources : i18nResources;
|
||||||
|
const resources = await fn(args);
|
||||||
const json = JSON.parse(new TextDecoder().decode(resources.json));
|
const json = JSON.parse(new TextDecoder().decode(resources.json));
|
||||||
|
|
||||||
const newBundles: FluentBundle[] = [];
|
const newBundles: FluentBundle[] = [];
|
||||||
|
|
|
||||||
36
yarn.lock
36
yarn.lock
|
|
@ -1116,6 +1116,22 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@tauri-apps/api@npm:^2, @tauri-apps/api@npm:^2.8.0":
|
||||||
|
version: 2.8.0
|
||||||
|
resolution: "@tauri-apps/api@npm:2.8.0"
|
||||||
|
checksum: 10c0/fb111e4d7572372997b440ebe6879543fa8c4765151878e3fddfbfe809b18da29eed142ce83061d14a9ca6d896b3266dc8a4927c642d71cdc0b4277dc7e3aabf
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@tauri-apps/plugin-log@npm:^2.7.0":
|
||||||
|
version: 2.7.0
|
||||||
|
resolution: "@tauri-apps/plugin-log@npm:2.7.0"
|
||||||
|
dependencies:
|
||||||
|
"@tauri-apps/api": "npm:^2.8.0"
|
||||||
|
checksum: 10c0/c699710898a666ebc888ec8b2a3f36d5887e5cc06f8c69e9b404438407f6a0df19840e00732a3651aa83ba5f056cba403a5a14c556d3097368e143bfe995b1a8
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@tootallnate/once@npm:2":
|
"@tootallnate/once@npm:2":
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
resolution: "@tootallnate/once@npm:2.0.0"
|
resolution: "@tootallnate/once@npm:2.0.0"
|
||||||
|
|
@ -1787,6 +1803,22 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@xterm/addon-webgl@npm:^0.18.0":
|
||||||
|
version: 0.18.0
|
||||||
|
resolution: "@xterm/addon-webgl@npm:0.18.0"
|
||||||
|
peerDependencies:
|
||||||
|
"@xterm/xterm": ^5.0.0
|
||||||
|
checksum: 10c0/682a3f5f128ee09a0cf1b41cbb7b2f925a5e43056e12ba0c523b93a1f5f188045caef9e31f32db933b8a7a1b12d8f9babaddfa11e6f11df0c7b265009103476c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@xterm/xterm@npm:^5.5.0":
|
||||||
|
version: 5.5.0
|
||||||
|
resolution: "@xterm/xterm@npm:5.5.0"
|
||||||
|
checksum: 10c0/358801feece58617d777b2783bec68dac1f52f736da3b0317f71a34f4e25431fb0b1920244f678b8d673f797145b4858c2a5ccb463a4a6df7c10c9093f1c9267
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"abab@npm:^2.0.5, abab@npm:^2.0.6":
|
"abab@npm:^2.0.5, abab@npm:^2.0.6":
|
||||||
version: 2.0.6
|
version: 2.0.6
|
||||||
resolution: "abab@npm:2.0.6"
|
resolution: "abab@npm:2.0.6"
|
||||||
|
|
@ -1904,6 +1936,8 @@ __metadata:
|
||||||
"@sveltejs/adapter-static": "npm:^3.0.0"
|
"@sveltejs/adapter-static": "npm:^3.0.0"
|
||||||
"@sveltejs/kit": "npm:^2.22.2"
|
"@sveltejs/kit": "npm:^2.22.2"
|
||||||
"@sveltejs/vite-plugin-svelte": "npm:5.1"
|
"@sveltejs/vite-plugin-svelte": "npm:5.1"
|
||||||
|
"@tauri-apps/api": "npm:^2"
|
||||||
|
"@tauri-apps/plugin-log": "npm:^2.7.0"
|
||||||
"@types/bootstrap": "npm:^5.0.12"
|
"@types/bootstrap": "npm:^5.0.12"
|
||||||
"@types/codemirror": "npm:^5.60.0"
|
"@types/codemirror": "npm:^5.60.0"
|
||||||
"@types/d3": "npm:^7.0.0"
|
"@types/d3": "npm:^7.0.0"
|
||||||
|
|
@ -1916,6 +1950,8 @@ __metadata:
|
||||||
"@types/node": "npm:^22"
|
"@types/node": "npm:^22"
|
||||||
"@typescript-eslint/eslint-plugin": "npm:^5.60.1"
|
"@typescript-eslint/eslint-plugin": "npm:^5.60.1"
|
||||||
"@typescript-eslint/parser": "npm:^5.60.1"
|
"@typescript-eslint/parser": "npm:^5.60.1"
|
||||||
|
"@xterm/addon-webgl": "npm:^0.18.0"
|
||||||
|
"@xterm/xterm": "npm:^5.5.0"
|
||||||
bootstrap: "npm:^5.3.0"
|
bootstrap: "npm:^5.3.0"
|
||||||
bootstrap-icons: "npm:^1.10.5"
|
bootstrap-icons: "npm:^1.10.5"
|
||||||
caniuse-lite: "npm:^1.0.30001431"
|
caniuse-lite: "npm:^1.0.30001431"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue