Compare commits
6 Commits
a477b1fb9a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 42b0c7e7af | |||
| 405d980311 | |||
| 905e905eca | |||
| cda7bb284a | |||
| ffe529df57 | |||
| 984f3ddbd6 |
@ -113,6 +113,7 @@ import {
|
|||||||
} from '@/helpers/events.js'
|
} from '@/helpers/events.js'
|
||||||
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
|
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
|
||||||
import { type DirectLinkSyncReport, get as getInstance, run } from '@/helpers/instance'
|
import { type DirectLinkSyncReport, get as getInstance, run } from '@/helpers/instance'
|
||||||
|
import { PlayerSelectionNavigatedAwayError } from '@/helpers/instance-player'
|
||||||
import { reconcileMojangAuthSourceAtStartup } from '@/helpers/mojang-auth'
|
import { reconcileMojangAuthSourceAtStartup } from '@/helpers/mojang-auth'
|
||||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||||
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
|
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
|
||||||
@ -1757,6 +1758,10 @@ async function handleCommand(e) {
|
|||||||
} else if (e.event === 'LaunchInstance') {
|
} else if (e.event === 'LaunchInstance') {
|
||||||
const instance = await getInstance(e.id).catch(() => null)
|
const instance = await getInstance(e.id).catch(() => null)
|
||||||
const handleLaunchCommandError = async (launchError) => {
|
const handleLaunchCommandError = async (launchError) => {
|
||||||
|
// Navigating to the skin-site login to pick a player is a deliberate
|
||||||
|
// user action, not a launch failure: stay silent and let the user
|
||||||
|
// re-trigger the launch after signing in.
|
||||||
|
if (launchError instanceof PlayerSelectionNavigatedAwayError) return
|
||||||
const handled =
|
const handled =
|
||||||
(await minecraftCrashModal.value?.handleLaunchError(launchError, {
|
(await minecraftCrashModal.value?.handleLaunchError(launchError, {
|
||||||
instance_id: e.id,
|
instance_id: e.id,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
import { users } from '@/helpers/auth'
|
import { users } from '@/helpers/auth'
|
||||||
import { getInstanceMode } from '@/helpers/hosted-packs'
|
import { getInstanceMode } from '@/helpers/hosted-packs'
|
||||||
import {
|
import {
|
||||||
|
PlayerSelectionNavigatedAwayError,
|
||||||
registerInstancePlayerPicker,
|
registerInstancePlayerPicker,
|
||||||
saveInstancePlayer,
|
saveInstancePlayer,
|
||||||
waitForSkinSiteSession,
|
waitForSkinSiteSession,
|
||||||
@ -127,6 +128,14 @@ async function select(player: PlayerChoice) {
|
|||||||
function signInSkinSite() {
|
function signInSkinSite() {
|
||||||
awaitingSkinLogin.value = true
|
awaitingSkinLogin.value = true
|
||||||
hideForLogin = true
|
hideForLogin = true
|
||||||
|
active.value = false
|
||||||
|
generation++
|
||||||
|
// Settle the pending selection so `prepareInstancePlayer` releases its
|
||||||
|
// in-flight entry; otherwise a later launch would await this forever and
|
||||||
|
// never re-prompt. The caller treats this sentinel as a silent abort.
|
||||||
|
rejectSelection?.(new PlayerSelectionNavigatedAwayError())
|
||||||
|
resolveSelection = undefined
|
||||||
|
rejectSelection = undefined
|
||||||
openSkinSiteLogin()
|
openSkinSiteLogin()
|
||||||
modal.value?.hide()
|
modal.value?.hide()
|
||||||
void router.push('/starlight-skin')
|
void router.push('/starlight-skin')
|
||||||
|
|||||||
@ -291,6 +291,7 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from '@/helpers/auth'
|
} from '@/helpers/auth'
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
|
import { registerSkinSitePlayers } from '@/helpers/instance-player'
|
||||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
import type { Skin } from '@/helpers/skins'
|
import type { Skin } from '@/helpers/skins'
|
||||||
import { get_available_skins } from '@/helpers/skins'
|
import { get_available_skins } from '@/helpers/skins'
|
||||||
@ -630,11 +631,22 @@ async function setAccount(account: MinecraftCredential) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[skinSitePlayers, defaultUser],
|
[skinSitePlayers, defaultUser, skinSiteUser],
|
||||||
([availablePlayers, selectedLocalUser]) => {
|
([availablePlayers, selectedLocalUser, siteUser]) => {
|
||||||
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
|
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
|
||||||
selectSkinSitePlayer(availablePlayers[0].uuid)
|
selectSkinSitePlayer(availablePlayers[0].uuid)
|
||||||
}
|
}
|
||||||
|
// Register skin-site players as launcher accounts as soon as they are
|
||||||
|
// available, so the account picker shows them without requiring a first
|
||||||
|
// launch. `registerSkinSitePlayers` is idempotent and best-effort.
|
||||||
|
if (siteUser?.uuid && availablePlayers.length > 0) {
|
||||||
|
const pendingIds = availablePlayers.map((player) => player.uuid)
|
||||||
|
void registerSkinSitePlayers(pendingIds, siteUser.uuid)
|
||||||
|
.then(() => refreshValues())
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('Failed to register skin site players:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|||||||
@ -17,6 +17,18 @@ export type InstancePlayer = {
|
|||||||
skin_site_user?: string | null
|
skin_site_user?: string | null
|
||||||
}
|
}
|
||||||
export type PlayerChoice = InstancePlayer & { head?: string }
|
export type PlayerChoice = InstancePlayer & { head?: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the player picker is dismissed because the user navigated to the
|
||||||
|
* skin-site login page. Callers should treat this as a silent launch abort
|
||||||
|
* (the user will pick a player next time), not as a real failure.
|
||||||
|
*/
|
||||||
|
export class PlayerSelectionNavigatedAwayError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('已跳转至皮肤站登录,请登录后重新启动。')
|
||||||
|
this.name = 'PlayerSelectionNavigatedAwayError'
|
||||||
|
}
|
||||||
|
}
|
||||||
type Picker = (instanceId: string, locked: InstancePlayer | null) => Promise<InstancePlayer>
|
type Picker = (instanceId: string, locked: InstancePlayer | null) => Promise<InstancePlayer>
|
||||||
let picker: Picker | undefined
|
let picker: Picker | undefined
|
||||||
const preparing = new Map<string, Promise<void>>()
|
const preparing = new Map<string, Promise<void>>()
|
||||||
@ -56,6 +68,52 @@ export async function authenticateInstancePlayer(player: InstancePlayer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers every skin-site player as a launcher account so they show up in the
|
||||||
|
* account picker immediately after signing in to the skin site, instead of only
|
||||||
|
* after a first launch. Idempotent: players that already exist in the account
|
||||||
|
* list (matched by profile UUID) are skipped, and already-signed-in players are
|
||||||
|
* not re-requested. Best-effort: a single player failing does not abort the rest.
|
||||||
|
*/
|
||||||
|
export async function registerSkinSitePlayers(
|
||||||
|
playerIds: string[],
|
||||||
|
userUuid: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (playerIds.length === 0) return
|
||||||
|
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userUuid) return
|
||||||
|
|
||||||
|
let known = new Set<string>()
|
||||||
|
try {
|
||||||
|
const existing = await users()
|
||||||
|
known = new Set(
|
||||||
|
(existing as Array<{ profile?: { id?: string } }>)
|
||||||
|
.map((account) => account?.profile?.id)
|
||||||
|
.filter((id): id is string => typeof id === 'string'),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// If the account list cannot be read, still attempt to register; the
|
||||||
|
// backend upsert is idempotent.
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const playerId of playerIds) {
|
||||||
|
if (known.has(playerId)) continue
|
||||||
|
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userUuid) return
|
||||||
|
try {
|
||||||
|
// Request a fresh download token per player: the skin site login
|
||||||
|
// endpoint may bind a token to a single player id.
|
||||||
|
const token = await requestSkinSiteDownloadToken()
|
||||||
|
await invoke('plugin:auth|login_skin_site_player', {
|
||||||
|
token,
|
||||||
|
playerId,
|
||||||
|
userId: userUuid,
|
||||||
|
})
|
||||||
|
known.add(playerId)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to register skin site player ${playerId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveInstancePlayer(instanceId: string, player: InstancePlayer) {
|
export async function saveInstancePlayer(instanceId: string, player: InstancePlayer) {
|
||||||
await authenticateInstancePlayer(player)
|
await authenticateInstancePlayer(player)
|
||||||
await invoke('plugin:auth|set_instance_player', { instanceId, player })
|
await invoke('plugin:auth|set_instance_player', { instanceId, player })
|
||||||
|
|||||||
@ -436,16 +436,23 @@ pub async fn create(
|
|||||||
// e.g. `<root>/<pack name>`. Avoid a `versions/<name>` layout: that shape
|
// e.g. `<root>/<pack name>`. Avoid a `versions/<name>` layout: that shape
|
||||||
// is reserved for externally linked launcher instances and would make the
|
// is reserved for externally linked launcher instances and would make the
|
||||||
// launcher expect a Minecraft version JSON beside the pack.
|
// launcher expect a Minecraft version JSON beside the pack.
|
||||||
let game_dir_override = game_dir_root
|
let game_dir_override = match game_dir_root
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|root| !root.is_empty())
|
.filter(|root| !root.is_empty())
|
||||||
.map(|root| {
|
{
|
||||||
Path::new(root)
|
Some(root) => {
|
||||||
.join(&publication.manifest.name)
|
// The pack's game files live in their own folder under the chosen
|
||||||
.to_string_lossy()
|
// root, e.g. `<root>/<pack name>`. If that folder already exists
|
||||||
.into_owned()
|
// (a previous install of the same pack, or a name clash), pick a
|
||||||
});
|
// suffixed sibling instead of sharing the folder with another
|
||||||
|
// instance.
|
||||||
|
let base = Path::new(root).join(&publication.manifest.name);
|
||||||
|
let resolved = unique_game_dir(&base);
|
||||||
|
Some(resolved.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
let instance = crate::state::create_instance(
|
let instance = crate::state::create_instance(
|
||||||
crate::state::CreateInstance {
|
crate::state::CreateInstance {
|
||||||
name: publication.manifest.name.clone(),
|
name: publication.manifest.name.clone(),
|
||||||
@ -480,6 +487,29 @@ pub async fn create(
|
|||||||
Ok(instance.id)
|
Ok(instance.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `base` when its directory does not exist yet; otherwise returns the
|
||||||
|
/// first `base (n)` (n = 1, 2, …) whose directory is still free. Mirrors the
|
||||||
|
/// instance-folder de-duplication in `create_instance::resolve_instance_path`,
|
||||||
|
/// so re-installing the same hosted pack no longer makes two instances share a
|
||||||
|
/// single game folder.
|
||||||
|
fn unique_game_dir(base: &Path) -> PathBuf {
|
||||||
|
if !base.exists() {
|
||||||
|
return base.to_path_buf();
|
||||||
|
}
|
||||||
|
let parent = base.parent().unwrap_or_else(|| Path::new(""));
|
||||||
|
let name = base
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "instance".to_string());
|
||||||
|
let mut which = 1u32;
|
||||||
|
loop {
|
||||||
|
let candidate = parent.join(format!("{name} ({which})"));
|
||||||
|
if !candidate.exists() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
which += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
pub async fn binding(instance_id: &str) -> crate::Result<Option<Binding>> {
|
pub async fn binding(instance_id: &str) -> crate::Result<Option<Binding>> {
|
||||||
read_json(&crate::instance::get_full_path(instance_id).await?, BINDING)
|
read_json(&crate::instance::get_full_path(instance_id).await?, BINDING)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -266,7 +266,9 @@ async fn import_atlauncher_unmanaged(
|
|||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
finish_import(
|
finish_import(
|
||||||
instance_id,
|
instance_id,
|
||||||
minecraft_folder,
|
Some(minecraft_folder),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
&state.io_semaphore,
|
&state.io_semaphore,
|
||||||
reporter,
|
reporter,
|
||||||
details,
|
details,
|
||||||
|
|||||||
@ -170,7 +170,9 @@ pub(crate) async fn import_axolotl(
|
|||||||
|
|
||||||
finish_import(
|
finish_import(
|
||||||
instance_id,
|
instance_id,
|
||||||
source_path,
|
Some(source_path),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
&state.io_semaphore,
|
&state.io_semaphore,
|
||||||
reporter,
|
reporter,
|
||||||
details,
|
details,
|
||||||
|
|||||||
@ -221,7 +221,9 @@ pub async fn import_curseforge(
|
|||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
finish_import(
|
finish_import(
|
||||||
instance_id,
|
instance_id,
|
||||||
curseforge_instance_folder,
|
Some(curseforge_instance_folder),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
&state.io_semaphore,
|
&state.io_semaphore,
|
||||||
reporter,
|
reporter,
|
||||||
details,
|
details,
|
||||||
|
|||||||
@ -118,7 +118,9 @@ pub async fn import_gdlauncher(
|
|||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
finish_import(
|
finish_import(
|
||||||
instance_id,
|
instance_id,
|
||||||
gdlauncher_instance_folder,
|
Some(gdlauncher_instance_folder),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
&state.io_semaphore,
|
&state.io_semaphore,
|
||||||
reporter,
|
reporter,
|
||||||
details,
|
details,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ use std::{
|
|||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ImportOverrides, instance_json};
|
use super::{ImportOverrides, instance_json, resolve_import_game_root};
|
||||||
use crate::{
|
use crate::{
|
||||||
State,
|
State,
|
||||||
install::{InstallPhaseDetails, InstallProgressReporter},
|
install::{InstallPhaseDetails, InstallProgressReporter},
|
||||||
@ -29,29 +29,129 @@ pub async fn import_generic(
|
|||||||
overrides: &ImportOverrides,
|
overrides: &ImportOverrides,
|
||||||
instance_path: Option<PathBuf>, // For compatible mode: path to versions/<version>/
|
instance_path: Option<PathBuf>, // For compatible mode: path to versions/<version>/
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
let (name, dotminecraft, json_path) = if let Some(ref inst_path) =
|
// Resolve the source layout. Three inputs describe the same import from
|
||||||
instance_path
|
// different angles and must be reconciled consistently:
|
||||||
{
|
//
|
||||||
let name = inst_path
|
// - `instance_folder`: the game root chosen by the caller (normally the
|
||||||
|
// `.minecraft` root for a PCL/HMCL install, or the folder itself).
|
||||||
|
// - `instance_path`: when present, the specific `versions/<name>` folder
|
||||||
|
// the user selected. A `.minecraft` root can hold many versions; only
|
||||||
|
// this one belongs to the instance being imported.
|
||||||
|
// - `overrides.game_dir_override`: the user's explicit version-isolation
|
||||||
|
// choice.
|
||||||
|
//
|
||||||
|
// The old behaviour copied/symlinked the whole `.minecraft` root and let
|
||||||
|
// the version folder dangle, which produced vanilla-only copies (mods
|
||||||
|
// stayed in versions/<name>) and cloned every sibling version too.
|
||||||
|
let layout = resolve_import_layout(
|
||||||
|
&instance_folder,
|
||||||
|
instance_path.as_deref(),
|
||||||
|
overrides.game_dir_override.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let info = detect_instance_info(&layout.json_source, overrides).await?;
|
||||||
|
register_instance(instance_id, &layout.name, &info).await?;
|
||||||
|
copy_instance_files(instance_id, &layout, reporter, details, symlink)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resolved source layout for a generic import.
|
||||||
|
///
|
||||||
|
/// `content_source` holds the shared game content (mods/saves/config) that
|
||||||
|
/// belongs to the instance; `version_dir` is the selected `versions/<name>`
|
||||||
|
/// folder. Both are merged into the instance directory by the copy/symlink
|
||||||
|
/// stage, so a version-isolated import keeps the root-level mods it used to
|
||||||
|
/// leave behind.
|
||||||
|
struct ImportLayout {
|
||||||
|
/// Display name for the instance (the version folder name when isolated,
|
||||||
|
/// otherwise the game root folder name).
|
||||||
|
name: String,
|
||||||
|
/// Directory the version JSON is detected from.
|
||||||
|
json_source: PathBuf,
|
||||||
|
/// Directory whose game content (mods/saves/config) belongs to the
|
||||||
|
/// instance. For a shared root this is the root itself; for the "move the
|
||||||
|
/// root content into versions/<name>" isolation strategy this is still the
|
||||||
|
/// root, but its content is copied *into* the instance (which then becomes
|
||||||
|
/// the game dir).
|
||||||
|
content_source: Option<PathBuf>,
|
||||||
|
/// Selected `versions/<name>` folder, when the source is a shared root.
|
||||||
|
version_dir: Option<PathBuf>,
|
||||||
|
/// Whether the instance uses version isolation.
|
||||||
|
isolated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconciles the three import inputs into one layout.
|
||||||
|
///
|
||||||
|
/// Rules:
|
||||||
|
/// - When `instance_path` is given it is the authoritative version folder; the
|
||||||
|
/// instance is version-isolated unless the user explicitly asked to share.
|
||||||
|
/// - When the user asked to share, the `.minecraft` root is the game dir.
|
||||||
|
/// - Without a selected version folder, fall back to the old auto-detection so
|
||||||
|
/// direct folder imports keep working.
|
||||||
|
fn resolve_import_layout(
|
||||||
|
instance_folder: &Path,
|
||||||
|
selected_version: Option<&Path>,
|
||||||
|
game_dir_override: Option<&str>,
|
||||||
|
) -> ImportLayout {
|
||||||
|
let root_name = instance_folder
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "imported".to_string());
|
||||||
|
|
||||||
|
// Explicit "version shared" choice: copy the whole `.minecraft` root.
|
||||||
|
let shared_forced = game_dir_override
|
||||||
|
.map(|dir| {
|
||||||
|
let normalized = dir.trim_end_matches(['/', '\\']);
|
||||||
|
normalized.eq_ignore_ascii_case(
|
||||||
|
instance_folder
|
||||||
|
.to_string_lossy()
|
||||||
|
.trim_end_matches(['/', '\\']),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if let Some(version_dir) = selected_version {
|
||||||
|
let version_name = version_dir
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| "imported".to_string());
|
.unwrap_or_else(|| root_name.clone());
|
||||||
tracing::debug!(
|
|
||||||
"import_generic: compatible mode - dotminecraft={}, json_path={}",
|
|
||||||
instance_folder.display(),
|
|
||||||
inst_path.display()
|
|
||||||
);
|
|
||||||
(name, instance_folder.to_path_buf(), inst_path.to_path_buf())
|
|
||||||
} else {
|
|
||||||
let (name, dotminecraft) = resolve_dotminecraft(&instance_folder);
|
|
||||||
let json_path = dotminecraft.clone(); // JSON detection will scan dotminecraft
|
|
||||||
(name, dotminecraft, json_path)
|
|
||||||
};
|
|
||||||
|
|
||||||
let info = detect_instance_info(&json_path, overrides).await?;
|
if shared_forced {
|
||||||
register_instance(instance_id, &name, &info).await?;
|
// User explicitly chose to share the `.minecraft` root even though
|
||||||
copy_instance_files(instance_id, &dotminecraft, reporter, details, symlink)
|
// a version folder was selected.
|
||||||
.await
|
return ImportLayout {
|
||||||
|
name: root_name,
|
||||||
|
json_source: version_dir.to_path_buf(),
|
||||||
|
content_source: Some(instance_folder.to_path_buf()),
|
||||||
|
version_dir: Some(version_dir.to_path_buf()),
|
||||||
|
isolated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version-isolated strategy (甲): the instance becomes the game dir.
|
||||||
|
// The version files (`versions/<name>`) and the shared root content
|
||||||
|
// (mods/saves/config) are both merged into the instance, so mods that
|
||||||
|
// live at the `.minecraft` root survive the import instead of being
|
||||||
|
// left behind.
|
||||||
|
return ImportLayout {
|
||||||
|
name: version_name,
|
||||||
|
json_source: version_dir.to_path_buf(),
|
||||||
|
content_source: Some(instance_folder.to_path_buf()),
|
||||||
|
version_dir: Some(version_dir.to_path_buf()),
|
||||||
|
isolated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No explicit version folder: fall back to auto-detection.
|
||||||
|
let (name, dotminecraft) = resolve_dotminecraft(instance_folder);
|
||||||
|
let game_root = resolve_import_game_root(&dotminecraft);
|
||||||
|
ImportLayout {
|
||||||
|
name,
|
||||||
|
json_source: dotminecraft.clone(),
|
||||||
|
content_source: Some(game_root.clone()),
|
||||||
|
version_dir: None,
|
||||||
|
isolated: game_root != dotminecraft,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stage 1 — resolve the name and the `.minecraft` directory of an imported
|
/// Stage 1 — resolve the name and the `.minecraft` directory of an imported
|
||||||
@ -325,21 +425,30 @@ async fn resolve_loader_version(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stage 4 — copy (or symlink) the source files into the instance profile.
|
/// Stage 4 — copy (or symlink) the source files into the instance profile.
|
||||||
|
///
|
||||||
|
/// Uses the reconciled [`ImportLayout`]: the shared content root (mods/saves/
|
||||||
|
/// config) and the selected `versions/<name>` folder are both merged into the
|
||||||
|
/// instance directory, so a version-isolated import keeps root-level content.
|
||||||
async fn copy_instance_files(
|
async fn copy_instance_files(
|
||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
dotminecraft: &Path,
|
layout: &ImportLayout,
|
||||||
reporter: InstallProgressReporter,
|
reporter: InstallProgressReporter,
|
||||||
details: InstallPhaseDetails,
|
details: InstallPhaseDetails,
|
||||||
symlink: bool,
|
symlink: bool,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"import_generic: finishing import for instance_id={}",
|
"import_generic: finishing import for instance_id={} content_source={:?} version_dir={:?} isolated={}",
|
||||||
instance_id
|
instance_id,
|
||||||
|
layout.content_source,
|
||||||
|
layout.version_dir,
|
||||||
|
layout.isolated
|
||||||
);
|
);
|
||||||
finish_import(
|
finish_import(
|
||||||
instance_id,
|
instance_id,
|
||||||
dotminecraft.to_path_buf(),
|
layout.content_source.clone(),
|
||||||
|
layout.version_dir.clone(),
|
||||||
|
layout.isolated,
|
||||||
&state.io_semaphore,
|
&state.io_semaphore,
|
||||||
reporter,
|
reporter,
|
||||||
details,
|
details,
|
||||||
@ -399,6 +508,7 @@ mod tests {
|
|||||||
game_version: Some("1.20.1".to_string()),
|
game_version: Some("1.20.1".to_string()),
|
||||||
loader: Some(ModLoader::Fabric),
|
loader: Some(ModLoader::Fabric),
|
||||||
loader_version: Some("0.15.11".to_string()),
|
loader_version: Some("0.15.11".to_string()),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let info = detect_instance_info(directory.path(), &overrides)
|
let info = detect_instance_info(directory.path(), &overrides)
|
||||||
@ -418,6 +528,7 @@ mod tests {
|
|||||||
game_version: Some("1.20.1".to_string()),
|
game_version: Some("1.20.1".to_string()),
|
||||||
loader: Some(ModLoader::Fabric),
|
loader: Some(ModLoader::Fabric),
|
||||||
loader_version: Some(loader_version.to_string()),
|
loader_version: Some(loader_version.to_string()),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let info = detect_instance_info(directory.path(), &overrides)
|
let info = detect_instance_info(directory.path(), &overrides)
|
||||||
|
|||||||
@ -196,7 +196,8 @@ pub(crate) fn normalize_imported_loader_version(
|
|||||||
game_version: &str,
|
game_version: &str,
|
||||||
detected_version: &str,
|
detected_version: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
let detected_version = detected_version.trim();
|
let detected_version = sanitize_loader_version(detected_version);
|
||||||
|
let detected_version = detected_version.as_str();
|
||||||
let without_family = match loader {
|
let without_family = match loader {
|
||||||
"fabric" | "legacy_fabric" => detected_version
|
"fabric" | "legacy_fabric" => detected_version
|
||||||
.strip_prefix("fabric-loader-")
|
.strip_prefix("fabric-loader-")
|
||||||
@ -212,7 +213,7 @@ pub(crate) fn normalize_imported_loader_version(
|
|||||||
}
|
}
|
||||||
.unwrap_or(detected_version);
|
.unwrap_or(detected_version);
|
||||||
|
|
||||||
match loader {
|
let normalized = match loader {
|
||||||
"fabric" | "legacy_fabric" | "quilt" => without_family
|
"fabric" | "legacy_fabric" | "quilt" => without_family
|
||||||
.strip_suffix(&format!("-{game_version}"))
|
.strip_suffix(&format!("-{game_version}"))
|
||||||
.unwrap_or(without_family)
|
.unwrap_or(without_family)
|
||||||
@ -232,7 +233,8 @@ pub(crate) fn normalize_imported_loader_version(
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
_ => without_family.to_string(),
|
_ => without_family.to_string(),
|
||||||
}
|
};
|
||||||
|
sanitize_loader_version(&normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_version(
|
fn extract_version(
|
||||||
@ -575,6 +577,11 @@ fn detect_adjuncts(
|
|||||||
|
|
||||||
/// Extracts the loader version string from JSON content by finding a needle
|
/// Extracts the loader version string from JSON content by finding a needle
|
||||||
/// and reading until a terminator character.
|
/// and reading until a terminator character.
|
||||||
|
///
|
||||||
|
/// The terminator set includes `:` `]` `[` and whitespace because non-standard
|
||||||
|
/// launcher JSONs (notably PCL) may embed the loader coordinate in a composite
|
||||||
|
/// string such as `net.neoforged:neoforge:21.1.250:client]`, where the real
|
||||||
|
/// version ends at the first extra `:` rather than at the closing quote.
|
||||||
fn try_extract_version_from_needle(
|
fn try_extract_version_from_needle(
|
||||||
content: &str,
|
content: &str,
|
||||||
needle: &str,
|
needle: &str,
|
||||||
@ -582,17 +589,32 @@ fn try_extract_version_from_needle(
|
|||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let pos = content.find(needle)?;
|
let pos = content.find(needle)?;
|
||||||
let after = &content[pos + needle.len()..];
|
let after = &content[pos + needle.len()..];
|
||||||
let end = after.find(&['"', ',', '\n', '}'] as &[char])?;
|
let end = after
|
||||||
|
.find(&['"', ',', '\n', '}', ']', '[', ':', ' '] as &[char])?;
|
||||||
let ver = &after[..end];
|
let ver = &after[..end];
|
||||||
if let Some(ch) = split_at
|
if let Some(ch) = split_at
|
||||||
&& let Some(pos) = ver.rfind(ch)
|
&& let Some(pos) = ver.rfind(ch)
|
||||||
{
|
{
|
||||||
Some(ver[pos + 1..].to_string())
|
Some(sanitize_loader_version(&ver[pos + 1..]))
|
||||||
} else {
|
} else {
|
||||||
Some(ver.to_string())
|
Some(sanitize_loader_version(ver))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trims junk that non-standard launcher JSONs append to a loader coordinate
|
||||||
|
/// (e.g. `21.1.250:client]`, `44.0.3 ` or `0.15.11\n`). Keeps only the leading
|
||||||
|
/// version token so the metadata resolver receives a clean id.
|
||||||
|
fn sanitize_loader_version(raw: &str) -> String {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
// Cut at the first character that cannot appear in a loader version id.
|
||||||
|
let end = trimmed
|
||||||
|
.find(|ch: char| {
|
||||||
|
!(ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' || ch == '+')
|
||||||
|
})
|
||||||
|
.unwrap_or(trimmed.len());
|
||||||
|
trimmed[..end].trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -837,4 +859,35 @@ mod tests {
|
|||||||
assert_eq!(info.loader.as_deref(), Some("fabric"));
|
assert_eq!(info.loader.as_deref(), Some("fabric"));
|
||||||
assert_eq!(info.loader_version.as_deref(), Some("0.15.11"));
|
assert_eq!(info.loader_version.as_deref(), Some("0.15.11"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitizes_loader_version_with_launcher_suffix() {
|
||||||
|
// PCL and other launchers may embed composite coordinates such as
|
||||||
|
// `net.neoforged:neoforge:21.1.250:client]`; the extracted version must
|
||||||
|
// stop at the first extra `:` instead of swallowing `:client]`.
|
||||||
|
assert_eq!(sanitize_loader_version("21.1.250:client]"), "21.1.250");
|
||||||
|
assert_eq!(sanitize_loader_version("44.0.3 "), "44.0.3");
|
||||||
|
assert_eq!(sanitize_loader_version("0.15.11\n"), "0.15.11");
|
||||||
|
assert_eq!(sanitize_loader_version("1.21.1-52.0.0"), "1.21.1-52.0.0");
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_loader_version("1.7.10-10.13.4.1614-1.7.10"),
|
||||||
|
"1.7.10-10.13.4.1614-1.7.10"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_loader_version_stops_at_extra_colon() {
|
||||||
|
assert_loader(
|
||||||
|
r#"{
|
||||||
|
"id": "1.21.1-neoforge-21.1.250",
|
||||||
|
"libraries": [
|
||||||
|
{
|
||||||
|
"name": "net.neoforged:neoforge:21.1.250:client]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
"neoforge",
|
||||||
|
Some("21.1.250"),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -303,7 +303,9 @@ async fn import_mmc_unmanaged(
|
|||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
finish_import(
|
finish_import(
|
||||||
instance_id,
|
instance_id,
|
||||||
minecraft_folder,
|
Some(minecraft_folder),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
&state.io_semaphore,
|
&state.io_semaphore,
|
||||||
reporter,
|
reporter,
|
||||||
details,
|
details,
|
||||||
|
|||||||
@ -661,6 +661,10 @@ pub(crate) struct ImportOverrides {
|
|||||||
pub game_version: Option<String>,
|
pub game_version: Option<String>,
|
||||||
pub loader: Option<ModLoader>,
|
pub loader: Option<ModLoader>,
|
||||||
pub loader_version: Option<String>,
|
pub loader_version: Option<String>,
|
||||||
|
/// The user's explicit game directory (version isolation choice). When set,
|
||||||
|
/// it is the absolute path the instance should use as its working
|
||||||
|
/// directory; when `None`, the layout is auto-detected.
|
||||||
|
pub game_dir_override: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn import_instance_with_reporter(
|
pub(crate) async fn import_instance_with_reporter(
|
||||||
@ -1045,7 +1049,9 @@ pub async fn recache_icon(
|
|||||||
|
|
||||||
pub(crate) async fn copy_dotminecraft_with_reporter(
|
pub(crate) async fn copy_dotminecraft_with_reporter(
|
||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
dotminecraft: PathBuf,
|
content_source: Option<PathBuf>,
|
||||||
|
version_dir: Option<PathBuf>,
|
||||||
|
isolated: bool,
|
||||||
io_semaphore: &IoSemaphore,
|
io_semaphore: &IoSemaphore,
|
||||||
reporter: InstallProgressReporter,
|
reporter: InstallProgressReporter,
|
||||||
details: InstallPhaseDetails,
|
details: InstallPhaseDetails,
|
||||||
@ -1053,7 +1059,36 @@ pub(crate) async fn copy_dotminecraft_with_reporter(
|
|||||||
let instance_path =
|
let instance_path =
|
||||||
crate::api::instance::get_full_path(instance_id).await?;
|
crate::api::instance::get_full_path(instance_id).await?;
|
||||||
|
|
||||||
let files = collect_dotminecraft_files(&dotminecraft).await?;
|
let mut files: Vec<(PathBuf, PathBuf)> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(content_root) = &content_source {
|
||||||
|
// Copy the shared content (mods/saves/config/…). When a specific
|
||||||
|
// version folder is in play, every sibling under `versions/` belongs to
|
||||||
|
// a different instance and must not be cloned here.
|
||||||
|
//
|
||||||
|
// - shared import: keep the selected version, drop the rest;
|
||||||
|
// - isolated import (甲): drop the whole `versions/` tree here — the
|
||||||
|
// selected version is copied separately below and merged into the
|
||||||
|
// instance root.
|
||||||
|
let keep_version = if isolated {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
version_dir.as_deref()
|
||||||
|
};
|
||||||
|
let mut content_files =
|
||||||
|
collect_dotminecraft_files(content_root, keep_version, isolated)
|
||||||
|
.await?;
|
||||||
|
files.append(&mut content_files);
|
||||||
|
}
|
||||||
|
|
||||||
|
if isolated && let Some(version) = &version_dir {
|
||||||
|
// Merge the selected version files (`<name>.json`, `<name>.jar`, and any
|
||||||
|
// nested `mods/`, `config/`, … that live inside the version folder)
|
||||||
|
// directly into the instance root so the instance directory becomes a
|
||||||
|
// self-contained game dir.
|
||||||
|
let mut version_files = collect_version_files(version).await?;
|
||||||
|
files.append(&mut version_files);
|
||||||
|
}
|
||||||
|
|
||||||
let total = files.len() as u64;
|
let total = files.len() as u64;
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
@ -1085,6 +1120,8 @@ pub(crate) async fn copy_dotminecraft_with_reporter(
|
|||||||
/// at the source root (`<dirname>.json` and `<dirname>.jar`).
|
/// at the source root (`<dirname>.json` and `<dirname>.jar`).
|
||||||
async fn collect_dotminecraft_files(
|
async fn collect_dotminecraft_files(
|
||||||
dotminecraft: &Path,
|
dotminecraft: &Path,
|
||||||
|
keep_version: Option<&Path>,
|
||||||
|
isolated: bool,
|
||||||
) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
|
) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
|
||||||
// Collect all files recursively
|
// Collect all files recursively
|
||||||
let files = get_all_subfiles(dotminecraft, false).await?;
|
let files = get_all_subfiles(dotminecraft, false).await?;
|
||||||
@ -1098,6 +1135,14 @@ async fn collect_dotminecraft_files(
|
|||||||
let skip_json = format!("{dirname}.json");
|
let skip_json = format!("{dirname}.json");
|
||||||
let skip_jar = format!("{dirname}.jar");
|
let skip_jar = format!("{dirname}.jar");
|
||||||
|
|
||||||
|
// When a specific version folder is requested from a shared `.minecraft`
|
||||||
|
// root, every other entry under `versions/` belongs to a different
|
||||||
|
// instance. Resolve the relative keep-path once so the loop can compare
|
||||||
|
// cheaply (e.g. `versions/1.21.1-NeoForge_21.1.250`).
|
||||||
|
let keep_relative = keep_version
|
||||||
|
.and_then(|version| version.strip_prefix(dotminecraft).ok())
|
||||||
|
.map(|rel| rel.to_path_buf());
|
||||||
|
|
||||||
let mut collected = Vec::new();
|
let mut collected = Vec::new();
|
||||||
for abs_path in files {
|
for abs_path in files {
|
||||||
let metadata = tokio::fs::symlink_metadata(&abs_path)
|
let metadata = tokio::fs::symlink_metadata(&abs_path)
|
||||||
@ -1117,6 +1162,23 @@ async fn collect_dotminecraft_files(
|
|||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// In the isolated strategy the whole `versions/` tree is handled
|
||||||
|
// separately (only the selected version is copied, and it is merged
|
||||||
|
// into the instance root), so skip it entirely here to avoid cloning
|
||||||
|
// sibling versions.
|
||||||
|
if isolated {
|
||||||
|
if rel.components().next().map(|c| c.as_os_str())
|
||||||
|
== Some("versions".as_ref())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if let Some(keep) = &keep_relative
|
||||||
|
&& is_other_version_entry(&rel, keep)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if rel
|
if rel
|
||||||
.parent()
|
.parent()
|
||||||
.is_some_and(|path| !path.as_os_str().is_empty())
|
.is_some_and(|path| !path.as_os_str().is_empty())
|
||||||
@ -1132,6 +1194,70 @@ async fn collect_dotminecraft_files(
|
|||||||
Ok(collected)
|
Ok(collected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collects every file inside a selected `versions/<name>` folder, mapping each
|
||||||
|
/// path relative to that folder so the contents merge directly into the
|
||||||
|
/// instance root (the instance becomes a self-contained, version-isolated game
|
||||||
|
/// dir).
|
||||||
|
async fn collect_version_files(
|
||||||
|
version_dir: &Path,
|
||||||
|
) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
|
||||||
|
let files = get_all_subfiles(version_dir, false).await?;
|
||||||
|
let mut collected = Vec::new();
|
||||||
|
for abs_path in files {
|
||||||
|
let metadata = tokio::fs::symlink_metadata(&abs_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| IOError::with_path(error, &abs_path))?;
|
||||||
|
if crate::util::io::is_symlink_or_reparse(&metadata) {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %abs_path.display(),
|
||||||
|
"Skipping nested symlink or reparse point while copying a version folder"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(rel) = abs_path.strip_prefix(version_dir) {
|
||||||
|
collected.push((abs_path, rel.to_path_buf()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(collected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if `rel` lives under `versions/` but does not belong to the selected
|
||||||
|
/// version folder `keep` (which is itself relative to the `.minecraft` root,
|
||||||
|
/// e.g. `versions/1.21.1-NeoForge_21.1.250`).
|
||||||
|
///
|
||||||
|
/// `rel` may be the version directory itself, a file directly inside it, or a
|
||||||
|
/// path nested deeper. Anything sharing the first two path components with
|
||||||
|
/// `keep` is kept; every other `versions/<other>` entry is excluded.
|
||||||
|
fn is_other_version_entry(rel: &Path, keep: &Path) -> bool {
|
||||||
|
let mut rel_components = rel.components();
|
||||||
|
let mut keep_components = keep.components();
|
||||||
|
|
||||||
|
// Both must start with the literal `versions` component.
|
||||||
|
if rel_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if keep_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rel_version = rel_components.next().map(|c| c.as_os_str());
|
||||||
|
let keep_version = keep_components.next().map(|c| c.as_os_str());
|
||||||
|
|
||||||
|
// `rel` is always a *file* path relative to the `.minecraft` root. A file
|
||||||
|
// that sits directly under `versions/` (e.g. `versions/version_manifest.json`)
|
||||||
|
// has exactly two components and is shared metadata, not a version folder:
|
||||||
|
// leave it alone. Only when there is at least a third component
|
||||||
|
// (`versions/<name>/<file>`) can the second component be treated as a
|
||||||
|
// version directory name.
|
||||||
|
let rel_is_inside_version_dir = rel_components.next().is_some();
|
||||||
|
|
||||||
|
match (rel_version, keep_version) {
|
||||||
|
(Some(rel_v), Some(keep_v)) if rel_is_inside_version_dir => rel_v != keep_v,
|
||||||
|
// A file directly under `versions/`, shared metadata: keep it.
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Copies the collected files into the instance profile concurrently, bounded
|
/// Copies the collected files into the instance profile concurrently, bounded
|
||||||
/// by the I/O semaphore, reporting progress after every completed file.
|
/// by the I/O semaphore, reporting progress after every completed file.
|
||||||
async fn copy_files_with_progress(
|
async fn copy_files_with_progress(
|
||||||
@ -1225,7 +1351,7 @@ async fn copy_files_with_progress(
|
|||||||
/// back to the source folder itself: the game creates the content folders
|
/// back to the source folder itself: the game creates the content folders
|
||||||
/// there on first run, and for imports the user's explicit game-dir choice
|
/// there on first run, and for imports the user's explicit game-dir choice
|
||||||
/// (or no override, i.e. the managed symlink) decides the rest.
|
/// (or no override, i.e. the managed symlink) decides the rest.
|
||||||
fn resolve_import_game_root(source: &Path) -> PathBuf {
|
pub(crate) fn resolve_import_game_root(source: &Path) -> PathBuf {
|
||||||
// The source is itself the game root: either a whole Minecraft folder that
|
// The source is itself the game root: either a whole Minecraft folder that
|
||||||
// carries a game body, or any folder that already holds game content
|
// carries a game body, or any folder that already holds game content
|
||||||
// (a version-isolated `versions/<name>` with mods/saves/config inside).
|
// (a version-isolated `versions/<name>` with mods/saves/config inside).
|
||||||
@ -1300,19 +1426,27 @@ fn dir_has_game_content(root: &Path) -> bool {
|
|||||||
|
|
||||||
pub(crate) async fn finish_import(
|
pub(crate) async fn finish_import(
|
||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
dotminecraft: PathBuf,
|
content_source: Option<PathBuf>,
|
||||||
|
version_dir: Option<PathBuf>,
|
||||||
|
isolated: bool,
|
||||||
io_semaphore: &IoSemaphore,
|
io_semaphore: &IoSemaphore,
|
||||||
reporter: InstallProgressReporter,
|
reporter: InstallProgressReporter,
|
||||||
details: InstallPhaseDetails,
|
details: InstallPhaseDetails,
|
||||||
symlink: bool,
|
symlink: bool,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
let local_source = LocalRuntimeSource::discover(&dotminecraft);
|
// The directory the game body / version JSON lives in, used to discover the
|
||||||
|
// local runtime source. Prefer the selected version folder, else the
|
||||||
|
// content root.
|
||||||
|
let primary_source = version_dir
|
||||||
|
.clone()
|
||||||
|
.or_else(|| content_source.clone())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError(
|
||||||
|
"Import has no content source".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let local_source = LocalRuntimeSource::discover(&primary_source);
|
||||||
|
|
||||||
// Respect an explicitly chosen game-dir override (the user's isolated /
|
|
||||||
// not-isolated selection, already stored on the instance row at creation).
|
|
||||||
// Only fall back to auto-detection for symlink imports that did not carry
|
|
||||||
// an explicit override, so copy imports always stay built-in (no override)
|
|
||||||
// and the frontend's choice is never clobbered.
|
|
||||||
let state = crate::state::State::get().await?;
|
let state = crate::state::State::get().await?;
|
||||||
let pool = &state.pool;
|
let pool = &state.pool;
|
||||||
let existing_override =
|
let existing_override =
|
||||||
@ -1324,17 +1458,15 @@ pub(crate) async fn finish_import(
|
|||||||
.map(|(_, override_dir)| override_dir)
|
.map(|(_, override_dir)| override_dir)
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
if existing_override.is_none() && symlink {
|
if existing_override.is_none() && symlink {
|
||||||
// For a non-version-isolated import the game content (mods, saves, config)
|
// For a symlinked import the game dir is the referenced source root,
|
||||||
// lives in the `.minecraft` root, not in the detected `versions/<name>`
|
// not the empty managed instance folder. Record it so the instance
|
||||||
// subfolder. Detect that and record the override so the instance uses the
|
// launches from the real location.
|
||||||
// real game root directly instead of an empty version subfolder.
|
if let Some(content_root) = &content_source {
|
||||||
let game_root = resolve_import_game_root(&dotminecraft);
|
|
||||||
if game_root != dotminecraft {
|
|
||||||
crate::state::edit_instance(
|
crate::state::edit_instance(
|
||||||
instance_id,
|
instance_id,
|
||||||
crate::state::EditInstance {
|
crate::state::EditInstance {
|
||||||
game_dir_override: Some(Some(
|
game_dir_override: Some(Some(
|
||||||
game_root.to_string_lossy().to_string(),
|
content_root.to_string_lossy().to_string(),
|
||||||
)),
|
)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@ -1345,6 +1477,11 @@ pub(crate) async fn finish_import(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if symlink {
|
if symlink {
|
||||||
|
let source_root = content_source.clone().ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InputError(
|
||||||
|
"Symlink import requires a content source".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
let relative_path =
|
let relative_path =
|
||||||
instance_rows::get_instance_path_by_id(instance_id, &state.pool)
|
instance_rows::get_instance_path_by_id(instance_id, &state.pool)
|
||||||
@ -1354,7 +1491,7 @@ pub(crate) async fn finish_import(
|
|||||||
})?;
|
})?;
|
||||||
// The instance's managed folder lives at instances_dir/<path>. This is
|
// The instance's managed folder lives at instances_dir/<path>. This is
|
||||||
// where the symlink is created; it must NOT go through the game-dir
|
// where the symlink is created; it must NOT go through the game-dir
|
||||||
// override (which points at the external .minecraft root).
|
// override (which points at the external source root).
|
||||||
let instance_path =
|
let instance_path =
|
||||||
state.directories.instances_dir().join(&relative_path);
|
state.directories.instances_dir().join(&relative_path);
|
||||||
|
|
||||||
@ -1402,7 +1539,7 @@ pub(crate) async fn finish_import(
|
|||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
io::create_symlink(&dotminecraft, &instance_path).await
|
io::create_symlink(&source_root, &instance_path).await
|
||||||
{
|
{
|
||||||
let _ = io::rename_or_move(&backup_path, &instance_path).await;
|
let _ = io::rename_or_move(&backup_path, &instance_path).await;
|
||||||
watch_instance_folder(
|
watch_instance_folder(
|
||||||
@ -1423,14 +1560,14 @@ pub(crate) async fn finish_import(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
io::create_symlink(&dotminecraft, &instance_path).await?;
|
io::create_symlink(&source_root, &instance_path).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::state::edit_instance(
|
crate::state::edit_instance(
|
||||||
instance_id,
|
instance_id,
|
||||||
crate::state::EditInstance {
|
crate::state::EditInstance {
|
||||||
symlink_target: Some(Some(
|
symlink_target: Some(Some(
|
||||||
dotminecraft.to_string_lossy().to_string(),
|
source_root.to_string_lossy().to_string(),
|
||||||
)),
|
)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@ -1440,7 +1577,9 @@ pub(crate) async fn finish_import(
|
|||||||
} else {
|
} else {
|
||||||
copy_dotminecraft_with_reporter(
|
copy_dotminecraft_with_reporter(
|
||||||
instance_id,
|
instance_id,
|
||||||
dotminecraft,
|
content_source,
|
||||||
|
version_dir,
|
||||||
|
isolated,
|
||||||
io_semaphore,
|
io_semaphore,
|
||||||
reporter.clone(),
|
reporter.clone(),
|
||||||
details,
|
details,
|
||||||
|
|||||||
@ -210,7 +210,9 @@ pub async fn import_instance(
|
|||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
finish_import(
|
finish_import(
|
||||||
instance_id,
|
instance_id,
|
||||||
source,
|
Some(source),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
&state.io_semaphore,
|
&state.io_semaphore,
|
||||||
reporter,
|
reporter,
|
||||||
details,
|
details,
|
||||||
|
|||||||
@ -1529,10 +1529,10 @@ async fn run_request(
|
|||||||
game_version,
|
game_version,
|
||||||
loader,
|
loader,
|
||||||
loader_version,
|
loader_version,
|
||||||
game_dir_override: _,
|
game_dir_override,
|
||||||
} => {
|
} => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"InstallRequest::ImportInstance: launcher_type={launcher_type} base_path={} instance_folder={instance_folder} symlink={symlink}",
|
"InstallRequest::ImportInstance: launcher_type={launcher_type} base_path={} instance_folder={instance_folder} symlink={symlink} game_dir_override={game_dir_override:?}",
|
||||||
base_path.display()
|
base_path.display()
|
||||||
);
|
);
|
||||||
let Some(instance_id) = current_instance_id(job_state) else {
|
let Some(instance_id) = current_instance_id(job_state) else {
|
||||||
@ -1562,6 +1562,7 @@ async fn run_request(
|
|||||||
game_version,
|
game_version,
|
||||||
loader,
|
loader,
|
||||||
loader_version,
|
loader_version,
|
||||||
|
game_dir_override,
|
||||||
},
|
},
|
||||||
// TODO(B2): apply overrides to launcher-specific importers
|
// TODO(B2): apply overrides to launcher-specific importers
|
||||||
// (MultiMC/Prism/ATLauncher/GDLauncher/Curseforge/ModrinthApp);
|
// (MultiMC/Prism/ATLauncher/GDLauncher/Curseforge/ModrinthApp);
|
||||||
@ -1590,8 +1591,12 @@ async fn run_request(
|
|||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
crate::api::pack::import::copy_dotminecraft_with_reporter(
|
crate::api::pack::import::copy_dotminecraft_with_reporter(
|
||||||
&instance_id,
|
&instance_id,
|
||||||
crate::api::instance::get_full_path(&source_instance_id)
|
Some(
|
||||||
.await?,
|
crate::api::instance::get_full_path(&source_instance_id)
|
||||||
|
.await?,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
&state.io_semaphore,
|
&state.io_semaphore,
|
||||||
InstallProgressReporter::new(job_id, job_state.clone()),
|
InstallProgressReporter::new(job_id, job_state.clone()),
|
||||||
InstallPhaseDetails::Empty,
|
InstallPhaseDetails::Empty,
|
||||||
@ -2526,7 +2531,9 @@ async fn copy_physical_instance_contents(
|
|||||||
)?;
|
)?;
|
||||||
crate::api::pack::import::copy_dotminecraft_with_reporter(
|
crate::api::pack::import::copy_dotminecraft_with_reporter(
|
||||||
target_instance_id,
|
target_instance_id,
|
||||||
source_path,
|
Some(source_path),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
&state.io_semaphore,
|
&state.io_semaphore,
|
||||||
InstallProgressReporter::new(job_id, job_state.clone()),
|
InstallProgressReporter::new(job_id, job_state.clone()),
|
||||||
InstallPhaseDetails::Empty,
|
InstallPhaseDetails::Empty,
|
||||||
|
|||||||
@ -32,12 +32,17 @@ pub(crate) async fn remove_instance(
|
|||||||
.game_dir_override
|
.game_dir_override
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.filter(|path| is_version_isolated_game_dir(path))
|
.filter(|path| {
|
||||||
|
// Delete the external game directory when the instance owns it.
|
||||||
|
// A version-isolated `versions/<name>` folder is obviously
|
||||||
|
// exclusive; a plain `<root>/<pack name>` folder is also owned by
|
||||||
|
// this instance. A shared `.minecraft` root, however, holds the
|
||||||
|
// game's libraries/assets and must never be deleted with one
|
||||||
|
// instance.
|
||||||
|
is_version_isolated_game_dir(path)
|
||||||
|
|| !is_shared_minecraft_root(path)
|
||||||
|
})
|
||||||
{
|
{
|
||||||
// New instances created against a configured `.minecraft` root use
|
|
||||||
// a private `versions/<name>` directory. Remove that external
|
|
||||||
// directory when the instance is deleted, while preserving shared
|
|
||||||
// (non-isolated) overrides for backwards compatibility.
|
|
||||||
game_dir_override
|
game_dir_override
|
||||||
} else {
|
} else {
|
||||||
state.directories.instances_dir().join(&instance.path)
|
state.directories.instances_dir().join(&instance.path)
|
||||||
@ -69,3 +74,12 @@ fn is_version_isolated_game_dir(path: &Path) -> bool {
|
|||||||
.and_then(|name| name.to_str())
|
.and_then(|name| name.to_str())
|
||||||
== Some("versions")
|
== Some("versions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Heuristic: a shared `.minecraft` root holds the game's libraries and
|
||||||
|
/// assets, which must survive the removal of any single instance that points
|
||||||
|
/// at it. Instance-owned external folders (e.g. a hosted modpack's
|
||||||
|
/// `<root>/<pack name>` directory) contain only mods/saves/config and no such
|
||||||
|
/// shared game body.
|
||||||
|
fn is_shared_minecraft_root(path: &Path) -> bool {
|
||||||
|
path.join("libraries").is_dir() || path.join("assets").is_dir()
|
||||||
|
}
|
||||||
|
|||||||
@ -616,220 +616,62 @@ impl Process {
|
|||||||
let mut buf_reader = BufReader::new(reader);
|
let mut buf_reader = BufReader::new(reader);
|
||||||
|
|
||||||
if xml_logging {
|
if xml_logging {
|
||||||
let mut reader = Reader::from_reader(buf_reader);
|
// NOTE: we deliberately do NOT use quick-xml's streaming async reader
|
||||||
reader.config_mut().enable_all_checks(false);
|
// here. Its parser marks itself `ParseState::Done` permanently after
|
||||||
|
// any I/O/parse error or a transient `Eof` (see quick-xml #513), so a
|
||||||
let mut buf = Vec::new();
|
// single split XML frame on the live pipe would silently kill all
|
||||||
let mut current_event = Log4jEvent::default();
|
// further log forwarding — which is exactly the "logs stop after the
|
||||||
let mut in_event = false;
|
// client finished starting" bug.
|
||||||
let mut in_message = false;
|
//
|
||||||
let mut in_throwable = false;
|
// Instead we accumulate raw bytes into a buffer and cut out complete
|
||||||
let mut current_content = String::new();
|
// `<log4j:Event ...>…</log4j:Event>` frames, parsing each frame in one
|
||||||
|
// synchronous pass. Malformed or partial frames are skipped without
|
||||||
|
// poisoning the stream, so forwarding always continues.
|
||||||
|
let mut pending = String::new();
|
||||||
|
let mut chunk = [0u8; 8192];
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match reader.read_event_into_async(&mut buf).await {
|
let read = match tokio::io::AsyncReadExt::read(&mut buf_reader, &mut chunk).await {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => n,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::warn!("Live log read error: {e}");
|
||||||
"Error at position {}: {:?}",
|
|
||||||
reader.buffer_position(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// exits the loop when reaching end of file
|
};
|
||||||
Ok(Event::Eof) => break,
|
|
||||||
|
|
||||||
Ok(Event::Start(e)) => {
|
pending.push_str(&String::from_utf8_lossy(&chunk[..read]));
|
||||||
match e.name().as_ref() {
|
|
||||||
b"log4j:Event" => {
|
|
||||||
// Reset for new event
|
|
||||||
current_event = Log4jEvent::default();
|
|
||||||
in_event = true;
|
|
||||||
|
|
||||||
// Extract attributes
|
// Drain every complete frame currently buffered.
|
||||||
for attr in e.attributes().flatten() {
|
while let Some(frame) = take_next_log4j_frame(&mut pending) {
|
||||||
let key = String::from_utf8_lossy(
|
Self::handle_log4j_frame(
|
||||||
attr.key.into_inner(),
|
instance_id,
|
||||||
)
|
instance_name,
|
||||||
.to_string();
|
process_id,
|
||||||
let value =
|
&log_path,
|
||||||
String::from_utf8_lossy(&attr.value)
|
&frame,
|
||||||
.to_string();
|
)
|
||||||
|
.await;
|
||||||
match key.as_str() {
|
|
||||||
"logger" => {
|
|
||||||
current_event.logger_name =
|
|
||||||
Some(value)
|
|
||||||
}
|
|
||||||
"level" => {
|
|
||||||
current_event.level = Some(value)
|
|
||||||
}
|
|
||||||
"thread" => {
|
|
||||||
current_event.thread_name =
|
|
||||||
Some(value)
|
|
||||||
}
|
|
||||||
"timestamp" => {
|
|
||||||
current_event.timestamp_millis =
|
|
||||||
value.parse::<i64>().ok()
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b"log4j:Message" => {
|
|
||||||
in_message = true;
|
|
||||||
current_content = String::new();
|
|
||||||
}
|
|
||||||
b"log4j:Throwable" => {
|
|
||||||
in_throwable = true;
|
|
||||||
current_content = String::new();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::End(e)) => {
|
|
||||||
match e.name().as_ref() {
|
|
||||||
b"log4j:Message" => {
|
|
||||||
in_message = false;
|
|
||||||
current_event.message =
|
|
||||||
Some(current_content.clone());
|
|
||||||
}
|
|
||||||
b"log4j:Throwable" => {
|
|
||||||
in_throwable = false;
|
|
||||||
current_event.throwable =
|
|
||||||
if current_content.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(current_content.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
// Write log entry + throwable to file
|
|
||||||
if let Some(formatted_log) =
|
|
||||||
Self::format_log4j_entry(¤t_event)
|
|
||||||
{
|
|
||||||
if let Err(e) = Process::append_to_log_file(
|
|
||||||
&log_path,
|
|
||||||
&formatted_log,
|
|
||||||
) {
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to write to log file: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref throwable) =
|
|
||||||
current_event.throwable
|
|
||||||
&& let Err(e) =
|
|
||||||
Process::append_to_log_file(
|
|
||||||
&log_path, throwable,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to write throwable to log file: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::emit_log4j_event(
|
|
||||||
instance_id,
|
|
||||||
¤t_event,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
b"log4j:Event" => {
|
|
||||||
in_event = false;
|
|
||||||
// If no throwable was present, write the log entry at the end of the event
|
|
||||||
if current_event.message.is_some()
|
|
||||||
&& current_event.throwable.is_none()
|
|
||||||
{
|
|
||||||
if let Some(formatted_log) =
|
|
||||||
Self::format_log4j_entry(¤t_event)
|
|
||||||
&& let Err(e) =
|
|
||||||
Process::append_to_log_file(
|
|
||||||
&log_path,
|
|
||||||
&formatted_log,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to write to log file: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(timestamp_millis) =
|
|
||||||
current_event.timestamp_millis
|
|
||||||
{
|
|
||||||
let timestamp =
|
|
||||||
timestamp_millis.to_string();
|
|
||||||
let message = current_event
|
|
||||||
.message
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
crate::api::multiplayer::observe_minecraft_log(
|
|
||||||
instance_id,
|
|
||||||
instance_name,
|
|
||||||
process_id,
|
|
||||||
message,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
if let Err(e) = Self::maybe_handle_server_join_logging(
|
|
||||||
instance_id,
|
|
||||||
×tamp,
|
|
||||||
message,
|
|
||||||
).await {
|
|
||||||
tracing::error!("Failed to handle server join logging: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::emit_log4j_event(
|
|
||||||
instance_id,
|
|
||||||
¤t_event,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::Text(mut e)) => {
|
|
||||||
if in_message || in_throwable {
|
|
||||||
if let Ok(text) = e.xml_content() {
|
|
||||||
append_bounded_log4j_content(
|
|
||||||
&mut current_content,
|
|
||||||
&text,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if !in_event
|
|
||||||
&& !e.inplace_trim_end()
|
|
||||||
&& !e.inplace_trim_start()
|
|
||||||
&& let Ok(text) = e.xml_content()
|
|
||||||
{
|
|
||||||
if let Err(e) = Process::append_to_log_file(
|
|
||||||
&log_path,
|
|
||||||
&format!("{text}\n"),
|
|
||||||
) {
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to write to log file: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Self::emit_legacy_log(instance_id, &text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::CData(e)) => {
|
|
||||||
if (in_message || in_throwable)
|
|
||||||
&& let Ok(text) = e.xml_content()
|
|
||||||
{
|
|
||||||
append_bounded_log4j_content(
|
|
||||||
&mut current_content,
|
|
||||||
&text,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.clear();
|
// Guard against a runaway buffer if no frame delimiters ever
|
||||||
|
// appear (e.g. raw non-XML output on a logging-configured
|
||||||
|
// instance). Flush it as legacy text so it is not lost.
|
||||||
|
if pending.len() > MAX_PERSISTED_LOG_LINE_BYTES {
|
||||||
|
let text = std::mem::take(&mut pending);
|
||||||
|
if let Err(e) = Self::append_to_log_file(&log_path, &text) {
|
||||||
|
tracing::warn!("Failed to write to log file: {e}");
|
||||||
|
}
|
||||||
|
Self::emit_legacy_log(instance_id, text.trim_end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush any trailing partial content on stream end.
|
||||||
|
if !pending.trim().is_empty() {
|
||||||
|
if let Err(e) = Self::append_to_log_file(&log_path, &pending) {
|
||||||
|
tracing::warn!("Failed to write to log file: {e}");
|
||||||
|
}
|
||||||
|
Self::emit_legacy_log(instance_id, pending.trim_end());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while let Ok(Some(line)) =
|
while let Ok(Some(line)) =
|
||||||
@ -862,6 +704,164 @@ impl Process {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses one complete `<log4j:Event …>…</log4j:Event>` frame and forwards
|
||||||
|
/// its content to the log file / frontend. A frame that fails to parse is
|
||||||
|
/// logged and dropped; it never stops the reader loop.
|
||||||
|
async fn handle_log4j_frame(
|
||||||
|
instance_id: &str,
|
||||||
|
instance_name: &str,
|
||||||
|
process_id: &str,
|
||||||
|
log_path: &Path,
|
||||||
|
frame: &str,
|
||||||
|
) {
|
||||||
|
let mut reader = Reader::from_str(frame);
|
||||||
|
reader.config_mut().enable_all_checks(false);
|
||||||
|
|
||||||
|
let mut current_event = Log4jEvent::default();
|
||||||
|
let mut in_message = false;
|
||||||
|
let mut in_throwable = false;
|
||||||
|
let mut current_content = String::new();
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Malformed live log frame: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Event::Eof) => break,
|
||||||
|
Ok(Event::Start(e)) => match e.name().as_ref() {
|
||||||
|
b"log4j:Event" => {
|
||||||
|
current_event = Log4jEvent::default();
|
||||||
|
for attr in e.attributes().flatten() {
|
||||||
|
let key =
|
||||||
|
String::from_utf8_lossy(attr.key.into_inner())
|
||||||
|
.to_string();
|
||||||
|
let value = String::from_utf8_lossy(&attr.value)
|
||||||
|
.to_string();
|
||||||
|
match key.as_str() {
|
||||||
|
"logger" => {
|
||||||
|
current_event.logger_name = Some(value)
|
||||||
|
}
|
||||||
|
"level" => current_event.level = Some(value),
|
||||||
|
"thread" => {
|
||||||
|
current_event.thread_name = Some(value)
|
||||||
|
}
|
||||||
|
"timestamp" => {
|
||||||
|
current_event.timestamp_millis =
|
||||||
|
value.parse::<i64>().ok()
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b"log4j:Message" => {
|
||||||
|
in_message = true;
|
||||||
|
current_content = String::new();
|
||||||
|
}
|
||||||
|
b"log4j:Throwable" => {
|
||||||
|
in_throwable = true;
|
||||||
|
current_content = String::new();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::End(e)) => match e.name().as_ref() {
|
||||||
|
b"log4j:Message" => {
|
||||||
|
in_message = false;
|
||||||
|
current_event.message = Some(current_content.clone());
|
||||||
|
}
|
||||||
|
b"log4j:Throwable" => {
|
||||||
|
in_throwable = false;
|
||||||
|
current_event.throwable =
|
||||||
|
if current_content.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(current_content.clone())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
b"log4j:Event" => {
|
||||||
|
if let Some(formatted) =
|
||||||
|
Self::format_log4j_entry(¤t_event)
|
||||||
|
{
|
||||||
|
if let Err(e) =
|
||||||
|
Self::append_to_log_file(log_path, &formatted)
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to write to log file: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(ref throwable) = current_event.throwable
|
||||||
|
&& let Err(e) = Self::append_to_log_file(
|
||||||
|
log_path,
|
||||||
|
throwable,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to write throwable to log file: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(timestamp_millis) =
|
||||||
|
current_event.timestamp_millis
|
||||||
|
{
|
||||||
|
let timestamp = timestamp_millis.to_string();
|
||||||
|
let message = current_event
|
||||||
|
.message
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
crate::api::multiplayer::observe_minecraft_log(
|
||||||
|
instance_id,
|
||||||
|
instance_name,
|
||||||
|
process_id,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(e) =
|
||||||
|
Self::maybe_handle_server_join_logging(
|
||||||
|
instance_id,
|
||||||
|
×tamp,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to handle server join logging: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::emit_log4j_event(instance_id, ¤t_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::Text(e)) => {
|
||||||
|
if (in_message || in_throwable)
|
||||||
|
&& let Ok(text) = e.xml_content()
|
||||||
|
{
|
||||||
|
append_bounded_log4j_content(
|
||||||
|
&mut current_content,
|
||||||
|
&text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::CData(e)) => {
|
||||||
|
if (in_message || in_throwable)
|
||||||
|
&& let Ok(text) = e.xml_content()
|
||||||
|
{
|
||||||
|
append_bounded_log4j_content(
|
||||||
|
&mut current_content,
|
||||||
|
&text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn format_timestamp(timestamp_millis: Option<i64>) -> String {
|
fn format_timestamp(timestamp_millis: Option<i64>) -> String {
|
||||||
if let Some(timestamp_val) = timestamp_millis {
|
if let Some(timestamp_val) = timestamp_millis {
|
||||||
let datetime_utc = if timestamp_val > i32::MAX as i64 {
|
let datetime_utc = if timestamp_val > i32::MAX as i64 {
|
||||||
@ -1267,7 +1267,28 @@ impl Process {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Cuts the next complete `<log4j:Event …>…</log4j:Event>` frame out of the
|
||||||
|
/// live buffer and returns it as an owned string, leaving any trailing partial
|
||||||
|
/// frame in place.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the buffer does not yet contain a full frame. This is a
|
||||||
|
/// plain string operation on purpose: it never poisons any parser state, so a
|
||||||
|
/// split or malformed frame on the live pipe cannot stop log forwarding.
|
||||||
|
fn take_next_log4j_frame(buffer: &mut String) -> Option<String> {
|
||||||
|
const OPEN: &str = "<log4j:Event";
|
||||||
|
const CLOSE: &str = "</log4j:Event>";
|
||||||
|
|
||||||
|
let start = buffer.find(OPEN)?;
|
||||||
|
// Discard anything before the frame (raw text, XML prolog, …).
|
||||||
|
if start > 0 {
|
||||||
|
buffer.drain(..start);
|
||||||
|
}
|
||||||
|
let close = buffer.find(CLOSE)?;
|
||||||
|
let end = close + CLOSE.len();
|
||||||
|
let frame = buffer[..end].to_string();
|
||||||
|
buffer.drain(..end);
|
||||||
|
Some(frame)
|
||||||
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod post_upgrade_tests {
|
mod post_upgrade_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -19,32 +19,30 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<!--
|
||||||
v-else
|
Native virtualization: every line is rendered, but `content-visibility:
|
||||||
class="log-viewport-spacer relative w-full min-w-max"
|
auto` lets the browser skip layout/paint for off-screen lines, while
|
||||||
:style="{ height: totalHeight + 'px' }"
|
`contain-intrinsic-size` gives the skipped elements a placeholder size
|
||||||
>
|
so the scrollbar stays stable. This avoids the manual height estimation
|
||||||
|
that used to make tall (wrapped / highlighted) lines overlap.
|
||||||
|
-->
|
||||||
|
<div v-else class="log-viewport-spacer relative w-full min-w-max">
|
||||||
<div
|
<div
|
||||||
class="absolute inset-x-0 top-0"
|
v-for="item in lines"
|
||||||
:style="{ transform: 'translateY(' + topOffset + 'px)' }"
|
:key="item.originalIndex"
|
||||||
|
:data-line="item.originalIndex + 1"
|
||||||
|
class="log-line log-line-cv flex items-stretch whitespace-pre"
|
||||||
|
:class="entryClass(item.line)"
|
||||||
|
:style="lineStyle"
|
||||||
>
|
>
|
||||||
<div
|
<span
|
||||||
v-for="item in windowItems"
|
class="flex shrink-0 w-[52px] items-center justify-end leading-none text-right text-secondary bg-surface-3 border-r border-solid border-surface-3 select-none overflow-hidden"
|
||||||
:key="item.originalIndex"
|
>{{ item.originalIndex + 1 }}</span
|
||||||
:data-line="item.originalIndex + 1"
|
|
||||||
class="log-line flex items-stretch whitespace-pre"
|
|
||||||
:class="entryClass(item.line)"
|
|
||||||
:style="{ height: estimateHeight(item) + 'px' }"
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="flex shrink-0 w-[52px] items-center justify-end leading-none text-right text-secondary bg-surface-3 border-r border-solid border-surface-3 select-none overflow-hidden"
|
class="log-line-content flex-1 px-2 break-all [overflow-wrap:anywhere]"
|
||||||
>{{ item.originalIndex + 1 }}</span
|
v-html="renderLine(item)"
|
||||||
>
|
></span>
|
||||||
<span
|
|
||||||
class="log-line-content flex-1 px-2 break-all [overflow-wrap:anywhere]"
|
|
||||||
v-html="renderLine(item)"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -99,114 +97,21 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const viewportRef = ref<HTMLElement | null>(null)
|
const viewportRef = ref<HTMLElement | null>(null)
|
||||||
const scrollTop = ref(0)
|
|
||||||
const viewportHeight = ref(0)
|
|
||||||
const stickToBottom = ref(true)
|
const stickToBottom = ref(true)
|
||||||
|
|
||||||
// 行高:单行 = 字号 × 1.4(与等宽字体匹配),wrap 时按估算折行数放大
|
// Placeholder row height for `contain-intrinsic-size`. Native
|
||||||
const lineHeightPx = computed(() => Math.round(props.fontSize * 1.4))
|
// `content-visibility: auto` replaces this with the real measured height once a
|
||||||
// wrap 折行估算:0.6em 为等宽字符平均宽,乘 0.9 留保守余量(行高宁高勿矮,避免内容溢出重叠)
|
// line enters the viewport, so it only needs to be a reasonable estimate to
|
||||||
const charsPerLine = computed(() => {
|
// keep the scrollbar from jumping. Wrapped lines can be taller, so bias higher.
|
||||||
const vp = viewportRef.value
|
const intrinsicLineHeight = computed(() => {
|
||||||
if (!vp) return 120
|
const single = Math.round(props.fontSize * 1.4)
|
||||||
return Math.max(20, Math.floor((vp.clientWidth / (props.fontSize * 0.6)) * 0.9))
|
return props.wrap ? single * 2 : single
|
||||||
})
|
})
|
||||||
|
|
||||||
function estimateHeight(item: ViewportLine): number {
|
const lineStyle = computed(() => ({
|
||||||
if (!props.wrap) return lineHeightPx.value
|
'content-visibility': 'auto',
|
||||||
const lines = Math.max(1, Math.ceil(item.line.text.length / charsPerLine.value))
|
'contain-intrinsic-size': `auto ${intrinsicLineHeight.value}px`,
|
||||||
return lines * lineHeightPx.value
|
}))
|
||||||
}
|
|
||||||
|
|
||||||
// 高度前缀和缓存:lines/wrap/fontSize 变化时重建(O(n)),滚动时二分查找(O(log n))
|
|
||||||
// 总高度必须是响应式的:普通变量 + 无依赖 computed 会缓存过期值,
|
|
||||||
// 清空控制台后模板不再读取它,重启后 spacer 会以旧高度渲染(底部空白)。
|
|
||||||
let heightPrefix: number[] | null = null
|
|
||||||
const heightTotal = ref(0)
|
|
||||||
|
|
||||||
function rebuildHeights() {
|
|
||||||
const n = props.lines.length
|
|
||||||
if (!props.wrap) {
|
|
||||||
heightPrefix = null
|
|
||||||
heightTotal.value = n * lineHeightPx.value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const prefix = new Array<number>(n)
|
|
||||||
let acc = 0
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
prefix[i] = acc
|
|
||||||
acc += estimateHeight(props.lines[i]!)
|
|
||||||
}
|
|
||||||
heightPrefix = prefix
|
|
||||||
heightTotal.value = acc
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [props.lines, props.wrap, props.fontSize] as const,
|
|
||||||
([lines], previous) => {
|
|
||||||
rebuildHeights()
|
|
||||||
// A fresh stream after an empty console (clear, restart, initial
|
|
||||||
// hydration) always resumes bottom-following.
|
|
||||||
if (previous && previous[0].length === 0 && lines.length > 0) {
|
|
||||||
stickToBottom.value = true
|
|
||||||
}
|
|
||||||
if (lines.length === 0) {
|
|
||||||
// Reset the virtual window state along with the DOM scroll position;
|
|
||||||
// browsers may clamp silently without firing a scroll event.
|
|
||||||
scrollTop.value = 0
|
|
||||||
if (viewportRef.value) viewportRef.value.scrollTop = 0
|
|
||||||
}
|
|
||||||
if (stickToBottom.value) {
|
|
||||||
nextTick(scrollToBottom)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalHeight = computed(() => heightTotal.value)
|
|
||||||
|
|
||||||
// 虚拟窗口:可见行 + 上下缓冲
|
|
||||||
const WINDOW_BUFFER = 15
|
|
||||||
|
|
||||||
function computeWindow(): { items: ViewportLine[]; startIndex: number } {
|
|
||||||
const n = props.lines.length
|
|
||||||
if (n === 0) return { items: [], startIndex: 0 }
|
|
||||||
|
|
||||||
let start = 0
|
|
||||||
let end = n - 1
|
|
||||||
|
|
||||||
if (n > WINDOW_BUFFER * 2) {
|
|
||||||
if (props.wrap && heightPrefix) {
|
|
||||||
let lo = 0
|
|
||||||
let hi = n - 1
|
|
||||||
while (lo < hi) {
|
|
||||||
const mid = (lo + hi + 1) >> 1
|
|
||||||
if (heightPrefix[mid]! <= scrollTop.value) lo = mid
|
|
||||||
else hi = mid - 1
|
|
||||||
}
|
|
||||||
start = Math.max(0, lo - WINDOW_BUFFER)
|
|
||||||
} else {
|
|
||||||
const first = Math.floor(scrollTop.value / lineHeightPx.value)
|
|
||||||
start = Math.max(0, first - WINDOW_BUFFER)
|
|
||||||
}
|
|
||||||
end = Math.min(
|
|
||||||
n - 1,
|
|
||||||
start + Math.ceil(viewportHeight.value / lineHeightPx.value) + WINDOW_BUFFER * 2,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items: props.lines.slice(start, end + 1), startIndex: start }
|
|
||||||
}
|
|
||||||
|
|
||||||
const windowState = computed(computeWindow)
|
|
||||||
const windowItems = computed(() => windowState.value.items)
|
|
||||||
|
|
||||||
const topOffset = computed(() => {
|
|
||||||
const { startIndex } = windowState.value
|
|
||||||
if (startIndex === 0) return 0
|
|
||||||
if (props.wrap && heightPrefix) return heightPrefix[startIndex]!
|
|
||||||
return startIndex * lineHeightPx.value
|
|
||||||
})
|
|
||||||
|
|
||||||
function entryClass(line: LogLine): string {
|
function entryClass(line: LogLine): string {
|
||||||
if (line.level === 'error') return 'entry-error'
|
if (line.level === 'error') return 'entry-error'
|
||||||
@ -235,43 +140,47 @@ function renderLine(item: ViewportLine): string {
|
|||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const vp = viewportRef.value
|
const vp = viewportRef.value
|
||||||
if (!vp) return
|
if (!vp) return
|
||||||
scrollTop.value = vp.scrollTop
|
stickToBottom.value = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 32
|
||||||
viewportHeight.value = vp.clientHeight
|
|
||||||
stickToBottom.value = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - lineHeightPx.value * 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
const vp = viewportRef.value
|
const vp = viewportRef.value
|
||||||
if (!vp) return
|
if (!vp) return
|
||||||
vp.scrollTop = vp.scrollHeight
|
vp.scrollTop = vp.scrollHeight
|
||||||
scrollTop.value = vp.scrollTop
|
|
||||||
stickToBottom.value = true
|
stickToBottom.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncViewportSize() {
|
|
||||||
const vp = viewportRef.value
|
|
||||||
if (!vp) return
|
|
||||||
viewportHeight.value = vp.clientHeight
|
|
||||||
// 窗口宽度影响 wrap 折行估算,resize 时重建高度缓存
|
|
||||||
if (props.wrap) rebuildHeights()
|
|
||||||
}
|
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncViewportSize()
|
|
||||||
if (stickToBottom.value) nextTick(scrollToBottom)
|
if (stickToBottom.value) nextTick(scrollToBottom)
|
||||||
resizeObserver = new ResizeObserver(syncViewportSize)
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (stickToBottom.value) scrollToBottom()
|
||||||
|
})
|
||||||
if (viewportRef.value) resizeObserver.observe(viewportRef.value)
|
if (viewportRef.value) resizeObserver.observe(viewportRef.value)
|
||||||
window.addEventListener('resize', syncViewportSize)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
resizeObserver = null
|
resizeObserver = null
|
||||||
window.removeEventListener('resize', syncViewportSize)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Follow the tail while new lines stream in, but only when the user has not
|
||||||
|
// scrolled up. A fresh stream after an empty console (clear, restart, initial
|
||||||
|
// hydration) always resumes bottom-following.
|
||||||
|
watch(
|
||||||
|
() => props.lines,
|
||||||
|
(lines, previous) => {
|
||||||
|
if (previous && previous.length === 0 && lines.length > 0) {
|
||||||
|
stickToBottom.value = true
|
||||||
|
}
|
||||||
|
if (stickToBottom.value) {
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user