Compare commits

...

4 Commits

Author SHA1 Message Date
42b0c7e7af fix: 修复未登录皮肤站时启动卡住且不再弹玩家选择
Some checks failed
Axolotl desktop CI / guardrails (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
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
Sync LobeHub models / sync (push) Has been cancelled
未登录皮肤站时启动游戏会弹窗要求选择/登录玩家。若点击“登录皮肤站”
跳转后不登录直接返回,再次启动不会重新弹窗,而是卡在“正在启动”。

原因:InstancePlayerModal 的 signInSkinSite 跳转登录时只隐藏弹窗,
没有 settle 玩家选择的 Promise,因此 prepareInstancePlayer 在
preparing 表中留下的任务永不结束。再次启动时复用了这个悬空任务,
既不再弹窗也无法继续启动。

修复:
- instance-player 新增哨兵错误 PlayerSelectionNavigatedAwayError。
- signInSkinSite 跳转登录前以该错误 reject 当前选择,使
  prepareInstancePlayer 的任务正常结束、清理 in-flight 记录,下次
  启动会重新弹出玩家选择。
- App.vue 的启动错误处理识别该哨兵:跳转登录是用户主动操作而非启动
  失败,静默中止启动,不弹错误提示。
2026-09-19 20:01:46 +08:00
405d980311 fix: 登录皮肤站后侧栏立即显示玩家选择器
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 皮肤站后,侧栏账户选择器(AccountsCard)不会列出
皮肤站玩家,必须先使用该玩家启动一次实例,玩家才会出现。

原因:账户选择器列出的是 auth::get_users 的账户库,而皮肤站玩家
是 iframe 推送的另一套数据。两者唯一的打通点是
auth|login_skin_site_player(把玩家注册为 yggdrasil 账户),而它
此前只在启动实例选择玩家时被调用。

修复:
- instance-player 新增 registerSkinSitePlayers:玩家列表就绪后,
  把尚未注册的皮肤站玩家逐个注册为账户。幂等(跳过账户库中已存在
  的 profile id)、best-effort(单个失败不影响其余)、每个玩家
  单独获取下载 token(皮肤站登录接口可能将 token 绑定到单一玩家)。
- AccountsCard 的玩家监听新增 skinSiteUser 依赖,玩家就绪后调用
  注册并刷新账户列表,使玩家无需先启动实例即可在选择器中选择。
2026-09-19 19:51:42 +08:00
905e905eca fix: 修复 SLS 实例删除残留外部数据与重装共用文件夹
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
删除实例残留外部数据:
- remove_instance 原先只在 game_dir_override 指向 versions/<name>
  隔离目录时才删除外部目录;SLS(hosted)实例使用版本共享布局
  (外部目录直接作为游戏根),删除时落到托管目录分支,外部 mods/
  存档/配置残留。
- 现在 game_dir_override 只要指向实例独占目录(非共享 .minecraft
  根)就一并删除;新增 is_shared_minecraft_root 判据:含
  libraries/ 或 assets/ 的目录视为共享游戏根,删除实例时保留。

重装共用同一文件夹:
- hosted::create 生成 game_dir_override 时直接拼接
  `<root>/<pack name>`,无冲突处理;同一整合包安装两次会指向同一
  目录,两个实例共用一份游戏数据。
- 新增 unique_game_dir:目标目录已存在时依次尝试 `<name> (1)`、
  `<name> (2)` …,与 create_instance::resolve_instance_path 的
  实例目录去重逻辑保持一致。
2026-09-19 19:37:08 +08:00
cda7bb284a fix: 修复实例日志窗口停更与日志行重叠
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
日志停更(实例启动后日志不再刷新):
- process.rs 的 XML 日志分支原先使用 quick-xml 流式异步 Reader。
  该解析器在任何 I/O/解析错误或瞬时 Eof 后会把内部状态永久置为
  ParseState::Done(quick-xml #513),此后永远只返回 Eof,导致
  日志转发在客户端启动、日志量变大后彻底停止。
- 改为把原始字节累积到缓冲区,切出完整的 <log4j:Event …>…
  </log4j:Event> 帧后逐帧同步解析;坏帧/分片帧只跳过,不再毒化
  解析状态,日志转发持续进行。
- 新增 take_next_log4j_frame(切帧)与 handle_log4j_frame(单帧
  解析与转发)两个辅助函数。

日志行重叠:
- LogViewport.vue 原先用手动虚拟滚动,按估算高度为每行设置固定
  height;估算与实际渲染高度不符(高亮、wrap 折行等)时,绝对
  定位的行会溢出并相互重叠。
- 改用原生虚拟化 content-visibility: auto + contain-intrinsic-size,
  全量渲染由浏览器跳过屏外行的布局绘制,进入视口时以真实高度
  修正,从根本上消除估算误差导致的重叠。
- 保留 scrollToBottom / 自动跟随底部 / 搜索高亮 / 错误警告配色
  等既有行为与对外接口。
2026-09-19 19:13:59 +08:00
8 changed files with 420 additions and 362 deletions

View File

@ -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,

View File

@ -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')

View File

@ -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 },
) )

View File

@ -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 })

View File

@ -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

View File

@ -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()
}

View File

@ -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(&current_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,
&current_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(&current_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,
&timestamp,
message,
).await {
tracing::error!("Failed to handle server join logging: {e}");
}
}
Self::emit_log4j_event(
instance_id,
&current_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(&current_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,
&timestamp,
message,
)
.await
{
tracing::error!(
"Failed to handle server join logging: {e}"
);
}
}
Self::emit_log4j_event(instance_id, &current_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::*;

View File

@ -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,
}) })