From 35a889e1ed8174e7e64c40358d48a8e09b82e8df Mon Sep 17 00:00:00 2001 From: llama Date: Tue, 22 Jul 2025 19:11:33 +0800 Subject: [PATCH 01/18] Prioritise prefix matches in tag autocomplete results (#4212) * prioritise prefix matches in tag autocomplete results * update/add tests * fix lint was fine locally though? --- rslib/src/tags/complete.rs | 58 ++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/rslib/src/tags/complete.rs b/rslib/src/tags/complete.rs index 1093017b0..f995b63a2 100644 --- a/rslib/src/tags/complete.rs +++ b/rslib/src/tags/complete.rs @@ -12,14 +12,20 @@ impl Collection { .map(component_to_regex) .collect::>()?; let mut tags = vec![]; + let mut priority = vec![]; self.storage.get_tags_by_predicate(|tag| { - if tags.len() <= limit && filters_match(&filters, tag) { - tags.push(tag.to_string()); + if priority.len() + tags.len() <= limit { + match filters_match(&filters, tag) { + Some(true) => priority.push(tag.to_string()), + Some(_) => tags.push(tag.to_string()), + _ => {} + } } // we only need the tag name false })?; - Ok(tags) + priority.append(&mut tags); + Ok(priority) } } @@ -27,20 +33,26 @@ fn component_to_regex(component: &str) -> Result { Regex::new(&format!("(?i){}", regex::escape(component))).map_err(Into::into) } -fn filters_match(filters: &[Regex], tag: &str) -> bool { +/// Returns None if tag wasn't a match, otherwise whether it was a consecutive +/// prefix match +fn filters_match(filters: &[Regex], tag: &str) -> Option { let mut remaining_tag_components = tag.split("::"); + let mut is_prefix = true; 'outer: for filter in filters { loop { if let Some(component) = remaining_tag_components.next() { - if filter.is_match(component) { + if let Some(m) = filter.find(component) { + is_prefix &= m.start() == 0; continue 'outer; + } else { + is_prefix = false; } } else { - return false; + return None; } } } - true + Some(is_prefix) } #[cfg(test)] @@ -50,28 +62,32 @@ mod test { #[test] fn matching() -> Result<()> { let filters = &[component_to_regex("b")?]; - assert!(filters_match(filters, "ABC")); - assert!(filters_match(filters, "ABC::def")); - assert!(filters_match(filters, "def::abc")); - assert!(!filters_match(filters, "def")); + assert!(filters_match(filters, "ABC").is_some()); + assert!(filters_match(filters, "ABC::def").is_some()); + assert!(filters_match(filters, "def::abc").is_some()); + assert!(filters_match(filters, "def").is_none()); let filters = &[component_to_regex("b")?, component_to_regex("E")?]; - assert!(!filters_match(filters, "ABC")); - assert!(filters_match(filters, "ABC::def")); - assert!(!filters_match(filters, "def::abc")); - assert!(!filters_match(filters, "def")); + assert!(filters_match(filters, "ABC").is_none()); + assert!(filters_match(filters, "ABC::def").is_some()); + assert!(filters_match(filters, "def::abc").is_none()); + assert!(filters_match(filters, "def").is_none()); let filters = &[ component_to_regex("a")?, component_to_regex("c")?, component_to_regex("e")?, ]; - assert!(!filters_match(filters, "ace")); - assert!(!filters_match(filters, "a::c")); - assert!(!filters_match(filters, "c::e")); - assert!(filters_match(filters, "a::c::e")); - assert!(filters_match(filters, "a::b::c::d::e")); - assert!(filters_match(filters, "1::a::b::c::d::e::f")); + assert!(filters_match(filters, "ace").is_none()); + assert!(filters_match(filters, "a::c").is_none()); + assert!(filters_match(filters, "c::e").is_none()); + assert!(filters_match(filters, "a::c::e").is_some()); + assert!(filters_match(filters, "a::b::c::d::e").is_some()); + assert!(filters_match(filters, "1::a::b::c::d::e::f").is_some()); + + assert_eq!(filters_match(filters, "a1::c2::e3"), Some(true)); + assert_eq!(filters_match(filters, "a1::c2::?::e4"), Some(false)); + assert_eq!(filters_match(filters, "a1::c2::3e"), Some(false)); Ok(()) } From 47c10941959d927c59fa2fe0f556d0f7b3978fce Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Tue, 22 Jul 2025 16:56:44 +0530 Subject: [PATCH 02/18] Add last_review_time to _to_backend_card (#4218) Presumably, without this change, add-ons would delete the value of last_review_time from the card when they modify the card. --- pylib/anki/cards.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pylib/anki/cards.py b/pylib/anki/cards.py index 854d4ed18..b8154510e 100644 --- a/pylib/anki/cards.py +++ b/pylib/anki/cards.py @@ -133,6 +133,7 @@ class Card(DeprecatedNamesMixin): memory_state=self.memory_state, desired_retention=self.desired_retention, decay=self.decay, + last_review_time_secs=self.last_review_time, ) @deprecated(info="please use col.update_card()") From 1f3d03f7f8600b501acfde3913122672e05aeff3 Mon Sep 17 00:00:00 2001 From: llama Date: Tue, 22 Jul 2025 19:32:42 +0800 Subject: [PATCH 03/18] add io mask rotation snapping (#4214) --- ts/routes/image-occlusion/mask-editor.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ts/routes/image-occlusion/mask-editor.ts b/ts/routes/image-occlusion/mask-editor.ts index c5492d413..b632abf98 100644 --- a/ts/routes/image-occlusion/mask-editor.ts +++ b/ts/routes/image-occlusion/mask-editor.ts @@ -106,6 +106,9 @@ function initCanvas(): fabric.Canvas { fabric.Object.prototype.cornerStyle = "circle"; fabric.Object.prototype.cornerStrokeColor = "#000000"; fabric.Object.prototype.padding = 8; + // snap rotation around 0 by +-3deg + fabric.Object.prototype.snapAngle = 360; + fabric.Object.prototype.snapThreshold = 3; // disable rotation when selecting canvas.on("selection:created", () => { const g = canvas.getActiveObject(); From 229337dbe072060f96e0e1c794528131371bdd6c Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 24 Jul 2025 18:40:41 +0700 Subject: [PATCH 04/18] Work around Conda breaking launcher Closes #4216 --- qt/launcher/src/main.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 903cc2b60..15320e84b 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -275,6 +275,20 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re } } + // remove CONDA_PREFIX/bin from PATH to avoid conda interference + #[cfg(target_os = "macos")] + 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); + } + } + command .env("UV_CACHE_DIR", &state.uv_cache_dir) .env("UV_PYTHON_INSTALL_DIR", &state.uv_python_install_dir) From 19f9afba644c5e219e7305c87d48887d59db4a5d Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 24 Jul 2025 18:49:29 +0700 Subject: [PATCH 05/18] Start requiring two channels for recording Closes #4225 --- qt/aqt/sound.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index d20365232..25fe07e53 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -633,7 +633,7 @@ class QtAudioInputRecorder(Recorder): from PyQt6.QtMultimedia import QAudioFormat, QAudioSource # type: ignore format = QAudioFormat() - format.setChannelCount(1) + format.setChannelCount(2) format.setSampleRate(44100) format.setSampleFormat(QAudioFormat.SampleFormat.Int16) From ef69f424c18cdca2e4b698d3c702654bd834ae80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Jul 2025 18:51:12 +0700 Subject: [PATCH 06/18] Bump form-data from 4.0.1 to 4.0.4 (#4219) Bumps [form-data](https://github.com/form-data/form-data) from 4.0.1 to 4.0.4. - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.1...v4.0.4) --- updated-dependencies: - dependency-name: form-data dependency-version: 4.0.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index de9d00e95..54ba593bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2255,6 +2255,16 @@ __metadata: languageName: node linkType: hard +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 + languageName: node + linkType: hard + "call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7": version: 1.0.7 resolution: "call-bind@npm:1.0.7" @@ -3120,6 +3130,17 @@ __metadata: languageName: node linkType: hard +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -3241,6 +3262,13 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c + languageName: node + linkType: hard + "es-errors@npm:^1.2.1, es-errors@npm:^1.3.0": version: 1.3.0 resolution: "es-errors@npm:1.3.0" @@ -3264,6 +3292,15 @@ __metadata: languageName: node linkType: hard +"es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c + languageName: node + linkType: hard + "es-set-tostringtag@npm:^2.0.3": version: 2.0.3 resolution: "es-set-tostringtag@npm:2.0.3" @@ -3275,6 +3312,18 @@ __metadata: languageName: node linkType: hard +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" + dependencies: + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af + languageName: node + linkType: hard + "es-shim-unscopables@npm:^1.0.0, es-shim-unscopables@npm:^1.0.2": version: 1.0.2 resolution: "es-shim-unscopables@npm:1.0.2" @@ -3947,13 +3996,15 @@ __metadata: linkType: hard "form-data@npm:^4.0.0": - version: 4.0.1 - resolution: "form-data@npm:4.0.1" + version: 4.0.4 + resolution: "form-data@npm:4.0.4" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" mime-types: "npm:^2.1.12" - checksum: 10c0/bb102d570be8592c23f4ea72d7df9daa50c7792eb0cf1c5d7e506c1706e7426a4e4ae48a35b109e91c85f1c0ec63774a21ae252b66f4eb981cb8efef7d0463c8 + checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695 languageName: node linkType: hard @@ -4031,6 +4082,34 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.2.6": + version: 1.3.0 + resolution: "get-intrinsic@npm:1.3.0" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10c0/52c81808af9a8130f581e6a6a83e1ba4a9f703359e7a438d1369a5267a25412322f03dcbd7c549edaef0b6214a0630a28511d7df0130c93cfd380f4fa0b5b66a + languageName: node + linkType: hard + +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c + languageName: node + linkType: hard + "get-symbol-description@npm:^1.0.2": version: 1.0.2 resolution: "get-symbol-description@npm:1.0.2" @@ -4141,6 +4220,13 @@ __metadata: languageName: node linkType: hard +"gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead + languageName: node + linkType: hard + "graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -4199,6 +4285,13 @@ __metadata: languageName: node linkType: hard +"has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e + languageName: node + linkType: hard + "has-tostringtag@npm:^1.0.0, has-tostringtag@npm:^1.0.2": version: 1.0.2 resolution: "has-tostringtag@npm:1.0.2" @@ -4920,6 +5013,13 @@ __metadata: languageName: node linkType: hard +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f + languageName: node + linkType: hard + "mathjax@npm:^3.1.2": version: 3.2.2 resolution: "mathjax@npm:3.2.2" From aee71afebef42bb9701caf7c2fd77f4986f4514b Mon Sep 17 00:00:00 2001 From: llama Date: Thu, 24 Jul 2025 19:55:47 +0800 Subject: [PATCH 07/18] set min size for card info dialog (#4221) --- qt/aqt/browser/card_info.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qt/aqt/browser/card_info.py b/qt/aqt/browser/card_info.py index fe031e630..c925d43bb 100644 --- a/qt/aqt/browser/card_info.py +++ b/qt/aqt/browser/card_info.py @@ -51,6 +51,7 @@ class CardInfoDialog(QDialog): def _setup_ui(self, card_id: CardId | None) -> None: self.mw.garbage_collect_on_dialog_finish(self) + self.setMinimumSize(400, 300) disable_help_button(self) restoreGeom(self, self.GEOMETRY_KEY, default_size=(800, 800)) add_close_shortcut(self) From 00bc0354c993cb3efa291bcd65df6a3d87b2aa64 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 24 Jul 2025 20:12:39 +0700 Subject: [PATCH 08/18] Provide better output when downloading versions fails - include stdout/stderr when utf8_output() fails - don't swallow the error returned by handle_Version_install_or_update - skip codesigning when NODMG set Closes #4224 --- qt/launcher/mac/build.sh | 24 +++++++-------- qt/launcher/src/main.rs | 12 +++++--- rslib/process/src/lib.rs | 65 ++++++++++++++++++++++++++++++---------- 3 files changed, 69 insertions(+), 32 deletions(-) diff --git a/qt/launcher/mac/build.sh b/qt/launcher/mac/build.sh index d521e155b..6143451b4 100755 --- a/qt/launcher/mac/build.sh +++ b/qt/launcher/mac/build.sh @@ -38,19 +38,19 @@ cp ../pyproject.toml "$APP_LAUNCHER/Contents/Resources/" cp ../../../.python-version "$APP_LAUNCHER/Contents/Resources/" cp ../versions.py "$APP_LAUNCHER/Contents/Resources/" -# Codesign -for i in "$APP_LAUNCHER/Contents/MacOS/uv" "$APP_LAUNCHER/Contents/MacOS/launcher" "$APP_LAUNCHER"; do - codesign --force -vvvv -o runtime -s "Developer ID Application:" \ - --entitlements entitlements.python.xml \ - "$i" -done - -# Check -codesign -vvv "$APP_LAUNCHER" -spctl -a "$APP_LAUNCHER" - -# Notarize and bundle (skip if NODMG is set) +# Codesign/bundle if [ -z "$NODMG" ]; then + for i in "$APP_LAUNCHER/Contents/MacOS/uv" "$APP_LAUNCHER/Contents/MacOS/launcher" "$APP_LAUNCHER"; do + codesign --force -vvvv -o runtime -s "Developer ID Application:" \ + --entitlements entitlements.python.xml \ + "$i" + done + + # Check + codesign -vvv "$APP_LAUNCHER" + spctl -a "$APP_LAUNCHER" + + # Notarize and build dmg ./notarize.sh "$OUTPUT_DIR" ./dmg/build.sh "$OUTPUT_DIR" fi \ No newline at end of file diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 15320e84b..a124b7fe1 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -378,9 +378,7 @@ fn main_menu_loop(state: &State) -> Result<()> { continue; } choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => { - if handle_version_install_or_update(state, choice.clone()).is_err() { - continue; - } + handle_version_install_or_update(state, choice.clone())?; break; } } @@ -650,7 +648,13 @@ fn fetch_versions(state: &State) -> Result> { .args(["run", "--no-project"]) .arg(&versions_script); - let output = cmd.utf8_output()?; + let output = match cmd.utf8_output() { + Ok(output) => output, + Err(e) => { + print!("Unable to check for Anki versions. Please check your internet connection.\n\n"); + return Err(e.into()); + } + }; let versions = serde_json::from_str(&output.stdout).context("Failed to parse versions JSON")?; Ok(versions) } diff --git a/rslib/process/src/lib.rs b/rslib/process/src/lib.rs index dcf0703f6..2a82bb9cc 100644 --- a/rslib/process/src/lib.rs +++ b/rslib/process/src/lib.rs @@ -11,6 +11,24 @@ use snafu::ensure; use snafu::ResultExt; use snafu::Snafu; +#[derive(Debug)] +pub struct CodeDisplay(Option); + +impl std::fmt::Display for CodeDisplay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0 { + Some(code) => write!(f, "{code}"), + None => write!(f, "?"), + } + } +} + +impl From> for CodeDisplay { + fn from(code: Option) -> Self { + CodeDisplay(code) + } +} + #[derive(Debug, Snafu)] pub enum Error { #[snafu(display("Failed to execute: {cmdline}"))] @@ -18,8 +36,15 @@ pub enum Error { cmdline: String, source: std::io::Error, }, - #[snafu(display("Failed with code {code:?}: {cmdline}"))] - ReturnedError { cmdline: String, code: Option }, + #[snafu(display("Failed to run ({code}): {cmdline}"))] + ReturnedError { cmdline: String, code: CodeDisplay }, + #[snafu(display("Failed to run ({code}): {cmdline}: {stdout}{stderr}"))] + ReturnedWithOutputError { + cmdline: String, + code: CodeDisplay, + stdout: String, + stderr: String, + }, #[snafu(display("Couldn't decode stdout/stderr as utf8"))] InvalidUtf8 { cmdline: String, @@ -71,31 +96,36 @@ impl CommandExt for Command { status.success(), ReturnedSnafu { cmdline: get_cmdline(self), - code: status.code(), + code: CodeDisplay::from(status.code()), } ); Ok(self) } fn utf8_output(&mut self) -> Result { + let cmdline = get_cmdline(self); let output = self.output().with_context(|_| DidNotExecuteSnafu { - cmdline: get_cmdline(self), + cmdline: cmdline.clone(), })?; + + let stdout = String::from_utf8(output.stdout).with_context(|_| InvalidUtf8Snafu { + cmdline: cmdline.clone(), + })?; + let stderr = String::from_utf8(output.stderr).with_context(|_| InvalidUtf8Snafu { + cmdline: cmdline.clone(), + })?; + ensure!( output.status.success(), - ReturnedSnafu { - cmdline: get_cmdline(self), - code: output.status.code(), + ReturnedWithOutputSnafu { + cmdline, + code: CodeDisplay::from(output.status.code()), + stdout: stdout.clone(), + stderr: stderr.clone(), } ); - Ok(Utf8Output { - stdout: String::from_utf8(output.stdout).with_context(|_| InvalidUtf8Snafu { - cmdline: get_cmdline(self), - })?, - stderr: String::from_utf8(output.stderr).with_context(|_| InvalidUtf8Snafu { - cmdline: get_cmdline(self), - })?, - }) + + Ok(Utf8Output { stdout, stderr }) } fn ensure_spawn(&mut self) -> Result { @@ -135,7 +165,10 @@ mod test { #[cfg(not(windows))] assert!(matches!( Command::new("false").ensure_success(), - Err(Error::ReturnedError { code: Some(1), .. }) + Err(Error::ReturnedError { + code: CodeDisplay(_), + .. + }) )); } } From c74a97a5fa11f9e7eb9400d0213de31ed87ddf99 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 24 Jul 2025 20:33:16 +0700 Subject: [PATCH 09/18] Increase default network timeout in launcher https://forums.ankiweb.net/t/the-desktop-anki-app-cant-launch/64425/4 --- qt/launcher/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index a124b7fe1..a768c3c94 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -292,6 +292,10 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re command .env("UV_CACHE_DIR", &state.uv_cache_dir) .env("UV_PYTHON_INSTALL_DIR", &state.uv_python_install_dir) + .env( + "UV_HTTP_TIMEOUT", + std::env::var("UV_HTTP_TIMEOUT").unwrap_or_else(|_| "180".to_string()), + ) .args(["sync", "--upgrade", "--managed-python", "--no-config"]); // Add python version if .python-version file exists From 416e7af02bd927a27a136878ffa2509b90deb29e Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 24 Jul 2025 21:32:23 +0700 Subject: [PATCH 10/18] Pass --managed-python when fetching versions Tentatively closes https://github.com/ankitects/anki/issues/4227 --- qt/launcher/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index a768c3c94..cbf1271ec 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -649,7 +649,7 @@ fn fetch_versions(state: &State) -> Result> { let mut cmd = Command::new(&state.uv_path); cmd.current_dir(&state.uv_install_root) - .args(["run", "--no-project"]) + .args(["run", "--no-project", "--no-config", "--managed-python"]) .arg(&versions_script); let output = match cmd.utf8_output() { From d6e49f8ea5bf68a4de5fe872306081dd83d581f7 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 24 Jul 2025 21:35:05 +0700 Subject: [PATCH 11/18] Update translations --- ftl/core-repo | 2 +- ftl/qt-repo | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ftl/core-repo b/ftl/core-repo index b90ef6f03..939298f7c 160000 --- a/ftl/core-repo +++ b/ftl/core-repo @@ -1 +1 @@ -Subproject commit b90ef6f03c251eb336029ac7c5f551200d41273f +Subproject commit 939298f7c461407951988f362b1a08b451336a1e diff --git a/ftl/qt-repo b/ftl/qt-repo index 9aa63c335..bc2da83c7 160000 --- a/ftl/qt-repo +++ b/ftl/qt-repo @@ -1 +1 @@ -Subproject commit 9aa63c335c61b30421d39cf43fd8e3975179059c +Subproject commit bc2da83c77749d96f3df8144f00c87d68dd2187a From e511d63b7ef57e7d7e81612a9905172b871d18df Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 24 Jul 2025 21:35:20 +0700 Subject: [PATCH 12/18] Bump version to 25.07.4 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 3820668b1..0839d0d21 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -25.07.3 +25.07.4 From ca0459d8ee43e0b248ca83ed90c0ac10469b0215 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 24 Jul 2025 21:55:52 +0700 Subject: [PATCH 13/18] Use pip-system-certs when checking Anki versions --- qt/launcher/src/main.rs | 1 + qt/launcher/versions.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index cbf1271ec..c2699b145 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -650,6 +650,7 @@ fn fetch_versions(state: &State) -> Result> { let mut cmd = Command::new(&state.uv_path); cmd.current_dir(&state.uv_install_root) .args(["run", "--no-project", "--no-config", "--managed-python"]) + .args(["--with", "pip-system-certs"]) .arg(&versions_script); let output = match cmd.utf8_output() { diff --git a/qt/launcher/versions.py b/qt/launcher/versions.py index 02e16ba69..5d314d84f 100644 --- a/qt/launcher/versions.py +++ b/qt/launcher/versions.py @@ -5,6 +5,10 @@ import json import sys import urllib.request +import pip_system_certs.wrapt_requests + +pip_system_certs.wrapt_requests.inject_truststore() + def main(): """Fetch and return all versions from PyPI, sorted by upload time.""" From 20b7bb66db7a95bcf067df443f8860d6917156bd Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 25 Jul 2025 14:45:04 +0700 Subject: [PATCH 14/18] Fix 'limits start from top' link --- ts/lib/tslib/help-page.ts | 1 + ts/routes/deck-options/DailyLimits.svelte | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ts/lib/tslib/help-page.ts b/ts/lib/tslib/help-page.ts index ff4e7e434..e3f209c6a 100644 --- a/ts/lib/tslib/help-page.ts +++ b/ts/lib/tslib/help-page.ts @@ -24,6 +24,7 @@ export const HelpPage = { displayOrder: "https://docs.ankiweb.net/deck-options.html#display-order", maximumReviewsday: "https://docs.ankiweb.net/deck-options.html#maximum-reviewsday", newCardsday: "https://docs.ankiweb.net/deck-options.html#new-cardsday", + limitsFromTop: "https://docs.ankiweb.net/deck-options.html#limits-start-from-top", dailyLimits: "https://docs.ankiweb.net/deck-options.html#daily-limits", audio: "https://docs.ankiweb.net/deck-options.html#audio", fsrs: "http://docs.ankiweb.net/deck-options.html#fsrs", diff --git a/ts/routes/deck-options/DailyLimits.svelte b/ts/routes/deck-options/DailyLimits.svelte index ea403c1f4..0e08ea38d 100644 --- a/ts/routes/deck-options/DailyLimits.svelte +++ b/ts/routes/deck-options/DailyLimits.svelte @@ -140,7 +140,7 @@ applyAllParentLimits: { title: tr.deckConfigApplyAllParentLimits(), help: applyAllParentLimitsHelp, - url: HelpPage.DeckOptions.newCardsday, + url: HelpPage.DeckOptions.limitsFromTop, global: true, }, }; From 177c4833982c3ee013725cd68e32361d6292a79d Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 25 Jul 2025 15:38:18 +0700 Subject: [PATCH 15/18] Stop copying updated pyproject/python pin on startup The 'latest' and 'choose version' paths always read from the the dist file these days, so the file doesn't need to be copied in advance. The other reason for the current behaviour was so that when the user manually installs a new launcher, it opens into the launcher on next run, as that's probably what the user wanted. But this causes problems when the launcher is updated automatically by things like homebrew. https://forums.ankiweb.net/t/anki-homebrew-issues-terminal-and-crash-on-exit/64413/4 --- qt/launcher/src/main.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index c2699b145..16986fa10 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -11,7 +11,6 @@ use std::time::SystemTime; use std::time::UNIX_EPOCH; use anki_io::copy_file; -use anki_io::copy_if_newer; use anki_io::create_dir_all; use anki_io::modified_time; use anki_io::read_file; @@ -120,13 +119,8 @@ fn run() -> Result<()> { return Ok(()); } - // Create install directory and copy project files in + // Create install directory create_dir_all(&state.uv_install_root)?; - copy_if_newer(&state.dist_pyproject_path, &state.user_pyproject_path)?; - copy_if_newer( - &state.dist_python_version_path, - &state.user_python_version_path, - )?; let launcher_requested = state.launcher_trigger_file.exists(); From e2692b5ac9e29b4c384fdbc8f828ca8ee607332f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 25 Jul 2025 16:49:31 +0700 Subject: [PATCH 16/18] Fix inability to upgrade/downgrade from a Python 3.9 version Resolves AttributeError: module 'pip_system_certs.wrapt_requests' has no attribute 'inject_truststore' --- qt/launcher/src/main.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 16986fa10..2146d5548 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -644,8 +644,17 @@ fn fetch_versions(state: &State) -> Result> { let mut cmd = Command::new(&state.uv_path); cmd.current_dir(&state.uv_install_root) .args(["run", "--no-project", "--no-config", "--managed-python"]) - .args(["--with", "pip-system-certs"]) - .arg(&versions_script); + .args(["--with", "pip-system-certs"]); + + 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, From 78c6db20231f82e4f1f48f6d3f4afb0b3f70ed5a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 25 Jul 2025 19:12:06 +0700 Subject: [PATCH 17/18] Bump version --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 0839d0d21..7815b40ec 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -25.07.4 +25.07.5 From 7172b2d26684c7ef9d10e249bd43dc5bf73ae00c Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 25 Jul 2025 23:34:50 +0700 Subject: [PATCH 18/18] More launcher fixes - The pyproject copy change broke the initial run case - Avoid calling 'uv pip show' before venv created, as it causes a pop-up prompting the user to install developer tools on macOS (#4227) - Add a tip to the user about the unwanted install_name_tool pop-up (Also tracked in #4227) - Print 'Checking for updates...' prior to the potentially slow network request --- qt/launcher/src/main.rs | 61 ++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 2146d5548..2d9f0aaf3 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -49,6 +49,7 @@ struct State { pyproject_modified_by_user: bool, previous_version: Option, resources_dir: std::path::PathBuf, + venv_folder: std::path::PathBuf, } #[derive(Debug, Clone)] @@ -110,6 +111,7 @@ fn run() -> Result<()> { pyproject_modified_by_user: false, // calculated later previous_version: None, resources_dir, + venv_folder: uv_install_root.join(".venv"), }; // Check for uninstall request from Windows uninstaller @@ -122,7 +124,8 @@ fn run() -> Result<()> { // Create install directory create_dir_all(&state.uv_install_root)?; - let launcher_requested = state.launcher_trigger_file.exists(); + let launcher_requested = + state.launcher_trigger_file.exists() || !state.user_pyproject_path.exists(); // Calculate whether user has custom edits that need syncing let pyproject_time = file_timestamp_secs(&state.user_pyproject_path); @@ -152,7 +155,7 @@ fn run() -> Result<()> { check_versions(&mut state); - let first_run = !state.uv_install_root.join(".venv").exists(); + let first_run = !state.venv_folder.exists(); if first_run { handle_version_install_or_update(&state, MainMenuChoice::Latest)?; } else { @@ -160,7 +163,7 @@ fn run() -> Result<()> { } // Write marker file to indicate we've completed the sync process - write_sync_marker(&state.sync_complete_marker)?; + write_sync_marker(&state)?; #[cfg(target_os = "macos")] { @@ -186,13 +189,15 @@ fn run() -> Result<()> { Ok(()) } -fn extract_aqt_version( - uv_path: &std::path::Path, - uv_install_root: &std::path::Path, -) -> Option { - let output = Command::new(uv_path) - .current_dir(uv_install_root) - .env("VIRTUAL_ENV", uv_install_root.join(".venv")) +fn extract_aqt_version(state: &State) -> Option { + // Check if .venv exists first + if !state.venv_folder.exists() { + return None; + } + + let output = Command::new(&state.uv_path) + .current_dir(&state.uv_install_root) + .env("VIRTUAL_ENV", &state.venv_folder) .args(["pip", "show", "aqt"]) .output() .ok()?; @@ -217,7 +222,7 @@ fn check_versions(state: &mut State) { } // Determine current version by invoking uv pip show aqt - match extract_aqt_version(&state.uv_path, &state.uv_install_root) { + match extract_aqt_version(state) { Some(version) => { state.current_version = Some(version); } @@ -242,12 +247,12 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re update_pyproject_for_version(choice.clone(), state)?; // Extract current version before syncing (but don't write to file yet) - let previous_version_to_save = extract_aqt_version(&state.uv_path, &state.uv_install_root); + let previous_version_to_save = extract_aqt_version(state); // Remove sync marker before attempting sync let _ = remove_file(&state.sync_complete_marker); - println!("\x1B[1mUpdating Anki...\x1B[0m\n"); + println!("Updating Anki...\n"); let python_version_trimmed = if state.user_python_version_path.exists() { let python_version = read_file(&state.user_python_version_path)?; @@ -258,6 +263,11 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re None }; + let have_venv = state.venv_folder.exists(); + if cfg!(target_os = "macos") && !have_developer_tools() && !have_venv { + println!("If you see a pop-up about 'install_name_tool', you can cancel it, and ignore the warning below.\n"); + } + // Prepare to sync the venv let mut command = Command::new(&state.uv_path); command.current_dir(&state.uv_install_root); @@ -305,7 +315,7 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re Ok(_) => { // Sync succeeded if matches!(&choice, MainMenuChoice::Version(VersionKind::PyOxidizer(_))) { - inject_helper_addon(&state.uv_install_root)?; + inject_helper_addon()?; } // Now that sync succeeded, save the previous version @@ -384,12 +394,12 @@ fn main_menu_loop(state: &State) -> Result<()> { Ok(()) } -fn write_sync_marker(sync_complete_marker: &std::path::Path) -> Result<()> { +fn write_sync_marker(state: &State) -> Result<()> { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .context("Failed to get system time")? .as_secs(); - write_file(sync_complete_marker, timestamp.to_string())?; + write_file(&state.sync_complete_marker, timestamp.to_string())?; Ok(()) } @@ -483,8 +493,6 @@ fn get_main_menu_choice(state: &State) -> Result { } fn get_version_kind(state: &State) -> Result> { - println!("Please wait..."); - let releases = get_releases(state)?; let releases_str = releases .latest @@ -668,6 +676,7 @@ fn fetch_versions(state: &State) -> Result> { } fn get_releases(state: &State) -> Result { + println!("Checking for updates..."); let include_prereleases = state.prerelease_marker.exists(); let all_versions = fetch_versions(state)?; let all_versions = filter_and_normalize_versions(all_versions, include_prereleases); @@ -794,7 +803,7 @@ fn parse_version_kind(version: &str) -> Option { } } -fn inject_helper_addon(_uv_install_root: &std::path::Path) -> Result<()> { +fn inject_helper_addon() -> Result<()> { let addons21_path = get_anki_addons21_path()?; if !addons21_path.exists() { @@ -896,16 +905,24 @@ fn handle_uninstall(state: &State) -> Result { Ok(true) } +fn have_developer_tools() -> bool { + Command::new("xcode-select") + .args(["-p"]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + fn build_python_command(state: &State, args: &[String]) -> Result { let python_exe = if cfg!(target_os = "windows") { let show_console = std::env::var("ANKI_CONSOLE").is_ok(); if show_console { - state.uv_install_root.join(".venv/Scripts/python.exe") + state.venv_folder.join("Scripts/python.exe") } else { - state.uv_install_root.join(".venv/Scripts/pythonw.exe") + state.venv_folder.join("Scripts/pythonw.exe") } } else { - state.uv_install_root.join(".venv/bin/python") + state.venv_folder.join("bin/python") }; let mut cmd = Command::new(&python_exe);