feat: 一键安装支持自选游戏目录
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

StarLight 实例一键安装此前固定落到启动器默认目录,无法选择游戏数据位置。
现复用创建流程的外部游戏目录能力:点击一键安装后弹出目录选择框,
只允许修改游戏目录,其余参数仍由服务器整合包决定。

- hosted::create 接受 game_dir_root,拼为 <root>/<包名> 作为 game_dir_override
  (刻意避开 versions/ 布局,该结构被外部直链实例检测占用)
- 新增顶层命令 get_launcher_root_dir,作为弹窗默认值(可执行文件所在目录)
- 新增 HostedGameDirModal 弹窗:只读路径框 + 浏览 + 默认位置 + 安装预览
- 补齐 en-US / zh-CN / zh-TW 三语文案

hosted_create 增加 game_dir_root 参数,前端 hostedCreate/install 同步透传。
This commit is contained in:
Xiao-no-love
2026-09-18 13:04:28 +08:00
parent 51fb0c30f7
commit 5006f432dc
10 changed files with 252 additions and 8 deletions

View File

@ -0,0 +1,161 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
max-width="560px"
:on-hide="handleHide"
>
<div class="flex flex-col gap-4">
<p class="m-0 text-secondary">
{{ formatMessage(messages.description) }}
</p>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.gameDirLabel) }}
</span>
<div class="flex gap-2">
<StyledInput
class="flex-1"
:model-value="selectedPath"
readonly
:placeholder="defaultPath || formatMessage(messages.noSelection)"
/>
<ButtonStyled>
<button type="button" @click="browse">
<FolderOpenIcon />
{{ formatMessage(messages.browse) }}
</button>
</ButtonStyled>
</div>
<button
v-if="defaultPath && selectedPath !== defaultPath"
type="button"
class="self-start text-sm text-brand underline decoration-transparent underline-offset-2 transition-colors hover:decoration-current"
@click="selectedPath = defaultPath"
>
{{ formatMessage(messages.resetDefault) }}
</button>
</div>
<p v-if="previewPath" class="m-0 text-sm text-secondary break-all">
{{ formatMessage(messages.preview, { path: previewPath }) }}
</p>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button type="button" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="!selectedPath" @click="handleConfirm">
<CheckIcon />
{{ formatMessage(messages.confirm) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, FolderOpenIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectFilePicker,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
const emit = defineEmits<{
(e: 'confirm', gameDirRoot: string): void
(e: 'cancel'): void
}>()
const { formatMessage } = useVIntl()
const filePicker = injectFilePicker()
const modal = ref<InstanceType<typeof NewModal>>()
const defaultPath = ref('')
const selectedPath = ref('')
const accepted = ref(false)
const messages = defineMessages({
header: {
id: 'app.hosted-install.game-dir.header',
defaultMessage: 'Choose game directory',
},
description: {
id: 'app.hosted-install.game-dir.description',
defaultMessage:
'StarLight instance data (mods, saves, configs, resource packs) is stored in an external game directory. Pick a root folder — the modpack gets its own subfolder inside it.',
},
gameDirLabel: {
id: 'app.hosted-install.game-dir.label',
defaultMessage: 'Game directory root',
},
browse: {
id: 'app.hosted-install.game-dir.browse',
defaultMessage: 'Browse',
},
noSelection: {
id: 'app.hosted-install.game-dir.no-selection',
defaultMessage: 'No folder selected',
},
resetDefault: {
id: 'app.hosted-install.game-dir.reset-default',
defaultMessage: 'Use default location',
},
preview: {
id: 'app.hosted-install.game-dir.preview',
defaultMessage: 'Game files will be installed to: {path}',
},
confirm: {
id: 'app.hosted-install.game-dir.confirm',
defaultMessage: 'Install',
},
})
const previewPath = computed(() => {
const base = (selectedPath.value ?? '').replace(/[\\/]+$/, '')
return base ? `${base}/<pack name>` : ''
})
async function show() {
if (!defaultPath.value) {
defaultPath.value = await invoke<string>('get_launcher_root_dir').catch(() => '')
}
if (!selectedPath.value) selectedPath.value = defaultPath.value
accepted.value = false
modal.value?.show()
}
async function browse() {
const picked = await filePicker.pickFolder?.()
if (picked?.path) selectedPath.value = picked.path
}
function handleCancel() {
modal.value?.hide()
}
function handleConfirm() {
accepted.value = true
modal.value?.hide()
emit('confirm', selectedPath.value)
}
function handleHide() {
if (!accepted.value) emit('cancel')
}
defineExpose({ show })
</script>

View File

@ -23,7 +23,7 @@ export function useHostedCreation() {
completed.value = false
}
}
async function install() {
async function install(gameDirRoot?: string | null) {
if (installing.value) return
installing.value = true
installError.value = ''
@ -40,7 +40,7 @@ export function useHostedCreation() {
}
}
if (completed.value) return createdInstance.value
createdInstance.value ??= await hostedCreate()
createdInstance.value ??= await hostedCreate(gameDirRoot)
const instanceId = createdInstance.value
await hostedSync(instanceId)
if (attempt !== generation) return

View File

@ -99,7 +99,10 @@ export interface HostedSyncResult {
}
export const hostedDefault = () =>
invokeWithSession<HostedPublication>('plugin:install|hosted_default')
export const hostedCreate = () => invokeWithSession<string>('plugin:install|hosted_create')
export const hostedCreate = (gameDirRoot?: string | null) =>
invokeWithSession<string>('plugin:install|hosted_create', {
gameDirRoot: gameDirRoot ?? null,
})
export const hostedBinding = (instanceId: string) =>
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
export const hostedSync = (instanceId: string) => {

View File

@ -29,6 +29,14 @@
"app.hosted-packs.auto-installing": { "message": "Downloading and installing the server modpack…" },
"app.hosted-packs.auto-description": { "message": "Download and automatically install the modpack from StarLight to play on the StarLight server with one click." },
"app.hosted-packs.auto-install": { "message": "Install the StarLight modpack" },
"app.hosted-install.game-dir.header": { "message": "Choose game directory" },
"app.hosted-install.game-dir.description": { "message": "StarLight instance data (mods, saves, configs, resource packs) is stored in an external game directory. Pick a root folder — the modpack gets its own subfolder inside it." },
"app.hosted-install.game-dir.label": { "message": "Game directory root" },
"app.hosted-install.game-dir.browse": { "message": "Browse" },
"app.hosted-install.game-dir.no-selection": { "message": "No folder selected" },
"app.hosted-install.game-dir.reset-default": { "message": "Use default location" },
"app.hosted-install.game-dir.preview": { "message": "Game files will be installed to: {path}" },
"app.hosted-install.game-dir.confirm": { "message": "Install" },
"app.onboarding.instance-mode.title": { "message": "Choose your instance type" },
"app.onboarding.instance-mode.description": { "message": "StarLight automatically installs the administrator-selected modpack and versions, then checks and completes updates before every launch. A StarLight login is required. Choose Local to select your own versions and modpacks for other servers or single-player." },
"app.instance-mode.title": { "message": "Instance type" },

View File

@ -29,6 +29,14 @@
"app.hosted-packs.auto-installing": { "message": "正在下载并安装服务器整合包…" },
"app.hosted-packs.auto-description": { "message": "从StarLight服务器获取整合包并自动安装可一键游玩StarLight服务器" },
"app.hosted-packs.auto-install": { "message": "安装 StarLight 官方整合包" },
"app.hosted-install.game-dir.header": { "message": "选择游戏目录" },
"app.hosted-install.game-dir.description": { "message": "StarLight 实例的游戏数据mods、存档、配置、资源包会存放在外部游戏目录中。请选择一个根目录整合包会在其中单独建一个子文件夹。" },
"app.hosted-install.game-dir.label": { "message": "游戏目录根路径" },
"app.hosted-install.game-dir.browse": { "message": "浏览" },
"app.hosted-install.game-dir.no-selection": { "message": "尚未选择文件夹" },
"app.hosted-install.game-dir.reset-default": { "message": "使用默认位置" },
"app.hosted-install.game-dir.preview": { "message": "游戏文件将安装到:{path}" },
"app.hosted-install.game-dir.confirm": { "message": "安装" },
"app.onboarding.instance-mode.title": { "message": "选择实例类型" },
"app.onboarding.instance-mode.description": { "message": "StarLight 实例自动安装管理员指定的整合包和版本,每次启动先检查并完成更新,需要登录 StarLight 账号。本地实例可自选版本和整合包,适合第三方服务器与单人游玩。" },
"app.instance-mode.title": { "message": "实例类型" },

View File

@ -12,6 +12,14 @@
"app.hosted-packs.auto-installing": { "message": "正在下載並安裝伺服器整合包…" },
"app.hosted-packs.auto-description": { "message": "從StarLight伺服器取得整合包並自動安裝可一鍵遊玩StarLight伺服器" },
"app.hosted-packs.auto-install": { "message": "安裝 StarLight 官方整合包" },
"app.hosted-install.game-dir.header": { "message": "選擇遊戲目錄" },
"app.hosted-install.game-dir.description": { "message": "StarLight 實例的遊戲資料mods、存檔、設定、資源包會存放在外部遊戲目錄中。請選擇一個根目錄整合包會在其中另外建立一個子資料夾。" },
"app.hosted-install.game-dir.label": { "message": "遊戲目錄根路徑" },
"app.hosted-install.game-dir.browse": { "message": "瀏覽" },
"app.hosted-install.game-dir.no-selection": { "message": "尚未選擇資料夾" },
"app.hosted-install.game-dir.reset-default": { "message": "使用預設位置" },
"app.hosted-install.game-dir.preview": { "message": "遊戲檔案將安裝到:{path}" },
"app.hosted-install.game-dir.confirm": { "message": "安裝" },
"app.onboarding.instance-mode.title": { "message": "選擇實例類型" },
"app.onboarding.instance-mode.description": { "message": "StarLight 實例自動安裝管理員指定的整合包和版本,每次啟動先檢查並完成更新,需要登入 StarLight 帳號。本地實例可自行選擇版本和整合包,適合第三方伺服器與單人遊玩。" },
"app.instance-mode.title": { "message": "實例類型" },

View File

@ -4,6 +4,7 @@ import { BigOptionButton, Button, defineMessages, useVIntl } from '@modrinth/ui'
import { inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
import HostedGameDirModal from '@/components/instance/HostedGameDirModal.vue'
import HostedPackProgress from '@/components/instance/HostedPackProgress.vue'
import type { InstanceMode } from '@/helpers/hosted-packs'
import { useHostedCreation } from '@/composables/useHostedCreation'
@ -15,6 +16,18 @@ const { installing, installError, createdInstance, completed, acknowledge, insta
const instanceMode = ref<InstanceMode>(
installing.value || createdInstance.value ? 'starlight' : 'local',
)
const hostedGameDirModal = ref<InstanceType<typeof HostedGameDirModal>>()
// One-click StarLight installs reuse the same external game-directory choice
// as the custom creation flow: the pack gets its own folder under `<root>`.
function promptHostedGameDir() {
hostedGameDirModal.value?.show()
}
async function installHostedWithGameDir(gameDirRoot: string) {
await install(gameDirRoot)
}
async function openCompleted(instanceId: string) {
try {
const failure = await router.push(`/instance/${encodeURIComponent(instanceId)}/`)
@ -103,7 +116,12 @@ async function handleStartFresh() {
await openCompleted(createdInstance.value)
return
}
await install()
if (createdInstance.value) {
// A previous attempt already created the instance; retry in place.
await install()
return
}
promptHostedGameDir()
return
}
showModal?.({
@ -179,6 +197,10 @@ function handleImportExisting() {
/>
</div>
<HostedGameDirModal
ref="hostedGameDirModal"
@confirm="installHostedWithGameDir"
/>
<HostedPackProgress :instance-id="createdInstance" :active="installing" />
<p v-if="installError" class="m-0 text-red" role="alert">{{ installError }}</p>
<p v-if="instanceMode === 'local'" class="m-0 text-sm text-secondary">

View File

@ -66,8 +66,10 @@ pub async fn hosted_default() -> Result<theseus::pack::hosted::Publication> {
}
#[tauri::command]
pub async fn hosted_create() -> Result<String> {
Ok(theseus::pack::hosted::create().await?)
pub async fn hosted_create(
game_dir_root: Option<String>,
) -> Result<String> {
Ok(theseus::pack::hosted::create(game_dir_root).await?)
}
#[tauri::command]

View File

@ -232,6 +232,21 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
Ok(())
}
/// Directory that contains the launcher executable. Used as the default
/// external game-directory root for the one-click StarLight install flow.
#[tauri::command]
fn get_launcher_root_dir() -> api::Result<String> {
let exe = std::env::current_exe().map_err(|error| {
theseus::Error::from(theseus::ErrorKind::FSError(error.to_string()))
})?;
let dir = exe.parent().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::FSError(
"Launcher executable has no parent directory".to_string(),
))
})?;
Ok(dir.to_string_lossy().into_owned())
}
#[tauri::command]
fn get_update_channel(app: tauri::AppHandle) -> api::Result<String> {
let channel = read_update_channel_state(&app)?
@ -838,6 +853,7 @@ fn main() {
.manage(PendingUpdateData::default())
.invoke_handler(tauri::generate_handler![
initialize_state,
get_launcher_root_dir,
get_update_channel,
get_current_app_database_path,
set_update_channel,