fix: follow live logs and reconcile duplicate managed mods
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
Sync LobeHub models / sync (push) Has been cancelled
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
Sync LobeHub models / sync (push) Has been cancelled
This commit is contained in:
1 parent
c2bc7c2441
commit
19098f7159
10 files changed
+628
-125
No files matched your search
@@ -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')
|
|
||||||
})
|
|
||||||
@@ -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')
|
|
||||||
}
|
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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<LogCursor>,
|
||||||
|
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<void> | null = null
|
||||||
|
|
||||||
|
function refresh(): Promise<void> {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
get_minecraft_latest_log_cursor,
|
get_minecraft_latest_log_cursor,
|
||||||
} from '@/helpers/logs'
|
} from '@/helpers/logs'
|
||||||
|
|
||||||
import { mergeLiveLogHistory } from './console-history'
|
import { createLatestLogReader } from './latest-log-reader'
|
||||||
|
|
||||||
type ConsoleState = ReturnType<typeof createConsoleState>
|
type ConsoleState = ReturnType<typeof createConsoleState>
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ interface InstanceConsoleEntry {
|
|||||||
historicalCache: Map<string, string>
|
historicalCache: Map<string, string>
|
||||||
logList: LogEntry[] | null
|
logList: LogEntry[] | null
|
||||||
liveHistoryHydration: Promise<void> | null
|
liveHistoryHydration: Promise<void> | null
|
||||||
|
latestLog: ReturnType<typeof createLatestLogReader>
|
||||||
}
|
}
|
||||||
|
|
||||||
const instances = new Map<string, InstanceConsoleEntry>()
|
const instances = new Map<string, InstanceConsoleEntry>()
|
||||||
@@ -34,12 +35,20 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
|
|||||||
let entry = instances.get(instanceId)
|
let entry = instances.get(instanceId)
|
||||||
if (entry) return entry
|
if (entry) return entry
|
||||||
|
|
||||||
|
const liveConsole = createConsoleState()
|
||||||
entry = {
|
entry = {
|
||||||
liveConsole: createConsoleState(),
|
liveConsole,
|
||||||
historicalConsole: createConsoleState(),
|
historicalConsole: createConsoleState(),
|
||||||
historicalCache: new Map(),
|
historicalCache: new Map(),
|
||||||
logList: null,
|
logList: null,
|
||||||
liveHistoryHydration: 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)
|
instances.set(instanceId, entry)
|
||||||
return entry
|
return entry
|
||||||
@@ -47,25 +56,17 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
|
|||||||
|
|
||||||
async function hydrate(instanceId: string): Promise<void> {
|
async function hydrate(instanceId: string): Promise<void> {
|
||||||
const entry = getOrCreate(instanceId)
|
const entry = getOrCreate(instanceId)
|
||||||
if (entry.liveConsole.output.value.length > 0) return
|
|
||||||
|
|
||||||
if (entry.liveHistoryHydration) {
|
if (entry.liveHistoryHydration) {
|
||||||
return entry.liveHistoryHydration
|
return entry.liveHistoryHydration
|
||||||
}
|
}
|
||||||
|
|
||||||
const hydration = (async () => {
|
const hydration = (async () => {
|
||||||
const [latestLog, buffer] = await Promise.all([
|
await entry.latestLog.refresh().catch(console.warn)
|
||||||
get_minecraft_latest_log_cursor(instanceId, 0)
|
if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) {
|
||||||
.then((result) => result.output)
|
const buffer = await get_live_log_buffer(instanceId)
|
||||||
.catch(() => ''),
|
if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) {
|
||||||
get_live_log_buffer(instanceId),
|
await entry.liveConsole.addLegacyLog(buffer)
|
||||||
])
|
}
|
||||||
|
|
||||||
if (entry.liveConsole.output.value.length > 0) return
|
|
||||||
|
|
||||||
const history = mergeLiveLogHistory(latestLog, buffer)
|
|
||||||
if (history) {
|
|
||||||
await entry.liveConsole.addLegacyLog(history)
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -108,11 +109,13 @@ function invalidate(instanceId: string): void {
|
|||||||
|
|
||||||
async function clearLive(instanceId: string): Promise<void> {
|
async function clearLive(instanceId: string): Promise<void> {
|
||||||
const entry = getOrCreate(instanceId)
|
const entry = getOrCreate(instanceId)
|
||||||
|
entry.latestLog.clear()
|
||||||
entry.liveConsole.clear()
|
entry.liveConsole.clear()
|
||||||
await clear_log_buffer(instanceId).catch(() => {})
|
await clear_log_buffer(instanceId).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function destroy(instanceId: string): Promise<void> {
|
async function destroy(instanceId: string): Promise<void> {
|
||||||
|
instances.get(instanceId)?.latestLog.reset()
|
||||||
instances.delete(instanceId)
|
instances.delete(instanceId)
|
||||||
await clear_log_buffer(instanceId).catch(() => {})
|
await clear_log_buffer(instanceId).catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -127,6 +130,13 @@ export function useInstanceConsole(instanceId: string) {
|
|||||||
getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename),
|
getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename),
|
||||||
invalidate: () => invalidate(instanceId),
|
invalidate: () => invalidate(instanceId),
|
||||||
clearLive: () => clearLive(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),
|
destroy: () => destroy(instanceId),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,6 +86,10 @@ const {
|
|||||||
getHistoricalContent,
|
getHistoricalContent,
|
||||||
invalidate,
|
invalidate,
|
||||||
clearLive,
|
clearLive,
|
||||||
|
refreshLive,
|
||||||
|
flushLive,
|
||||||
|
acceptsProcessLogs,
|
||||||
|
resetLive,
|
||||||
} = useInstanceConsole(instanceId.value)
|
} = useInstanceConsole(instanceId.value)
|
||||||
|
|
||||||
await hydrate()
|
await hydrate()
|
||||||
@@ -217,6 +221,7 @@ if (!props.playing) {
|
|||||||
|
|
||||||
const unlistenLog = await log_listener((payload) => {
|
const unlistenLog = await log_listener((payload) => {
|
||||||
if (payload.instance_id !== instanceId.value) return
|
if (payload.instance_id !== instanceId.value) return
|
||||||
|
if (!acceptsProcessLogs()) return
|
||||||
|
|
||||||
if (payload.type === 'log4j') {
|
if (payload.type === 'log4j') {
|
||||||
liveConsole.addLog4jEvent(payload)
|
liveConsole.addLog4jEvent(payload)
|
||||||
@@ -228,12 +233,14 @@ const unlistenLog = await log_listener((payload) => {
|
|||||||
const unlistenProcesses = await process_listener(async (e) => {
|
const unlistenProcesses = await process_listener(async (e) => {
|
||||||
if (e.instance_id !== instanceId.value) return
|
if (e.instance_id !== instanceId.value) return
|
||||||
if (e.event === 'launched') {
|
if (e.event === 'launched') {
|
||||||
liveConsole.clear()
|
resetLive()
|
||||||
clearCrashAnalysis()
|
clearCrashAnalysis()
|
||||||
invalidate()
|
invalidate()
|
||||||
selectedLogIndex.value = 0
|
selectedLogIndex.value = 0
|
||||||
}
|
}
|
||||||
if (e.event === 'finished') {
|
if (e.event === 'finished') {
|
||||||
|
await refreshLive().catch(console.warn)
|
||||||
|
flushLive()
|
||||||
invalidate()
|
invalidate()
|
||||||
const freshLogs = await getHistoricalLogs()
|
const freshLogs = await getHistoricalLogs()
|
||||||
logs.value = buildLogList(freshLogs)
|
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(() => {
|
onUnmounted(() => {
|
||||||
|
disposed = true
|
||||||
|
clearTimeout(refreshTimer)
|
||||||
unlistenLog()
|
unlistenLog()
|
||||||
unlistenProcesses()
|
unlistenProcesses()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -737,14 +737,35 @@ pub async fn get_generic_live_log_cursor(
|
|||||||
file.seek(SeekFrom::Start(display_start))
|
file.seek(SeekFrom::Start(display_start))
|
||||||
.map_err(|e| IOError::with_path(e, &path))
|
.map_err(|e| IOError::with_path(e, &path))
|
||||||
.await?; // Seek to cursor
|
.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)
|
.read_to_end(&mut buffer)
|
||||||
.map_err(|e| IOError::with_path(e, &path))
|
.map_err(|e| IOError::with_path(e, &path))
|
||||||
.await?; // Read to end of file
|
.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 output = String::from_utf8_lossy(&buffer); // Convert to String
|
||||||
let compacted =
|
let compacted =
|
||||||
cap_log_for_display(compact_duplicate_lines(&output), source_truncated);
|
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)
|
let credentials = Credentials::get_all(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
@@ -827,6 +848,75 @@ mod tests {
|
|||||||
(minecraft, metadata)
|
(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]
|
#[tokio::test]
|
||||||
async fn direct_link_instance_resolves_to_linked_game_dir() {
|
async fn direct_link_instance_resolves_to_linked_game_dir() {
|
||||||
let state = global_state().await;
|
let state = global_state().await;
|
||||||
@@ -890,9 +980,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_compaction_bounds_many_distinct_lines() {
|
fn display_compaction_bounds_many_distinct_lines() {
|
||||||
let log = (0..100_000)
|
let log = (0..300_000)
|
||||||
.map(|index| format!("line-{index}\n"))
|
.map(|index| format!("line-{index}\n"))
|
||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
|
assert!(log.len() > MAX_LOG_DISPLAY_BYTES);
|
||||||
|
|
||||||
let compacted = compact_log_for_display(&log);
|
let compacted = compact_log_for_display(&log);
|
||||||
|
|
||||||
|
|||||||
@@ -426,9 +426,7 @@ pub async fn default_publication() -> crate::Result<Publication> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(
|
pub async fn create(game_dir_root: Option<String>) -> crate::Result<String> {
|
||||||
game_dir_root: Option<String>,
|
|
||||||
) -> crate::Result<String> {
|
|
||||||
let publication = default_publication().await?;
|
let publication = default_publication().await?;
|
||||||
let runtime = &publication.manifest.runtime;
|
let runtime = &publication.manifest.runtime;
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
@@ -1036,14 +1034,8 @@ async fn synchronize_with_progress(
|
|||||||
resolved_external.push(resolved.clone());
|
resolved_external.push(resolved.clone());
|
||||||
files.push(resolved);
|
files.push(resolved);
|
||||||
}
|
}
|
||||||
let duplicate_mods = tagged::merge(
|
tagged::merge(&root, &cache, &mut files, &mut sources, &tagged_manifest)
|
||||||
&root,
|
.await?;
|
||||||
&cache,
|
|
||||||
&mut files,
|
|
||||||
&mut sources,
|
|
||||||
&tagged_manifest,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
validate(&files)?;
|
validate(&files)?;
|
||||||
if let Some(old) = &previous {
|
if let Some(old) = &previous {
|
||||||
validate(&old.files)?;
|
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();
|
let mut tagged_downloads = Vec::new();
|
||||||
pending_downloads.retain(|_, (file, url, object)| {
|
pending_downloads.retain(|_, (file, url, object)| {
|
||||||
if tagged_manifest.contains(&file.path) {
|
if tagged_manifest.contains(&file.path) {
|
||||||
@@ -1305,6 +1291,21 @@ async fn synchronize_with_progress(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
ensure_session(&auth).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()
|
if actions.is_empty()
|
||||||
&& !runtime_changed
|
&& !runtime_changed
|
||||||
&& metadata.instance.install_stage == InstanceInstallStage::Installed
|
&& metadata.instance.install_stage == InstanceInstallStage::Installed
|
||||||
|
|||||||
@@ -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]
|
#[tokio::test]
|
||||||
async fn replaces_external_mod_using_saved_identity_when_cache_is_missing()
|
async fn replaces_external_mod_using_saved_identity_when_cache_is_missing()
|
||||||
{
|
{
|
||||||
@@ -298,9 +506,6 @@ pub(super) async fn merge(
|
|||||||
.iter()
|
.iter()
|
||||||
.flat_map(|file| file.mod_ids.iter().map(String::as_str))
|
.flat_map(|file| file.mod_ids.iter().map(String::as_str))
|
||||||
.collect();
|
.collect();
|
||||||
if ids.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
// The modpack is the installation baseline. A tagged snapshot with the
|
// The modpack is the installation baseline. A tagged snapshot with the
|
||||||
// same content already passes verification, even when the skin site uses
|
// same content already passes verification, even when the skin site uses
|
||||||
@@ -321,10 +526,13 @@ pub(super) async fn merge(
|
|||||||
let mut replaced: HashSet<String> = manifest
|
let mut replaced: HashSet<String> = manifest
|
||||||
.replaces
|
.replaces
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|path| !satisfied_pack_paths.contains(*path))
|
.filter(|path| !ids.is_empty() && !satisfied_pack_paths.contains(*path))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.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)
|
if satisfied_pack_paths.contains(&file.path)
|
||||||
|| replaced.contains(&file.path)
|
|| replaced.contains(&file.path)
|
||||||
{
|
{
|
||||||
@@ -334,7 +542,19 @@ pub(super) async fn merge(
|
|||||||
let declared = if !file.mod_ids.is_empty() {
|
let declared = if !file.mod_ids.is_empty() {
|
||||||
file.mod_ids.clone()
|
file.mod_ids.clone()
|
||||||
} else {
|
} 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)? {
|
if replaces_ids(&declared, &ids)? {
|
||||||
replaced.insert(file.path.clone());
|
replaced.insert(file.path.clone());
|
||||||
@@ -383,7 +603,14 @@ pub(super) async fn merge(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let local = target(root, &path)?;
|
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 {
|
duplicate_actions.push(Action {
|
||||||
path,
|
path,
|
||||||
old_hash: hash(&local).await?,
|
old_hash: hash(&local).await?,
|
||||||
@@ -395,6 +622,26 @@ pub(super) async fn merge(
|
|||||||
Ok(duplicate_actions)
|
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<PackFile>,
|
||||||
|
sources: &mut BTreeMap<String, String>,
|
||||||
|
manifest: &TaggedManifest,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) -> crate::Result<Vec<Action>> {
|
||||||
|
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(
|
pub(super) async fn download(
|
||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
instance_name: &str,
|
instance_name: &str,
|
||||||
|
|||||||
@@ -19,21 +19,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--
|
<!-- Keep rows in normal flow, without cached off-screen sizes. Font size,
|
||||||
Native virtualization: every line is rendered, but `content-visibility:
|
wrapping and container width can all change while a row is off screen. -->
|
||||||
auto` lets the browser skip layout/paint for off-screen lines, while
|
|
||||||
`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 v-else class="log-viewport-spacer relative w-full min-w-max">
|
||||||
<div
|
<div
|
||||||
v-for="item in lines"
|
v-for="item in lines"
|
||||||
:key="item.originalIndex"
|
:key="item.originalIndex"
|
||||||
:data-line="item.originalIndex + 1"
|
:data-line="item.originalIndex + 1"
|
||||||
class="log-line log-line-cv flex items-stretch whitespace-pre"
|
class="log-line flex items-stretch whitespace-pre"
|
||||||
:class="entryClass(item.line)"
|
:class="entryClass(item.line)"
|
||||||
:style="lineStyle"
|
|
||||||
>
|
>
|
||||||
<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="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"
|
||||||
@@ -63,7 +57,7 @@
|
|||||||
// 逐行正则高亮来自 ./composables/log-highlight.ts(移植自 logParser.worker.ts)。
|
// 逐行正则高亮来自 ./composables/log-highlight.ts(移植自 logParser.worker.ts)。
|
||||||
// LogShare-Web-UI 为 MIT License, Copyright (c) 2024 LogShare.CN Team,详见 packages/ui/COPYING.md。
|
// LogShare-Web-UI 为 MIT License, Copyright (c) 2024 LogShare.CN Team,详见 packages/ui/COPYING.md。
|
||||||
import { ChevronDownIcon } from '@modrinth/assets'
|
import { ChevronDownIcon } from '@modrinth/assets'
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||||
import EmptyState from '#ui/components/base/EmptyState.vue'
|
import EmptyState from '#ui/components/base/EmptyState.vue'
|
||||||
@@ -99,20 +93,6 @@ const props = withDefaults(
|
|||||||
const viewportRef = ref<HTMLElement | null>(null)
|
const viewportRef = ref<HTMLElement | null>(null)
|
||||||
const stickToBottom = ref(true)
|
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 {
|
function entryClass(line: LogLine): string {
|
||||||
if (line.level === 'error') return 'entry-error'
|
if (line.level === 'error') return 'entry-error'
|
||||||
if (line.level === 'warn') return 'entry-warning'
|
if (line.level === 'warn') return 'entry-warning'
|
||||||
@@ -214,7 +194,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-viewport-wrap .log-line {
|
.log-viewport-wrap .log-line {
|
||||||
white-space: normal;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-viewport-wrap .log-line-content {
|
.log-viewport-wrap .log-line-content {
|
||||||
@@ -247,8 +227,8 @@ defineExpose({
|
|||||||
|
|
||||||
/* ===== LogShare token 高亮(LogsAnalysis.css 移植,前景色用主题变量) ===== */
|
/* ===== LogShare token 高亮(LogsAnalysis.css 移植,前景色用主题变量) ===== */
|
||||||
|
|
||||||
.level {
|
.log-viewport .level {
|
||||||
white-space: pre-wrap;
|
white-space: inherit;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user