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
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:
161
apps/app-frontend/src/components/instance/HostedGameDirModal.vue
Normal file
161
apps/app-frontend/src/components/instance/HostedGameDirModal.vue
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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": "实例类型" },
|
||||
|
||||
@ -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": "實例類型" },
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user