From 19098f71599a33d899304e78db7cd8839d010570 Mon Sep 17 00:00:00 2001 From: Disy920 Date: Thu, 24 Sep 2026 22:36:21 +0800 Subject: [PATCH] fix: follow live logs and reconcile duplicate managed mods --- .../src/composables/console-history.test.ts | 23 -- .../src/composables/console-history.ts | 31 --- .../src/composables/latest-log-reader.test.ts | 120 ++++++++ .../src/composables/latest-log-reader.ts | 88 ++++++ .../src/composables/useInstanceConsole.ts | 42 +-- apps/app-frontend/src/pages/instance/Logs.vue | 22 +- packages/app-lib/src/api/logs.rs | 97 ++++++- packages/app-lib/src/api/pack/hosted.rs | 35 +-- .../app-lib/src/api/pack/hosted/tagged.rs | 261 +++++++++++++++++- .../shared/console/components/LogViewport.vue | 34 +-- 10 files changed, 628 insertions(+), 125 deletions(-) delete mode 100644 apps/app-frontend/src/composables/console-history.test.ts delete mode 100644 apps/app-frontend/src/composables/console-history.ts create mode 100644 apps/app-frontend/src/composables/latest-log-reader.test.ts create mode 100644 apps/app-frontend/src/composables/latest-log-reader.ts diff --git a/apps/app-frontend/src/composables/console-history.test.ts b/apps/app-frontend/src/composables/console-history.test.ts deleted file mode 100644 index 40979fc..0000000 --- a/apps/app-frontend/src/composables/console-history.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { mergeLiveLogHistory } from './console-history.ts' - -test('retains the persisted latest.log history and appends a live-only tail', () => { - assert.equal( - mergeLiveLogHistory('first\nsecond\nthird', 'second\nthird\nfourth'), - 'first\nsecond\nthird\nfourth', - ) -}) - -test('does not duplicate a buffer already persisted in latest.log', () => { - assert.equal(mergeLiveLogHistory('first\nsecond\nthird', 'second\nthird'), 'first\nsecond\nthird') -}) - -test('keeps live output when it is not present in latest.log', () => { - assert.equal(mergeLiveLogHistory('minecraft', 'launcher'), 'minecraft\nlauncher') -}) - -test('accepts an unavailable latest.log while the instance is starting', () => { - assert.equal(mergeLiveLogHistory('', 'launcher output'), 'launcher output') -}) diff --git a/apps/app-frontend/src/composables/console-history.ts b/apps/app-frontend/src/composables/console-history.ts deleted file mode 100644 index 015ecf1..0000000 --- a/apps/app-frontend/src/composables/console-history.ts +++ /dev/null @@ -1,31 +0,0 @@ -function nonEmptyLines(content: string): string[] { - return content.split(/\r\n|\r|\n/).filter(Boolean) -} - -function overlapLength(history: readonly string[], liveBuffer: readonly string[]): number { - const maximum = Math.min(history.length, liveBuffer.length) - - for (let length = maximum; length > 0; length--) { - let overlaps = true - for (let index = 0; index < length; index++) { - if (history[history.length - length + index] !== liveBuffer[index]) { - overlaps = false - break - } - } - if (overlaps) return length - } - - return 0 -} - -/** - * Combines the persisted Minecraft log with output that may not have reached - * latest.log yet. The ring buffer commonly repeats the tail of latest.log, so - * retain only its non-overlapping suffix. - */ -export function mergeLiveLogHistory(latestLog: string, liveBuffer: string): string { - const history = nonEmptyLines(latestLog) - const buffer = nonEmptyLines(liveBuffer) - return [...history, ...buffer.slice(overlapLength(history, buffer))].join('\n') -} diff --git a/apps/app-frontend/src/composables/latest-log-reader.test.ts b/apps/app-frontend/src/composables/latest-log-reader.test.ts new file mode 100644 index 0000000..146381b --- /dev/null +++ b/apps/app-frontend/src/composables/latest-log-reader.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { createLatestLogReader } from './latest-log-reader.ts' + +test('follows file-only output after startup, re-entry, clear and file rotation', async () => { + let file = '' + let displayed = 'stdout startup' + const reader = createLatestLogReader( + async (cursor) => ({ + cursor: file.length, + output: file.slice(cursor > file.length ? 0 : cursor), + new_file: cursor > file.length, + }), + (output, replace) => { + displayed = (replace ? '' : displayed) + output + }, + ) + await reader.refresh() + assert.equal(reader.active, false) + file = 'Startup complete\n' + await reader.refresh() + assert.equal(reader.active, true) + assert.equal(displayed, file) // Replaces stdout, not appended a second time. + file += 'Joined world\nDisconnected\n' + await reader.refresh() + await reader.refresh() // Re-entering the view must not replay history. + assert.equal(displayed, file) + reader.clear() + displayed = '' + file += 'After clear\n' + await reader.refresh() + assert.equal(displayed, 'After clear\n') + file = 'Rotated\n' + await reader.refresh() + assert.equal(displayed, file) + file = '' + await reader.refresh() + assert.equal(displayed, '') + assert.equal(reader.active, false) + file = 'File recreated\n' + await reader.refresh() + assert.equal(displayed, file) + reader.reset() + assert.equal(reader.active, false) + file = 'New session with a longer file\n' + await reader.refresh() + assert.equal(displayed, file) +}) + +test('serializes reads, ignores pre-restart responses, consumes pre-clear responses', async () => { + const resolvers: ((value: { cursor: number; output: string; new_file: boolean }) => void)[] = [] + const cursors: number[] = [] + const output: string[] = [] + const reader = createLatestLogReader( + (cursor) => { + cursors.push(cursor) + return new Promise((resolve) => resolvers.push(resolve)) + }, + (text) => output.push(text), + ) + const old = reader.refresh() + const concurrent = reader.refresh() + assert.deepEqual(cursors, [0]) + reader.reset() + const fresh = reader.refresh() + resolvers[0]({ cursor: 100, output: 'old session', new_file: false }) + await Promise.all([old, concurrent]) + assert.deepEqual(output, []) + reader.clear() + resolvers[1]({ cursor: 20, output: 'before clear', new_file: false }) + await fresh + assert.deepEqual(output, []) + const next = reader.refresh() + assert.deepEqual(cursors, [0, 0, 20]) + resolvers[2]({ cursor: 40, output: 'after clear\n', new_file: false }) + await next + assert.deepEqual(output, ['after clear\n']) +}) + +test('a temporary read failure keeps the cursor and retries without duplicating output', async () => { + let failed = true + const output: string[] = [] + const reader = createLatestLogReader( + async (cursor) => { + assert.equal(cursor, 0) + if (failed) throw new Error('file temporarily unavailable') + return { cursor: 6, output: 'ready\n', new_file: false } + }, + (text) => output.push(text), + ) + await assert.rejects(reader.refresh()) + assert.equal(reader.active, false) + failed = false + await reader.refresh() + assert.deepEqual(output, ['ready\n']) +}) + +test('joins split lines and flushes the final unterminated line on exit', async () => { + let chunk = 'Startup\n[Render thread/INFO] Join' + let cursor = 0 + let displayed = '' + const reader = createLatestLogReader( + async () => ({ + cursor: (cursor += chunk.length), + output: chunk, + new_file: false, + }), + (text, replace) => { + displayed = (replace ? '' : displayed) + text + }, + ) + await reader.refresh() + assert.equal(displayed, 'Startup\n') + chunk = 'ed world\nLast line' + await reader.refresh() + assert.equal(displayed, 'Startup\n[Render thread/INFO] Joined world\n') + reader.flush() + assert.equal(displayed, 'Startup\n[Render thread/INFO] Joined world\nLast line') +}) diff --git a/apps/app-frontend/src/composables/latest-log-reader.ts b/apps/app-frontend/src/composables/latest-log-reader.ts new file mode 100644 index 0000000..4449391 --- /dev/null +++ b/apps/app-frontend/src/composables/latest-log-reader.ts @@ -0,0 +1,88 @@ +interface LogCursor { + cursor: number + output: string + new_file: boolean +} + +/** One authoritative stream: use process events until latest.log becomes available. */ +export function createLatestLogReader( + read: (cursor: number) => Promise, + append: (output: string, replace: boolean) => void, +) { + let cursor = 0 + let active = false + let session = 0 + let cleared = 0 + let partialLine = '' + let replaceOnAppend = false + let pending: Promise | null = null + + function refresh(): Promise { + if (pending) return pending + const requestSession = session + const requestClear = cleared + const request = (async () => { + const result = await read(cursor) + if (requestSession !== session) return + const rotated = result.new_file || result.cursor < cursor + const replace = !active || rotated + cursor = result.cursor + if (result.cursor === 0 && !result.output) { + if (rotated) { + active = false + partialLine = '' + replaceOnAppend = false + if (requestClear === cleared) append('', true) + } + return + } + active = true + // Clearing during a read consumes its bytes without restoring cleared text. + if (requestClear === cleared && (result.output || result.new_file)) { + if (replace) { + partialLine = '' + replaceOnAppend = true + } + partialLine += result.output + // File writes can end mid-line. Do not turn the next chunk into a + // separate log entry; bound unterminated output just like the console. + const end = + partialLine.length > 64 * 1024 ? partialLine.length : partialLine.lastIndexOf('\n') + 1 + if (end > 0) { + append(partialLine.slice(0, end), replaceOnAppend) + partialLine = partialLine.slice(end) + replaceOnAppend = false + } + } + })() + pending = request + return request.finally(() => { + if (pending === request) pending = null + }) + } + + return { + refresh, + get active() { + return active + }, + clear() { + cleared++ + partialLine = '' + replaceOnAppend = false + }, + flush() { + if (partialLine) append(partialLine, replaceOnAppend) + partialLine = '' + replaceOnAppend = false + }, + reset() { + session++ + cursor = 0 + active = false + partialLine = '' + replaceOnAppend = false + pending = null + }, + } +} diff --git a/apps/app-frontend/src/composables/useInstanceConsole.ts b/apps/app-frontend/src/composables/useInstanceConsole.ts index 3427d46..64ff099 100644 --- a/apps/app-frontend/src/composables/useInstanceConsole.ts +++ b/apps/app-frontend/src/composables/useInstanceConsole.ts @@ -7,7 +7,7 @@ import { get_minecraft_latest_log_cursor, } from '@/helpers/logs' -import { mergeLiveLogHistory } from './console-history' +import { createLatestLogReader } from './latest-log-reader' type ConsoleState = ReturnType @@ -26,6 +26,7 @@ interface InstanceConsoleEntry { historicalCache: Map logList: LogEntry[] | null liveHistoryHydration: Promise | null + latestLog: ReturnType } const instances = new Map() @@ -34,12 +35,20 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry { let entry = instances.get(instanceId) if (entry) return entry + const liveConsole = createConsoleState() entry = { - liveConsole: createConsoleState(), + liveConsole, historicalConsole: createConsoleState(), historicalCache: new Map(), logList: null, liveHistoryHydration: null, + latestLog: createLatestLogReader( + (cursor) => get_minecraft_latest_log_cursor(instanceId, cursor), + (output, replace) => { + if (replace) liveConsole.clear() + void liveConsole.addLegacyLog(output) + }, + ), } instances.set(instanceId, entry) return entry @@ -47,25 +56,17 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry { async function hydrate(instanceId: string): Promise { const entry = getOrCreate(instanceId) - if (entry.liveConsole.output.value.length > 0) return - if (entry.liveHistoryHydration) { return entry.liveHistoryHydration } const hydration = (async () => { - const [latestLog, buffer] = await Promise.all([ - get_minecraft_latest_log_cursor(instanceId, 0) - .then((result) => result.output) - .catch(() => ''), - get_live_log_buffer(instanceId), - ]) - - if (entry.liveConsole.output.value.length > 0) return - - const history = mergeLiveLogHistory(latestLog, buffer) - if (history) { - await entry.liveConsole.addLegacyLog(history) + await entry.latestLog.refresh().catch(console.warn) + if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) { + const buffer = await get_live_log_buffer(instanceId) + if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) { + await entry.liveConsole.addLegacyLog(buffer) + } } })() @@ -108,11 +109,13 @@ function invalidate(instanceId: string): void { async function clearLive(instanceId: string): Promise { const entry = getOrCreate(instanceId) + entry.latestLog.clear() entry.liveConsole.clear() await clear_log_buffer(instanceId).catch(() => {}) } async function destroy(instanceId: string): Promise { + instances.get(instanceId)?.latestLog.reset() instances.delete(instanceId) await clear_log_buffer(instanceId).catch(() => {}) } @@ -127,6 +130,13 @@ export function useInstanceConsole(instanceId: string) { getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename), invalidate: () => invalidate(instanceId), clearLive: () => clearLive(instanceId), + refreshLive: () => entry.latestLog.refresh(), + flushLive: () => entry.latestLog.flush(), + acceptsProcessLogs: () => !entry.latestLog.active, + resetLive: () => { + entry.latestLog.reset() + entry.liveConsole.clear() + }, destroy: () => destroy(instanceId), } } diff --git a/apps/app-frontend/src/pages/instance/Logs.vue b/apps/app-frontend/src/pages/instance/Logs.vue index c1c5945..8f2f5fb 100644 --- a/apps/app-frontend/src/pages/instance/Logs.vue +++ b/apps/app-frontend/src/pages/instance/Logs.vue @@ -86,6 +86,10 @@ const { getHistoricalContent, invalidate, clearLive, + refreshLive, + flushLive, + acceptsProcessLogs, + resetLive, } = useInstanceConsole(instanceId.value) await hydrate() @@ -217,6 +221,7 @@ if (!props.playing) { const unlistenLog = await log_listener((payload) => { if (payload.instance_id !== instanceId.value) return + if (!acceptsProcessLogs()) return if (payload.type === 'log4j') { liveConsole.addLog4jEvent(payload) @@ -228,12 +233,14 @@ const unlistenLog = await log_listener((payload) => { const unlistenProcesses = await process_listener(async (e) => { if (e.instance_id !== instanceId.value) return if (e.event === 'launched') { - liveConsole.clear() + resetLive() clearCrashAnalysis() invalidate() selectedLogIndex.value = 0 } if (e.event === 'finished') { + await refreshLive().catch(console.warn) + flushLive() invalidate() const freshLogs = await getHistoricalLogs() logs.value = buildLogList(freshLogs) @@ -241,7 +248,20 @@ const unlistenProcesses = await process_listener(async (e) => { } }) +// Keep following the file even when a mod stops writing to stdout after startup. +// Schedule after each read so slow disk/IPC cannot pile up concurrent requests. +let disposed = false +let refreshTimer +async function followLatestLog() { + await refreshLive().catch(console.warn) + if (!props.playing) flushLive() + if (!disposed) refreshTimer = setTimeout(followLatestLog, 1000) +} +void followLatestLog() + onUnmounted(() => { + disposed = true + clearTimeout(refreshTimer) unlistenLog() unlistenProcesses() }) diff --git a/packages/app-lib/src/api/logs.rs b/packages/app-lib/src/api/logs.rs index 022f94a..dc63d76 100644 --- a/packages/app-lib/src/api/logs.rs +++ b/packages/app-lib/src/api/logs.rs @@ -737,14 +737,35 @@ pub async fn get_generic_live_log_cursor( file.seek(SeekFrom::Start(display_start)) .map_err(|e| IOError::with_path(e, &path)) .await?; // Seek to cursor - file.take(MAX_LOG_DISPLAY_BYTES as u64) + // Snapshot the read length: bytes appended during this read belong to the + // next request, not to a cursor captured before they existed. + file.take(metadata.len().saturating_sub(display_start)) .read_to_end(&mut buffer) .map_err(|e| IOError::with_path(e, &path)) .await?; // Read to end of file + // Censor complete records, not halves of a credential split across file + // writes. After the process exits, also expose its unterminated final line. + if state.process_manager.get_all().iter().any(|process| { + process.instance_id == instance_id + || process.instance_path == instance_path + }) { + if let Some(end) = buffer.iter().rposition(|byte| *byte == b'\n') { + buffer.truncate(end + 1); + } else if buffer.len() < MAX_LOG_DISPLAY_BYTES { + buffer.clear(); + } + } + // Leave an incomplete UTF-8 character for the next read (e.g. a split CJK + // character). Invalid complete sequences still use lossy decoding. + if let Err(error) = std::str::from_utf8(&buffer) + && error.error_len().is_none() + { + buffer.truncate(error.valid_up_to()); + } + let cursor = display_start + buffer.len() as u64; let output = String::from_utf8_lossy(&buffer); // Convert to String let compacted = cap_log_for_display(compact_duplicate_lines(&output), source_truncated); - let cursor = metadata.len(); // Consume skipped output as well as the display window. let credentials = Credentials::get_all(&state.pool) .await? @@ -827,6 +848,75 @@ mod tests { (minecraft, metadata) } + #[tokio::test] + async fn live_cursor_follows_file_appends_and_split_utf8() { + let (_minecraft, metadata) = + create_direct_link_fixture("logs-live-cursor").await; + let state = global_state().await; + let (path, game_dir_override) = + resolve_instance_path(&metadata.instance.id, &state) + .await + .unwrap(); + let game_dir = state + .directories + .resolve_game_dir(&path, game_dir_override.as_deref()); + let logs = state.directories.game_logs_dir(&game_dir); + std::fs::create_dir_all(&logs).unwrap(); + let path = logs.join("latest.log"); + std::fs::write(&path, b"startup\n").unwrap(); + let first = + get_generic_live_log_cursor(&metadata.instance.id, "latest.log", 0) + .await + .unwrap(); + assert_eq!(first.output.0, "startup\n"); + + let mut output = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + let message = "进入世界\n".as_bytes(); + // A file write ends in the middle of a Chinese character. + std::io::Write::write_all(&mut output, &message[..2]).unwrap(); + let partial = get_generic_live_log_cursor( + &metadata.instance.id, + "latest.log", + first.cursor, + ) + .await + .unwrap(); + assert_eq!(partial.cursor, first.cursor); + assert!(partial.output.0.is_empty()); + std::io::Write::write_all(&mut output, &message[2..]).unwrap(); + let next = get_generic_live_log_cursor( + &metadata.instance.id, + "latest.log", + partial.cursor, + ) + .await + .unwrap(); + assert_eq!(next.output.0, "进入世界\n"); + let idle = get_generic_live_log_cursor( + &metadata.instance.id, + "latest.log", + next.cursor, + ) + .await + .unwrap(); + assert!(idle.output.0.is_empty()); + drop(output); + + std::fs::write(&path, b"restart\n").unwrap(); + let restarted = get_generic_live_log_cursor( + &metadata.instance.id, + "latest.log", + next.cursor, + ) + .await + .unwrap(); + assert!(restarted.new_file); + assert_eq!(restarted.output.0, "restart\n"); + } + #[tokio::test] async fn direct_link_instance_resolves_to_linked_game_dir() { let state = global_state().await; @@ -890,9 +980,10 @@ mod tests { #[test] fn display_compaction_bounds_many_distinct_lines() { - let log = (0..100_000) + let log = (0..300_000) .map(|index| format!("line-{index}\n")) .collect::(); + assert!(log.len() > MAX_LOG_DISPLAY_BYTES); let compacted = compact_log_for_display(&log); diff --git a/packages/app-lib/src/api/pack/hosted.rs b/packages/app-lib/src/api/pack/hosted.rs index 1bc6023..23fc92c 100644 --- a/packages/app-lib/src/api/pack/hosted.rs +++ b/packages/app-lib/src/api/pack/hosted.rs @@ -426,9 +426,7 @@ pub async fn default_publication() -> crate::Result { }) } -pub async fn create( - game_dir_root: Option, -) -> crate::Result { +pub async fn create(game_dir_root: Option) -> crate::Result { let publication = default_publication().await?; let runtime = &publication.manifest.runtime; let state = State::get().await?; @@ -1036,14 +1034,8 @@ async fn synchronize_with_progress( resolved_external.push(resolved.clone()); files.push(resolved); } - let duplicate_mods = tagged::merge( - &root, - &cache, - &mut files, - &mut sources, - &tagged_manifest, - ) - .await?; + tagged::merge(&root, &cache, &mut files, &mut sources, &tagged_manifest) + .await?; validate(&files)?; if let Some(old) = &previous { validate(&old.files)?; @@ -1139,12 +1131,6 @@ async fn synchronize_with_progress( }); } } - for action in duplicate_mods { - preserved.retain(|path| path != &action.path); - if !actions.iter().any(|existing| existing.path == action.path) { - actions.push(action); - } - } let mut tagged_downloads = Vec::new(); pending_downloads.retain(|_, (file, url, object)| { if tagged_manifest.contains(&file.path) { @@ -1305,6 +1291,21 @@ async fn synchronize_with_progress( ) .await?; ensure_session(&auth).await?; + let duplicate_mods = tagged::reconcile_staged( + &root, + &cache, + &mut files, + &mut sources, + &tagged_manifest, + &mut actions, + ) + .await?; + for action in duplicate_mods { + preserved.retain(|path| path != &action.path); + if !actions.iter().any(|existing| existing.path == action.path) { + actions.push(action); + } + } if actions.is_empty() && !runtime_changed && metadata.instance.install_stage == InstanceInstallStage::Installed diff --git a/packages/app-lib/src/api/pack/hosted/tagged.rs b/packages/app-lib/src/api/pack/hosted/tagged.rs index 15cb4be..a88685f 100644 --- a/packages/app-lib/src/api/pack/hosted/tagged.rs +++ b/packages/app-lib/src/api/pack/hosted/tagged.rs @@ -97,6 +97,214 @@ mod tests { } } + fn write_mod(root: &Path, name: &str, id: &str) -> PathBuf { + let path = root.join("mods").join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut zip = + zip::ZipWriter::new(std::fs::File::create(&path).unwrap()); + zip.set_comment(name.to_owned()); + zip.start_file( + "fabric.mod.json", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + zip.write_all( + format!(r#"{{"schemaVersion":1,"id":"{id}","version":"1"}}"#) + .as_bytes(), + ) + .unwrap(); + zip.finish().unwrap(); + path + } + + #[tokio::test] + async fn removes_orphaned_managed_mods_even_when_no_tags_are_selected() { + let root = tempfile::tempdir().unwrap(); + let stale = write_mod(root.path(), "example.starlight.jar", "example"); + let personal = write_mod(root.path(), "personal.jar", "personal"); + let actions = merge( + root.path(), + &root.path().join("cache"), + &mut vec![], + &mut BTreeMap::new(), + &TaggedManifest { + files: vec![], + replaces: vec![], + }, + ) + .await + .unwrap(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].path, "mods/example.starlight.jar"); + assert!(actions[0].next_hash.is_none()); + // Planning must not touch the files; application uses the normal backup transaction. + assert!(stale.is_file()); + assert!(personal.is_file()); + let backup = ".starlight-pack-backup-orphan-test"; + apply_files(root.path(), &root.path().join("cache"), backup, &actions) + .await + .unwrap(); + assert!(!stale.exists()); + assert!( + root.path() + .join(backup) + .join("mods/example.starlight.jar") + .is_file() + ); + assert!(personal.is_file()); + restore_files(root.path(), backup, &actions).await.unwrap(); + assert!(stale.is_file()); + } + + #[tokio::test] + async fn replaces_installed_pack_mod_when_identity_and_cache_are_missing() { + let root = tempfile::tempdir().unwrap(); + let local = write_mod(root.path(), "example-old.jar", "example"); + let mut files = vec![PackFile { + mod_ids: vec![], + external: None, + path: "mods/example-old.jar".into(), + sha256: hash(&local).await.unwrap().unwrap(), + size: std::fs::metadata(&local).unwrap().len(), + force: true, + preserve: false, + }]; + let actions = merge( + root.path(), + &root.path().join("cache"), + &mut files, + &mut BTreeMap::new(), + &manifest(), + ) + .await + .unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "mods/example.starlight.jar"); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].path, "mods/example-old.jar"); + } + + #[tokio::test] + async fn fresh_install_reconciles_newly_downloaded_jars_before_applying() { + let root = tempfile::tempdir().unwrap(); + let fixture = tempfile::tempdir().unwrap(); + let cache = root.path().join("cache"); + std::fs::create_dir(&cache).unwrap(); + let old_jar = write_mod(fixture.path(), "old.jar", "example"); + let old = PackFile { + mod_ids: vec![], + external: None, + path: "mods/old.jar".into(), + sha256: hash(&old_jar).await.unwrap().unwrap(), + size: std::fs::metadata(&old_jar).unwrap().len(), + force: true, + preserve: false, + }; + let mut files = vec![old.clone()]; + let mut sources = BTreeMap::new(); + let replacement = write_mod(fixture.path(), "new.jar", "example"); + let mut tagged = manifest(); + tagged.files[0].sha256 = hash(&replacement).await.unwrap().unwrap(); + tagged.files[0].size = std::fs::metadata(&replacement).unwrap().len(); + merge(root.path(), &cache, &mut files, &mut sources, &tagged) + .await + .unwrap(); + assert_eq!(files.len(), 2, "uncached legacy manifests have no IDs yet"); + let mut actions: Vec<_> = files + .iter() + .map(|file| Action { + path: file.path.clone(), + old_hash: None, + next_hash: Some(file.sha256.clone()), + }) + .collect(); + // Downloads complete in the cache, not the live mods directory. + std::fs::copy(&old_jar, cache.join(&old.sha256)).unwrap(); + std::fs::copy(&replacement, cache.join(&tagged.files[0].sha256)) + .unwrap(); + let duplicates = reconcile_staged( + root.path(), + &cache, + &mut files, + &mut sources, + &tagged, + &mut actions, + ) + .await + .unwrap(); + assert!(duplicates.is_empty()); + assert_eq!(files.len(), 1); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].path, "mods/example.starlight.jar"); + apply_files( + root.path(), + &cache, + ".starlight-pack-backup-fresh-test", + &actions, + ) + .await + .unwrap(); + assert!(root.path().join("mods/example.starlight.jar").is_file()); + assert!(!root.path().join("mods/old.jar").exists()); + } + + #[tokio::test] + async fn desired_managed_file_is_kept_without_selected_tags() { + let root = tempfile::tempdir().unwrap(); + let local = write_mod(root.path(), "example.starlight.jar", "example"); + let mut files = vec![PackFile { + mod_ids: vec!["example".into()], + external: None, + path: "mods/example.starlight.jar".into(), + sha256: hash(&local).await.unwrap().unwrap(), + size: std::fs::metadata(&local).unwrap().len(), + force: true, + preserve: false, + }]; + let actions = merge( + root.path(), + &root.path().join("cache"), + &mut files, + &mut BTreeMap::new(), + &TaggedManifest { + files: vec![], + replaces: vec![], + }, + ) + .await + .unwrap(); + assert!(actions.is_empty()); + assert_eq!(files.len(), 1); + assert!(local.is_file()); + } + + #[tokio::test] + async fn modified_local_file_is_not_used_as_the_pack_identity() { + let root = tempfile::tempdir().unwrap(); + let local = write_mod(root.path(), "personal.jar", "example"); + let mut files = vec![PackFile { + mod_ids: vec![], + external: None, + path: "mods/personal.jar".into(), + sha256: "b".repeat(64), + size: 10, + force: true, + preserve: false, + }]; + let actions = merge( + root.path(), + &root.path().join("cache"), + &mut files, + &mut BTreeMap::new(), + &manifest(), + ) + .await + .unwrap(); + assert!(actions.is_empty()); + assert_eq!(files.len(), 2); + assert!(local.is_file()); + } + #[tokio::test] async fn replaces_external_mod_using_saved_identity_when_cache_is_missing() { @@ -298,9 +506,6 @@ pub(super) async fn merge( .iter() .flat_map(|file| file.mod_ids.iter().map(String::as_str)) .collect(); - if ids.is_empty() { - return Ok(Vec::new()); - } // The modpack is the installation baseline. A tagged snapshot with the // same content already passes verification, even when the skin site uses @@ -321,10 +526,13 @@ pub(super) async fn merge( let mut replaced: HashSet = manifest .replaces .iter() - .filter(|path| !satisfied_pack_paths.contains(*path)) + .filter(|path| !ids.is_empty() && !satisfied_pack_paths.contains(*path)) .cloned() .collect(); - for file in files.iter().filter(|file| is_mod_path(&file.path)) { + for file in files + .iter() + .filter(|file| !ids.is_empty() && is_mod_path(&file.path)) + { if satisfied_pack_paths.contains(&file.path) || replaced.contains(&file.path) { @@ -334,7 +542,19 @@ pub(super) async fn merge( let declared = if !file.mod_ids.is_empty() { file.mod_ids.clone() } else { - mod_ids(object).await? + let cached = mod_ids(object).await?; + if cached.is_empty() { + let local = target(root, &file.path)?; + // An installed pack is still identifiable after its cache was + // cleared. Do not use a user-modified JAR as the pack identity. + if hash(&local).await?.as_deref() == Some(&file.sha256) { + mod_ids(local).await? + } else { + cached + } + } else { + cached + } }; if replaces_ids(&declared, &ids)? { replaced.insert(file.path.clone()); @@ -383,7 +603,14 @@ pub(super) async fn merge( continue; } let local = target(root, &path)?; - if replaces_ids(&mod_ids(local.clone()).await?, &ids)? { + // This suffix is the launcher's managed namespace. Clean orphaned + // tagged JARs even if the user no longer has any selected tags or + // the old binding did not record them. Ordinary user JARs require + // a confirmed identity overlap and retain the multi-Mod guard. + if path.to_ascii_lowercase().ends_with(".starlight.jar") + || (!ids.is_empty() + && replaces_ids(&mod_ids(local.clone()).await?, &ids)?) + { duplicate_actions.push(Action { path, old_hash: hash(&local).await?, @@ -395,6 +622,26 @@ pub(super) async fn merge( Ok(duplicate_actions) } +/// The first pass may not know IDs of uncached pack JARs. Reconcile once they +/// are staged, before journaling or modifying the instance, and drop installs +/// of pack files now superseded by a tagged Mod. +pub(super) async fn reconcile_staged( + root: &Path, + cache: &Path, + files: &mut Vec, + sources: &mut BTreeMap, + manifest: &TaggedManifest, + actions: &mut Vec, +) -> crate::Result> { + let duplicates = merge(root, cache, files, sources, manifest).await?; + let desired: HashSet<_> = + files.iter().map(|file| file.path.as_str()).collect(); + actions.retain(|action| { + action.next_hash.is_none() || desired.contains(action.path.as_str()) + }); + Ok(duplicates) +} + pub(super) async fn download( instance_id: &str, instance_name: &str, diff --git a/packages/ui/src/layouts/shared/console/components/LogViewport.vue b/packages/ui/src/layouts/shared/console/components/LogViewport.vue index 37df96a..e6a31af 100644 --- a/packages/ui/src/layouts/shared/console/components/LogViewport.vue +++ b/packages/ui/src/layouts/shared/console/components/LogViewport.vue @@ -19,21 +19,15 @@ /> - +
(null) const stickToBottom = ref(true) -// Placeholder row height for `contain-intrinsic-size`. Native -// `content-visibility: auto` replaces this with the real measured height once a -// line enters the viewport, so it only needs to be a reasonable estimate to -// keep the scrollbar from jumping. Wrapped lines can be taller, so bias higher. -const intrinsicLineHeight = computed(() => { - const single = Math.round(props.fontSize * 1.4) - return props.wrap ? single * 2 : single -}) - -const lineStyle = computed(() => ({ - 'content-visibility': 'auto', - 'contain-intrinsic-size': `auto ${intrinsicLineHeight.value}px`, -})) - function entryClass(line: LogLine): string { if (line.level === 'error') return 'entry-error' if (line.level === 'warn') return 'entry-warning' @@ -214,7 +194,7 @@ defineExpose({ } .log-viewport-wrap .log-line { - white-space: normal; + white-space: pre-wrap; } .log-viewport-wrap .log-line-content { @@ -247,8 +227,8 @@ defineExpose({ /* ===== LogShare token 高亮(LogsAnalysis.css 移植,前景色用主题变量) ===== */ -.level { - white-space: pre-wrap; +.log-viewport .level { + white-space: inherit; word-break: break-all; overflow-wrap: anywhere; }