fix(logs): include the current session in the viewer

Read the active launcher, osu!, and patcher logs directly so the current session appears before rotation.
This commit is contained in:
HorizonCode committed 2026-11-04 22:36:16 +01:00
1 parent 8616253065
commit 660fb557a6
2 files changed
+138 -31

No files matched your search

+130 -23
View File
@@ -393,7 +393,97 @@ pub struct LogBundleEntry {
pub content: String, pub content: String,
} }
/// List the rotating ZIP bundles, newest first, for the in-app log viewer. const MAX_LOG_VIEW_BYTES: u64 = 2 * 1024 * 1024;
fn modified_millis(metadata: &fs::Metadata) -> u64 {
metadata
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
/// Describe the active session. Unlike archived bundles, these files are still
/// being written and appear in the viewer without waiting for a rotation.
fn current_log_bundle(dir: &Path) -> Option<LogBundleInfo> {
let mut size = 0;
let mut modified = 0;
let mut has_logs = false;
for stream in Stream::ALL {
let Ok(metadata) = fs::metadata(dir.join(stream.file_name())) else {
continue;
};
if metadata.len() == 0 {
continue;
}
has_logs = true;
size += metadata.len();
modified = modified.max(modified_millis(&metadata));
}
has_logs.then(|| LogBundleInfo {
path: dir.to_string_lossy().into_owned(),
name: "Current session".to_string(),
size,
modified,
})
}
fn read_log_entry(
name: String,
reader: &mut impl Read,
size: u64,
) -> Result<LogBundleEntry, String> {
let mut bytes = Vec::new();
reader
.take(MAX_LOG_VIEW_BYTES)
.read_to_end(&mut bytes)
.map_err(|error| format!("Could not read a log entry: {error}"))?;
let mut content = String::from_utf8_lossy(&bytes).into_owned();
if size > bytes.len() as u64 {
content.push_str("\n… log truncated in the viewer …\n");
}
Ok(LogBundleEntry { name, content })
}
fn read_current_logs(root: &Path) -> Result<Vec<LogBundleEntry>, String> {
let mut entries = Vec::new();
for stream in Stream::ALL {
let path = root.join(stream.file_name());
let metadata = match fs::metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(format!(
"Could not read the current {}: {error}",
stream.file_name()
));
}
};
if metadata.len() == 0 {
continue;
}
let mut file = File::open(&path).map_err(|error| {
format!("Could not read the current {}: {error}", stream.file_name())
})?;
entries.push(read_log_entry(
stream.file_name().to_string(),
&mut file,
metadata.len(),
)?);
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
/// List the active session followed by the rotating ZIP bundles, newest first,
/// for the in-app log viewer.
#[tauri::command] #[tauri::command]
pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> { pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
let dir = logs_directory(); let dir = logs_directory();
@@ -401,6 +491,7 @@ pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let current = current_log_bundle(&dir);
let mut bundles = fs::read_dir(&dir) let mut bundles = fs::read_dir(&dir)
.map_err(|error| format!("Could not read the logs folder: {error}"))? .map_err(|error| format!("Could not read the logs folder: {error}"))?
.filter_map(|entry| entry.ok()) .filter_map(|entry| entry.ok())
@@ -411,26 +502,23 @@ pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
return None; return None;
} }
let metadata = entry.metadata().ok()?; let metadata = entry.metadata().ok()?;
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0);
Some(LogBundleInfo { Some(LogBundleInfo {
path: path.to_string_lossy().into_owned(), path: path.to_string_lossy().into_owned(),
name, name,
size: metadata.len(), size: metadata.len(),
modified, modified: modified_millis(&metadata),
}) })
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
bundles.sort_by_key(|b| std::cmp::Reverse(b.modified)); bundles.sort_by_key(|b| std::cmp::Reverse(b.modified));
if let Some(current) = current {
bundles.insert(0, current);
}
Ok(bundles) Ok(bundles)
} }
/// Read the launcher/osu/patcher logs out of one bundle. /// Read the active logs or the launcher/osu/patcher logs out of an archive.
#[tauri::command] #[tauri::command]
pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> { pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
let dir = logs_directory(); let dir = logs_directory();
@@ -441,6 +529,9 @@ pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
if !requested.starts_with(&root) { if !requested.starts_with(&root) {
return Err("The log bundle is outside the logs folder".to_string()); return Err("The log bundle is outside the logs folder".to_string());
} }
if requested == root {
return read_current_logs(&root);
}
let file = File::open(&requested) let file = File::open(&requested)
.map_err(|error| format!("Could not read the log bundle: {error}"))?; .map_err(|error| format!("Could not read the log bundle: {error}"))?;
@@ -456,20 +547,9 @@ pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
continue; continue;
} }
let mut bytes = Vec::new(); let name = entry.name().to_string();
entry let size = entry.size();
.by_ref() entries.push(read_log_entry(name, &mut entry, size)?);
.take(2 * 1024 * 1024)
.read_to_end(&mut bytes)
.map_err(|error| format!("Could not read a log entry: {error}"))?;
let mut content = String::from_utf8_lossy(&bytes).into_owned();
if entry.size() > bytes.len() as u64 {
content.push_str("\n… log truncated in the viewer …\n");
}
entries.push(LogBundleEntry {
name: entry.name().to_string(),
content,
});
} }
entries.sort_by(|a, b| a.name.cmp(&b.name)); entries.sort_by(|a, b| a.name.cmp(&b.name));
@@ -634,4 +714,31 @@ mod tests {
drop(current); drop(current);
fs::remove_dir_all(dir).unwrap(); fs::remove_dir_all(dir).unwrap();
} }
#[test]
fn current_session_includes_active_logs() {
let dir = temp_dir("current");
let logs = Arc::new(LogSet::new(dir.clone()));
write_line(&logs, Stream::Launcher, "launcher current\n");
write_line(&logs, Stream::Osu, "osu current\n");
let current = current_log_bundle(&dir).expect("active logs should have a session");
assert_eq!(current.name, "Current session");
assert_eq!(current.path, dir.to_string_lossy());
assert!(current.size > 0);
let entries = read_current_logs(&dir).unwrap();
assert_eq!(
entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>(),
["launcher.log", "osu.log"]
);
assert_eq!(entries[0].content, "launcher current\n");
assert_eq!(entries[1].content, "osu current\n");
drop(logs);
fs::remove_dir_all(dir).unwrap();
}
} }
+8 -8
View File
@@ -154,9 +154,9 @@
<Terminal class="size-4" /> <Terminal class="size-4" />
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<h2 id="log-viewer-title" class="text-sm font-semibold">Log bundles</h2> <h2 id="log-viewer-title" class="text-sm font-semibold">Logs</h2>
<p class="truncate text-[10px] text-muted-foreground"> <p class="truncate text-[10px] text-muted-foreground">
Launcher, osu! and patcher output, grouped by session Current session plus archived launcher, osu! and patcher output
</p> </p>
</div> </div>
</div> </div>
@@ -191,7 +191,7 @@
<div <div
class="flex shrink-0 items-center justify-between border-b border-theme-800 px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground" class="flex shrink-0 items-center justify-between border-b border-theme-800 px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
> >
<span>Bundles</span> <span>Sessions</span>
<span>{bundles.length}</span> <span>{bundles.length}</span>
</div> </div>
<div class="min-h-0 flex-1 overflow-y-auto p-1.5"> <div class="min-h-0 flex-1 overflow-y-auto p-1.5">
@@ -202,8 +202,8 @@
</div> </div>
{:else if bundles.length === 0} {:else if bundles.length === 0}
<div class="p-3 text-[11px] leading-relaxed text-muted-foreground"> <div class="p-3 text-[11px] leading-relaxed text-muted-foreground">
No bundles yet. Logs are zipped on the next launcher restart or when a log reaches 5 No logs yet. The current session appears once the launcher writes its first line;
MiB. archived bundles are created when logs rotate.
</div> </div>
{:else} {:else}
{#each bundles as bundle (bundle.path)} {#each bundles as bundle (bundle.path)}
@@ -291,14 +291,14 @@
class="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground" class="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground"
> >
<LoaderCircle class="size-4 animate-spin" /> <LoaderCircle class="size-4 animate-spin" />
Reading bundle… Reading logs…
</div> </div>
{:else if !selectedBundle} {:else if !selectedBundle}
<div <div
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground" class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
> >
<Terminal class="size-8 opacity-40" /> <Terminal class="size-8 opacity-40" />
<p class="text-xs">Choose a log bundle to inspect it.</p> <p class="text-xs">Choose a log session to inspect it.</p>
</div> </div>
{:else if filteredLines.length === 0} {:else if filteredLines.length === 0}
<div <div
@@ -328,7 +328,7 @@
<span> <span>
{selectedBundle {selectedBundle
? `${selectedBundle.name} · ${formatBytes(selectedBundle.size)}` ? `${selectedBundle.name} · ${formatBytes(selectedBundle.size)}`
: 'No bundle selected'} : 'No session selected'}
</span> </span>
</footer> </footer>
</main> </main>