feat: integrate StarLight updates and improve font settings and skin editor loading
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
This commit is contained in:
11
.github/workflows/axolotl-release.yml
vendored
11
.github/workflows/axolotl-release.yml
vendored
@ -198,7 +198,7 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$version = "${{ github.ref_name }}" -replace '^v', ''
|
$version = "${{ github.ref_name }}" -replace '^v', ''
|
||||||
$appName = "Axolotl Launcher"
|
$appName = (Get-Content apps/app/tauri.conf.json -Raw | ConvertFrom-Json).mainBinaryName
|
||||||
$outDir = "target/release/bundle/nsis"
|
$outDir = "target/release/bundle/nsis"
|
||||||
$buildDir = "target/portable-build"
|
$buildDir = "target/portable-build"
|
||||||
$publishDir = Join-Path $env:RUNNER_TEMP "windows-assets"
|
$publishDir = Join-Path $env:RUNNER_TEMP "windows-assets"
|
||||||
@ -249,6 +249,15 @@ jobs:
|
|||||||
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl" | Out-Null
|
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl" | Out-Null
|
||||||
Copy-Item -Force $appExe "$buildDir\Axolotl\"
|
Copy-Item -Force $appExe "$buildDir\Axolotl\"
|
||||||
|
|
||||||
|
$editorResources = "target/release/resources/blockbench-skin"
|
||||||
|
foreach ($requiredFile in @('index.html', 'css/setup.css', 'dist/skin.bundle.js.gz')) {
|
||||||
|
if (!(Test-Path -LiteralPath (Join-Path $editorResources $requiredFile) -PathType Leaf)) {
|
||||||
|
throw "Missing skin editor resource: $requiredFile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\resources" | Out-Null
|
||||||
|
Copy-Item -LiteralPath $editorResources -Destination "$buildDir\Axolotl\resources" -Recurse -Force
|
||||||
|
|
||||||
# Create empty .Axolotl folder to trigger portable mode
|
# Create empty .Axolotl folder to trigger portable mode
|
||||||
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\.Axolotl" | Out-Null
|
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\.Axolotl" | Out-Null
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,15 @@ import SettingsSection from './SettingsSection.vue'
|
|||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
forceUnicodeFont: {
|
||||||
|
id: 'app.settings.defaults.force-unicode-font',
|
||||||
|
defaultMessage: 'Force Unicode font',
|
||||||
|
},
|
||||||
|
forceUnicodeFontDescription: {
|
||||||
|
id: 'app.settings.defaults.force-unicode-font-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Use the Unicode font when initializing a new instance. Off by default; existing font settings are preserved.',
|
||||||
|
},
|
||||||
fullscreen: { id: 'app.settings.defaults.fullscreen', defaultMessage: 'Fullscreen' },
|
fullscreen: { id: 'app.settings.defaults.fullscreen', defaultMessage: 'Fullscreen' },
|
||||||
fullscreenDescription: {
|
fullscreenDescription: {
|
||||||
id: 'app.settings.defaults.fullscreen-description',
|
id: 'app.settings.defaults.fullscreen-description',
|
||||||
@ -205,6 +214,24 @@ watch(
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>
|
||||||
|
<span id="settings-target-defaults-unicode-font" tabindex="-1">
|
||||||
|
{{ formatMessage(messages.forceUnicodeFont) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #description>{{ formatMessage(messages.forceUnicodeFontDescription) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<Toggle
|
||||||
|
id="force-unicode-font"
|
||||||
|
v-model="settings.force_unicode_font"
|
||||||
|
:aria-label="formatMessage(messages.forceUnicodeFont)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsRow stacked>
|
<SettingsRow stacked>
|
||||||
<template #label>
|
<template #label>
|
||||||
|
|||||||
@ -294,7 +294,9 @@ async function loadLatestChannelVersions() {
|
|||||||
const versions = await Promise.all(
|
const versions = await Promise.all(
|
||||||
(['release', 'beta'] as const).map(async (channel) => {
|
(['release', 'beta'] as const).map(async (channel) => {
|
||||||
try {
|
try {
|
||||||
const response = await tauriFetch(`https://update.axlmc.org/latest?channel=${channel}`)
|
const response = await tauriFetch(
|
||||||
|
`https://skin.starlight.cool/starlight/launcher/latest?channel=${channel}`,
|
||||||
|
)
|
||||||
if (!response.ok) return [channel, undefined] as const
|
if (!response.ok) return [channel, undefined] as const
|
||||||
const payload = (await response.json()) as { version?: string }
|
const payload = (await response.json()) as { version?: string }
|
||||||
return [channel, payload.version] as const
|
return [channel, payload.version] as const
|
||||||
|
|||||||
@ -254,6 +254,16 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
|
|||||||
targetId: 'settings-target-defaults-environment',
|
targetId: 'settings-target-defaults-environment',
|
||||||
label: message('app.settings.defaults.environment-variables', 'Environment variables'),
|
label: message('app.settings.defaults.environment-variables', 'Environment variables'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'defaults-unicode-font',
|
||||||
|
categoryId: 'launch-defaults',
|
||||||
|
targetId: 'settings-target-defaults-unicode-font',
|
||||||
|
label: message('app.settings.defaults.force-unicode-font', 'Force Unicode font'),
|
||||||
|
description: message(
|
||||||
|
'app.settings.defaults.force-unicode-font-description',
|
||||||
|
'Use the Unicode font when initializing a new instance. Off by default; existing font settings are preserved.',
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'defaults-launch-hooks',
|
id: 'defaults-launch-hooks',
|
||||||
categoryId: 'launch-defaults',
|
categoryId: 'launch-defaults',
|
||||||
|
|||||||
@ -104,6 +104,28 @@ test('settings search index has unique entries with categories', () => {
|
|||||||
assert.deepEqual(validateSettingsSearchEntries(), [])
|
assert.deepEqual(validateSettingsSearchEntries(), [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Unicode font is searchable in Chinese and English', () => {
|
||||||
|
const entry = settingsSearchEntries.find((entry) => entry.id === 'defaults-unicode-font')!
|
||||||
|
assert.equal(entry.categoryId, 'launch-defaults')
|
||||||
|
assert.ok(
|
||||||
|
readFileSync(new URL('./DefaultInstanceSettings.vue', import.meta.url), 'utf8').includes(
|
||||||
|
`id="${getSettingsSearchTargetId(entry)}"`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for (const translated of [false, true]) {
|
||||||
|
const documents = settingsSearchEntries.map((entry) => ({
|
||||||
|
item: entry,
|
||||||
|
text: translated
|
||||||
|
? (chineseLocale[entry.label.id]?.message ?? entry.label.defaultMessage ?? '')
|
||||||
|
: (entry.label.defaultMessage ?? ''),
|
||||||
|
}))
|
||||||
|
for (const query of translated ? ['字体', 'Unicode'] : ['font', 'Unicode']) {
|
||||||
|
const matches = filterSettingsSearchDocuments(query, documents)
|
||||||
|
assert.ok(matches.some(({ item }) => item.id === 'defaults-unicode-font'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('settings search keywords are valid message descriptors', () => {
|
test('settings search keywords are valid message descriptors', () => {
|
||||||
for (const entry of settingsSearchEntries) {
|
for (const entry of settingsSearchEntries) {
|
||||||
for (const keyword of entry.keywords ?? []) {
|
for (const keyword of entry.keywords ?? []) {
|
||||||
|
|||||||
@ -183,6 +183,7 @@ export type AppSettings = {
|
|||||||
custom_env_vars: [string, string][]
|
custom_env_vars: [string, string][]
|
||||||
memory: MemorySettings
|
memory: MemorySettings
|
||||||
force_fullscreen: boolean
|
force_fullscreen: boolean
|
||||||
|
force_unicode_font: boolean
|
||||||
maximize_window: boolean
|
maximize_window: boolean
|
||||||
game_resolution: [number, number]
|
game_resolution: [number, number]
|
||||||
hide_on_process_start: boolean
|
hide_on_process_start: boolean
|
||||||
@ -227,6 +228,7 @@ function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings)
|
|||||||
settings.auto_concurrent_downloads ??= true
|
settings.auto_concurrent_downloads ??= true
|
||||||
settings.download_engine ??= 'legacy'
|
settings.download_engine ??= 'legacy'
|
||||||
settings.auto_set_java_high_performance_mode ??= true
|
settings.auto_set_java_high_performance_mode ??= true
|
||||||
|
settings.force_unicode_font ??= false
|
||||||
settings.minecraft_metadata_source ??=
|
settings.minecraft_metadata_source ??=
|
||||||
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_minecraft_mirror)
|
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_minecraft_mirror)
|
||||||
settings.minecraft_file_source ??=
|
settings.minecraft_file_source ??=
|
||||||
|
|||||||
@ -5066,6 +5066,9 @@
|
|||||||
"app.lab.skin-editor.retry": {
|
"app.lab.skin-editor.retry": {
|
||||||
"message": "Try again"
|
"message": "Try again"
|
||||||
},
|
},
|
||||||
|
"app.lab.skin-editor.resource-error": {
|
||||||
|
"message": "Skin editor files are missing or damaged. Reinstall the launcher or extract the complete portable archive."
|
||||||
|
},
|
||||||
"app.lab.skin-editor.title": {
|
"app.lab.skin-editor.title": {
|
||||||
"message": "Skin editor"
|
"message": "Skin editor"
|
||||||
},
|
},
|
||||||
@ -6836,6 +6839,12 @@
|
|||||||
"app.settings.defaults.environment-variables-placeholder": {
|
"app.settings.defaults.environment-variables-placeholder": {
|
||||||
"message": "Enter environment variables..."
|
"message": "Enter environment variables..."
|
||||||
},
|
},
|
||||||
|
"app.settings.defaults.force-unicode-font": {
|
||||||
|
"message": "Force Unicode font"
|
||||||
|
},
|
||||||
|
"app.settings.defaults.force-unicode-font-description": {
|
||||||
|
"message": "Use the Unicode font when initializing a new instance. Off by default; existing font settings are preserved."
|
||||||
|
},
|
||||||
"app.settings.defaults.fullscreen": {
|
"app.settings.defaults.fullscreen": {
|
||||||
"message": "Fullscreen"
|
"message": "Fullscreen"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5152,6 +5152,9 @@
|
|||||||
"app.lab.skin-editor.retry": {
|
"app.lab.skin-editor.retry": {
|
||||||
"message": "重试"
|
"message": "重试"
|
||||||
},
|
},
|
||||||
|
"app.lab.skin-editor.resource-error": {
|
||||||
|
"message": "皮肤编辑器文件缺失或损坏,请重新安装启动器,或完整解压便携版。"
|
||||||
|
},
|
||||||
"app.lab.skin-editor.title": {
|
"app.lab.skin-editor.title": {
|
||||||
"message": "皮肤编辑器"
|
"message": "皮肤编辑器"
|
||||||
},
|
},
|
||||||
@ -6895,6 +6898,12 @@
|
|||||||
"app.settings.defaults.environment-variables-placeholder": {
|
"app.settings.defaults.environment-variables-placeholder": {
|
||||||
"message": "输入环境变量……"
|
"message": "输入环境变量……"
|
||||||
},
|
},
|
||||||
|
"app.settings.defaults.force-unicode-font": {
|
||||||
|
"message": "强制使用 Unicode 字体"
|
||||||
|
},
|
||||||
|
"app.settings.defaults.force-unicode-font-description": {
|
||||||
|
"message": "初始化新实例时使用 Unicode 字体。默认关闭;实例已有的字体设置会保留。"
|
||||||
|
},
|
||||||
"app.settings.defaults.fullscreen": {
|
"app.settings.defaults.fullscreen": {
|
||||||
"message": "全屏"
|
"message": "全屏"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6575,6 +6575,12 @@
|
|||||||
"app.settings.defaults.environment-variables-placeholder": {
|
"app.settings.defaults.environment-variables-placeholder": {
|
||||||
"message": "輸入環境變數..."
|
"message": "輸入環境變數..."
|
||||||
},
|
},
|
||||||
|
"app.settings.defaults.force-unicode-font": {
|
||||||
|
"message": "強制使用 Unicode 字型"
|
||||||
|
},
|
||||||
|
"app.settings.defaults.force-unicode-font-description": {
|
||||||
|
"message": "初始化新例項時使用 Unicode 字型。預設關閉;例項既有的字型設定會保留。"
|
||||||
|
},
|
||||||
"app.settings.defaults.fullscreen": {
|
"app.settings.defaults.fullscreen": {
|
||||||
"message": "全螢幕"
|
"message": "全螢幕"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { save } from '@tauri-apps/plugin-dialog'
|
import { save } from '@tauri-apps/plugin-dialog'
|
||||||
import { writeFile } from '@tauri-apps/plugin-fs'
|
import { writeFile } from '@tauri-apps/plugin-fs'
|
||||||
import { platform } from '@tauri-apps/plugin-os'
|
import { platform } from '@tauri-apps/plugin-os'
|
||||||
@ -27,6 +28,11 @@ const messages = defineMessages({
|
|||||||
id: 'app.lab.skin-editor.load-error-description',
|
id: 'app.lab.skin-editor.load-error-description',
|
||||||
defaultMessage: 'The embedded editor did not finish loading. Try again.',
|
defaultMessage: 'The embedded editor did not finish loading. Try again.',
|
||||||
},
|
},
|
||||||
|
resourceError: {
|
||||||
|
id: 'app.lab.skin-editor.resource-error',
|
||||||
|
defaultMessage:
|
||||||
|
'Skin editor files are missing or damaged. Reinstall the launcher or extract the complete portable archive.',
|
||||||
|
},
|
||||||
retry: { id: 'app.lab.skin-editor.retry', defaultMessage: 'Try again' },
|
retry: { id: 'app.lab.skin-editor.retry', defaultMessage: 'Try again' },
|
||||||
exportSkin: { id: 'app.lab.skin-editor.export-skin', defaultMessage: 'Minecraft skin PNG' },
|
exportSkin: { id: 'app.lab.skin-editor.export-skin', defaultMessage: 'Minecraft skin PNG' },
|
||||||
})
|
})
|
||||||
@ -40,7 +46,10 @@ const blockbenchLocale = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const editorState = ref<'loading' | 'ready' | 'error'>('loading')
|
const editorState = ref<'loading' | 'ready' | 'error'>('loading')
|
||||||
|
const errorDetail = ref('')
|
||||||
|
const resourceError = ref(false)
|
||||||
const frameKey = ref(0)
|
const frameKey = ref(0)
|
||||||
|
let loadAttempt = 0
|
||||||
let loadTimeout: number | undefined
|
let loadTimeout: number | undefined
|
||||||
|
|
||||||
const editorUrl = computed(() => {
|
const editorUrl = computed(() => {
|
||||||
@ -60,6 +69,8 @@ function clearLoadTimeout() {
|
|||||||
|
|
||||||
function beginEditorLoad() {
|
function beginEditorLoad() {
|
||||||
clearLoadTimeout()
|
clearLoadTimeout()
|
||||||
|
errorDetail.value = ''
|
||||||
|
resourceError.value = false
|
||||||
editorState.value = 'loading'
|
editorState.value = 'loading'
|
||||||
loadTimeout = window.setTimeout(() => {
|
loadTimeout = window.setTimeout(() => {
|
||||||
editorState.value = 'error'
|
editorState.value = 'error'
|
||||||
@ -77,11 +88,22 @@ function markEditorError() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reloadEditor() {
|
async function reloadEditor() {
|
||||||
|
const attempt = ++loadAttempt
|
||||||
beginEditorLoad()
|
beginEditorLoad()
|
||||||
if (!editorUrl.value) {
|
if (!import.meta.env.DEV) {
|
||||||
|
platformName.value = undefined
|
||||||
try {
|
try {
|
||||||
platformName.value = await platform()
|
const errors = await invoke<string[]>('get_skin_editor_resource_errors')
|
||||||
|
if (attempt !== loadAttempt) return
|
||||||
|
if (errors.length) {
|
||||||
|
resourceError.value = true
|
||||||
|
errorDetail.value = errors.join(', ')
|
||||||
|
markEditorError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platformName.value = platform()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (attempt !== loadAttempt) return
|
||||||
markEditorError()
|
markEditorError()
|
||||||
handleError(error)
|
handleError(error)
|
||||||
return
|
return
|
||||||
@ -104,7 +126,18 @@ function handleFrameLoad() {
|
|||||||
async function handleEditorMessage(event: MessageEvent<unknown>) {
|
async function handleEditorMessage(event: MessageEvent<unknown>) {
|
||||||
if (event.source !== frame.value?.contentWindow) return
|
if (event.source !== frame.value?.contentWindow) return
|
||||||
if (!event.data || typeof event.data !== 'object') 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') {
|
if (message.type === 'axolotl-skin-theme-ready') {
|
||||||
sendThemeToEditor()
|
sendThemeToEditor()
|
||||||
markEditorReady()
|
markEditorReady()
|
||||||
@ -149,15 +182,11 @@ onMounted(async () => {
|
|||||||
attributeFilter: ['class', 'style'],
|
attributeFilter: ['class', 'style'],
|
||||||
})
|
})
|
||||||
if (!import.meta.env.DEV) {
|
if (!import.meta.env.DEV) {
|
||||||
try {
|
await reloadEditor()
|
||||||
platformName.value = await platform()
|
|
||||||
} catch (error) {
|
|
||||||
markEditorError()
|
|
||||||
handleError(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
loadAttempt += 1
|
||||||
clearLoadTimeout()
|
clearLoadTimeout()
|
||||||
window.removeEventListener('message', handleEditorMessage)
|
window.removeEventListener('message', handleEditorMessage)
|
||||||
themeObserver?.disconnect()
|
themeObserver?.disconnect()
|
||||||
@ -184,7 +213,12 @@ onUnmounted(() => {
|
|||||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||||
{{ formatMessage(messages.loadErrorTitle) }}
|
{{ formatMessage(messages.loadErrorTitle) }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0 max-w-md text-secondary">{{ formatMessage(messages.loadErrorDescription) }}</p>
|
<p class="m-0 max-w-md text-secondary">
|
||||||
|
{{ formatMessage(resourceError ? messages.resourceError : messages.loadErrorDescription) }}
|
||||||
|
</p>
|
||||||
|
<p v-if="errorDetail" class="m-0 max-w-xl break-words text-sm text-secondary">
|
||||||
|
{{ errorDetail }}
|
||||||
|
</p>
|
||||||
<ButtonStyled color="brand" @click="reloadEditor">
|
<ButtonStyled color="brand" @click="reloadEditor">
|
||||||
{{ formatMessage(messages.retry) }}
|
{{ formatMessage(messages.retry) }}
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@ -195,8 +229,8 @@ onUnmounted(() => {
|
|||||||
ref="frame"
|
ref="frame"
|
||||||
:title="formatMessage(messages.title)"
|
:title="formatMessage(messages.title)"
|
||||||
:src="editorUrl"
|
:src="editorUrl"
|
||||||
class="h-full min-h-0 w-full flex-1 border-0 transition-opacity duration-150"
|
class="h-full min-h-0 w-full flex-1 border-0"
|
||||||
:class="editorState === 'ready' ? 'opacity-100' : 'pointer-events-none opacity-0'"
|
:class="{ 'pointer-events-none': editorState !== 'ready' }"
|
||||||
:aria-label="formatMessage(messages.title)"
|
:aria-label="formatMessage(messages.title)"
|
||||||
:aria-hidden="editorState !== 'ready'"
|
:aria-hidden="editorState !== 'ready'"
|
||||||
@load="handleFrameLoad"
|
@load="handleFrameLoad"
|
||||||
|
|||||||
@ -59,7 +59,7 @@
|
|||||||
"url": "https://api.purpurmc.org/*"
|
"url": "https://api.purpurmc.org/*"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "https://update.axlmc.org/*"
|
"url": "https://skin.starlight.cool/*"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "http://localhost:8000/*"
|
"url": "http://localhost:8000/*"
|
||||||
|
|||||||
@ -77,9 +77,10 @@ fn blockbench_skin_response(
|
|||||||
} else {
|
} else {
|
||||||
relative_path.to_path_buf()
|
relative_path.to_path_buf()
|
||||||
});
|
});
|
||||||
let contents = match fs::read(file_path) {
|
let contents = match fs::read(&file_path) {
|
||||||
Ok(contents) => contents,
|
Ok(contents) => contents,
|
||||||
Err(_) => {
|
Err(error) => {
|
||||||
|
tracing::warn!(path = %file_path.display(), %error, "Skin editor resource could not be read");
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::NOT_FOUND)
|
.status(StatusCode::NOT_FOUND)
|
||||||
.body(Vec::new())
|
.body(Vec::new())
|
||||||
@ -88,10 +89,10 @@ fn blockbench_skin_response(
|
|||||||
};
|
};
|
||||||
let contents = if is_compressed_bundle {
|
let contents = if is_compressed_bundle {
|
||||||
let mut decompressed = Vec::new();
|
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)
|
.read_to_end(&mut decompressed)
|
||||||
.is_err()
|
|
||||||
{
|
{
|
||||||
|
tracing::warn!(path = %file_path.display(), %error, "Skin editor bundle could not be decoded");
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
.body(Vec::new())
|
.body(Vec::new())
|
||||||
@ -125,6 +126,91 @@ fn blockbench_skin_response(
|
|||||||
.expect("failed to build Blockbench skin response")
|
.expect("failed to build Blockbench skin response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn skin_editor_resource_errors(resource_dir: &Path) -> Vec<String> {
|
||||||
|
["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"), "<!doctype 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<Vec<String>, 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(
|
fn is_allowed_blockbench_skin_request(
|
||||||
request: &tauri::http::Request<Vec<u8>>,
|
request: &tauri::http::Request<Vec<u8>>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
@ -685,6 +771,10 @@ fn main() {
|
|||||||
"axolotl-skin",
|
"axolotl-skin",
|
||||||
move |context, request| {
|
move |context, request| {
|
||||||
if !is_allowed_blockbench_skin_request(&request) {
|
if !is_allowed_blockbench_skin_request(&request) {
|
||||||
|
tracing::warn!(
|
||||||
|
path = request.uri().path(),
|
||||||
|
"Skin editor resource request was rejected"
|
||||||
|
);
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::FORBIDDEN)
|
.status(StatusCode::FORBIDDEN)
|
||||||
.body(Vec::new())
|
.body(Vec::new())
|
||||||
@ -703,6 +793,13 @@ fn main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
builder = builder
|
builder = builder
|
||||||
|
.plugin(
|
||||||
|
tauri::plugin::Builder::<tauri::Wry>::new("skin-editor-errors")
|
||||||
|
.js_init_script_on_all_frames(include_str!(
|
||||||
|
"skin_editor_bridge.js"
|
||||||
|
))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri::plugin::Builder::<tauri::Wry>::new("skin-site-session")
|
tauri::plugin::Builder::<tauri::Wry>::new("skin-site-session")
|
||||||
.js_init_script_on_all_frames(include_str!(
|
.js_init_script_on_all_frames(include_str!(
|
||||||
@ -853,6 +950,7 @@ fn main() {
|
|||||||
.manage(PendingUpdateData::default())
|
.manage(PendingUpdateData::default())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
initialize_state,
|
initialize_state,
|
||||||
|
get_skin_editor_resource_errors,
|
||||||
get_launcher_root_dir,
|
get_launcher_root_dir,
|
||||||
get_update_channel,
|
get_update_channel,
|
||||||
get_current_app_database_path,
|
get_current_app_database_path,
|
||||||
|
|||||||
32
apps/app/src/skin_editor_bridge.js
Normal file
32
apps/app/src/skin_editor_bridge.js
Normal file
@ -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))
|
||||||
|
})
|
||||||
|
})()
|
||||||
@ -3,21 +3,23 @@ use futures::StreamExt;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::http::HeaderValue;
|
|
||||||
use tauri::http::header::ACCEPT;
|
use tauri::http::header::ACCEPT;
|
||||||
|
use tauri::http::HeaderValue;
|
||||||
use tauri::{Manager, ResourceId, Runtime, Webview};
|
use tauri::{Manager, ResourceId, Runtime, Webview};
|
||||||
use tauri_plugin_http::reqwest;
|
use tauri_plugin_http::reqwest;
|
||||||
use tauri_plugin_http::reqwest::ClientBuilder;
|
use tauri_plugin_http::reqwest::ClientBuilder;
|
||||||
use tauri_plugin_updater::{Error, Update, UpdaterExt};
|
use tauri_plugin_updater::{Error, Update, UpdaterExt};
|
||||||
use theseus::{
|
use theseus::{
|
||||||
LoadingBarType, emit_loading, init_loading, launcher_user_agent,
|
emit_loading, init_loading, launcher_user_agent, LoadingBarType,
|
||||||
};
|
};
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
const UPDATE_SERVER_LATEST_URL: &str = "https://update.axlmc.org/latest";
|
const STARLIGHT_UPDATE_LATEST_URL: &str =
|
||||||
const UPDATE_SERVER_API: &str = "https://update.axlmc.org/api/versions";
|
"https://skin.starlight.cool/starlight/launcher/latest";
|
||||||
const UPDATE_SERVER_BASE: &str = "https://update.axlmc.org/";
|
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
|
// The updater plugin builds `Update` with no request timeout, so a stalled
|
||||||
// connection would hang the download forever. Bound the whole download.
|
// connection would hang the download forever. Bound the whole download.
|
||||||
@ -62,7 +64,10 @@ struct ArtifactEntry {
|
|||||||
variant: Option<String>,
|
variant: Option<String>,
|
||||||
platform: String,
|
platform: String,
|
||||||
architecture: String,
|
architecture: String,
|
||||||
relative_path: String,
|
#[serde(default)]
|
||||||
|
relative_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
download_url: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
sha256: Option<String>,
|
sha256: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -94,7 +99,7 @@ async fn fetch_apt_deb_asset(version: &str) -> Result<AptDebAsset> {
|
|||||||
.user_agent(launcher_user_agent())
|
.user_agent(launcher_user_agent())
|
||||||
.timeout(UPDATE_DOWNLOAD_TIMEOUT)
|
.timeout(UPDATE_DOWNLOAD_TIMEOUT)
|
||||||
.build()?
|
.build()?
|
||||||
.get(UPDATE_SERVER_API)
|
.get(STARLIGHT_UPDATE_VERSIONS_URL)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -139,13 +144,7 @@ async fn fetch_apt_deb_asset(version: &str) -> Result<AptDebAsset> {
|
|||||||
)))
|
)))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let url =
|
let url = artifact_download_url(artifact)?;
|
||||||
Url::parse(&format!("{UPDATE_SERVER_BASE}{}", artifact.relative_path))
|
|
||||||
.map_err(|error| {
|
|
||||||
theseus::Error::from(theseus::ErrorKind::OtherError(
|
|
||||||
error.to_string(),
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(AptDebAsset {
|
Ok(AptDebAsset {
|
||||||
url,
|
url,
|
||||||
@ -154,6 +153,31 @@ async fn fetch_apt_deb_asset(version: &str) -> Result<AptDebAsset> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn artifact_download_url(artifact: &ArtifactEntry) -> Result<Url> {
|
||||||
|
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 ───────────────────────────────────────
|
// ── Updater plugin helpers ───────────────────────────────────────
|
||||||
|
|
||||||
fn update_channel(channel: &str) -> Result<&str> {
|
fn update_channel(channel: &str) -> Result<&str> {
|
||||||
@ -183,7 +207,7 @@ fn update_platform() -> Result<&'static str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update_endpoint() -> Result<Url> {
|
fn update_endpoint() -> Result<Url> {
|
||||||
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()))
|
theseus::Error::from(theseus::ErrorKind::OtherError(error.to_string()))
|
||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNGNEE1MkMxOTI0MDA4NzYKUldSMkNFQ1N3VkpLUC9kcEtlZFRWak5FRXJsWUw3YllxWGh6bkg3ZEh3K1ZQa1VNZHl6Y0IvQysK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNGNEE1MkMxOTI0MDA4NzYKUldSMkNFQ1N3VkpLUC9kcEtlZFRWak5FRXJsWUw3YllxWGh6bkg3ZEh3K1ZQa1VNZHl6Y0IvQysK",
|
||||||
"endpoints": ["https://update.axlmc.org/latest"],
|
"endpoints": ["https://skin.starlight.cool/starlight/launcher/latest"],
|
||||||
"windows": {
|
"windows": {
|
||||||
"installMode": "quiet"
|
"installMode": "quiet"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -111,7 +111,7 @@
|
|||||||
"capabilities": ["core", "plugins"],
|
"capabilities": ["core", "plugins"],
|
||||||
"devCsp": {
|
"devCsp": {
|
||||||
"default-src": "'self' customprotocol: asset:",
|
"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/"],
|
"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:",
|
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||||
"style-src": "'unsafe-inline' 'self'",
|
"style-src": "'unsafe-inline' 'self'",
|
||||||
@ -123,7 +123,7 @@
|
|||||||
},
|
},
|
||||||
"csp": {
|
"csp": {
|
||||||
"default-src": "'self' customprotocol: asset:",
|
"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/"],
|
"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:",
|
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||||
"style-src": "'unsafe-inline' 'self'",
|
"style-src": "'unsafe-inline' 'self'",
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE settings ADD COLUMN mc_force_unicode_font INTEGER NOT NULL DEFAULT FALSE;
|
||||||
@ -24,11 +24,14 @@ enum LanguageCodeStyle {
|
|||||||
/// directory (e.g. modpacks that ship a preconfigured `options.txt`). For
|
/// directory (e.g. modpacks that ship a preconfigured `options.txt`). For
|
||||||
/// instances the player already uses, their in-game choice is kept and only
|
/// 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.
|
/// 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(
|
pub fn game_language_options(
|
||||||
launcher_locale: &str,
|
launcher_locale: &str,
|
||||||
game_release_time: DateTime<Utc>,
|
game_release_time: DateTime<Utc>,
|
||||||
options_txt: &str,
|
options_txt: &str,
|
||||||
has_saves: bool,
|
has_saves: bool,
|
||||||
|
force_unicode_font: bool,
|
||||||
) -> Vec<(String, String)> {
|
) -> Vec<(String, String)> {
|
||||||
let style = match language_code_style(game_release_time) {
|
let style = match language_code_style(game_release_time) {
|
||||||
LanguageCodeStyle::Unsupported => return Vec::new(),
|
LanguageCodeStyle::Unsupported => return Vec::new(),
|
||||||
@ -47,16 +50,21 @@ pub fn game_language_options(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|code| normalize_language_code(code, legacy_region_case))
|
.and_then(|code| normalize_language_code(code, legacy_region_case))
|
||||||
};
|
};
|
||||||
let Some(desired) = desired else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut options = 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));
|
options.push(("lang".to_string(), desired));
|
||||||
}
|
}
|
||||||
if fresh && needs_unicode_font(launcher_locale) {
|
if fresh
|
||||||
options.push(("forceUnicodeFont".to_string(), "true".to_string()));
|
&& !options_txt
|
||||||
|
.lines()
|
||||||
|
.any(|line| line.starts_with("forceUnicodeFont:"))
|
||||||
|
{
|
||||||
|
options.push((
|
||||||
|
"forceUnicodeFont".to_string(),
|
||||||
|
force_unicode_font.to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
options
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -142,10 +136,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn fresh_instance_follows_launcher_language() {
|
fn fresh_instance_follows_launcher_language() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
game_language_options("zh-CN", modern(), "", false),
|
game_language_options("zh-CN", modern(), "", false, false),
|
||||||
vec![
|
vec![
|
||||||
("lang".to_string(), "zh_cn".to_string()),
|
("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]
|
#[test]
|
||||||
fn legacy_versions_use_uppercase_region() {
|
fn legacy_versions_use_uppercase_region() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
game_language_options("zh-CN", legacy(), "", false),
|
game_language_options("zh-CN", legacy(), "", false, false),
|
||||||
vec![
|
vec![
|
||||||
("lang".to_string(), "zh_CN".to_string()),
|
("lang".to_string(), "zh_CN".to_string()),
|
||||||
("forceUnicodeFont".to_string(), "true".to_string()),
|
("forceUnicodeFont".to_string(), "false".to_string()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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!(
|
assert_eq!(
|
||||||
game_language_options("en-US", modern(), "", false),
|
game_language_options("en-US", modern(), "", false, false),
|
||||||
vec![("lang".to_string(), "en_us".to_string())]
|
vec![
|
||||||
|
("lang".to_string(), "en_us".to_string()),
|
||||||
|
("forceUnicodeFont".to_string(), "false".to_string()),
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn versions_before_1_1_are_left_alone() {
|
fn versions_before_1_1_are_left_alone() {
|
||||||
assert_eq!(
|
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()
|
Vec::new()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -184,6 +202,7 @@ mod tests {
|
|||||||
"zh-CN",
|
"zh-CN",
|
||||||
modern(),
|
modern(),
|
||||||
"fullscreen:false\nlang:ja_jp\n",
|
"fullscreen:false\nlang:ja_jp\n",
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
),
|
),
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@ -193,7 +212,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn played_instances_get_their_casing_normalized() {
|
fn played_instances_get_their_casing_normalized() {
|
||||||
assert_eq!(
|
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())]
|
vec![("lang".to_string(), "zh_cn".to_string())]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -201,10 +226,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn preconfigured_language_without_saves_is_overridden() {
|
fn preconfigured_language_without_saves_is_overridden() {
|
||||||
assert_eq!(
|
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![
|
vec![
|
||||||
("lang".to_string(), "zh_tw".to_string()),
|
("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]
|
#[test]
|
||||||
fn matching_language_needs_no_update() {
|
fn matching_language_needs_no_update() {
|
||||||
assert_eq!(
|
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()
|
Vec::new()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_locale_makes_no_changes() {
|
fn empty_locale_only_initializes_the_font_default() {
|
||||||
assert_eq!(game_language_options("", modern(), "", false), Vec::new());
|
|
||||||
assert_eq!(
|
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()
|
Vec::new()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -229,8 +269,39 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn crlf_options_files_are_parsed() {
|
fn crlf_options_files_are_parsed() {
|
||||||
assert_eq!(
|
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()
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2326,6 +2326,7 @@ pub async fn launch_minecraft(
|
|||||||
&& (!mc_set_options.is_empty()
|
&& (!mc_set_options.is_empty()
|
||||||
|| offline_skin_pack.enabled_pack_id.is_some()
|
|| offline_skin_pack.enabled_pack_id.is_some()
|
||||||
|| options_existed
|
|| options_existed
|
||||||
|
|| settings.force_unicode_font
|
||||||
|| !settings.locale.is_empty())
|
|| !settings.locale.is_empty())
|
||||||
{
|
{
|
||||||
let (mut options_string, input_encoding) = if options_existed {
|
let (mut options_string, input_encoding) = if options_existed {
|
||||||
@ -2353,6 +2354,7 @@ pub async fn launch_minecraft(
|
|||||||
launch_release_time,
|
launch_release_time,
|
||||||
&options_string,
|
&options_string,
|
||||||
instance_path.join("saves").exists(),
|
instance_path.join("saves").exists(),
|
||||||
|
settings.force_unicode_font,
|
||||||
);
|
);
|
||||||
|
|
||||||
if !mc_set_options.is_empty()
|
if !mc_set_options.is_empty()
|
||||||
|
|||||||
@ -166,6 +166,8 @@ pub struct Settings {
|
|||||||
pub memory: MemorySettings,
|
pub memory: MemorySettings,
|
||||||
pub force_fullscreen: bool,
|
pub force_fullscreen: bool,
|
||||||
pub maximize_window: bool,
|
pub maximize_window: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_unicode_font: bool,
|
||||||
pub game_resolution: WindowSize,
|
pub game_resolution: WindowSize,
|
||||||
pub hide_on_process_start: bool,
|
pub hide_on_process_start: bool,
|
||||||
pub enter_lightweight_mode_on_game_launch: bool,
|
pub enter_lightweight_mode_on_game_launch: bool,
|
||||||
@ -347,6 +349,11 @@ impl Settings {
|
|||||||
},
|
},
|
||||||
force_fullscreen: res.mc_force_fullscreen == 1,
|
force_fullscreen: res.mc_force_fullscreen == 1,
|
||||||
maximize_window: res.mc_maximize_window == 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(
|
game_resolution: WindowSize(
|
||||||
res.mc_game_resolution_x as u16,
|
res.mc_game_resolution_x as u16,
|
||||||
res.mc_game_resolution_y as u16,
|
res.mc_game_resolution_y as u16,
|
||||||
@ -590,6 +597,12 @@ impl Settings {
|
|||||||
.bind(self.memory.optimize_before_launch)
|
.bind(self.memory.optimize_before_launch)
|
||||||
.execute(exec)
|
.execute(exec)
|
||||||
.await?;
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE settings SET mc_force_unicode_font = ? WHERE id = 0",
|
||||||
|
)
|
||||||
|
.bind(self.force_unicode_font)
|
||||||
|
.execute(exec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -1092,6 +1105,58 @@ mod tests {
|
|||||||
assert!(!reloaded.bypass_curseforge_download_restrictions);
|
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]
|
#[tokio::test]
|
||||||
async fn memory_optimization_round_trips_in_a_fresh_database() {
|
async fn memory_optimization_round_trips_in_a_fresh_database() {
|
||||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
|||||||
@ -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 = {
|
const manifest = {
|
||||||
version: tag.replace(/^v/, ''),
|
version: tag.replace(/^v/, ''),
|
||||||
notes: release.body ?? '',
|
notes: release.body ?? '',
|
||||||
pub_date: new Date().toISOString(),
|
pub_date: new Date().toISOString(),
|
||||||
platforms,
|
platforms,
|
||||||
|
apt,
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||||
|
|||||||
70
scripts/axolotl/create-update-manifest.test.mjs
Normal file
70
scripts/axolotl/create-update-manifest.test.mjs
Normal file
@ -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 })
|
||||||
|
}
|
||||||
49
scripts/axolotl/skin-editor-bridge.test.mjs
Normal file
49
scripts/axolotl/skin-editor-bridge.test.mjs
Normal file
@ -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)
|
||||||
|
})
|
||||||
@ -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}`)
|
console.log(`Verified signed ${source} updater manifest for ${expectedVersion}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user