) {
if (event.source !== frame.value?.contentWindow) return
if (!event.data || typeof event.data !== 'object') return
- const message = event.data as { type?: unknown; name?: unknown; dataUrl?: unknown }
+ const message = event.data as {
+ type?: unknown
+ name?: unknown
+ dataUrl?: unknown
+ error?: unknown
+ }
+ if (message.type === 'axolotl-skin-load-error' && typeof message.error === 'string') {
+ if (editorState.value === 'ready') return
+ errorDetail.value = message.error.slice(0, 1000)
+ markEditorError()
+ return
+ }
if (message.type === 'axolotl-skin-theme-ready') {
sendThemeToEditor()
markEditorReady()
@@ -149,15 +182,11 @@ onMounted(async () => {
attributeFilter: ['class', 'style'],
})
if (!import.meta.env.DEV) {
- try {
- platformName.value = await platform()
- } catch (error) {
- markEditorError()
- handleError(error)
- }
+ await reloadEditor()
}
})
onUnmounted(() => {
+ loadAttempt += 1
clearLoadTimeout()
window.removeEventListener('message', handleEditorMessage)
themeObserver?.disconnect()
@@ -184,7 +213,12 @@ onUnmounted(() => {
{{ formatMessage(messages.loadErrorTitle) }}
- {{ formatMessage(messages.loadErrorDescription) }}
+
+ {{ formatMessage(resourceError ? messages.resourceError : messages.loadErrorDescription) }}
+
+
+ {{ errorDetail }}
+
{{ formatMessage(messages.retry) }}
@@ -195,8 +229,8 @@ onUnmounted(() => {
ref="frame"
:title="formatMessage(messages.title)"
:src="editorUrl"
- class="h-full min-h-0 w-full flex-1 border-0 transition-opacity duration-150"
- :class="editorState === 'ready' ? 'opacity-100' : 'pointer-events-none opacity-0'"
+ class="h-full min-h-0 w-full flex-1 border-0"
+ :class="{ 'pointer-events-none': editorState !== 'ready' }"
:aria-label="formatMessage(messages.title)"
:aria-hidden="editorState !== 'ready'"
@load="handleFrameLoad"
diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json
index bc64fd7..a6c16aa 100644
--- a/apps/app/capabilities/plugins.json
+++ b/apps/app/capabilities/plugins.json
@@ -59,7 +59,7 @@
"url": "https://api.purpurmc.org/*"
},
{
- "url": "https://update.axlmc.org/*"
+ "url": "https://skin.starlight.cool/*"
},
{
"url": "http://localhost:8000/*"
diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs
index 9d11770..941b886 100644
--- a/apps/app/src/main.rs
+++ b/apps/app/src/main.rs
@@ -77,9 +77,10 @@ fn blockbench_skin_response(
} else {
relative_path.to_path_buf()
});
- let contents = match fs::read(file_path) {
+ let contents = match fs::read(&file_path) {
Ok(contents) => contents,
- Err(_) => {
+ Err(error) => {
+ tracing::warn!(path = %file_path.display(), %error, "Skin editor resource could not be read");
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Vec::new())
@@ -88,10 +89,10 @@ fn blockbench_skin_response(
};
let contents = if is_compressed_bundle {
let mut decompressed = Vec::new();
- if flate2::read::GzDecoder::new(contents.as_slice())
+ if let Err(error) = flate2::read::GzDecoder::new(contents.as_slice())
.read_to_end(&mut decompressed)
- .is_err()
{
+ tracing::warn!(path = %file_path.display(), %error, "Skin editor bundle could not be decoded");
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Vec::new())
@@ -125,6 +126,91 @@ fn blockbench_skin_response(
.expect("failed to build Blockbench skin response")
}
+fn skin_editor_resource_errors(resource_dir: &Path) -> Vec {
+ ["index.html", "css/setup.css", "dist/skin.bundle.js"]
+ .into_iter()
+ .filter(|path| {
+ let response = blockbench_skin_response(path, resource_dir);
+ !response.status().is_success() || response.body().is_empty()
+ })
+ .map(str::to_owned)
+ .collect()
+}
+
+#[cfg(test)]
+mod skin_editor_tests {
+ use super::*;
+ use std::io::Write;
+
+ fn editor_resources() -> tempfile::TempDir {
+ let directory = tempfile::tempdir().unwrap();
+ fs::create_dir(directory.path().join("css")).unwrap();
+ fs::create_dir(directory.path().join("dist")).unwrap();
+ fs::write(directory.path().join("index.html"), "")
+ .unwrap();
+ fs::write(directory.path().join("css/setup.css"), "body {}").unwrap();
+ let mut bundle = flate2::write::GzEncoder::new(
+ Vec::new(),
+ flate2::Compression::default(),
+ );
+ bundle.write_all(b"window.editor = true;").unwrap();
+ fs::write(
+ directory.path().join("dist/skin.bundle.js.gz"),
+ bundle.finish().unwrap(),
+ )
+ .unwrap();
+ directory
+ }
+
+ #[test]
+ fn packaged_skin_editor_bundle_is_readable() {
+ let directory = editor_resources();
+ assert!(skin_editor_resource_errors(directory.path()).is_empty());
+ let response =
+ blockbench_skin_response("/dist/skin.bundle.js", directory.path());
+ assert_eq!(response.body(), b"window.editor = true;");
+ assert_eq!(
+ response.headers()[header::CONTENT_TYPE],
+ "text/javascript; charset=utf-8"
+ );
+ }
+
+ #[test]
+ fn missing_skin_editor_resources_are_reported() {
+ let directory = tempfile::tempdir().unwrap();
+ assert_eq!(
+ skin_editor_resource_errors(directory.path()),
+ ["index.html", "css/setup.css", "dist/skin.bundle.js"]
+ );
+ }
+
+ #[test]
+ fn corrupt_skin_editor_bundle_is_reported() {
+ let directory = editor_resources();
+ fs::write(
+ directory.path().join("dist/skin.bundle.js.gz"),
+ "invalid gzip",
+ )
+ .unwrap();
+ assert_eq!(
+ skin_editor_resource_errors(directory.path()),
+ ["dist/skin.bundle.js"]
+ );
+ }
+}
+
+#[tauri::command]
+fn get_skin_editor_resource_errors(
+ app: tauri::AppHandle,
+) -> Result, String> {
+ let resource_dir = app
+ .path()
+ .resource_dir()
+ .map_err(|error| error.to_string())?
+ .join(BLOCKBENCH_SKIN_RESOURCE_DIR);
+ Ok(skin_editor_resource_errors(&resource_dir))
+}
+
fn is_allowed_blockbench_skin_request(
request: &tauri::http::Request>,
) -> bool {
@@ -685,6 +771,10 @@ fn main() {
"axolotl-skin",
move |context, request| {
if !is_allowed_blockbench_skin_request(&request) {
+ tracing::warn!(
+ path = request.uri().path(),
+ "Skin editor resource request was rejected"
+ );
return Response::builder()
.status(StatusCode::FORBIDDEN)
.body(Vec::new())
@@ -703,6 +793,13 @@ fn main() {
);
builder = builder
+ .plugin(
+ tauri::plugin::Builder::::new("skin-editor-errors")
+ .js_init_script_on_all_frames(include_str!(
+ "skin_editor_bridge.js"
+ ))
+ .build(),
+ )
.plugin(
tauri::plugin::Builder::::new("skin-site-session")
.js_init_script_on_all_frames(include_str!(
@@ -853,6 +950,7 @@ fn main() {
.manage(PendingUpdateData::default())
.invoke_handler(tauri::generate_handler![
initialize_state,
+ get_skin_editor_resource_errors,
get_launcher_root_dir,
get_update_channel,
get_current_app_database_path,
diff --git a/apps/app/src/skin_editor_bridge.js b/apps/app/src/skin_editor_bridge.js
new file mode 100644
index 0000000..356099c
--- /dev/null
+++ b/apps/app/src/skin_editor_bridge.js
@@ -0,0 +1,32 @@
+;(() => {
+ const isEditor =
+ location.origin === 'http://axolotl-skin.localhost' ||
+ (location.protocol === 'axolotl-skin:' && location.host === 'localhost') ||
+ (location.origin === 'http://localhost:5201' &&
+ location.pathname === '/__blockbench_skin__/index.html')
+ if (
+ !isEditor ||
+ window.parent === window ||
+ new URLSearchParams(location.search).get('embed') !== 'skin'
+ )
+ return
+
+ function report(error) {
+ window.parent.postMessage(
+ { type: 'axolotl-skin-load-error', error: String(error).slice(0, 1000) },
+ '*',
+ )
+ }
+ window.addEventListener('error', (event) => {
+ if (event.message) {
+ if (event.message.startsWith('ResizeObserver loop')) return
+ report(event.message)
+ }
+ })
+ window.addEventListener('unhandledrejection', (event) => {
+ report(event.reason?.message || event.reason)
+ })
+ window.addEventListener('DOMContentLoaded', () => {
+ window.blockbenchBundleReady?.catch((error) => report(error?.message || error))
+ })
+})()
diff --git a/apps/app/src/updater_impl.rs b/apps/app/src/updater_impl.rs
index 07d0e43..421e7c0 100644
--- a/apps/app/src/updater_impl.rs
+++ b/apps/app/src/updater_impl.rs
@@ -3,21 +3,23 @@ use futures::StreamExt;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex};
-use tauri::http::HeaderValue;
use tauri::http::header::ACCEPT;
+use tauri::http::HeaderValue;
use tauri::{Manager, ResourceId, Runtime, Webview};
use tauri_plugin_http::reqwest;
use tauri_plugin_http::reqwest::ClientBuilder;
use tauri_plugin_updater::{Error, Update, UpdaterExt};
use theseus::{
- LoadingBarType, emit_loading, init_loading, launcher_user_agent,
+ emit_loading, init_loading, launcher_user_agent, LoadingBarType,
};
use tokio::time::Instant;
use url::Url;
-const UPDATE_SERVER_LATEST_URL: &str = "https://update.axlmc.org/latest";
-const UPDATE_SERVER_API: &str = "https://update.axlmc.org/api/versions";
-const UPDATE_SERVER_BASE: &str = "https://update.axlmc.org/";
+const STARLIGHT_UPDATE_LATEST_URL: &str =
+ "https://skin.starlight.cool/starlight/launcher/latest";
+const STARLIGHT_UPDATE_VERSIONS_URL: &str =
+ "https://skin.starlight.cool/starlight/launcher/versions";
+const STARLIGHT_UPDATE_BASE_URL: &str = "https://skin.starlight.cool/";
// The updater plugin builds `Update` with no request timeout, so a stalled
// connection would hang the download forever. Bound the whole download.
@@ -62,7 +64,10 @@ struct ArtifactEntry {
variant: Option,
platform: String,
architecture: String,
- relative_path: String,
+ #[serde(default)]
+ relative_path: Option,
+ #[serde(default)]
+ download_url: Option,
#[serde(default)]
sha256: Option,
#[serde(default)]
@@ -94,7 +99,7 @@ async fn fetch_apt_deb_asset(version: &str) -> Result {
.user_agent(launcher_user_agent())
.timeout(UPDATE_DOWNLOAD_TIMEOUT)
.build()?
- .get(UPDATE_SERVER_API)
+ .get(STARLIGHT_UPDATE_VERSIONS_URL)
.send()
.await?;
@@ -139,13 +144,7 @@ async fn fetch_apt_deb_asset(version: &str) -> Result {
)))
})?;
- let url =
- Url::parse(&format!("{UPDATE_SERVER_BASE}{}", artifact.relative_path))
- .map_err(|error| {
- theseus::Error::from(theseus::ErrorKind::OtherError(
- error.to_string(),
- ))
- })?;
+ let url = artifact_download_url(artifact)?;
Ok(AptDebAsset {
url,
@@ -154,6 +153,31 @@ async fn fetch_apt_deb_asset(version: &str) -> Result {
})
}
+fn artifact_download_url(artifact: &ArtifactEntry) -> Result {
+ if let Some(download_url) = artifact.download_url.as_deref() {
+ return Url::parse(download_url).map_err(|error| {
+ theseus::Error::from(theseus::ErrorKind::OtherError(
+ error.to_string(),
+ ))
+ .into()
+ });
+ }
+
+ let relative_path = artifact.relative_path.as_deref().ok_or_else(|| {
+ theseus::Error::from(theseus::ErrorKind::OtherError(
+ "Update catalog artifact has no download URL".to_string(),
+ ))
+ })?;
+ Url::parse(STARLIGHT_UPDATE_BASE_URL)
+ .and_then(|base| base.join(relative_path))
+ .map_err(|error| {
+ theseus::Error::from(theseus::ErrorKind::OtherError(
+ error.to_string(),
+ ))
+ .into()
+ })
+}
+
// ── Updater plugin helpers ───────────────────────────────────────
fn update_channel(channel: &str) -> Result<&str> {
@@ -183,7 +207,7 @@ fn update_platform() -> Result<&'static str> {
}
fn update_endpoint() -> Result {
- Url::parse(UPDATE_SERVER_LATEST_URL).map_err(|error| {
+ Url::parse(STARLIGHT_UPDATE_LATEST_URL).map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(error.to_string()))
.into()
})
diff --git a/apps/app/tauri-release.conf.json b/apps/app/tauri-release.conf.json
index 0e5ae50..07dd969 100644
--- a/apps/app/tauri-release.conf.json
+++ b/apps/app/tauri-release.conf.json
@@ -13,7 +13,7 @@
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNGNEE1MkMxOTI0MDA4NzYKUldSMkNFQ1N3VkpLUC9kcEtlZFRWak5FRXJsWUw3YllxWGh6bkg3ZEh3K1ZQa1VNZHl6Y0IvQysK",
- "endpoints": ["https://update.axlmc.org/latest"],
+ "endpoints": ["https://skin.starlight.cool/starlight/launcher/latest"],
"windows": {
"installMode": "quiet"
}
diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json
index 1a7addc..582bd44 100644
--- a/apps/app/tauri.conf.json
+++ b/apps/app/tauri.conf.json
@@ -111,7 +111,7 @@
"capabilities": ["core", "plugins"],
"devCsp": {
"default-src": "'self' customprotocol: asset:",
- "connect-src": "ipc: http://ipc.localhost http://localhost:5201 ws://localhost:5201 https://modrinth.com https://*.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://fill.papermc.io https://api.papermc.io https://piston-meta.mojang.com https://launchermeta.mojang.com https://meta.fabricmc.net https://files.minecraftforge.net https://maven.minecraftforge.net https://api.purpurmc.org https://mod.mcimirror.top https://mod.tianpao.top https://admin.axlmc.org 'self' data: blob:",
+ "connect-src": "ipc: http://ipc.localhost http://localhost:5201 ws://localhost:5201 https://modrinth.com https://*.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://fill.papermc.io https://api.papermc.io https://piston-meta.mojang.com https://launchermeta.mojang.com https://meta.fabricmc.net https://files.minecraftforge.net https://maven.minecraftforge.net https://api.purpurmc.org https://mod.mcimirror.top https://mod.tianpao.top https://admin.axlmc.org https://skin.starlight.cool 'self' data: blob:",
"font-src": ["'self'", "data:", "https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
@@ -123,7 +123,7 @@
},
"csp": {
"default-src": "'self' customprotocol: asset:",
- "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://fill.papermc.io https://api.papermc.io https://piston-meta.mojang.com https://launchermeta.mojang.com https://meta.fabricmc.net https://files.minecraftforge.net https://maven.minecraftforge.net https://api.purpurmc.org https://mod.mcimirror.top https://mod.tianpao.top https://admin.axlmc.org 'self' data: blob:",
+ "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://fill.papermc.io https://api.papermc.io https://piston-meta.mojang.com https://launchermeta.mojang.com https://meta.fabricmc.net https://files.minecraftforge.net https://maven.minecraftforge.net https://api.purpurmc.org https://mod.mcimirror.top https://mod.tianpao.top https://admin.axlmc.org https://skin.starlight.cool 'self' data: blob:",
"font-src": ["'self'", "data:", "https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
diff --git a/packages/app-lib/migrations/20260919000000_force-unicode-font.sql b/packages/app-lib/migrations/20260919000000_force-unicode-font.sql
new file mode 100644
index 0000000..5606417
--- /dev/null
+++ b/packages/app-lib/migrations/20260919000000_force-unicode-font.sql
@@ -0,0 +1 @@
+ALTER TABLE settings ADD COLUMN mc_force_unicode_font INTEGER NOT NULL DEFAULT FALSE;
diff --git a/packages/app-lib/src/launcher/language.rs b/packages/app-lib/src/launcher/language.rs
index cb6730b..a181d23 100644
--- a/packages/app-lib/src/launcher/language.rs
+++ b/packages/app-lib/src/launcher/language.rs
@@ -24,11 +24,14 @@ enum LanguageCodeStyle {
/// directory (e.g. modpacks that ship a preconfigured `options.txt`). For
/// instances the player already uses, their in-game choice is kept and only
/// its casing is normalized for the game version to avoid resets or crashes.
+/// The font preference is initialized independently of the language, only
+/// when a fresh instance has no explicit font choice in its options file.
pub fn game_language_options(
launcher_locale: &str,
game_release_time: DateTime,
options_txt: &str,
has_saves: bool,
+ force_unicode_font: bool,
) -> Vec<(String, String)> {
let style = match language_code_style(game_release_time) {
LanguageCodeStyle::Unsupported => return Vec::new(),
@@ -47,16 +50,21 @@ pub fn game_language_options(
.as_deref()
.and_then(|code| normalize_language_code(code, legacy_region_case))
};
- let Some(desired) = desired else {
- return Vec::new();
- };
-
let mut options = Vec::new();
- if current.as_deref() != Some(desired.as_str()) {
+ if let Some(desired) = desired
+ && current.as_deref() != Some(desired.as_str())
+ {
options.push(("lang".to_string(), desired));
}
- if fresh && needs_unicode_font(launcher_locale) {
- options.push(("forceUnicodeFont".to_string(), "true".to_string()));
+ if fresh
+ && !options_txt
+ .lines()
+ .any(|line| line.starts_with("forceUnicodeFont:"))
+ {
+ options.push((
+ "forceUnicodeFont".to_string(),
+ force_unicode_font.to_string(),
+ ));
}
options
}
@@ -105,20 +113,6 @@ fn normalize_language_code(
}
}
-/// CJK glyphs are not covered by the game's default bitmap font in older
-/// versions, so first-time setups for these languages also force the
-/// unicode font.
-fn needs_unicode_font(launcher_locale: &str) -> bool {
- launcher_locale
- .split(['-', '_'])
- .next()
- .is_some_and(|language| {
- language.eq_ignore_ascii_case("zh")
- || language.eq_ignore_ascii_case("ja")
- || language.eq_ignore_ascii_case("ko")
- })
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -142,10 +136,10 @@ mod tests {
#[test]
fn fresh_instance_follows_launcher_language() {
assert_eq!(
- game_language_options("zh-CN", modern(), "", false),
+ game_language_options("zh-CN", modern(), "", false, false),
vec![
("lang".to_string(), "zh_cn".to_string()),
- ("forceUnicodeFont".to_string(), "true".to_string()),
+ ("forceUnicodeFont".to_string(), "false".to_string()),
]
);
}
@@ -153,26 +147,50 @@ mod tests {
#[test]
fn legacy_versions_use_uppercase_region() {
assert_eq!(
- game_language_options("zh-CN", legacy(), "", false),
+ game_language_options("zh-CN", legacy(), "", false, false),
vec![
("lang".to_string(), "zh_CN".to_string()),
- ("forceUnicodeFont".to_string(), "true".to_string()),
+ ("forceUnicodeFont".to_string(), "false".to_string()),
]
);
}
#[test]
- fn non_cjk_languages_skip_unicode_font() {
+ fn unicode_font_can_be_enabled_for_any_language() {
+ for locale in ["zh-CN", "zh-TW", "ja-JP", "ko-KR", "en-US", ""] {
+ let options =
+ game_language_options(locale, modern(), "", false, true);
+ assert!(
+ options.contains(&(
+ "forceUnicodeFont".to_string(),
+ "true".to_string()
+ )),
+ "{locale}"
+ );
+ }
+ }
+
+ #[test]
+ fn non_cjk_languages_also_use_the_font_default() {
assert_eq!(
- game_language_options("en-US", modern(), "", false),
- vec![("lang".to_string(), "en_us".to_string())]
+ game_language_options("en-US", modern(), "", false, false),
+ vec![
+ ("lang".to_string(), "en_us".to_string()),
+ ("forceUnicodeFont".to_string(), "false".to_string()),
+ ]
);
}
#[test]
fn versions_before_1_1_are_left_alone() {
assert_eq!(
- game_language_options("zh-CN", release(2011, 11, 17), "", false),
+ game_language_options(
+ "zh-CN",
+ release(2011, 11, 17),
+ "",
+ false,
+ true
+ ),
Vec::new()
);
}
@@ -184,6 +202,7 @@ mod tests {
"zh-CN",
modern(),
"fullscreen:false\nlang:ja_jp\n",
+ true,
true
),
Vec::new()
@@ -193,7 +212,13 @@ mod tests {
#[test]
fn played_instances_get_their_casing_normalized() {
assert_eq!(
- game_language_options("en-US", modern(), "lang:zh_CN\n", true),
+ game_language_options(
+ "en-US",
+ modern(),
+ "lang:zh_CN\n",
+ true,
+ true
+ ),
vec![("lang".to_string(), "zh_cn".to_string())]
);
}
@@ -201,10 +226,16 @@ mod tests {
#[test]
fn preconfigured_language_without_saves_is_overridden() {
assert_eq!(
- game_language_options("zh-TW", modern(), "lang:en_us\n", false),
+ game_language_options(
+ "zh-TW",
+ modern(),
+ "lang:en_us\n",
+ false,
+ false
+ ),
vec![
("lang".to_string(), "zh_tw".to_string()),
- ("forceUnicodeFont".to_string(), "true".to_string()),
+ ("forceUnicodeFont".to_string(), "false".to_string()),
]
);
}
@@ -212,16 +243,25 @@ mod tests {
#[test]
fn matching_language_needs_no_update() {
assert_eq!(
- game_language_options("ja-JP", modern(), "lang:ja_jp\n", true),
+ game_language_options(
+ "ja-JP",
+ modern(),
+ "lang:ja_jp\n",
+ true,
+ false
+ ),
Vec::new()
);
}
#[test]
- fn empty_locale_makes_no_changes() {
- assert_eq!(game_language_options("", modern(), "", false), Vec::new());
+ fn empty_locale_only_initializes_the_font_default() {
assert_eq!(
- game_language_options("", modern(), "lang:zh_cn\n", true),
+ game_language_options("", modern(), "", false, false),
+ vec![("forceUnicodeFont".to_string(), "false".to_string())]
+ );
+ assert_eq!(
+ game_language_options("", modern(), "lang:zh_cn\n", true, false),
Vec::new()
);
}
@@ -229,8 +269,39 @@ mod tests {
#[test]
fn crlf_options_files_are_parsed() {
assert_eq!(
- game_language_options("ko-KR", modern(), "lang:ko_kr\r\n", true),
+ game_language_options(
+ "ko-KR",
+ modern(),
+ "lang:ko_kr\r\n",
+ true,
+ false
+ ),
Vec::new()
);
}
+
+ #[test]
+ fn existing_font_choices_are_preserved_even_without_saves_or_language() {
+ for has_saves in [false, true] {
+ for enabled in [false, true] {
+ for language in ["", "lang:zh_cn\r\n"] {
+ let options_txt = format!(
+ "{language}forceUnicodeFont:{enabled}\r\nfullscreen:false\r\n"
+ );
+ let options = game_language_options(
+ "zh-CN",
+ modern(),
+ &options_txt,
+ has_saves,
+ !enabled,
+ );
+ assert!(
+ options
+ .iter()
+ .all(|(key, _)| key != "forceUnicodeFont")
+ );
+ }
+ }
+ }
+ }
}
diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs
index 49107d6..8369a7e 100644
--- a/packages/app-lib/src/launcher/mod.rs
+++ b/packages/app-lib/src/launcher/mod.rs
@@ -2326,6 +2326,7 @@ pub async fn launch_minecraft(
&& (!mc_set_options.is_empty()
|| offline_skin_pack.enabled_pack_id.is_some()
|| options_existed
+ || settings.force_unicode_font
|| !settings.locale.is_empty())
{
let (mut options_string, input_encoding) = if options_existed {
@@ -2353,6 +2354,7 @@ pub async fn launch_minecraft(
launch_release_time,
&options_string,
instance_path.join("saves").exists(),
+ settings.force_unicode_font,
);
if !mc_set_options.is_empty()
diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs
index ed253f7..11d69a7 100644
--- a/packages/app-lib/src/state/settings.rs
+++ b/packages/app-lib/src/state/settings.rs
@@ -166,6 +166,8 @@ pub struct Settings {
pub memory: MemorySettings,
pub force_fullscreen: bool,
pub maximize_window: bool,
+ #[serde(default)]
+ pub force_unicode_font: bool,
pub game_resolution: WindowSize,
pub hide_on_process_start: bool,
pub enter_lightweight_mode_on_game_launch: bool,
@@ -347,6 +349,11 @@ impl Settings {
},
force_fullscreen: res.mc_force_fullscreen == 1,
maximize_window: res.mc_maximize_window == 1,
+ force_unicode_font: sqlx::query_scalar(
+ "SELECT mc_force_unicode_font FROM settings WHERE id = 0",
+ )
+ .fetch_one(exec)
+ .await?,
game_resolution: WindowSize(
res.mc_game_resolution_x as u16,
res.mc_game_resolution_y as u16,
@@ -590,6 +597,12 @@ impl Settings {
.bind(self.memory.optimize_before_launch)
.execute(exec)
.await?;
+ sqlx::query(
+ "UPDATE settings SET mc_force_unicode_font = ? WHERE id = 0",
+ )
+ .bind(self.force_unicode_font)
+ .execute(exec)
+ .await?;
Ok(())
}
@@ -1092,6 +1105,58 @@ mod tests {
assert!(!reloaded.bypass_curseforge_download_restrictions);
}
+ #[tokio::test]
+ async fn unicode_font_defaults_off_after_upgrade_and_round_trips() {
+ let migrator = sqlx::migrate!();
+ for previous_schema in [false, true] {
+ let pool = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .unwrap();
+ if previous_schema {
+ let previous_migrator = sqlx::migrate::Migrator {
+ migrations: std::borrow::Cow::Owned(
+ migrator
+ .iter()
+ .filter(|migration| {
+ migration.version < 20260919000000
+ })
+ .cloned()
+ .collect(),
+ ),
+ ..sqlx::migrate::Migrator::DEFAULT
+ };
+ previous_migrator.run(&pool).await.unwrap();
+ sqlx::query(
+ "UPDATE settings SET locale = 'zh-TW' WHERE id = 0",
+ )
+ .execute(&pool)
+ .await
+ .unwrap();
+ }
+ migrator.run(&pool).await.unwrap();
+
+ let mut settings = Settings::get(&pool).await.unwrap();
+ assert!(!settings.force_unicode_font);
+ if previous_schema {
+ assert_eq!(settings.locale, "zh-TW");
+ }
+ for enabled in [true, false] {
+ settings.force_unicode_font = enabled;
+ settings.update(&pool).await.unwrap();
+ let reloaded = Settings::get(&pool).await.unwrap();
+ assert_eq!(reloaded.force_unicode_font, enabled);
+ }
+
+ // Older clients and serialized settings omit the newly added field.
+ let mut legacy = serde_json::to_value(&settings).unwrap();
+ legacy.as_object_mut().unwrap().remove("force_unicode_font");
+ let restored: Settings = serde_json::from_value(legacy).unwrap();
+ assert!(!restored.force_unicode_font);
+ }
+ }
+
#[tokio::test]
async fn memory_optimization_round_trips_in_a_fresh_database() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
diff --git a/scripts/axolotl/create-update-manifest.mjs b/scripts/axolotl/create-update-manifest.mjs
index 51f22f1..5513652 100644
--- a/scripts/axolotl/create-update-manifest.mjs
+++ b/scripts/axolotl/create-update-manifest.mjs
@@ -62,11 +62,42 @@ for (const target of targets) {
}
}
+function digest(asset) {
+ if (typeof asset.digest !== 'string' || !asset.digest.startsWith('sha256:')) {
+ throw new Error(`Release asset ${asset.name} has no SHA-256 digest`)
+ }
+ return asset.digest.slice('sha256:'.length)
+}
+
+const apt = {}
+for (const target of [
+ { platform: 'linux-x86_64', assetSuffix: '_amd64.deb' },
+ { platform: 'linux-aarch64', assetSuffix: '_arm64.deb' },
+]) {
+ const matches = assets.filter((asset) => asset.name?.endsWith(target.assetSuffix))
+ if (matches.length !== 1) {
+ throw new Error(
+ `Expected one release asset ending in ${target.assetSuffix}, found ${matches.length}`,
+ )
+ }
+ const asset = matches[0]
+ const url = asset.browser_download_url ?? asset.url
+ if (!url || !Number.isSafeInteger(asset.size) || asset.size <= 0) {
+ throw new Error(`Release asset ${asset.name} has invalid download metadata`)
+ }
+ apt[target.platform] = {
+ url,
+ sha256: digest(asset),
+ size: asset.size,
+ }
+}
+
const manifest = {
version: tag.replace(/^v/, ''),
notes: release.body ?? '',
pub_date: new Date().toISOString(),
platforms,
+ apt,
}
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`)
diff --git a/scripts/axolotl/create-update-manifest.test.mjs b/scripts/axolotl/create-update-manifest.test.mjs
new file mode 100644
index 0000000..013a9d8
--- /dev/null
+++ b/scripts/axolotl/create-update-manifest.test.mjs
@@ -0,0 +1,70 @@
+import assert from 'node:assert/strict'
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { spawnSync } from 'node:child_process'
+
+const root = path.resolve(import.meta.dirname, '..', '..')
+const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'starlight-update-manifest-'))
+const releasePath = path.join(directory, 'release.json')
+const signaturesPath = path.join(directory, 'signatures')
+const outputPath = path.join(directory, 'latest.json')
+const tag = 'v1.9.7'
+const updaterAssets = [
+ 'Axolotl_Launcher_universal.app.tar.gz',
+ 'Axolotl_Launcher_1.9.7_aarch64.AppImage.tar.gz',
+ 'Axolotl_Launcher_1.9.7_amd64.AppImage.tar.gz',
+ 'Axolotl_Launcher_1.9.7_x64-setup.nsis.zip',
+]
+const debAssets = [
+ 'Axolotl_Launcher_1.9.7_amd64.deb',
+ 'Axolotl_Launcher_1.9.7_arm64.deb',
+]
+
+try {
+ fs.mkdirSync(signaturesPath)
+ for (const name of updaterAssets) {
+ fs.writeFileSync(path.join(signaturesPath, `${name}.sig`), 'signature'.repeat(8))
+ }
+ fs.writeFileSync(
+ releasePath,
+ JSON.stringify({
+ body: '测试版本',
+ assets: [...updaterAssets, ...debAssets].map((name, index) => ({
+ name,
+ size: index + 1024,
+ digest: `sha256:${crypto.createHash('sha256').update(name).digest('hex')}`,
+ browser_download_url: `https://github.com/Mystic-Stars/Axolotl/releases/download/${tag}/${name}`,
+ })),
+ }),
+ )
+
+ const create = spawnSync(
+ process.execPath,
+ [
+ 'scripts/axolotl/create-update-manifest.mjs',
+ releasePath,
+ signaturesPath,
+ tag,
+ outputPath,
+ ],
+ { cwd: root, encoding: 'utf8' },
+ )
+ assert.equal(create.status, 0, create.stderr)
+
+ const manifest = JSON.parse(fs.readFileSync(outputPath, 'utf8'))
+ assert.equal(manifest.version, '1.9.7')
+ assert.deepEqual(Object.keys(manifest.apt).sort(), ['linux-aarch64', 'linux-x86_64'])
+ assert.equal(manifest.apt['linux-x86_64'].size, 1028)
+ assert.match(manifest.apt['linux-aarch64'].sha256, /^[0-9a-f]{64}$/)
+
+ const verify = spawnSync(
+ process.execPath,
+ ['scripts/axolotl/verify-update-manifest.mjs', outputPath, tag],
+ { cwd: root, encoding: 'utf8' },
+ )
+ assert.equal(verify.status, 0, verify.stderr)
+} finally {
+ fs.rmSync(directory, { recursive: true, force: true })
+}
diff --git a/scripts/axolotl/skin-editor-bridge.test.mjs b/scripts/axolotl/skin-editor-bridge.test.mjs
new file mode 100644
index 0000000..a2fb938
--- /dev/null
+++ b/scripts/axolotl/skin-editor-bridge.test.mjs
@@ -0,0 +1,49 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+import { runInNewContext } from 'node:vm'
+
+const source = readFileSync(
+ new URL('../../apps/app/src/skin_editor_bridge.js', import.meta.url),
+ 'utf8',
+)
+
+function createFrame(url = 'http://axolotl-skin.localhost/index.html?embed=skin') {
+ const listeners = new Map()
+ const messages = []
+ const window = {
+ parent: { postMessage: (message) => messages.push(message) },
+ addEventListener: (type, handler) => listeners.set(type, handler),
+ }
+ runInNewContext(source, { window, location: new URL(url), URLSearchParams })
+ return { window, listeners, messages }
+}
+
+test('reports startup exceptions to the launcher', () => {
+ const frame = createFrame()
+ frame.listeners.get('error')({ message: 'ReferenceError: missing editor dependency' })
+ assert.equal(frame.messages[0].type, 'axolotl-skin-load-error')
+ assert.match(frame.messages[0].error, /missing editor dependency/)
+})
+
+test('reports rejected module imports even when the editor handles the rejection', async () => {
+ const frame = createFrame()
+ frame.window.blockbenchBundleReady = Promise.reject(new Error('Failed to fetch editor module'))
+ frame.listeners.get('DOMContentLoaded')()
+ await Promise.resolve()
+ assert.equal(frame.messages[0].error, 'Failed to fetch editor module')
+})
+
+test('does not install the bridge on unrelated pages', () => {
+ for (const url of ['https://skin.starlight.cool/', 'http://axolotl-skin.localhost/index.html']) {
+ assert.equal(createFrame(url).listeners.size, 0)
+ }
+})
+
+test('ignores resize observer notifications', () => {
+ const frame = createFrame()
+ frame.listeners.get('error')({
+ message: 'ResizeObserver loop completed with undelivered notifications.',
+ })
+ assert.equal(frame.messages.length, 0)
+})
diff --git a/scripts/axolotl/verify-update-manifest.mjs b/scripts/axolotl/verify-update-manifest.mjs
index 644c249..ba982ab 100644
--- a/scripts/axolotl/verify-update-manifest.mjs
+++ b/scripts/axolotl/verify-update-manifest.mjs
@@ -42,4 +42,22 @@ for (const platform of requiredPlatforms) {
}
}
+for (const platform of ['linux-aarch64', 'linux-x86_64']) {
+ const artifact = manifest.apt?.[platform]
+ if (
+ !artifact ||
+ typeof artifact.sha256 !== 'string' ||
+ !/^[0-9a-f]{64}$/i.test(artifact.sha256) ||
+ !Number.isSafeInteger(artifact.size) ||
+ artifact.size <= 0
+ ) {
+ throw new Error(`Missing Debian update for ${platform}`)
+ }
+
+ const url = new URL(artifact.url)
+ if (url.protocol !== 'https:') {
+ throw new Error(`Unexpected Debian update URL for ${platform}: ${artifact.url}`)
+ }
+}
+
console.log(`Verified signed ${source} updater manifest for ${expectedVersion}`)